From e6e5d65d895bf61073cbfde4570b203bf5c83bdb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 14:14:24 +0000 Subject: [PATCH 1/2] fix(cli): ready banner no longer names a config that was not read (#8978) serve's Config: row printed relativeConfig unconditionally, computed before the artifact-fallback branch was decided. On an OS_ARTIFACT_URL boot the objectstack.config.ts in cwd is deliberately never executed; on the plain artifact-fallback path (no config authored, booting from dist/objectstack.json) the row named a config file that does not exist on disk at all. Both are the same defect: the row is what an operator reads to answer "what is this container actually running". resolveBannerConfigRow (serve.ts) now decides what the row should say: report the resolved artifact's already-redacted display when OS_ARTIFACT_URL pinned one, omit the row entirely on the other artifact-fallback paths (no safely-redacted value is in hand there), and report the config exactly as before on the ordinary boot path. printServerReady (format.ts) renders whichever of configFile / artifactSource the caller actually has. Co-Authored-By: Claude --- .../commands/serve-banner-config-row.test.ts | 68 +++++++++++++++++ packages/cli/src/commands/serve.ts | 41 ++++++++++- .../utils/format.config-artifact-row.test.ts | 73 +++++++++++++++++++ packages/cli/src/utils/format.ts | 31 +++++++- 4 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/serve-banner-config-row.test.ts create mode 100644 packages/cli/src/utils/format.config-artifact-row.test.ts diff --git a/packages/cli/src/commands/serve-banner-config-row.test.ts b/packages/cli/src/commands/serve-banner-config-row.test.ts new file mode 100644 index 0000000000..b7584dffe2 --- /dev/null +++ b/packages/cli/src/commands/serve-banner-config-row.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// framework#8978 — the ready banner's `Config:`/`Artifact:` row. +// +// `relativeConfig` is derived from `args.config` at the top of `run()`, +// before the artifact-fallback branch is decided, and used to be handed to +// `printServerReady` unconditionally. On an `OS_ARTIFACT_URL` boot the +// `objectstack.config.ts` in cwd is deliberately never executed — the boot +// diagnostics say so plainly a few lines above the banner — yet the banner +// still named it. The plain artifact-fallback path (no config authored, +// booting from `OS_ARTIFACT_PATH` or the `/dist/objectstack.json` +// convention) named a config file that does not exist on disk at all. +// +// This pins `resolveBannerConfigRow`, the pure decision serve.ts's banner +// call site now delegates to, against every boot shape it must distinguish. + +import { describe, it, expect } from 'vitest'; +import { resolveBannerConfigRow } from './serve.js'; + +describe('resolveBannerConfigRow (#8978)', () => { + it('reports the authored config on the ordinary config-boot path', () => { + expect(resolveBannerConfigRow({ + relativeConfig: 'objectstack.config.ts', + useArtifactFallback: false, + })).toEqual({ configFile: 'objectstack.config.ts' }); + }); + + it('reports the resolved artifact — never the config — on an OS_ARTIFACT_URL boot', () => { + // The #8978 repro: `OS_ARTIFACT_URL` set, config never read, but the old + // code still named `objectstack.config.ts` in the banner. + expect(resolveBannerConfigRow({ + relativeConfig: 'objectstack.config.ts', + useArtifactFallback: true, + pinnedArtifact: { display: 'http://127.0.0.1:41541/hotcrm-2.2.2.json' }, + })).toEqual({ artifactSource: 'http://127.0.0.1:41541/hotcrm-2.2.2.json' }); + }); + + it('never emits BOTH a configFile and an artifactSource for the same boot', () => { + const row = resolveBannerConfigRow({ + relativeConfig: 'objectstack.config.ts', + useArtifactFallback: true, + pinnedArtifact: { display: 'https://cdn.example.com/app.json' }, + }); + expect(row.configFile).toBeUndefined(); + expect(row.artifactSource).toBe('https://cdn.example.com/app.json'); + }); + + it('omits the row on the plain artifact-fallback path (no config authored, dist/objectstack.json)', () => { + // The card's second half: no OS_ARTIFACT_URL, no config on disk, booted + // from the default-host convention. There is no safely-redacted display + // in hand here (OS_ARTIFACT_PATH may itself be a credentialed URL), so + // the row is omitted rather than naming a nonexistent config file. + expect(resolveBannerConfigRow({ + relativeConfig: 'objectstack.config.ts', + useArtifactFallback: true, + pinnedArtifact: undefined, + })).toEqual({}); + }); + + 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. + expect(resolveBannerConfigRow({ + relativeConfig: 'objectstack.config.ts', + useArtifactFallback: true, + })).toEqual({}); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 11daef7138..24b262f548 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -3413,9 +3413,11 @@ export default class Serve extends Command { } catch { /* no seeds ran — nothing to show */ } // ── Clean startup summary ────────────────────────────────────── + // #8978 — the Config:/Artifact: row must name what actually booted, + // never `relativeConfig` unconditionally (see resolveBannerConfigRow). printServerReady({ port, - configFile: relativeConfig, + ...resolveBannerConfigRow({ relativeConfig, useArtifactFallback, pinnedArtifact }), isDev, pluginCount: loadedPlugins.length, pluginNames: loadedPlugins, @@ -4102,6 +4104,43 @@ export function describeRegisteredDriver(kernel: any): { label: string; url: str return null; } +/** + * Decide what the ready banner's `Config:`/`Artifact:` row should say + * (#8978). + * + * `relativeConfig` is derived from `args.config` at the top of `run()`, + * before the artifact-fallback branch is decided, and was being handed to + * {@link printServerReady} unconditionally. On an `OS_ARTIFACT_URL` boot + * the `objectstack.config.ts` in cwd is deliberately never executed — the + * boot diagnostics say so plainly a few lines above the banner — yet the + * banner still named it. On the plain artifact-fallback path (no config + * authored, booting from `OS_ARTIFACT_PATH` or the `/dist/objectstack.json` + * convention) the row named a config file that does not exist on disk at + * all. Both are the same defect: the row is the surface an operator reads + * to answer "what is this container actually running", and naming what + * did NOT boot points them at the wrong app (cloud#1292). + * + * - `pinnedArtifact` set (OS_ARTIFACT_URL, #8368) → report it. `.display` + * is already resolved and already redacted for a pre-signed URL by the + * resolver, so it is safe to print as-is. + * - `useArtifactFallback` set with no `pinnedArtifact` (OS_ARTIFACT_PATH, + * the `dist/objectstack.json` convention, or an empty/quick-start boot) + * → 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. + */ +export function resolveBannerConfigRow(opts: { + relativeConfig: string; + useArtifactFallback: boolean; + pinnedArtifact?: { display: string }; +}): { configFile?: string; artifactSource?: string } { + if (opts.pinnedArtifact) return { artifactSource: opts.pinnedArtifact.display }; + if (opts.useArtifactFallback) return {}; + return { configFile: opts.relativeConfig }; +} + /** * Collect the automation wiring facts for the startup banner (2026-07-17 * third-party eval: a flow that failed to arm emits no log line to find, so the diff --git a/packages/cli/src/utils/format.config-artifact-row.test.ts b/packages/cli/src/utils/format.config-artifact-row.test.ts new file mode 100644 index 0000000000..0564ddc45a --- /dev/null +++ b/packages/cli/src/utils/format.config-artifact-row.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// framework#8978 — the ready banner's `Config:`/`Artifact:` row must name +// what actually booted. `printServerReady` used to print `opts.configFile` +// unconditionally; the row is now printed only when the caller actually has +// something safe to say (see `resolveBannerConfigRow` in serve.ts for the +// decision, and its own pin test for that half). This file pins the OTHER +// half — printServerReady's own rendering — so a future edit to either +// function cannot silently reintroduce the always-print-configFile bug. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { printServerReady, type ServerReadyOptions } from './format.js'; + +// Built from a char code, never a literal escape spelling, so this source +// file never carries a raw ESC control byte (repo control-byte discipline — +// AGENTS.md — a raw ESC embedded via a regex literal has bitten this exact +// file shape before). +const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +describe('printServerReady Config:/Artifact: row (#8978)', () => { + const base: Omit = { + port: 3000, + isDev: true, + pluginCount: 1, + }; + + let lines: string[]; + let spy: ReturnType; + + beforeEach(() => { + lines = []; + // stderr, not stdout (#7915) — same capture pattern as the #4801 Tenancy + // row test. + spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + lines.push(args.join(' ').replace(ANSI_SGR, '')); + }); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + const configLine = () => lines.find((l) => l.includes('Config:'))?.trim(); + const artifactLine = () => lines.find((l) => l.includes('Artifact:'))?.trim(); + + it('prints the Config: row on the ordinary config-boot path', () => { + printServerReady({ ...base, configFile: 'objectstack.config.ts' }); + expect(configLine()).toBe('Config: objectstack.config.ts'); + expect(artifactLine()).toBeUndefined(); + }); + + it('prints an Artifact: row instead of Config: when artifactSource is set (OS_ARTIFACT_URL, #8368)', () => { + // The #8978 repro, at the rendering layer: even if a caller mistakenly + // passed both, the resolved artifact must win and the config must never + // appear — it was not read. + printServerReady({ + ...base, + configFile: 'objectstack.config.ts', + artifactSource: 'http://127.0.0.1:41541/hotcrm-2.2.2.json', + }); + expect(artifactLine()).toBe('Artifact: http://127.0.0.1:41541/hotcrm-2.2.2.json (OS_ARTIFACT_URL)'); + 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 + // or nonexistent-file claim. + printServerReady({ ...base }); + expect(configLine()).toBeUndefined(); + expect(artifactLine()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 99db27629f..f2eae30076 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -398,7 +398,26 @@ export function collectMetadataStats(config: any): MetadataStats { export interface ServerReadyOptions { port: number; - configFile: string; + /** + * The authored config file, relative to cwd — printed in the `Config:` + * row. Omit it when the boot did not actually read a config (#8978): on + * every artifact-fallback boot (`OS_ARTIFACT_URL`, `OS_ARTIFACT_PATH`, or + * the plain `/dist/objectstack.json` convention) the caller derives + * this path before deciding which source to boot from, so passing it + * unconditionally named a file that was either never read or does not + * exist on disk — on the SAME screen that just said so. See + * {@link artifactSource} for the OS_ARTIFACT_URL row this slot takes + * instead; the other artifact-fallback paths have no safely-redacted + * display to show here, so the row is omitted rather than fabricated. + */ + configFile?: string; + /** + * Set on an `OS_ARTIFACT_URL` boot (#8368) — the resolver's already + * redacted `display` string (pre-signed URL query strings stripped), + * printed in the `Config:` row's place. When present it takes priority + * over {@link configFile}: this is what actually booted (#8978). + */ + artifactSource?: string; isDev: boolean; pluginCount: number; pluginNames?: string[]; @@ -572,7 +591,15 @@ export function printServerReady(opts: ServerReadyOptions) { console.error(chalk.dim(' seeded on empty DB · dev only — do not use in production')); } console.error(''); - console.error(chalk.dim(` Config: ${opts.configFile}`)); + // #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. + if (opts.artifactSource) { + console.error(chalk.dim(` Artifact: ${opts.artifactSource} (OS_ARTIFACT_URL)`)); + } else if (opts.configFile) { + console.error(chalk.dim(` Config: ${opts.configFile}`)); + } console.error(chalk.dim(` Mode: ${opts.isDev ? 'development' : 'production'}`)); if (opts.driverLabel) { const dbInfo = opts.databaseUrl ? `${opts.driverLabel} ${chalk.dim('→')} ${opts.databaseUrl}` : opts.driverLabel; From 31f656b87bef93b51fdb4f9e3ac9e532d959ddfa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 14:39:25 +0000 Subject: [PATCH 2/2] chore: add changeset for #8978 serve banner fix Co-Authored-By: Claude --- .changeset/serve-banner-artifact-row.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/serve-banner-artifact-row.md diff --git a/.changeset/serve-banner-artifact-row.md b/.changeset/serve-banner-artifact-row.md new file mode 100644 index 0000000000..5b1ee463e1 --- /dev/null +++ b/.changeset/serve-banner-artifact-row.md @@ -0,0 +1,23 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os serve`'s ready banner no longer names a config file that was not read (#8978) + +On an `OS_ARTIFACT_URL` boot (#8368) the `objectstack.config.ts` in cwd is +deliberately never executed — the boot diagnostics say so — but the ready +banner's `Config:` row still printed it, because `relativeConfig` was derived +from `args.config` before the artifact-fallback branch was decided and handed +to `printServerReady` unconditionally. The plain artifact-fallback path (no +config authored, booting from the `/dist/objectstack.json` convention or +`OS_ARTIFACT_PATH`) had the same defect one level worse: the row named a +config file that does not exist on disk at all. + +The banner is the surface an operator reads to answer "what is this container +actually running" — naming what did NOT boot points them at the wrong app. + +`serve` now reports the resolved artifact's already-redacted `display` string +in an `Artifact: … (OS_ARTIFACT_URL)` row when `OS_ARTIFACT_URL` pinned one, +omits the row on the other artifact-fallback paths (no safely-redacted value +is in hand there), and reports the authored config exactly as before on the +ordinary config-boot path.