From 4804d8a7aa9f72ac60816c44db95be595de47723 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:47:30 +0000 Subject: [PATCH 1/3] fix(cli): os dev --no-watch reaches the watch opt-out; a PACKAGE matching nothing fails Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/cli/src/commands/dev.ts | 49 +++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index e9944c3dc33..02c1c4bb1d8 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -175,6 +175,18 @@ export function forwardSeedSettledToParent(msg: unknown): boolean { return true; } +/** + * Whether this `os dev` boot runs the watch-recompile loop (#20681). + * + * Off when the operator passed `--no-watch`, when `--artifact` was given (there + * is no source to watch), or when the cwd has no `objectstack.config.ts`. The + * one decision both the loop and the stale-artifact remedy line read, exported + * so `dev-no-watch.pin.test.ts` can drive it with what oclif actually parsed. + */ +export function devWatchActive(opts: { watch: boolean; artifact?: string; configExists: boolean }): boolean { + return opts.watch && !opts.artifact && opts.configExists; +} + export default class Dev extends Command { static override description = 'Start development mode — watch sources, rebuild the artifact, and restart the server on change'; @@ -184,7 +196,18 @@ export default class Dev extends Command { }; static override flags = { - watch: Flags.boolean({ char: 'w', description: 'Enable watch mode (default)', default: true }), + // `allowNo` is what makes the off branch below (`devWatchActive`, the + // watch-recompile loop) reachable at all: without it `--no-watch` is a + // "Nonexistent flag", and `--watch=false` is not a boolean spelling oclif + // reads — it parses as `--watch` plus the PACKAGE positional `false` + // (#20681). Same declaration as the sibling `compile` / `restart` / + // `seed-admin` switches. + watch: Flags.boolean({ + char: 'w', + description: 'Watch objectstack.config.ts and src/, rebuilding on change (default: on). Disable with --no-watch.', + default: true, + allowNo: true, + }), ui: Flags.boolean({ description: 'Enable the bundled Console portal at /_console/' }), verbose: Flags.boolean({ char: 'v', description: 'Verbose output (shortcut for --log-level debug)' }), 'log-level': Flags.string({ @@ -380,7 +403,7 @@ export default class Dev extends Command { // and dev still printed `Plugins: 38 loaded` until a manual build. // Warn loudly and name the remedy; never gate the boot (per triage: // remove the silence, not the start). - const watchActive = flags.watch !== false && !flags.artifact && configExists; + const watchActive = devWatchActive({ watch: flags.watch, artifact: flags.artifact, configExists }); if (!needsCompile && !flags.artifact && configExists) { const stale = assessArtifactStaleness({ artifactPath, @@ -697,7 +720,7 @@ export default class Dev extends Command { // running server keeps the old build. // // Skipped when: - // - --watch=false (user opted out) + // - --no-watch (user opted out) // - --artifact was passed (no source to watch) // - the environment has no objectstack.config.ts if (watchActive) { @@ -728,7 +751,25 @@ export default class Dev extends Command { process.exit(1); } - const filter = packageName === 'all' ? '' : `--filter ${packageName}`; + // `--no-watch` turns off THIS process's watch-recompile loop, which only + // exists when `os dev` boots one environment. Here each package's own + // `dev` script runs and decides its own watching, so the flag cannot be + // honoured — refuse it rather than print "Watch: enabled" under an + // operator who just asked for the opposite (#20681). + if (!flags.watch) { + printError('--no-watch has no effect in monorepo orchestration mode: each package\'s own `dev` script decides whether it watches.'); + console.error(chalk.yellow(' Run it in a directory with objectstack.config.ts, or pass --artifact , to boot one environment with watch off.')); + process.exit(1); + } + + // `--fail-if-no-match`: a PACKAGE that selects no workspace project fails + // non-zero instead of exiting 0 having started nothing (#20681). pnpm + // owns the filter grammar (names, globs, `./dir`, `...pkg`), so pnpm + // answers whether it matched — ⛔ no second reading of the workspace + // here. The measured trap it closes: `os dev --watch=false` parses as + // `--watch` plus the PACKAGE `false`, and used to print "No projects + // found" and exit 0. + const filter = packageName === 'all' ? '' : `--filter ${packageName} --fail-if-no-match`; printKV('Package', packageName === 'all' ? 'All packages' : packageName, '📦'); printKV('Watch', 'enabled', '🔄'); From fcf4f78e3097469af00e1c8321cbdd7f5d044fa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:55:58 +0000 Subject: [PATCH 2/3] test(cli): pin os dev --no-watch and the loud no-match PACKAGE Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/cli/test/dev-no-watch.pin.test.ts | 179 +++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 packages/cli/test/dev-no-watch.pin.test.ts diff --git a/packages/cli/test/dev-no-watch.pin.test.ts b/packages/cli/test/dev-no-watch.pin.test.ts new file mode 100644 index 00000000000..554d017bd02 --- /dev/null +++ b/packages/cli/test/dev-no-watch.pin.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#20681) — `os dev`'s watch opt-out is reachable, and the argv that used + * to miss it fails instead of exiting 0. + * + * Measured on `main` before this change, through the source entry + * (`tsx bin/run-dev.js`), in a directory holding an `objectstack.config.ts`: + * + * ``` + * $ os dev --no-watch + * Error: Nonexistent flag: --no-watch # exit 2 + * + * $ os dev --watch=false + * 📦 Package: false + * 🔄 Watch: enabled + * $ pnpm --filter false dev + * No projects found in "…" # exit 0, nothing started + * ``` + * + * The first because `watch` declared no `allowNo`. The second because a boolean + * flag has no `=value` spelling in oclif — `--watch=false` parses as `--watch` + * plus the PACKAGE positional `false` — and pnpm exits 0 on a filter that + * selects nothing. + * + * Two halves, two instruments: + * + * • the opt-out is a PARSE-AND-DECIDE fact: oclif's own parser over the + * command's real declarations, fed into the one decision the watch loop + * reads (`devWatchActive`). Observing the watcher's absence on a real boot + * costs a full kernel boot per case and proves nothing further; + * • the loud failures are SHELL facts: an exit status only exists once a real + * child has exited (the reason `invocation-loudness.e2e.test.ts` spawns), so + * those cases spawn the source entry — which is what puts this file in the + * `integration` tier (`vitest-tiers.ts`). + * + * ⛔ Refusal prose is not pinned. The spawned cases assert the exit status, the + * value the failure names, and that nothing was started. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Parser } from '@oclif/core'; +import Dev, { devWatchActive } from '../src/commands/dev.js'; +import { childEnv } from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +/** oclif + tsx cold start, with every command module loaded; ~2-10 s when healthy. */ +const RUN_TIMEOUT_MS = 180_000; + +/** What the fixture workspace's own `dev` script prints — proof it ran. */ +const DEV_SCRIPT_MARK = 'DEV-SCRIPT-RAN'; + +const parseDev = (argv: string[]) => + Parser.parse(argv, { flags: Dev.flags, args: Dev.args, strict: true }); + +interface Run { + code: number; + out: string; +} + +function runDev(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, 'dev', ...args], + { cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + // `err.code` is the real exit status; a signalled child has none and + // is reported as a failure, never as 0. + code: err + ? typeof (err as { code?: unknown }).code === 'number' + ? (err as unknown as { code: number }).code + : 1 + : 0, + out: `${String(stdout)}\n${String(stderr)}`, + }); + }, + ); + }); +} + +/** The `✗` line `printError` writes — the one sentence the failure is judged by. */ +const failureLine = (out: string): string => + out.split('\n').find((line) => line.trimStart().startsWith('✗')) ?? ''; + +describe('the watch opt-out is reachable', () => { + it('`os dev --no-watch` parses, and the boot it describes runs no watcher', async () => { + const { flags } = await parseDev(['--no-watch']); + expect(flags.watch).toBe(false); + expect(devWatchActive({ watch: flags.watch, artifact: flags.artifact, configExists: true })).toBe(false); + }); + + it('bare `os dev` keeps watch on', async () => { + const { flags } = await parseDev([]); + expect(flags.watch).toBe(true); + expect(devWatchActive({ watch: flags.watch, artifact: flags.artifact, configExists: true })).toBe(true); + }); + + it('`--watch=false` is still NOT an off spelling — it parses as the PACKAGE `false`', async () => { + // Why the orchestration half below exists: oclif gives a boolean flag no + // `=value` form, so this argv reaches the monorepo branch with a package + // name, not the single-environment boot with watch off. + const { flags, args } = await parseDev(['--watch=false']); + expect(flags.watch).toBe(true); + expect(args.package).toBe('false'); + }); +}); + +let projectDir: string; +let workspaceDir: string; +let watchEqualsFalse: Run; +let noWatchAtWorkspace: Run; +let bareAtWorkspace: Run; + +beforeAll(async () => { + // A project directory: a config file, and no pnpm workspace above it. + projectDir = mkdtempSync(join(tmpdir(), 'os-dev-no-watch-project-')); + writeFileSync(join(projectDir, 'objectstack.config.ts'), 'export default {};\n'); + + // A workspace root whose own `dev` script announces that it ran — so the + // `--no-watch` refusal below is measured against a fixture that DOES start + // something when asked (`bareAtWorkspace` is that control). + workspaceDir = mkdtempSync(join(tmpdir(), 'os-dev-no-watch-workspace-')); + writeFileSync(join(workspaceDir, 'pnpm-workspace.yaml'), 'packages: []\n'); + writeFileSync( + join(workspaceDir, 'package.json'), + `${JSON.stringify({ + name: 'os-dev-no-watch-fixture', + private: true, + scripts: { dev: `node -e "console.log('${DEV_SCRIPT_MARK}')"` }, + })}\n`, + ); + + // Sequential on purpose: three cold tsx starts, each loading every command + // module, in a container several agents share. + watchEqualsFalse = await runDev(['--watch=false'], projectDir); + noWatchAtWorkspace = await runDev(['--no-watch'], workspaceDir); + bareAtWorkspace = await runDev([], workspaceDir); +}, RUN_TIMEOUT_MS); + +afterAll(() => { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(workspaceDir, { recursive: true, force: true }); +}); + +describe('a PACKAGE that selects no workspace project fails', () => { + it('`os dev --watch=false` exits 1 instead of 0', () => { + expect(watchEqualsFalse.code).toBe(1); + }); + + it('and the failure names the value that matched nothing', () => { + expect(failureLine(watchEqualsFalse.out)).toMatch(/(^|\s)false(\s|$)/); + }); +}); + +describe('`--no-watch` where os dev boots no environment is refused, not ignored', () => { + it('the fixture starts its own dev script when asked — the control', () => { + expect(bareAtWorkspace.code).toBe(0); + expect(bareAtWorkspace.out).toContain(DEV_SCRIPT_MARK); + }); + + it('exits 1, naming the flag', () => { + expect(noWatchAtWorkspace.code).toBe(1); + expect(failureLine(noWatchAtWorkspace.out)).toContain('--no-watch'); + }); + + it('and starts nothing', () => { + expect(noWatchAtWorkspace.out).not.toContain(DEV_SCRIPT_MARK); + }); +}); From c36db55077d3f439811ec609f19bbbc9f7dae30c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 10:02:55 +0000 Subject: [PATCH 3/3] chore(changeset): cli patch for os dev --no-watch and the loud no-match PACKAGE Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/20681-dev-no-watch.md | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .changeset/20681-dev-no-watch.md diff --git a/.changeset/20681-dev-no-watch.md b/.changeset/20681-dev-no-watch.md new file mode 100644 index 00000000000..2118265bd00 --- /dev/null +++ b/.changeset/20681-dev-no-watch.md @@ -0,0 +1,33 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os dev --no-watch` turns watch mode off, and `os dev` fails when its PACKAGE argument selects no workspace package + +Clause-②: no + +`os dev` watches `objectstack.config.ts` and `src/` by default, and it already +had a branch for running without that watcher. No argument reached it: + +- `os dev --no-watch` was refused as a nonexistent flag (exit 2). +- `os dev --watch=false` is not a form the CLI reads for a boolean flag. It + parsed as `--watch` plus the PACKAGE argument `false`, so the command switched + to monorepo mode, ran `pnpm --filter false dev`, printed "No projects found", + and exited 0 with nothing started. + +What changes: + +- `os dev --no-watch` boots the environment with the watch-and-rebuild loop off. + Plain `os dev` keeps it on, as before. +- In monorepo mode, a PACKAGE argument that selects no workspace package now + exits 1, and the failure line names the value. The command passes + `--fail-if-no-match` to pnpm, so pnpm decides whether the filter matched, for + every filter form it accepts. `os dev --watch=false` is one such run: it now + fails instead of reporting success. Write `--no-watch` instead. +- `os dev --no-watch` in monorepo mode (a PACKAGE argument, or a workspace root + with no `objectstack.config.ts`) exits 1 and names the flag. In that mode each + package's own `dev` script decides whether it watches, so the CLI cannot turn + watching off. Run it in the project directory, or pass `--artifact`. + +Monorepo mode now needs pnpm 8.13.1 or later, the release that added +`--fail-if-no-match`.