Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/20681-dev-no-watch.md
Original file line number Diff line number Diff line change
@@ -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`.
49 changes: 45 additions & 4 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <path|url>, 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', '🔄');

Expand Down
179 changes: 179 additions & 0 deletions packages/cli/test/dev-no-watch.pin.test.ts
Original file line number Diff line number Diff line change
@@ -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<Run> {
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);
});
});
Loading