diff --git a/.changeset/21501-artifact-flag-precedence.md b/.changeset/21501-artifact-flag-precedence.md new file mode 100644 index 0000000000..f7f1d266d2 --- /dev/null +++ b/.changeset/21501-artifact-flag-precedence.md @@ -0,0 +1,17 @@ +--- +'@objectstack/cli': patch +--- + +`os dev -a PATH` and `os start --artifact PATH` now serve the artifact they name, also from a directory that holds an `objectstack.config.ts` (#21501). + +Clause-②: no + +- **One precedence, written once.** The order is `--artifact` > `OS_ARTIFACT_URL` > `OS_ARTIFACT_PATH` > `/dist/objectstack.json` > `/dist/objectstack.json` (`os start` only) > a cwd `objectstack.config.ts`, except that a cwd config joins the boot when the resolved artifact is its own compiled output. It is the order the `os start` reference already published. `os start` and `os dev` both resolve through one module, and the `serve` child they spawn boots exactly their answer. +- **Beside a config.** The child used to read the supervisor's answer only when the working directory held no config. So `os dev -a X` and `os start --artifact X` printed `Artifact: X` and served the config's `dist/objectstack.json`, or the config itself. A named artifact now boots alone, exactly as it boots from a directory with no config. The config takes part only when the artifact is its own compiled output: `/dist/objectstack.json`, or the path the command compiled it to. A bare `os dev`, a bare `os start` in a project, and `os start --artifact ./dist/objectstack.json` take that path, and are unchanged. A host config (its `plugins` hold code) boots its own module there, because its compiled output cannot carry that code. +- **`OS_ARTIFACT_PATH` beside a config** follows the same rule: `OS_ARTIFACT_PATH=Y os start` serves `Y` without loading the config. Under `os start --artifact ./dist/objectstack.json` the flag now also wins over an exported `OS_ARTIFACT_PATH` inside the config boot. +- **`os dev` under a local `OS_ARTIFACT_PATH`** compiles the cwd config into that path, so the file there is the config's own compiled output. The config takes part in the boot that serves it, and a host config compiled there keeps its plugins. +- **`os dev` gains the `OS_ARTIFACT_URL` rung.** `--artifact` outranks it. Before, the reference stayed in the child's environment and won. Without the flag the reference drives the boot, as under `os start`. The `Artifact:` row names it (redacted), and nothing is compiled into, watched for or judged stale against it. +- **Banner rows.** `os start` and `os dev` print `Config:` only when the config takes part in the boot. The child says it is not loading a config that sits beside a named artifact, instead of `No objectstack.config.ts found`. +- **The ready banner names what loaded.** On a config boot, a non-host config whose app was served from its compiled artifact gets `Artifact: dist/objectstack.json` in the ready banner, and a host config keeps `Config: objectstack.config.ts`. No ready-banner row names a file the boot did not load. + +Upgrading: a project that ran `os dev -a`, `os start --artifact` or `OS_ARTIFACT_PATH` beside its config, and relied on that config being loaded, should drop the override or point it at `./dist/objectstack.json`. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index f37d4c2764..17efe67e0e 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -181,7 +181,7 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32) | Flag | Env equivalent | Purpose | |---|---|---| -| `-a, --artifact ` | `OS_ARTIFACT_PATH` | Boot a pre-built artifact directly; **skips auto-compile** | +| `-a, --artifact ` | `OS_ARTIFACT_URL` / `OS_ARTIFACT_PATH` | Boot a pre-built artifact directly; **skips auto-compile** | | `-d, --database ` | `OS_DATABASE_URL` | `file:…` / `libsql://` / `postgres://` / `mongodb://` / `memory://` | | `--database-driver ` | `OS_DATABASE_DRIVER` | Force `sqlite` \| `sqlite-wasm` \| `turso` \| `postgres` \| `mysql` \| `mongodb` \| `memory` | | `--database-auth-token ` | `OS_DATABASE_AUTH_TOKEN` | libsql/Turso token | diff --git a/packages/cli/src/commands/artifact-child-env.pin.test.ts b/packages/cli/src/commands/artifact-child-env.pin.test.ts index a657edabce..14577ed3bc 100644 --- a/packages/cli/src/commands/artifact-child-env.pin.test.ts +++ b/packages/cli/src/commands/artifact-child-env.pin.test.ts @@ -13,9 +13,16 @@ * * The plumbing now travels on `OS_INTERNAL_ARTIFACT_PATH` * (`utils/internal-artifact-channel.ts`). This file pins both halves of the - * property, plus the two behaviours that had to survive the move: the - * resolution ladder, and `start`'s deliberate refusal to declare an empty boot - * acceptable when a reference is driving the boot. + * property, plus the behaviours that had to survive the move: the resolution + * ladder, and `start`'s deliberate refusal to declare an empty boot acceptable + * when a reference is driving the boot. + * + * #21501 — the ladder is now ONE resolver every door asks + * (`utils/artifact-precedence.ts`): `start` and `dev` resolve through it, and + * its last rung (does the cwd `objectstack.config.ts` take part?) is the one + * predicate both the supervisors' `Config:` row and the `serve` child read. + * The ladder pins below drive that resolver; the boots that prove the child + * obeys it live in `test/artifact-flag-precedence.integration.test.ts`. * * Two kinds of assertion here, and both are needed: * @@ -37,10 +44,16 @@ import path from 'path'; import ts from 'typescript'; import { INTERNAL_ARTIFACT_PATH_ENV, + INTERNAL_CONFIG_OUTPUT_PATH_ENV, childEnvWithResolvedArtifact, readInternalArtifactPath, + readInternalConfigOutputPath, } from '../utils/internal-artifact-channel.js'; -import { resolveArtifactSource } from './start.js'; +import { + cwdConfigJoinsBoot, + isConfigCompiledArtifact, + resolveArtifactBootSource, +} from '../utils/artifact-precedence.js'; const ARTIFACT = '/srv/app/objectstack.json'; @@ -99,6 +112,42 @@ describe('the child `serve` env — OS_ARTIFACT_PATH means an operator set it', } }); + it('a `resolved` answer REMOVES an outranked OS_ARTIFACT_URL; a reference keeps it (#21501)', () => { + // Through the one ladder a supervisor resolves an artifact while the + // reference is set only on the rung above it — `--artifact`. Leaving the + // reference in the child env let `serve` read it first: measured, + // `OS_ARTIFACT_URL=file://…/BRAVO.json os dev -a ALPHA.json` served BRAVO. + const parentEnv = { OS_ARTIFACT_URL: 'https://cdn.example.com/ref.json' }; + expect(childEnvWithResolvedArtifact(parentEnv, { kind: 'resolved', path: ARTIFACT }).OS_ARTIFACT_URL) + .toBeUndefined(); + for (const decision of [{ kind: 'reference' }, { kind: 'empty' }] as const) { + expect(childEnvWithResolvedArtifact(parentEnv, decision).OS_ARTIFACT_URL) + .toBe('https://cdn.example.com/ref.json'); + } + }); + + it('carries where the parent compiles the cwd config only when the decision says so — and owns that variable too', () => { + const named = '/srv/app/build/named.json'; + const withTarget = childEnvWithResolvedArtifact({}, { kind: 'resolved', path: named, configCompiledTo: named }); + expect(withTarget[INTERNAL_CONFIG_OUTPUT_PATH_ENV]).toBe(named); + expect(readInternalConfigOutputPath(withTarget)).toBe(named); + + // An inherited copy never speaks for a decision the parent did not make. + const parentEnv = { [INTERNAL_CONFIG_OUTPUT_PATH_ENV]: '/stale/inherited.json' }; + for (const decision of [ + { kind: 'resolved', path: ARTIFACT }, + { kind: 'reference' }, + { kind: 'empty' }, + ] as const) { + const childEnv = childEnvWithResolvedArtifact(parentEnv, decision); + expect( + Object.prototype.hasOwnProperty.call(childEnv, INTERNAL_CONFIG_OUTPUT_PATH_ENV), + `decision ${decision.kind} declared no compile path, so the variable must be absent`, + ).toBe(false); + } + expect(readInternalConfigOutputPath({ [INTERNAL_CONFIG_OUTPUT_PATH_ENV]: ' ' })).toBeUndefined(); + }); + it('reads a blank channel value as no decision at all', () => { expect(readInternalArtifactPath({})).toBeUndefined(); expect(readInternalArtifactPath({ [INTERNAL_ARTIFACT_PATH_ENV]: '' })).toBeUndefined(); @@ -140,7 +189,7 @@ describe('OS_BOOT_EMPTY — the artifact-reference refusal survives the move', ( }); }); -describe('resolveArtifactSource — the resolution ladder is unchanged', () => { +describe('resolveArtifactBootSource — THE ladder, written once (#21501)', () => { let cwd: string; let home: string; @@ -150,6 +199,8 @@ describe('resolveArtifactSource — the resolution ladder is unchanged', () => { writeFileSync(abs, '{}'); return abs; }; + const resolvedPath = (r: ReturnType) => + (r.kind === 'resolved' ? r.path : undefined); beforeEach(() => { cwd = mkdtempSync(path.join(tmpdir(), 'os-artifact-cwd-')); @@ -161,55 +212,143 @@ describe('resolveArtifactSource — the resolution ladder is unchanged', () => { } }); - it('rung 1: --artifact wins over everything, including an operator OS_ARTIFACT_PATH', () => { + it('rung 1: --artifact wins over everything, an operator OS_ARTIFACT_PATH and OS_ARTIFACT_URL included', () => { const flagFile = write(cwd, 'build/pinned.json'); write(cwd, 'dist/objectstack.json'); write(home, 'dist/objectstack.json'); - const r = resolveArtifactSource('build/pinned.json', home, { + const r = resolveArtifactBootSource({ + flag: 'build/pinned.json', cwd, - env: { OS_ARTIFACT_PATH: '/from/env.json' }, + homeDir: home, + env: { OS_ARTIFACT_PATH: '/from/env.json', OS_ARTIFACT_URL: 'https://cdn.example.com/ref.json' }, }); - expect(r?.path).toBe(flagFile); + expect(r).toMatchObject({ kind: 'resolved', rung: 'flag', path: flagFile }); }); it('rung 1: --artifact passes an http(s) URL through untouched', () => { const url = 'https://cdn.example.com/app.json'; - expect(resolveArtifactSource(url, home, { cwd, env: {} })?.path).toBe(url); + expect(resolvedPath(resolveArtifactBootSource({ flag: url, cwd, homeDir: home, env: {} }))).toBe(url); }); - it('rung 2: $OS_ARTIFACT_PATH wins over both auto-detected locations', () => { - write(cwd, 'dist/objectstack.json'); - write(home, 'dist/objectstack.json'); + it('rung 1: a named --artifact is not existence-checked — a missing one is the child\'s loud refusal', () => { + expect(resolveArtifactBootSource({ flag: 'nope.json', cwd, env: {} })) + .toMatchObject({ kind: 'resolved', rung: 'flag', path: path.join(cwd, 'nope.json') }); + }); - const r = resolveArtifactSource(undefined, home, { + it('rung 2a: OS_ARTIFACT_URL is a reference — resolved by the child, never here — and outranks OS_ARTIFACT_PATH', () => { + write(cwd, 'dist/objectstack.json'); + const r = resolveArtifactBootSource({ cwd, - env: { OS_ARTIFACT_PATH: 'custom/app.json' }, + homeDir: home, + env: { OS_ARTIFACT_URL: ' https://cdn.example.com/ref.json ', OS_ARTIFACT_PATH: 'custom/app.json' }, }); - // Anchored on the cwd, exactly as before — the ladder resolves it; the - // variable itself is inherited by the child untouched. - expect(r?.path).toBe(path.join(cwd, 'custom/app.json')); + expect(r).toEqual({ kind: 'reference', url: 'https://cdn.example.com/ref.json' }); + }); + + it('rung 2a: a blank OS_ARTIFACT_URL reads as unset', () => { + const cwdArtifact = write(cwd, 'dist/objectstack.json'); + expect(resolvedPath(resolveArtifactBootSource({ cwd, env: { OS_ARTIFACT_URL: ' ' } }))).toBe(cwdArtifact); + }); + + it('rung 2b: $OS_ARTIFACT_PATH wins over both auto-detected locations', () => { + write(cwd, 'dist/objectstack.json'); + write(home, 'dist/objectstack.json'); + + const r = resolveArtifactBootSource({ cwd, homeDir: home, env: { OS_ARTIFACT_PATH: 'custom/app.json' } }); + // Anchored on the cwd — the ladder resolves it; the variable itself is + // inherited by the child untouched. + expect(r).toMatchObject({ kind: 'resolved', rung: 'env-path', path: path.join(cwd, 'custom/app.json') }); }); - it('rung 2: $OS_ARTIFACT_PATH may itself be an http(s) URL', () => { + it('rung 2b: $OS_ARTIFACT_PATH may itself be an http(s) URL', () => { const url = 'https://cdn.example.com/env.json'; - expect(resolveArtifactSource(undefined, home, { cwd, env: { OS_ARTIFACT_PATH: url } })?.path) + expect(resolvedPath(resolveArtifactBootSource({ cwd, homeDir: home, env: { OS_ARTIFACT_PATH: url } }))) .toBe(url); }); it('rung 3: /dist/objectstack.json wins over /dist', () => { const cwdArtifact = write(cwd, 'dist/objectstack.json'); write(home, 'dist/objectstack.json'); - expect(resolveArtifactSource(undefined, home, { cwd, env: {} })?.path).toBe(cwdArtifact); + expect(resolveArtifactBootSource({ cwd, homeDir: home, env: {} })) + .toMatchObject({ kind: 'resolved', rung: 'cwd-dist', path: cwdArtifact }); }); - it('rung 4: /dist/objectstack.json is the last resort', () => { + it('rung 4: /dist/objectstack.json is the last artifact rung — and only for a door that passes a home', () => { const homeArtifact = write(home, 'dist/objectstack.json'); - expect(resolveArtifactSource(undefined, home, { cwd, env: {} })?.path).toBe(homeArtifact); + expect(resolveArtifactBootSource({ cwd, homeDir: home, env: {} })) + .toMatchObject({ kind: 'resolved', rung: 'home-dist', path: homeArtifact }); + // `os dev` passes no home: its home is per-run state, never an artifact source. + expect(resolveArtifactBootSource({ cwd, env: {} })).toEqual({ kind: 'unresolved' }); + }); + + it('rung 5: nothing reachable is `unresolved` — what is left is the cwd config', () => { + expect(resolveArtifactBootSource({ cwd, homeDir: home, env: {} })).toEqual({ kind: 'unresolved' }); + }); +}); + +describe('cwdConfigJoinsBoot — the last rung, one predicate for both ends (#21501)', () => { + const projectDir = path.join(tmpdir(), 'os-project'); + const configPath = path.join(projectDir, 'objectstack.config.ts'); + const ownArtifact = path.join(projectDir, 'dist', 'objectstack.json'); + + it('a config takes part when nothing above it answered', () => { + expect(cwdConfigJoinsBoot({ configExists: true, configPath, artifact: { kind: 'none' } })).toBe(true); + }); + + it('a config takes part when the artifact IS its own compiled output — however the path is spelled', () => { + expect(cwdConfigJoinsBoot({ configExists: true, configPath, artifact: { kind: 'path', path: ownArtifact } })) + .toBe(true); + expect(isConfigCompiledArtifact(path.join(projectDir, 'dist', '.', 'objectstack.json'), configPath)).toBe(true); + }); + + it('a config does NOT take part beside any other named artifact — leg 1 and leg 2 of the card', () => { + for (const other of [ + path.join(projectDir, 'build', 'pinned.json'), + path.join(tmpdir(), 'elsewhere', 'objectstack.json'), + path.join(projectDir, '.objectstack', 'dist', 'objectstack.json'), + 'https://cdn.example.com/objectstack.json', + ]) { + expect( + cwdConfigJoinsBoot({ configExists: true, configPath, artifact: { kind: 'path', path: other } }), + `${other} must boot alone, not under the cwd config`, + ).toBe(false); + } + }); + + it('a config takes part when the artifact is where THIS command compiled it — a named path (os dev under OS_ARTIFACT_PATH)', () => { + const named = path.join(projectDir, 'build', 'named.json'); + expect(cwdConfigJoinsBoot({ + configExists: true, + configPath, + artifact: { kind: 'path', path: named, configCompiledTo: named }, + })).toBe(true); + expect(isConfigCompiledArtifact(named, configPath, path.join(projectDir, 'build', '.', 'named.json'))).toBe(true); + // Declaring a compile path does not make a DIFFERENT artifact the config's own, + expect(cwdConfigJoinsBoot({ + configExists: true, + configPath, + artifact: { kind: 'path', path: path.join(tmpdir(), 'elsewhere.json'), configCompiledTo: named }, + })).toBe(false); + // and a URL is never a place a config was compiled to. + expect(isConfigCompiledArtifact('https://cdn.example.com/a.json', configPath, 'https://cdn.example.com/a.json')) + .toBe(false); + // The conventional path stays the config's own output beside a declared one. + expect(isConfigCompiledArtifact(ownArtifact, configPath, named)).toBe(true); + }); + + it('a config does NOT take part under a reference (OS_ARTIFACT_URL)', () => { + expect(cwdConfigJoinsBoot({ configExists: true, configPath, artifact: { kind: 'reference' } })).toBe(false); }); - it('rung 5: nothing reachable resolves to undefined', () => { - expect(resolveArtifactSource(undefined, home, { cwd, env: {} })).toBeUndefined(); + it('no config never takes part', () => { + for (const artifact of [ + { kind: 'none' }, + { kind: 'reference' }, + { kind: 'path', path: ownArtifact }, + ] as const) { + expect(cwdConfigJoinsBoot({ configExists: false, configPath, artifact })).toBe(false); + } }); }); @@ -311,3 +450,62 @@ describe('structural: the supervisors never write the operator knob', () => { expect(/OS_ARTIFACT_PATH\s*:/.test(textScanned)).toBe(false); }); }); + +describe('structural: the supervisors carry no private copy of the ladder (#21501)', () => { + /** + * `start` and `dev` each used to read the operator's artifact variables + * themselves, in their own order — and `dev`'s order had no + * `OS_ARTIFACT_URL` rung, which is how `--artifact` came to lose to the + * reference. Every rung is now read in `utils/artifact-precedence.ts` alone. + * A READ of either variable reappearing in a supervisor is a second copy of + * the order starting to grow, so it is refused here by the AST (strings and + * comments that merely NAME the variables stay free). + */ + const ARTIFACT_VARS = new Set(['OS_ARTIFACT_PATH', 'OS_ARTIFACT_URL']); + + const artifactVarReadsIn = (file: string, src: string): string[] => { + const sourceFile = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true); + const hits: string[] = []; + const at = (node: ts.Node) => + `${file}:${sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1}`; + const visit = (node: ts.Node): void => { + if (ts.isPropertyAccessExpression(node) && ARTIFACT_VARS.has(node.name.text)) { + hits.push(`${at(node)} ${node.getText(sourceFile)}`); + } + if ( + ts.isElementAccessExpression(node) + && ts.isStringLiteral(node.argumentExpression) + && ARTIFACT_VARS.has(node.argumentExpression.text) + ) { + hits.push(`${at(node)} ${node.getText(sourceFile)}`); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return hits; + }; + const artifactVarReads = (file: string): string[] => + artifactVarReadsIn(file, readFileSync(new URL(`./${file}`, import.meta.url), 'utf8')); + + for (const file of ['start.ts', 'dev.ts']) { + it(`${file} reads neither artifact variable itself — it asks resolveArtifactBootSource`, () => { + expect( + artifactVarReads(file), + `${file} must resolve the artifact through utils/artifact-precedence.ts, never by reading ` + + 'OS_ARTIFACT_PATH / OS_ARTIFACT_URL itself: a second reading is a second copy of the order.', + ).toEqual([]); + }); + } + + it('the detector sees both spellings of a read — and not a name inside a string', () => { + const specimen = [ + 'const a = process.env.OS_ARTIFACT_URL;', + "const b = env['OS_ARTIFACT_PATH'];", + "printKV('Artifact', `${x} (OS_ARTIFACT_URL)`); // OS_ARTIFACT_PATH in a comment", + ].join('\n'); + expect(artifactVarReadsIn('specimen.ts', specimen)).toEqual([ + 'specimen.ts:1 process.env.OS_ARTIFACT_URL', + "specimen.ts:2 env['OS_ARTIFACT_PATH']", + ]); + }); +}); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index aa107ad879..07076a34df 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -17,6 +17,14 @@ import { formatMtimeGap, } from '../utils/dev-restart.js'; import { childEnvWithResolvedArtifact } from '../utils/internal-artifact-channel.js'; +// THE artifact precedence, written once (#21501) — shared with `start`, and +// with the `serve` child this command spawns. ⛔ No rung of it is restated here. +import { + CONVENTIONAL_ARTIFACT_RELATIVE_PATH, + cwdConfigJoinsBoot, + isRemoteArtifact, + resolveArtifactBootSource, +} from '../utils/artifact-precedence.js'; import { artifactObjectNames } from '../utils/stack-collections.js'; import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types'; // The ONE port contract, shared with `start` and with the `serve` child this @@ -178,10 +186,12 @@ export function forwardSeedSettledToParent(msg: unknown): boolean { /** * 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. + * Off when the operator passed `--no-watch`, when the boot serves an artifact + * `os dev` does not build — `--artifact`, or the reference `OS_ARTIFACT_URL` + * (#21501) — since a rebuild would change nothing that is served, 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; @@ -242,7 +252,7 @@ export default class Dev extends Command { // source to compile from. All flags override the matching env var. artifact: Flags.string({ char: 'a', - description: 'Path or http(s):// URL to a compiled objectstack.json (skips auto-compile; overrides $OS_ARTIFACT_PATH)', + description: 'Path or http(s):// URL to a compiled objectstack.json (skips auto-compile; overrides $OS_ARTIFACT_URL, $OS_ARTIFACT_PATH and a cwd objectstack.config.ts)', }), 'environment-id': Flags.string({ description: 'Environment identifier (overrides $OS_ENVIRONMENT_ID, default env_local)', @@ -325,21 +335,47 @@ export default class Dev extends Command { // local config — semantically the same as `os start` but with the // dev conveniences (NODE_ENV=development, dev-fallback AUTH_SECRET, // --ui default-on, dev-mode error formatting). - const isUrl = !!flags.artifact && /^https?:\/\//i.test(flags.artifact); - const inferredArtifact = flags.artifact - ?? process.env.OS_ARTIFACT_PATH - ?? path.resolve(process.cwd(), 'dist/objectstack.json'); - const artifactPath = isUrl ? flags.artifact! : path.resolve(process.cwd(), inferredArtifact); - const useArtifactDirect = !!flags.artifact || !configExists; + // + // Resolved through THE precedence (`utils/artifact-precedence.ts`, #21501), + // the one `start` resolves through — `dev` used to carry its own copy with + // no `OS_ARTIFACT_URL` rung, so the reference beat `--artifact` in the + // child. The answer is printed, handed down and served as one value: the + // `serve` child boots it even beside a cwd `objectstack.config.ts`. + const bootSource = resolveArtifactBootSource({ flag: flags.artifact, env: process.env, cwd: process.cwd() }); + // `OS_ARTIFACT_URL` drives the boot: the child resolves it, as under `start`. + const artifactUrl = bootSource.kind === 'reference' ? bootSource.url : undefined; + // The artifact this boot serves — or, when no artifact rung answered, the + // conventional path the cwd config compiles to (the precedence's last rung). + const artifactPath = bootSource.kind === 'resolved' + ? bootSource.path + : path.resolve(process.cwd(), CONVENTIONAL_ARTIFACT_RELATIVE_PATH); + // An artifact this command does not build from the cwd sources: the flag, + // the reference, or a remote `OS_ARTIFACT_PATH`. Nothing is compiled into + // it, watched for it, or judged stale against it. + const pinnedArtifact = flags.artifact ?? artifactUrl + ?? (isRemoteArtifact(artifactPath) ? artifactPath : undefined); + // Where THIS command compiles the cwd config: the artifact it builds, which + // is `/dist/objectstack.json` or the operator's local + // `OS_ARTIFACT_PATH` (#21501, as triage ruled). That file is the config's + // own compiled output wherever it lives, so the config joins the boot that + // serves it, and the child is told the path so it recognises it too. + const configCompiledTo = pinnedArtifact || !configExists ? undefined : artifactPath; if (packageName === 'all' && (configExists || flags.artifact)) { - if (configExists && !flags.artifact) { + // `Config:` only when the cwd config takes part in this boot — the same + // predicate the `serve` child loads it by (#21501). + const configJoins = cwdConfigJoinsBoot({ + configExists, + configPath, + artifact: artifactUrl ? { kind: 'reference' } : { kind: 'path', path: artifactPath, configCompiledTo }, + }); + if (configJoins) { printKV('Config', configPath, '📂'); } - // Auto-compile only when we have a config AND no explicit artifact. - // Explicit `--artifact` means "use this, don't rebuild". - const needsCompile = !flags.artifact && (flags.compile || !fs.existsSync(artifactPath)); + // Auto-compile only when we have a config AND the boot's artifact is one + // this command builds. A pinned artifact means "use this, don't rebuild". + const needsCompile = !pinnedArtifact && (flags.compile || !fs.existsSync(artifactPath)); if (needsCompile) { if (!configExists) { printError('No objectstack.config.ts and no --artifact given — nothing to start.'); @@ -403,8 +439,8 @@ 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 = devWatchActive({ watch: flags.watch, artifact: flags.artifact, configExists }); - if (!needsCompile && !flags.artifact && configExists) { + const watchActive = devWatchActive({ watch: flags.watch, artifact: pinnedArtifact, configExists }); + if (!needsCompile && !pinnedArtifact && configExists) { const stale = assessArtifactStaleness({ artifactPath, configPath, @@ -523,7 +559,9 @@ export default class Dev extends Command { databaseDriverFlag: flags['database-driver'], env: process.env, cwd: process.cwd(), - artifactPath, + // The reference's bytes are the child's to fetch; a local file the + // reference outranks must not choose this boot's datasource (as `start`). + artifactPath: artifactUrl ? undefined : artifactPath, }); if (resolvedDb.notice) { // Legacy-file compat-read — one loud line naming the file being read @@ -535,17 +573,20 @@ export default class Dev extends Command { fs.mkdirSync(path.dirname(resolvedDb.url.replace(/^file:/, '')), { recursive: true }); } const effectiveDb = resolvedDb.url; - // `dev` always has a resolved artifact by this point (it compiled one, or - // was handed one with `--artifact`, or is pointing at the canonical - // `/dist/objectstack.json`), so the decision is unconditionally - // `resolved` — exactly as unconditional as the `OS_ARTIFACT_PATH` write - // it replaces. What changed is the channel: the resolved path travels on - // the CLI's own `OS_INTERNAL_ARTIFACT_PATH`, so an `OS_ARTIFACT_PATH` - // seen by a downstream `objectstack.config.ts` means an operator set it. - // The operator's own value is inherited verbatim, and `dev`'s ladder - // above still honours it on the rung it has always occupied. + // `dev` has a resolved artifact by this point (it compiled one, or was + // handed one, or is pointing at the canonical + // `/dist/objectstack.json`) — UNLESS `OS_ARTIFACT_URL` drives the + // boot, in which case it resolves nothing and the child resolves the + // reference, exactly as under `start` (#21501). The resolved path + // travels on the CLI's own `OS_INTERNAL_ARTIFACT_PATH`, so an + // `OS_ARTIFACT_PATH` seen by a downstream `objectstack.config.ts` means + // an operator set it; a `resolved` answer also removes an outranked + // `OS_ARTIFACT_URL` (`--artifact` over env). const localEnv: NodeJS.ProcessEnv = { - ...childEnvWithResolvedArtifact(process.env, { kind: 'resolved', path: artifactPath }), + ...childEnvWithResolvedArtifact( + process.env, + artifactUrl ? { kind: 'reference' } : { kind: 'resolved', path: artifactPath, configCompiledTo }, + ), OS_ENVIRONMENT_ID: environmentId, OS_SEED_ADMIN: seedAdmin ? '1' : '0', ...(seedAdmin && flags['admin-email'] ? { OS_SEED_ADMIN_EMAIL: flags['admin-email'] } : {}), @@ -558,7 +599,14 @@ export default class Dev extends Command { ...(flags['auth-secret'] ? { OS_AUTH_SECRET: flags['auth-secret'] } : {}), }; printKV('Environment ID', environmentId, '🎯'); - printKV('Artifact', isUrl ? artifactPath : path.relative(process.cwd(), artifactPath), '📦'); + if (artifactUrl) { + // Redacted: the reference may be a pre-signed URL whose query string IS + // the credential — the same row `start` prints for it. + const { redactArtifactUrl } = await import('@objectstack/runtime'); + printKV('Artifact', `${redactArtifactUrl(artifactUrl)} (OS_ARTIFACT_URL)`, '📦'); + } else { + printKV('Artifact', isRemoteArtifact(artifactPath) ? artifactPath : path.relative(process.cwd(), artifactPath), '📦'); + } printKV('Database', redactConnectionUrl(effectiveDb), '🗄️'); const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true }); @@ -721,7 +769,8 @@ export default class Dev extends Command { // // Skipped when: // - --no-watch (user opted out) - // - --artifact was passed (no source to watch) + // - the boot serves a pinned artifact — --artifact or OS_ARTIFACT_URL + // (no source of it to watch) // - the environment has no objectstack.config.ts if (watchActive) { this.startWatchRecompile({ diff --git a/packages/cli/src/commands/serve-banner-config-row.test.ts b/packages/cli/src/commands/serve-banner-config-row.test.ts index b7584dffe2..eed282c8ba 100644 --- a/packages/cli/src/commands/serve-banner-config-row.test.ts +++ b/packages/cli/src/commands/serve-banner-config-row.test.ts @@ -57,6 +57,23 @@ describe('resolveBannerConfigRow (#8978)', () => { })).toEqual({}); }); + it('names the compiled bundle — not the config — when a config boot served its app from one (#21501)', () => { + // A non-host config's standalone stack loaded dist/objectstack.json as the + // app bundle: the metadata served came from that file. + expect(resolveBannerConfigRow({ + relativeConfig: 'objectstack.config.ts', + useArtifactFallback: false, + configBootBundle: 'dist/objectstack.json', + })).toEqual({ bundleSource: 'dist/objectstack.json' }); + // A host config (or a config whose artifact was absent) loaded no bundle, + // so its row stays the config it booted. + expect(resolveBannerConfigRow({ + relativeConfig: 'objectstack.config.ts', + useArtifactFallback: false, + configBootBundle: undefined, + })).toEqual({ configFile: 'objectstack.config.ts' }); + }); + it('omits the row on an empty/quick-start boot (no config, no artifact)', () => { // `useArtifactFallback` is also set on the `OS_BOOT_EMPTY=1` quick-start // path — same defect, same fix: nothing was read, so nothing is named. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 1b98c322fb..2427afaaa8 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -9,7 +9,10 @@ import { bundleRequire } from 'bundle-require'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { mergeBootConfig } from '../utils/merge-boot-config.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; -import { readInternalArtifactPath } from '../utils/internal-artifact-channel.js'; +import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js'; +// The precedence's last rung — whether the cwd config takes part — decided by +// the SAME predicate the supervisors print their `Config:` row by (#21501). +import { cwdConfigJoinsBoot } from '../utils/artifact-precedence.js'; import { resolveDriverType, resolveStorageDefinition, @@ -2369,30 +2372,36 @@ export default class Serve extends Command { // the separate `objectstack-ai/cloud` repo, NOT a path in this one — and // lifted into the framework so any project can `objectstack start` // against just a `dist/objectstack.json`. - const configMissing = !configExists; let useArtifactFallback = false; let useEmptyBoot = false; + /** + * #21501 — the compiled artifact a CONFIG boot loaded as its app bundle, + * as the ready banner displays it; set only when one did. A non-host + * config's standalone stack serves its metadata from that bundle, so the + * banner's `Artifact:` row names it; a host config (its `plugins` hold + * code) boots its own module and the row stays `Config:`. + */ + let configBootBundle: string | undefined; // ── Artifact-pinned boot (#8368) ───────────────────────────────── // `OS_ARTIFACT_URL` names the artifact BY REFERENCE — an https:// URL // fetched at boot, or a file:// URL read directly (the volume-mount // workflow) — with an optional SRI-style `#sha256=` integrity pin in the // fragment. It is resolved here, before anything else looks for an - // artifact, and it wins over every local lookup: + // artifact, and it wins over every local lookup. // - // --artifact > OS_ARTIFACT_URL > OS_INTERNAL_ARTIFACT_PATH - // > OS_ARTIFACT_PATH > /dist/… - // - // `OS_INTERNAL_ARTIFACT_PATH` is the CLI's private parent-to-child channel: - // an `os start` / `os dev` supervisor resolved an artifact through its own - // ladder and is handing the answer down. It sits BELOW the reference (a - // supervisor that saw OS_ARTIFACT_URL resolves nothing and sends nothing, - // and `os dev` sends its answer unconditionally, so the reference has to - // keep outranking it) and ABOVE the operator's OS_ARTIFACT_PATH (which the - // supervisor no longer overwrites on the way down, so only a higher rung - // keeps `--artifact` beating an exported OS_ARTIFACT_PATH the way it does - // today). See `utils/internal-artifact-channel.ts` for why the CLI stopped - // writing the operator's knob at all. + // THE precedence is written once, in `utils/artifact-precedence.ts`; the + // supervisors (`os start`, `os dev`) resolve through it and hand their + // answer down on `OS_INTERNAL_ARTIFACT_PATH`, the CLI's private + // parent-to-child channel. This process reads the channels in this order: + // the reference, then the supervisor's answer, then the operator's + // OS_ARTIFACT_PATH. The answer sits BELOW the reference only because a + // supervisor never sends both (one holding `--artifact`, which outranks the + // reference, removes OS_ARTIFACT_URL from this env), and ABOVE the + // operator's OS_ARTIFACT_PATH (which the supervisor no longer overwrites on + // the way down, so only a higher rung keeps `--artifact` beating an + // exported OS_ARTIFACT_PATH). See `utils/internal-artifact-channel.ts` for + // why the CLI stopped writing the operator's knob at all. // // Beating OS_ARTIFACT_PATH is not a nicety, it is the acceptance // criterion: the official runtime image sets @@ -2452,13 +2461,41 @@ export default class Serve extends Command { } } - if (configMissing && !pinnedArtifact) { + // ── The supervisor's answer outranks a cwd config (#21501) ─────────── + // Read ONCE; every use below is this value. A cwd `objectstack.config.ts` + // is the LOWEST source, so it takes part in this boot only when the + // supervisor's answer IS that config's own compiled output (the path + // `os dev`, a bare `os start` in a project, and the documented + // `os start --artifact ./dist/objectstack.json` all take). Any other answer + // boots ALONE, exactly as it boots from a directory with no config. + // + // MEASURED before this read existed, both legs from a project directory + // whose config's `dist/objectstack.json` held a DIFFERENT stack: + // `os dev -a X` and `os start --artifact X` printed `Artifact: X` and served + // that `dist/objectstack.json` (the config boot's standalone stack read the + // conventional path, never the channel), and `os start --artifact X` beside + // a config with no `dist/` served the config itself. The same commands + // from a directory with no config served X. + const supervisorArtifact = readInternalArtifactPath(); + const configJoins = cwdConfigJoinsBoot({ + configExists, + configPath: absolutePath, + artifact: pinnedArtifact ? { kind: 'reference' } + : supervisorArtifact + // Where the supervisor compiles the cwd config, when it is not the + // conventional path (`os dev` under a local OS_ARTIFACT_PATH): the + // artifact there is that config's own compiled output too. + ? { kind: 'path', path: supervisorArtifact, configCompiledTo: readInternalConfigOutputPath() } + : { kind: 'none' }, + }); + + if (!configJoins && !pinnedArtifact) { const { resolveDefaultArtifactPath } = await import('@objectstack/runtime'); // A supervising `os start` / `os dev` passes its already-resolved answer // as the explicit override — the same position `OS_ARTIFACT_PATH` used to // occupy when the supervisor wrote it, so a named-but-missing artifact is // still a loud refusal rather than a silent empty boot. - const artifactSource = resolveDefaultArtifactPath(readInternalArtifactPath()); + const artifactSource = resolveDefaultArtifactPath(supervisorArtifact); if (!artifactSource) { // Quick-start mode: `objectstack start` lets the user boot an // empty kernel with no config and no artifact, then install apps @@ -2510,6 +2547,12 @@ export default class Serve extends Command { printDiagnostic(chalk.dim(' No objectstack.config.ts or artifact found — booting empty kernel...')); } else if (pinnedArtifact) { printDiagnostic(chalk.dim(' Booting from the artifact named by OS_ARTIFACT_URL (default host)...')); + } else if (useArtifactFallback && configExists) { + // Never "No objectstack.config.ts found" when one is sitting right there: + // say it is deliberately not loaded, and why. + printDiagnostic(chalk.dim( + ` ${relativeConfig} is not loaded — the artifact resolved for this boot outranks it; booting from that artifact (default host)...`, + )); } else if (useArtifactFallback) { printDiagnostic(chalk.dim(' No objectstack.config.ts found — booting from artifact (default host)...')); } else { @@ -2711,10 +2754,7 @@ export default class Serve extends Command { // being re-derived from the environment. ...(pinnedArtifact ? { artifactPath: pinnedArtifact.localPath } - : (() => { - const internal = readInternalArtifactPath(); - return internal ? { artifactPath: internal } : {}; - })()), + : supervisorArtifact ? { artifactPath: supervisorArtifact } : {}), }); // [#4002] `api` merges per key — see mergeBootConfig. A shallow spread // let the boot builder's two scoping keys wipe the author's whole `api` @@ -2731,8 +2771,30 @@ export default class Serve extends Command { // #2229: dev enables the native-better-sqlite3 → wasm → in-memory // step-down in the shared datasource factory; prod fails loudly. dev: isDev, + // #21501: a supervised config boot serves the supervisor's answer + // (here always the config's own compiled output — `configJoins`), + // handed over explicitly. Left to the standalone stack's own + // fallback, an exported OS_ARTIFACT_PATH would outrank + // `os start --artifact ./dist/objectstack.json` in this process. + ...(supervisorArtifact ? { artifactPath: supervisorArtifact } : {}), }; const bootResult = await createStandaloneStack(standaloneInput); + // #21501 — did the standalone stack load a compiled artifact as this + // app's bundle? Its AppPlugin over the bundle is the proof (it is + // pushed only when the bundle loaded, and a non-host config carries + // no plugin of its own). If so, the ready banner names THAT file: + // the metadata served came from it, not from the config module. The + // path is the runtime's own ladder over the same explicit input the + // stack was handed — never a copy of it. + if (Array.isArray((bootResult as any)?.plugins) && (bootResult as any).plugins.some(isAppPluginLike)) { + const { resolveDefaultArtifactPath, redactArtifactUrl } = await import('@objectstack/runtime'); + const loaded = resolveDefaultArtifactPath(supervisorArtifact); + if (loaded) { + configBootBundle = /^https?:\/\//i.test(loaded) + ? redactArtifactUrl(loaded) + : (path.relative(process.cwd(), loaded) || loaded); + } + } // [#4002] Per-key `api` merge — see mergeBootConfig. config = mergeBootConfig(originalConfig as any, bootResult as any) as any; } else { @@ -5298,7 +5360,7 @@ export default class Serve extends Command { // something unparseable; the banner then prints paths with no origin // rather than a confident wrong URL. externalBaseOrigin: resolveAuthBaseUrl(boundPort, boundProtocol).baseOrigin, - ...resolveBannerConfigRow({ relativeConfig, useArtifactFallback, pinnedArtifact }), + ...resolveBannerConfigRow({ relativeConfig, useArtifactFallback, pinnedArtifact, configBootBundle }), isDev, pluginCount: loadedPlugins.length, pluginNames: loadedPlugins, @@ -6901,16 +6963,22 @@ export function describeRegisteredDriver( * → no config was read and there is no safely-redacted display in hand * here (OS_ARTIFACT_PATH may itself be a credentialed URL) — omit the * row rather than name a nonexistent file or risk leaking a secret. - * - Neither set → the ordinary config-boot path; report `relativeConfig` - * exactly as before. + * - Neither set → the config-boot path, which names what that boot actually + * loaded as the app (#21501): `configBootBundle` set — a non-host config + * whose standalone stack served a compiled artifact as its bundle — reports + * THAT file as `bundleSource`; otherwise (a host config, whose `plugins` hold + * code and which boots its own module, or a config whose artifact was absent) + * report `relativeConfig`. No row names a file the boot did not load. */ export function resolveBannerConfigRow(opts: { relativeConfig: string; useArtifactFallback: boolean; pinnedArtifact?: { display: string }; -}): { configFile?: string; artifactSource?: string } { + configBootBundle?: string; +}): { configFile?: string; artifactSource?: string; bundleSource?: string } { if (opts.pinnedArtifact) return { artifactSource: opts.pinnedArtifact.display }; if (opts.useArtifactFallback) return {}; + if (opts.configBootBundle) return { bundleSource: opts.configBootBundle }; return { configFile: opts.relativeConfig }; } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index b6d246505b..8aafbc5e44 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -13,6 +13,13 @@ import { redirectStdoutToStderr } from '../utils/json-stdout.js'; import { redactConnectionUrl } from '../utils/connection-display.js'; import { databaseDriverFlag } from '../utils/database-driver-flag.js'; import { childEnvWithResolvedArtifact } from '../utils/internal-artifact-channel.js'; +// THE artifact precedence, written once (#21501) — shared with `dev`, and with +// the `serve` child this command spawns. ⛔ No rung of it is restated here. +import { + CONVENTIONAL_ARTIFACT_RELATIVE_PATH, + cwdConfigJoinsBoot, + resolveArtifactBootSource, +} from '../utils/artifact-precedence.js'; import { ServeRestartCoordinator } from '../utils/dev-restart.js'; import { readEnvWithDeprecation } from '@objectstack/types'; // The ONE port contract, shared with `dev` and with the `serve` child this @@ -186,25 +193,19 @@ export default class Start extends Command { } // ── Artifact resolution ──────────────────────────────────────── - // Priority: --artifact > $OS_ARTIFACT_PATH > ./dist/objectstack.json - // > /dist/objectstack.json > none + // Through THE precedence (`utils/artifact-precedence.ts`, #21501) — the + // one `dev` resolves through too. This command prints exactly its answer + // and hands exactly its answer down, and the `serve` child boots exactly + // that: an explicitly named artifact outranks a cwd `objectstack.config.ts` + // there as well as here. // - // This ladder resolves in the PARENT and is unchanged. What changed is how - // the answer reaches the child: it travels on the CLI's own - // `OS_INTERNAL_ARTIFACT_PATH` channel, never on `OS_ARTIFACT_PATH`, so an - // `OS_ARTIFACT_PATH` visible to a downstream `objectstack.config.ts` means - // an operator set it. See `utils/internal-artifact-channel.ts`. + // The answer travels on the CLI's own `OS_INTERNAL_ARTIFACT_PATH` channel, + // never on `OS_ARTIFACT_PATH`, so an `OS_ARTIFACT_PATH` visible to a + // downstream `objectstack.config.ts` means an operator set it. See + // `utils/internal-artifact-channel.ts`. Every read of the operator's + // variables here is a read of the PARENT's environment: this command never + // mutates `process.env`, it composes a separate child env. // - // Note every read of `process.env.OS_ARTIFACT_PATH` in this command — the - // ladder's second rung below, and the auto-compile guard — is a read of the - // PARENT's environment, i.e. of the operator's own value. This command - // never mutates `process.env`; it composes a separate child env. So those - // guards see exactly what they saw before. - // - // In project mode (objectstack.config.ts present) we additionally - // auto-compile the config to ./dist/objectstack.json when no - // artifact has been built yet, so `os start` works on a fresh - // clone without needing a separate `os build`. // ── Artifact-pinned boot (#8368) ──────────────────────────────── // `OS_ARTIFACT_URL` names a published artifact by reference. `start` does // NOT resolve it — `serve` does, once, and owns the fetch, the `#sha256=` @@ -212,28 +213,27 @@ export default class Start extends Command { // does is get out of the way: no local lookup, no auto-compile, and no // resolved-artifact channel or OS_BOOT_EMPTY in the child env that would // contradict the reference. The variable itself is inherited by the child. - // - // That "nothing in the child env that contradicts the reference" intent is - // now general rather than special-cased: the child env carries a resolved - // artifact only when this command actually resolved one, on a channel of - // the CLI's own — so the operator-facing knob says one thing and one thing - // only. - // - // An explicit `--artifact` still wins (flags over env, as everywhere in - // this command), and it wins by REMOVING the variable from the child env — + // An explicit `--artifact` outranks the reference, and the channel helper + // REMOVES the variable from the child env when it sends that answer — // leaving both set would hand `serve` two answers and let it pick. - const artifactUrl = flags.artifact ? undefined : process.env.OS_ARTIFACT_URL?.trim() || undefined; - - let artifactSource = artifactUrl ? undefined : resolveArtifactSource(flags.artifact, homeDir); - + const bootSource = resolveArtifactBootSource({ flag: flags.artifact, env: process.env, cwd, homeDir }); + const artifactUrl = bootSource.kind === 'reference' ? bootSource.url : undefined; + let artifactSource: { path: string; display: string } | undefined = + bootSource.kind === 'resolved' ? { path: bootSource.path, display: bootSource.display } : undefined; + + // The last rung: no artifact rung answered, so the cwd config IS the + // source — compiled to its conventional path so `os start` works on a fresh + // clone without a separate `os build`. `--compile` forces that rebuild over + // a conventionally located artifact too, never over a NAMED one + // (`--artifact`, `OS_ARTIFACT_URL`, `OS_ARTIFACT_PATH`). + const artifactNamed = bootSource.kind === 'reference' + || (bootSource.kind === 'resolved' && (bootSource.rung === 'flag' || bootSource.rung === 'env-path')); const shouldAutoCompile = hasProjectConfig - && !flags.artifact - && !artifactUrl - && !process.env.OS_ARTIFACT_PATH - && (flags.compile || !artifactSource); + && !artifactNamed + && (flags.compile || bootSource.kind === 'unresolved'); if (shouldAutoCompile) { - const outputPath = path.resolve(cwd, 'dist/objectstack.json'); + const outputPath = path.resolve(cwd, CONVENTIONAL_ARTIFACT_RELATIVE_PATH); printStep('Compiling objectstack.config.ts → dist/objectstack.json...'); const binPath = process.argv[1]; const compileResult = spawnSync( @@ -290,7 +290,18 @@ export default class Start extends Command { ?? readOrCreateAuthSecret(homeDir); // ── Banner ────────────────────────────────────────────────────── - if (hasProjectConfig) { + // `Config:` only when the cwd config takes part in this boot — the same + // predicate the `serve` child loads it by, so the two cannot disagree. A + // config beside an explicitly named artifact is NOT loaded (#21501), and a + // row naming it would say it was. + const configJoins = cwdConfigJoinsBoot({ + configExists: hasProjectConfig, + configPath: projectConfigPath, + artifact: artifactUrl ? { kind: 'reference' } + : artifactSource ? { kind: 'path', path: artifactSource.path } + : { kind: 'none' }, + }); + if (configJoins) { printKV('Config', path.relative(cwd, projectConfigPath) || 'objectstack.config.ts', '📂'); } printKV('Home', homeDir, '🏠'); @@ -396,7 +407,9 @@ export default class Start extends Command { // // The resolved path travels on `OS_INTERNAL_ARTIFACT_PATH`, so an // `OS_ARTIFACT_PATH` the child sees is the operator's own, inherited - // verbatim and never written by this command. + // verbatim and never written by this command. A `resolved` answer also + // removes an outranked `OS_ARTIFACT_URL` (flags over env) — see + // `childEnvWithResolvedArtifact`. const localEnv: NodeJS.ProcessEnv = { ...childEnvWithResolvedArtifact( process.env, @@ -414,9 +427,6 @@ export default class Start extends Command { ...(flags['database-auth-token'] ? { OS_DATABASE_AUTH_TOKEN: flags['database-auth-token'] } : {}), AUTH_SECRET: authSecret, }; - // Flags over env: an explicit --artifact removes the reference rather than - // racing it (see the resolution note above). - if (flags.artifact) delete localEnv.OS_ARTIFACT_URL; // NODE_ENV is only forced to production when the user has not set it. // Allows `NODE_ENV=development objectstack start` to work for debugging. // @@ -628,70 +638,6 @@ function resolveHome( return path.resolve(os.homedir(), '.objectstack'); } -export interface ResolvedArtifact { - /** - * Absolute path or URL handed to the child on the CLI's internal channel - * (`OS_INTERNAL_ARTIFACT_PATH`) — never on the operator's `OS_ARTIFACT_PATH`. - */ - path: string; - /** Human-friendly form for the banner. */ - display: string; -} - -/** - * `start`'s artifact resolution ladder, in one place: - * - * `--artifact` > `$OS_ARTIFACT_PATH` > `/dist/objectstack.json` - * > `/dist/objectstack.json` > none - * - * Exported (with `cwd` / `env` injectable) so the ladder itself is pinned - * rather than inferred: moving the CLI's plumbing off `OS_ARTIFACT_PATH` must - * not shift a single rung, and the operator's `$OS_ARTIFACT_PATH` in - * particular must keep being honoured exactly where it is honoured today. - */ -export function resolveArtifactSource( - flagValue: string | undefined, - homeDir: string, - opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, -): ResolvedArtifact | undefined { - const cwd = opts.cwd ?? process.cwd(); - const env = opts.env ?? process.env; - - // Explicit flag wins, including URLs. - if (flagValue) { - if (/^https?:\/\//i.test(flagValue)) return { path: flagValue, display: flagValue }; - const abs = path.resolve(cwd, flagValue); - if (!fs.existsSync(abs)) { - // We don't exit here — the user asked for this file. Defer to - // serve.ts which already prints a precise error. - return { path: abs, display: path.relative(cwd, abs) }; - } - return { path: abs, display: path.relative(cwd, abs) }; - } - - // Explicit env var wins next — the OPERATOR's value, read from the parent - // environment. It is resolved here and passed down on the internal channel; - // the variable itself is inherited by the child untouched. - const envPath = env.OS_ARTIFACT_PATH; - if (envPath) { - if (/^https?:\/\//i.test(envPath)) return { path: envPath, display: envPath }; - const abs = path.resolve(cwd, envPath); - return { path: abs, display: path.relative(cwd, abs) }; - } - - // Auto-detect — cwd first, then home. - const cwdCandidate = path.resolve(cwd, 'dist/objectstack.json'); - if (fs.existsSync(cwdCandidate)) { - return { path: cwdCandidate, display: path.relative(cwd, cwdCandidate) }; - } - const homeCandidate = path.resolve(homeDir, 'dist/objectstack.json'); - if (fs.existsSync(homeCandidate)) { - return { path: homeCandidate, display: homeCandidate }; - } - - return undefined; -} - /** * Read the persisted AUTH_SECRET from `/auth-secret`, or generate * one on first run and persist it so subsequent restarts keep existing diff --git a/packages/cli/src/utils/artifact-precedence.ts b/packages/cli/src/utils/artifact-precedence.ts new file mode 100644 index 0000000000..de70002071 --- /dev/null +++ b/packages/cli/src/utils/artifact-precedence.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * THE artifact precedence for the CLI's boot doors — written once, here. + * + * `--artifact` > `OS_ARTIFACT_URL` > `OS_ARTIFACT_PATH` + * > `/dist/objectstack.json` > `/dist/objectstack.json` (`os start` only) + * > a cwd `objectstack.config.ts` (compiled to `/dist/objectstack.json`) + * + * This is the order `content/docs/deployment/cli.mdx` already publishes for + * `os start` ("Resolution priority (artifact)"). Each rung is consulted only + * when every rung above it resolved nothing: the FIRST source that answers is + * the stack this boot serves, and nothing below it takes part — EXCEPT that a + * cwd config joins the boot when the resolved artifact is its own compiled + * output ({@link cwdConfigJoinsBoot}). That is the triage ruling's amendment + * for a host config, whose code plugins its compiled output cannot carry. + * + * ## Why one module, and who asks it + * + * `os start` and `os dev` each used to carry their own copy of this order, and + * the copies had drifted: `dev`'s had no `OS_ARTIFACT_URL` rung at all, so + * `OS_ARTIFACT_URL= os dev -a X` booted `` — the flag lost to the + * environment. Worse, the `serve` child both supervisors spawn decided for + * itself whether to read the supervisor's answer, and read it only when the cwd + * held no `objectstack.config.ts`. So `os dev -a X` and `os start --artifact X`, + * run from a project directory, printed `Artifact: X` and served the cwd + * config's stack (`/dist/objectstack.json`, or the config itself) — the + * flag lost to the working directory, silently. + * + * Every door now asks this module: + * + * - {@link resolveArtifactBootSource} — the supervisors (`os start`, + * `os dev`) resolve the boot's artifact through it, and print and hand + * down exactly its answer; + * - {@link cwdConfigJoinsBoot} — the last rung, asked by BOTH ends: the + * supervisors (for their `Config:` row) and the `serve` child (for whether + * it loads the cwd config at all), so the two cannot disagree about it. + * + * ⛔ No second copy of the order belongs anywhere in this package. A door that + * needs to know which source wins asks this module; a comment that needs to + * state the order points here. + * + * ## What stays outside, on purpose + * + * `@objectstack/runtime`'s `resolveDefaultArtifactPath` and the standalone + * stack's own path input are the RUNTIME's fallback for a caller that names no + * artifact — a direct `os serve`, or a library host. Every CLI boot that has a + * supervisor's answer hands it to them explicitly, so their fallback never + * decides a supervised boot. + */ + +import fs from 'fs'; +import path from 'path'; + +/** + * Where a project's compiled artifact lives by convention, relative to the + * directory that holds its `objectstack.config.ts` — the path `os build`, + * `os start` and `os dev` compile to. Rung 3 reads it relative to the cwd. + */ +export const CONVENTIONAL_ARTIFACT_RELATIVE_PATH = path.join('dist', 'objectstack.json'); + +/** Which rung of the precedence produced a local (or `http(s)://`) artifact. */ +export type ArtifactRung = 'flag' | 'env-path' | 'cwd-dist' | 'home-dist'; + +/** + * What the precedence answered for one boot. + * + * - `resolved` — a rung named an artifact. `path` is absolute (or an + * `http(s)://` URL, passed through verbatim); `display` is the banner form. + * A rung that NAMES an artifact (`flag`, `env-path`) is not existence-checked + * here: a named artifact that is missing is a loud refusal in the child, + * never a silent fall-through to the rung below. + * - `reference` — `OS_ARTIFACT_URL` drives the boot. The supervisor resolves + * nothing: the `serve` child owns the fetch, the `#sha256=` verification and + * the refusal. `url` is the operator's raw value (credentials and all) — print + * it only through a redactor. + * - `unresolved` — no artifact rung answered. What is left is the last rung, + * the cwd config (which a supervisor compiles to the conventional path), or + * nothing at all. + */ +export type ArtifactBootSource = + | { kind: 'resolved'; rung: ArtifactRung; path: string; display: string } + | { kind: 'reference'; url: string } + | { kind: 'unresolved' }; + +/** + * An `http(s)://` artifact source — fetched by the loader, never a local file. + * Exported so a door that must not treat a URL as a path (compile into it, stat + * it, watch it) asks the same test the precedence does. + */ +export function isRemoteArtifact(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +/** + * Resolve the boot's artifact through THE precedence (module docblock). + * + * @param opts.flag the command's `--artifact` value, if any + * @param opts.env the supervisor's OWN environment (the operator's values) + * @param opts.cwd the directory relative rungs anchor on + * @param opts.homeDir the `os start` home — enables rung 4. `os dev` passes + * none: its home is per-run state, never an artifact source. + */ +export function resolveArtifactBootSource(opts: { + flag?: string; + env: NodeJS.ProcessEnv; + cwd: string; + homeDir?: string; +}): ArtifactBootSource { + const { flag, env, cwd, homeDir } = opts; + + // Rung 1 — the explicit flag, including an http(s):// URL. It outranks the + // reference too: a supervisor holding a flag hands the child that artifact + // and removes `OS_ARTIFACT_URL` from the child env (`childEnvWithResolvedArtifact`). + if (flag) { + if (isRemoteArtifact(flag)) return { kind: 'resolved', rung: 'flag', path: flag, display: flag }; + const abs = path.resolve(cwd, flag); + return { kind: 'resolved', rung: 'flag', path: abs, display: path.relative(cwd, abs) }; + } + + // Rung 2a — the published reference. Blank reads as unset, as `serve` reads it. + const url = env.OS_ARTIFACT_URL?.trim(); + if (url) return { kind: 'reference', url }; + + // Rung 2b — the operator's path. Resolved here and handed down on the CLI's + // internal channel; the variable itself is inherited by the child untouched. + const envPath = env.OS_ARTIFACT_PATH; + if (envPath) { + if (isRemoteArtifact(envPath)) return { kind: 'resolved', rung: 'env-path', path: envPath, display: envPath }; + const abs = path.resolve(cwd, envPath); + return { kind: 'resolved', rung: 'env-path', path: abs, display: path.relative(cwd, abs) }; + } + + // Rungs 3 and 4 — the conventional locations, which only count when present. + const cwdCandidate = path.resolve(cwd, CONVENTIONAL_ARTIFACT_RELATIVE_PATH); + if (fs.existsSync(cwdCandidate)) { + return { kind: 'resolved', rung: 'cwd-dist', path: cwdCandidate, display: path.relative(cwd, cwdCandidate) }; + } + if (homeDir) { + const homeCandidate = path.resolve(homeDir, CONVENTIONAL_ARTIFACT_RELATIVE_PATH); + if (fs.existsSync(homeCandidate)) { + return { kind: 'resolved', rung: 'home-dist', path: homeCandidate, display: homeCandidate }; + } + } + + return { kind: 'unresolved' }; +} + +/** + * Is `artifactPath` the compiled output of the config at `configPath`? + * + * Two places hold a config's own compiled output, and either one counts: + * + * - the conventional `/dist/objectstack.json`, where `os build`, + * `os start` and a bare `os dev` compile it; + * - `compiledTo`, the path the supervising command ITSELF compiles the config + * to, when that is somewhere else. `os dev` with the operator's local + * `OS_ARTIFACT_PATH` compiles the cwd config INTO that path, watches it and + * rebuilds it, so the file there is the config's compiled output too, and a + * config recognised only at the conventional path would boot as a stranger + * to its own build (a host config's code plugins dropped). The command + * declares it, never this predicate: the child is told where the parent + * compiled the config (`OS_INTERNAL_CONFIG_OUTPUT_PATH`). + * + * Compared as resolved absolute paths. A URL is never a config's compiled + * output. A second spelling of the same file (a symlink) answers `false`, which + * is the safe direction: that boot serves the named artifact alone, and its + * bytes are the same file either way. + */ +export function isConfigCompiledArtifact(artifactPath: string, configPath: string, compiledTo?: string): boolean { + if (isRemoteArtifact(artifactPath)) return false; + const artifact = path.resolve(artifactPath); + if (artifact === path.resolve(path.dirname(configPath), CONVENTIONAL_ARTIFACT_RELATIVE_PATH)) return true; + return compiledTo !== undefined && !isRemoteArtifact(compiledTo) && artifact === path.resolve(compiledTo); +} + +/** + * The last rung, decided once for both ends of a supervised boot: does the cwd + * `objectstack.config.ts` take part in this boot at all? + * + * The config is the LOWEST source, so it joins only when nothing above it named + * a DIFFERENT stack — the order as triage amended it: a cwd config joins the + * boot when the resolved artifact is its own compiled output. + * + * - `reference` — `OS_ARTIFACT_URL` drives the boot → the config does not join. + * - `path` — a rung named an artifact → the config joins only when that + * artifact IS its own compiled output ({@link isConfigCompiledArtifact}, with + * `configCompiledTo` the command's own compile path when it has one): the + * config boot then serves that very file as its app bundle (the caller hands + * it over explicitly), which is the path `os dev`, a bare `os start` in a + * project, and the documented `os start --artifact ./dist/objectstack.json` + * all take. For a HOST config (its `plugins` hold code), its own compiled + * output cannot carry that code, so the config itself is what boots. Any + * other artifact boots ALONE — exactly as it boots from a directory with no + * config, never mixed with whatever source tree the process happens to stand + * in. + * - `none` — no artifact rung answered → the config is what boots. + */ +export function cwdConfigJoinsBoot(opts: { + configExists: boolean; + configPath: string; + artifact: + | { kind: 'none' } + | { kind: 'reference' } + | { kind: 'path'; path: string; configCompiledTo?: string }; +}): boolean { + if (!opts.configExists) return false; + switch (opts.artifact.kind) { + case 'reference': + return false; + case 'path': + return isConfigCompiledArtifact(opts.artifact.path, opts.configPath, opts.artifact.configCompiledTo); + case 'none': + return true; + } +} diff --git a/packages/cli/src/utils/format.config-artifact-row.test.ts b/packages/cli/src/utils/format.config-artifact-row.test.ts index 2aabdc96fa..bb26ccbc0c 100644 --- a/packages/cli/src/utils/format.config-artifact-row.test.ts +++ b/packages/cli/src/utils/format.config-artifact-row.test.ts @@ -18,7 +18,7 @@ import { printServerReady, type ServerReadyOptions } from './format.js'; const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); describe('printServerReady Config:/Artifact: row (#8978)', () => { - const base: Omit = { + const base: Omit = { externalBaseOrigin: 'http://localhost:3000', isDev: true, pluginCount: 1, @@ -62,6 +62,12 @@ describe('printServerReady Config:/Artifact: row (#8978)', () => { expect(configLine()).toBeUndefined(); }); + it('prints a plain Artifact: row for a config boot\'s compiled bundle — no OS_ARTIFACT_URL suffix, no Config: row (#21501)', () => { + printServerReady({ ...base, bundleSource: 'dist/objectstack.json' }); + expect(artifactLine()).toBe('Artifact: dist/objectstack.json'); + expect(configLine()).toBeUndefined(); + }); + it('omits BOTH rows when the caller has nothing safe to report (plain artifact-fallback path)', () => { // No configFile, no artifactSource — the plain `dist/objectstack.json` // fallback and the empty/quick-start boot. Absence beats a fabricated diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 59eec393d5..ef41fa38e0 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -741,6 +741,14 @@ export interface ServerReadyOptions { * over {@link configFile}: this is what actually booted (#8978). */ artifactSource?: string; + /** + * #21501 — the compiled artifact a CONFIG boot loaded as its app bundle (a + * non-host config's standalone stack), relative to cwd or redacted. Printed + * as a plain `Artifact:` row in the `Config:` row's place: the metadata + * served came from that file, so naming the config instead would name a + * file that is not what loaded. A host config never sets it. + */ + bundleSource?: string; isDev: boolean; pluginCount: number; pluginNames?: string[]; @@ -1185,11 +1193,14 @@ export function printServerReady(opts: ServerReadyOptions) { } console.error(''); // #8978 — name what actually booted, never a file that was not read. - // `artifactSource` (OS_ARTIFACT_URL) wins when present; a caller with - // neither (the other artifact-fallback paths) gets no row at all rather - // than a fabricated or nonexistent one. + // `artifactSource` (OS_ARTIFACT_URL) wins when present, then `bundleSource` + // (a config boot whose app came from a compiled artifact, #21501); a caller + // with none of them (the other artifact-fallback paths) gets no row at all + // rather than a fabricated or nonexistent one. if (opts.artifactSource) { console.error(chalk.dim(` Artifact: ${opts.artifactSource} (OS_ARTIFACT_URL)`)); + } else if (opts.bundleSource) { + console.error(chalk.dim(` Artifact: ${opts.bundleSource}`)); } else if (opts.configFile) { console.error(chalk.dim(` Config: ${opts.configFile}`)); } diff --git a/packages/cli/src/utils/internal-artifact-channel.ts b/packages/cli/src/utils/internal-artifact-channel.ts index 8a4c9de733..196ae0d89d 100644 --- a/packages/cli/src/utils/internal-artifact-channel.ts +++ b/packages/cli/src/utils/internal-artifact-channel.ts @@ -30,28 +30,30 @@ * a supported name, and this one is a private call between two processes the * CLI owns both ends of. * - * ## Precedence is unchanged + * ## Where the channel sits in the precedence * - * The channel is read by `serve` strictly between `OS_ARTIFACT_URL` and - * `OS_ARTIFACT_PATH`: - * - * `--artifact` > `OS_ARTIFACT_URL` > `OS_INTERNAL_ARTIFACT_PATH` > `OS_ARTIFACT_PATH` > `/dist/objectstack.json` - * - * That position is what preserves today's answers exactly, in both directions: + * The precedence itself is written once, in `utils/artifact-precedence.ts`, + * and both supervisors resolve through it; this channel only carries its + * answer. The child reads the channel BELOW `OS_ARTIFACT_URL` and ABOVE the + * operator's `OS_ARTIFACT_PATH`, which is what keeps a supervised boot equal to + * the supervisor's answer: * * - It must beat `OS_ARTIFACT_PATH`, because `os start --artifact X` run with - * an operator's `OS_ARTIFACT_PATH=Y` in the environment boots **X** today - * (the parent overwrote the variable on the way down). The operator's `Y` is - * now inherited by the child untouched, so only a higher-precedence channel - * keeps X winning. - * - It must lose to `OS_ARTIFACT_URL`, because `os dev` writes the channel - * unconditionally — as it wrote `OS_ARTIFACT_PATH` unconditionally — and - * `OS_ARTIFACT_URL` outranks `OS_ARTIFACT_PATH` in `serve` today. - * - * The parent's own resolution ladder is untouched, and so is the value: the - * child is handed exactly the path the parent resolved, "named" in the sense + * an operator's `OS_ARTIFACT_PATH=Y` in the environment boots **X**. The + * operator's `Y` is inherited by the child untouched, so only a + * higher-precedence channel keeps X winning. + * - It may sit below `OS_ARTIFACT_URL` because a supervisor never sends both: a + * reference resolves nothing (`reference` below), and an explicit + * `--artifact`, which outranks the reference, REMOVES `OS_ARTIFACT_URL` from + * the child env when it sends its `resolved` answer. + * + * And the channel outranks a cwd `objectstack.config.ts` too: the child loads + * that config only when the channel names the config's OWN compiled output + * (`cwdConfigJoinsBoot`). Any other artifact boots alone. + * + * The value is exactly the path the parent resolved, "named" in the sense * `resolveDefaultArtifactPath` means it — a named artifact that is missing is - * still a loud refusal, never a silent empty boot. + * a loud refusal, never a silent empty boot. */ /** @@ -60,11 +62,25 @@ */ export const INTERNAL_ARTIFACT_PATH_ENV = 'OS_INTERNAL_ARTIFACT_PATH'; +/** + * The second private variable on the same channel: WHERE the supervising + * command compiles the cwd config, when that is not the conventional + * `/dist/objectstack.json`. `os dev` with the operator's local + * `OS_ARTIFACT_PATH` compiles the config into that path, so the artifact there + * is the config's own compiled output, and the child must recognise it as such + * (`isConfigCompiledArtifact`). Same naming and the same ownership rule as + * {@link INTERNAL_ARTIFACT_PATH_ENV}: a private call between two processes the + * CLI owns, deliberately undocumented. + */ +export const INTERNAL_CONFIG_OUTPUT_PATH_ENV = 'OS_INTERNAL_CONFIG_OUTPUT_PATH'; + /** * What a supervisor command decided about the artifact, as handed to the child. * * - `resolved` — a local path or `http(s)://` URL the parent resolved. It is - * passed down verbatim. + * passed down verbatim. `configCompiledTo` is where the parent compiles the + * cwd config, when it compiles it somewhere other than the conventional path + * (`os dev` under a local `OS_ARTIFACT_PATH`). * - `reference` — `OS_ARTIFACT_URL` is driving this boot. The parent resolves * nothing and says nothing: the child owns the fetch, the `#sha256=` * verification and the refusal. @@ -72,7 +88,7 @@ export const INTERNAL_ARTIFACT_PATH_ENV = 'OS_INTERNAL_ARTIFACT_PATH'; * outcome (`os start`'s quick-start mode). */ export type ArtifactChannelDecision = - | { kind: 'resolved'; path: string } + | { kind: 'resolved'; path: string; configCompiledTo?: string } | { kind: 'reference' } | { kind: 'empty' }; @@ -80,12 +96,14 @@ export type ArtifactChannelDecision = * Build the child environment for a `serve` child: the parent environment plus * this command's artifact decision. * - * Two deliberate asymmetries, both load-bearing: + * Three deliberate asymmetries, all load-bearing: * * 1. **The parent OWNS `OS_INTERNAL_ARTIFACT_PATH` in the child env** — it is * set on a `resolved` decision and *deleted* otherwise, so the value the * child reads is a pure function of what the parent decided. An inherited - * copy can never speak for a decision the parent did not make. + * copy can never speak for a decision the parent did not make. The same + * holds for `OS_INTERNAL_CONFIG_OUTPUT_PATH`: set only when this decision + * carries a `configCompiledTo`, deleted otherwise. * * 2. **`OS_BOOT_EMPTY` is only ever ADDED, never removed.** An operator who * exported it keeps whatever it means for them today; this function does not @@ -95,6 +113,13 @@ export type ArtifactChannelDecision = * turn an unreachable artifact host into a silently empty platform instead * of the loud refusal the reference boot promises. * + * 3. **A `resolved` decision REMOVES `OS_ARTIFACT_URL` from the child env.** + * Through the one precedence (`utils/artifact-precedence.ts`) a supervisor + * resolves an artifact while `OS_ARTIFACT_URL` is set only on the rung that + * outranks it — an explicit `--artifact`. Leaving the reference in place + * hands `serve` two answers and lets it pick, and it reads the reference + * first: `OS_ARTIFACT_URL=… os dev -a X` booted the reference, not X. + * * `OS_ARTIFACT_PATH` is never written here, and never read here. Whatever the * parent inherited is passed through untouched — including its exact spelling, * so a config downstream sees the operator's own value rather than an @@ -108,10 +133,17 @@ export function childEnvWithResolvedArtifact( if (decision.kind === 'resolved') { childEnv[INTERNAL_ARTIFACT_PATH_ENV] = decision.path; + delete childEnv.OS_ARTIFACT_URL; } else { delete childEnv[INTERNAL_ARTIFACT_PATH_ENV]; } + if (decision.kind === 'resolved' && decision.configCompiledTo) { + childEnv[INTERNAL_CONFIG_OUTPUT_PATH_ENV] = decision.configCompiledTo; + } else { + delete childEnv[INTERNAL_CONFIG_OUTPUT_PATH_ENV]; + } + if (decision.kind === 'empty') { childEnv.OS_BOOT_EMPTY = '1'; } @@ -132,3 +164,14 @@ export function readInternalArtifactPath( const raw = env[INTERNAL_ARTIFACT_PATH_ENV]; return raw && raw.trim() !== '' ? raw : undefined; } + +/** + * Reader side, for `serve`: where the supervising `os dev` compiles the cwd + * config, when it is not the conventional path. Blank reads as unset. + */ +export function readInternalConfigOutputPath( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const raw = env[INTERNAL_CONFIG_OUTPUT_PATH_ENV]; + return raw && raw.trim() !== '' ? raw : undefined; +} diff --git a/packages/cli/test/artifact-flag-precedence.integration.test.ts b/packages/cli/test/artifact-flag-precedence.integration.test.ts new file mode 100644 index 0000000000..e8f54f7c28 --- /dev/null +++ b/packages/cli/test/artifact-flag-precedence.integration.test.ts @@ -0,0 +1,442 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21501 — an explicitly named artifact is the stack that is SERVED, even from + * a project directory: `--artifact` outranks `OS_ARTIFACT_URL`, which outranks + * `OS_ARTIFACT_PATH`, which outranks `/dist/objectstack.json`, which + * outranks a cwd `objectstack.config.ts` (`utils/artifact-precedence.ts`). + * + * ## The defect, measured at the public door before the fix + * + * Two artifacts that differ in ONE served value — the label of object + * `fx_widget` — read back through `GET /api/v1/meta/object/fx_widget`, booted + * through the BUILT entry (`bin/run.js`, the published CLI's shape): + * + * | boot | served | + * |-------------------------------------------------------------|--------------| + * | `os dev -a ALPHA`, beside a config whose `dist/` holds BRAVO | Widget BRAVO | + * | `os start --artifact ALPHA`, same directory | Widget BRAVO | + * | `os start --artifact ALPHA`, beside a config with no `dist/` | Widget CONFIG | + * | `os start --artifact ALPHA`, NO config (the control) | Widget ALPHA | + * | `os start --artifact ALPHA`, beside a HOST config | Widget CONFIG | + * | `OS_ARTIFACT_URL=…BRAVO os dev -a ALPHA` | Widget BRAVO | + * | `OS_ARTIFACT_PATH=ALPHA os start --artifact ./dist/…` (BRAVO) | Widget ALPHA | + * + * Every row but the control printed `Artifact:` naming what its flag named, + * and served something else. The `serve` child read the supervisor's answer + * only when the cwd held no config (and its config boot re-derived the artifact + * from the environment instead), and `dev` had no `OS_ARTIFACT_URL` rung, so the + * reference stayed in the child env and the child read it first. + * + * ## What each case pins + * + * - leg 1 — `dev -a` beside a config: served == the named artifact. + * - leg 2 — `start --artifact` beside a config (with and without a `dist/`), + * with its no-config control: all three serve the named artifact, so the + * config's presence changes nothing. + * - a HOST config (its `plugins` hold an instance) composes its own app, so + * only the child declining to load the config at all keeps it out — the case + * that holds `cwdConfigJoinsBoot` itself. Read off the boot's plugin roster: + * the config's marker plugin is absent beside a named artifact, and PRESENT + * when the artifact is the config's own compiled output (the marker's + * positive control, and the path a host app's own `os dev` / `os start` + * takes). The served label alone cannot tell these apart under the source + * entry: it runs `NODE_ENV=development`, where the dev metadata door over + * the supervisor's answer serves ALPHA even with the config loaded beside it. + * - the honest ready banner on the bare `os start` path (no flag, the config's + * own dist/ resolved, dist/ holding a different stack): a host config boots + * its own module and the `serve` child's ready banner says `Config:`; a + * non-host config serves its dist/ bundle and the banner says `Artifact:` + * that file. No ready-banner row names a file the boot did not load. + * - `os dev` under a local `OS_ARTIFACT_PATH` at a named path compiles a host + * config INTO that path; the file there is the config's own compiled output, + * so the config still composes its plugins (read through the roster). + * - flag over env — `dev -a` under an `OS_ARTIFACT_URL` naming the other one. + * - the documented first-project path — `start --artifact + * ./dist/objectstack.json` beside its config — is a boot the config still + * joins (that file is the config's OWN compiled output), and it serves + * that file even under an exported `OS_ARTIFACT_PATH` naming another: the + * config boot is handed the supervisor's answer instead of re-deriving it. + * + * The named-artifact cases also check the supervisor's `Artifact:` row named the + * file that was served: the card's rule is never to print one artifact and + * serve another. + * + * ## Spawn shape + * + * The tsx source entry, one process group per boot (`dev` and `start` each + * supervise a `serve` grandchild), the same harness the other boot-level files + * here use. Every boot runs in `beforeAll` and every `it` only reads what it + * recorded: clocked cases measure behaviour, never loading. A boot that fails + * is recorded against its own case, so one bad leg cannot hide the others. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + CLI, + childEnv, + E2E_SECRET_KEY, + portContentionError, + portDriftError, + probeThroughChild, + randomPort, + TSX, +} from './helpers/serve-process.js'; +import { defineStackSourceFromLiteral, linkSpec, writeDefineStackConfig } from './helpers/define-stack-fixture.js'; + +/** The banner's tail — every row above it has printed. */ +const READY = /Press Ctrl\+C to stop/; +const BOOT_TIMEOUT_MS = 180_000; +/** Ten boots, one after another, each well under its own budget when healthy. */ +const ALL_BOOTS_TIMEOUT_MS = 10 * (BOOT_TIMEOUT_MS + 30_000); +/** The host config's plugin — on the boot's plugin roster iff the config was loaded. */ +const HOST_MARKER = 'com.example.fx.host-marker'; + +/** The development dev-admin seed — the operator on every boot here. */ +const EMAIL = 'admin@objectos.ai'; +const PASSWORD = 'admin123'; +const OBJECT = 'fx_widget'; + +/** One stack per tag; the tag is the ONE served value that differs. */ +function stack(tag: string) { + return { + manifest: { id: 'com.example.fx', namespace: 'fx', version: '1.0.0', type: 'app', name: `Fx ${tag}` }, + objects: [{ + name: OBJECT, + label: `Widget ${tag}`, + pluralLabel: 'Widgets', + sharingModel: 'public_read_write', + fields: { title: { type: 'text', label: 'Title' } }, + }], + }; +} + +const groups: ChildProcess[] = []; +const dirs: string[] = []; + +interface Live { + child: ChildProcess; + base: string; + output: () => string; +} + +function boot(argv: string[], cwd: string, port: string, env: Record): Promise { + return new Promise((resolveBoot, rejectBoot) => { + const child = spawn(TSX, [CLI, ...argv], { + cwd, + // `childEnv`, never a bare `...process.env` — see its header. Neither + // artifact variable leaks in from the runner: each case states its own. + env: childEnv({ + NO_COLOR: '1', + OS_CLOUD_URL: 'off', + OS_LOG_LEVEL: 'warn', + OS_SECRET_KEY: E2E_SECRET_KEY, + OS_ARTIFACT_URL: undefined, + OS_ARTIFACT_PATH: undefined, + ...env, + }), + stdio: ['ignore', 'pipe', 'pipe'], + // Own process group: the supervisor runs a `serve` grandchild. + detached: true, + }); + groups.push(child); + let out = ''; + let settled = false; + const settle = (err: Error | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) rejectBoot(err); + else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + }; + const timer = setTimeout( + () => settle(new Error(`${argv[0]} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), + BOOT_TIMEOUT_MS, + ); + const onData = (d: unknown) => { + out += String(d); + if (READY.test(out)) settle(portDriftError(out, `os ${argv[0]}`, port)); + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('exit', (code) => + settle(portContentionError(out, `os ${argv[0]}`, port) + ?? new Error(`os ${argv[0]} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + ); + }); +} + +async function stopGroup(child: ChildProcess): Promise { + if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done) => { + const give = setTimeout(() => { + try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ } + done(); + }, 15_000); + child.once('exit', () => { clearTimeout(give); done(); }); + try { process.kill(-child.pid!, 'SIGTERM'); } catch { clearTimeout(give); done(); } + }); +} + +/** One exchange, attributed to the child if the transport fails. ⛔ No assertion inside it. */ +function http(live: Live, method: string, path: string, token: string, body?: unknown) { + return probeThroughChild( + { + child: live.child, + transcript: () => `\n--- child output ---\n${live.output().slice(-4000)}`, + label: 'artifact-flag-precedence', + what: `${method} ${path}`, + }, + async () => { + const r = await fetch(`${live.base}${path}`, { + method, + headers: { + origin: live.base, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await r.text(); + let parsed: any = text; + try { parsed = JSON.parse(text); } catch { /* keep the text */ } + return { status: r.status, body: parsed }; + }, + ); +} + +/** The `Widget …` label anywhere in the object answer, whichever envelope it came in. */ +function widgetLabel(body: unknown, depth = 0): string | undefined { + if (!body || typeof body !== 'object' || depth > 6) return undefined; + const label = (body as { label?: unknown }).label; + if (typeof label === 'string' && label.startsWith('Widget ')) return label; + for (const value of Object.values(body)) { + const found = widgetLabel(value, depth + 1); + if (found) return found; + } + return undefined; +} + +/** The supervisor's own `Artifact:` row (the parent prints it before it spawns). */ +function artifactRow(output: string): string | undefined { + return /^.*\bArtifact: [^\n]*$/m.exec(output)?.[0]; +} + +/** + * The `serve` child's READY banner row — `Config:` or `Artifact:` — read only + * after `Server is ready`, so neither the supervisor's pre-boot rows nor the + * child's boot diagnostics can answer for it. `undefined` when the banner + * carries neither (an artifact-fallback boot omits the row by design). + */ +function readyRow(output: string): { label: 'Config' | 'Artifact'; value: string } | undefined { + const at = output.search(/Server is ready/); + if (at < 0) return undefined; + const m = /^[ \t]+(Config|Artifact):[ \t]+([^\n]*)$/m.exec(output.slice(at)); + return m ? { label: m[1] as 'Config' | 'Artifact', value: m[2].trim() } : undefined; +} + +interface Reading { + served?: string; + status?: number; + artifactRow?: string; + /** Everything the boot printed, supervisor and `serve` child both. */ + output?: string; + error?: Error; +} + +async function measure(argv: string[], cwd: string, env: Record = {}): Promise { + const port = randomPort(); + let live: Live | undefined; + try { + live = await boot([...argv.slice(0, 1), '-p', port, ...argv.slice(1)], cwd, port, env); + const signIn = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD }); + const token = signIn.body?.token; + if (signIn.status !== 200 || typeof token !== 'string') { + throw new Error(`sign-in answered ${signIn.status}: ${JSON.stringify(signIn.body)}\n--- output ---\n${live.output().slice(-3000)}`); + } + const meta = await http(live, 'GET', `/api/v1/meta/object/${OBJECT}`, token); + return { + served: widgetLabel(meta.body), + status: meta.status, + artifactRow: artifactRow(live.output()), + output: live.output(), + }; + } catch (err) { + return { error: err as Error }; + } finally { + if (live) await stopGroup(live.child); + } +} + +const readings: Record = {}; + +/** A recorded boot failure is that case's failure, quoted in full. */ +function reading(name: string): Reading { + const r = readings[name]; + if (!r) throw new Error(`no reading recorded for ${name} — the beforeAll never reached it`); + if (r.error) throw r.error; + return r; +} + +let alpha = ''; + +beforeAll(async () => { + const root = mkdtempSync(join(tmpdir(), 'artifact-flag-precedence-')); + dirs.push(root); + + const artifacts = join(root, 'artifacts'); + mkdirSync(artifacts, { recursive: true }); + alpha = join(artifacts, 'ALPHA.json'); + const bravo = join(artifacts, 'BRAVO.json'); + writeFileSync(alpha, JSON.stringify(stack('ALPHA'), null, 2), 'utf8'); + writeFileSync(bravo, JSON.stringify(stack('BRAVO'), null, 2), 'utf8'); + + const project = (name: string, opts: { config: boolean | 'host'; dist: boolean }) => { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: `fx-${name}`, private: true }), 'utf8'); + if (opts.config === 'host') { + // A host config: a plugin INSTANCE in `plugins` (`isHostConfig`), so the + // config boot composes this module's own app instead of a standalone + // stack over `dist/` — no artifact path handed to it can reach it. + const literal = JSON.stringify(stack('CONFIG'), null, 2).replace(/\n}$/, `, + "plugins": [{ name: '${HOST_MARKER}', version: '1.0.0', init: async () => {}, start: async () => {} }] +}`); + // The splice must have landed, or this fixture silently stops being a host. + if (!literal.includes(HOST_MARKER)) throw new Error(`host-config fixture lost its plugin:\n${literal}`); + writeFileSync(join(dir, 'objectstack.config.ts'), defineStackSourceFromLiteral(literal)); + linkSpec(dir); + } else if (opts.config) { + writeDefineStackConfig(dir, stack('CONFIG')); + } + if (opts.dist) { + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify(stack('BRAVO'), null, 2), 'utf8'); + } + return dir; + }; + // A project whose config's compiled output holds a DIFFERENT stack (BRAVO) + // from the one named on the command line (ALPHA) — the card's reproduction. + const withConfig = project('with-config', { config: true, dist: true }); + const configOnly = project('config-only', { config: true, dist: false }); + const hostConfig = project('host-config', { config: 'host', dist: true }); + const noConfig = project('no-config', { config: false, dist: true }); + const bare = project('bare', { config: false, dist: false }); + + const devArgs = ['dev', '--fresh', '--no-watch']; + const startArgs = (home: string) => ['start', '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui']; + + readings.leg1 = await measure([...devArgs, '-a', alpha], withConfig); + readings.leg2 = await measure([...startArgs(join(root, 'h-leg2')), '--artifact', alpha], withConfig); + readings.leg2NoDist = await measure([...startArgs(join(root, 'h-leg2-nodist')), '--artifact', alpha], configOnly); + readings.leg2Control = await measure([...startArgs(join(root, 'h-leg2-ctl')), '--artifact', alpha], noConfig); + readings.hostConfig = await measure([...startArgs(join(root, 'h-host')), '--artifact', alpha], hostConfig); + // The bare `os start` path beside a config: no flag, the config's own dist/ + // resolved. dist/ holds BRAVO, a DIFFERENT stack from either config. + readings.hostConfigOwn = await measure([...startArgs(join(root, 'h-host-own'))], hostConfig); + readings.bareNonHost = await measure([...startArgs(join(root, 'h-bare-nonhost'))], withConfig); + readings.flagOverEnv = await measure([...devArgs, '-a', alpha], bare, { + OS_ARTIFACT_URL: pathToFileURL(bravo).href, + }); + readings.ownDist = await measure( + [...startArgs(join(root, 'h-own-dist')), '--artifact', './dist/objectstack.json'], + withConfig, + { OS_ARTIFACT_PATH: alpha }, + ); + // `os dev` under a local OS_ARTIFACT_PATH at a NON-default path compiles the + // host config INTO it (it does not exist yet), so that file is the config's + // own compiled output and the config must still compose its plugins. + readings.devNamedPathHost = await measure([...devArgs], hostConfig, { OS_ARTIFACT_PATH: 'build/named.json' }); +}, ALL_BOOTS_TIMEOUT_MS); + +afterAll(async () => { + for (const child of groups) await stopGroup(child); + for (const dir of dirs) { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } + } +}, 60_000); + +describe('#21501 — the named artifact is the served stack, beside a config or not', () => { + it('leg 1: `os dev -a ALPHA` beside a config whose dist/ holds BRAVO serves ALPHA', () => { + const r = reading('leg1'); + expect(r.status).toBe(200); + expect(r.served).toBe('Widget ALPHA'); + expect(r.artifactRow).toContain('ALPHA.json'); + }); + + it('leg 2: `os start --artifact ALPHA` beside a config whose dist/ holds BRAVO serves ALPHA', () => { + const r = reading('leg2'); + expect(r.status).toBe(200); + expect(r.served).toBe('Widget ALPHA'); + expect(r.artifactRow).toContain('ALPHA.json'); + }); + + it('leg 2: `os start --artifact ALPHA` beside a config with no dist/ serves ALPHA, not the config', () => { + const r = reading('leg2NoDist'); + expect(r.status).toBe(200); + expect(r.served).toBe('Widget ALPHA'); + expect(r.artifactRow).toContain('ALPHA.json'); + }); + + it('leg 2 control: the same command with NO config serves ALPHA — what the two legs above must equal', () => { + // The positive control: this harness reads ALPHA when ALPHA is what boots. + // It asserts its own reading only, so it stays green when the fix is gone. + const r = reading('leg2Control'); + expect(r.status).toBe(200); + expect(r.served).toBe('Widget ALPHA'); + expect(r.artifactRow).toContain('ALPHA.json'); + }); + + it('a HOST config: `os start --artifact ALPHA` beside one serves ALPHA and never loads the config', () => { + const r = reading('hostConfig'); + expect(r.status).toBe(200); + expect(r.served).toBe('Widget ALPHA'); + expect(r.artifactRow).toContain('ALPHA.json'); + expect(r.output).not.toContain(HOST_MARKER); + }); + + it('a bare `os start` beside a HOST config with a differing dist/ boots the config itself — and its ready banner says `Config:`', () => { + // Also the roster marker's positive control: the config's own compiled + // output is the one artifact a config still joins. + const r = reading('hostConfigOwn'); + expect(r.status).toBe(200); + expect(r.output).toContain(HOST_MARKER); + // The served label is not asserted here: under the source entry + // (`NODE_ENV=development`) the dev metadata door over dist/ composes beside + // the host module — see the header. + expect(readyRow(r.output ?? '')).toEqual({ label: 'Config', value: 'objectstack.config.ts' }); + }); + + it('a bare `os start` beside a NON-host config serves its dist/ bundle — and its ready banner says `Artifact:` that file', () => { + const r = reading('bareNonHost'); + expect(r.status).toBe(200); + expect(r.served).toBe('Widget BRAVO'); + expect(readyRow(r.output ?? '')).toEqual({ label: 'Artifact', value: 'dist/objectstack.json' }); + }); + + it('`os dev` under OS_ARTIFACT_PATH at a named path compiles a HOST config there, and the config still composes its plugins', () => { + const r = reading('devNamedPathHost'); + expect(r.status).toBe(200); + expect(r.artifactRow).toContain('build/named.json'); + expect(r.output).toContain(HOST_MARKER); + }); + + it('flag over env: `os dev -a ALPHA` under OS_ARTIFACT_URL naming BRAVO serves ALPHA', () => { + const r = reading('flagOverEnv'); + expect(r.status).toBe(200); + expect(r.served).toBe('Widget ALPHA'); + expect(r.artifactRow).toContain('ALPHA.json'); + }); + + it('the documented path: `os start --artifact ./dist/objectstack.json` beside its config serves that dist/, even under OS_ARTIFACT_PATH naming ALPHA', () => { + const r = reading('ownDist'); + expect(r.status).toBe(200); + expect(r.served).toBe('Widget BRAVO'); + expect(r.artifactRow).toContain('dist/objectstack.json'); + }); +});