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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/serve-banner-artifact-row.md
Original file line numberDiff line numberDiff line change
@@ -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 `<cwd>/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.
68 changes: 68 additions & 0 deletions packages/cli/src/commands/serve-banner-config-row.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 `<cwd>/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({});
});
});
41 changes: 40 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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 `<cwd>/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
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/utils/format.config-artifact-row.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<ServerReadyOptions, 'configFile' | 'artifactSource'> = {
port: 3000,
isDev: true,
pluginCount: 1,
};

let lines: string[];
let spy: ReturnType<typeof vi.spyOn>;

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();
});
});
31 changes: 29 additions & 2 deletions packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<cwd>/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[];
Expand DownExpand Up@@ -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;
Expand Down
Loading