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
14 changes: 14 additions & 0 deletions .changeset/22155-console-dist-remedy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/cli': patch
---

fix(cli): the "Console dist not found" boot warning names the remedy that works where the server runs (#22155)

When the Console package the server resolves has no built `dist/`, the boot warning used to say `install @object-ui/console (already built) or run pnpm --filter @object-ui/console build in the objectui workspace`, wherever it ran. The first remedy works nowhere, because the resolver never looks for an installed `@object-ui/console`. The second needs an objectui checkout, which a fresh clone of the framework repository does not have; there the Console is built by a root script.

The warning now names one remedy. It picks it by asking whether the nearest `package.json` above the resolved console package declares an `objectui:build` script:

- **In the framework repository** (the root manifest declares it), the warning gives the dist path, says that `pnpm build` does not produce it, and names `pnpm objectui:build` with the directory to run it in.
- **Anywhere else**, such as a project that installed `@objectstack/cli`, the warning gives the dist path and says the Console ships prebuilt in `@objectstack/console`, a dependency of `@objectstack/cli`. The remedy is to reinstall that package at the CLI's version.

Only the text changes. The Console mounts, or is skipped, exactly as before.
36 changes: 32 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,12 +222,40 @@ to run the whole loop end-to-end.
```bash
git clone https://github.com/objectstack-ai/objectstack.git
cd objectstack
pnpm install # Node 22+, pnpm 10 (corepack enable)
pnpm build # build all packages
pnpm dev # showcase example: REST + Console on :3000
pnpm test # run the test suite
pnpm install # Node 22+, pnpm 10 (corepack enable)
pnpm build # build all packages
pnpm objectui:build # build the Console SPA (not part of pnpm build)
pnpm dev # showcase example: REST + Console on :3000
```

`pnpm objectui:build` builds [objectui](https://github.com/objectstack-ai/objectui)
at the commit pinned in `.objectui-sha` into `packages/console/dist`
(gitignored). It builds from a `../objectui` checkout if one sits next to this
repo, otherwise from a shallow clone of objectui into `.cache/`; either way it
installs objectui's dependencies, so it needs network. Skip it and `pnpm dev`
still serves the REST API, without the Console. Rerun it when `.objectui-sha`
moves.

A first run is slow, not stuck. Measured once on a fresh clone of `main`
(4-vCPU container, Node 22.22, pnpm 10.31):

| Command | Time | |
|:---|---:|:---|
| `pnpm install` | 16 s | |
| `pnpm build` | 6 m 54 s | 72 turbo tasks |
| `pnpm objectui:build` | 10 m 36 s | clones objectui and builds the Console |
| `pnpm test` | 54 m 03 s | the full suite: 135 turbo tasks |

For your own change, test what it reaches instead of the full suite:

```bash
pnpm turbo run test --affected # packages your branch changes, and their dependents
pnpm --filter @objectstack/<pkg> test # one package
```

`--affected` compares your branch with your local `main`, so a stale `main`
widens the set; keep it current, or set `TURBO_SCM_BASE=origin/main`.

Other examples: `pnpm dev:crm`, `pnpm dev:todo`. Docs site: `pnpm docs:dev`.
[AGENTS.md](./AGENTS.md) is the working rulebook for both humans and agents;
[CONTRIBUTING.md](./CONTRIBUTING.md) covers the workflow.
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ import {
decideConsoleMount,
formatConsoleShaDriftWarning,
formatConsoleShaDriftRefusal,
formatConsoleDistMissingWarning,
createConsoleStaticPlugin,
createRuntimeAssetsPlugin,
type ConsoleShaDrift,
Expand Down Expand Up @@ -5066,7 +5067,7 @@ export default class Serve extends Command {
} else if (refusedForDrift && consoleDrift) {
console.error(chalk.red(formatConsoleShaDriftRefusal(consoleDrift)));
} else {
console.warn(chalk.yellow(` ⚠ Console dist not found — install \`@object-ui/console\` (already built) or run \`pnpm --filter @object-ui/console build\` in the objectui workspace`));
console.warn(chalk.yellow(formatConsoleDistMissingWarning(consolePath)));
}
}
}
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/src/utils/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,75 @@ export function hasConsoleDist(consolePath: string): boolean {
return fs.existsSync(path.join(consolePath, 'dist', 'index.html'));
}

// ─── "No dist" remedy — named for where the server runs ─────────────

/** The framework repo's root script that builds `packages/console/dist`. */
const CONSOLE_BUILD_SCRIPT = 'objectui:build';

/**
* The directory whose `package.json` declares `objectui:build` for this
* console package, or `null`.
*
* Only the NEAREST `package.json` above `consolePath` is asked. In the
* framework repo that is the root manifest (`packages/` carries none), which
* declares the script. An installed `@objectstack/console` stops at the
* consuming project's own manifest — including the pnpm store layout, whose
* intermediate directories carry none — so a project that merely sits inside
* a framework checkout is never told to run a script that does not build the
* console it actually resolved. The signal is the script itself rather than
* the repo's layout or its `.objectui-sha` pin, so the remedy is named only
* where it exists.
*/
function findConsoleBuildRoot(consolePath: string): string | null {
let dir = path.dirname(consolePath);
for (let depth = 0; depth < 8; depth++) {
const pkgPath = path.join(dir, 'package.json');
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
return typeof pkg?.scripts?.[CONSOLE_BUILD_SCRIPT] === 'string' ? dir : null;
} catch {
return null; // unreadable manifest — name the install remedy instead
}
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}

/**
* The boot warning for a resolved console package with no built `dist/`.
*
* Two places reach it, and each gets the remedy that works there:
*
* - the framework repo, where `packages/console/dist` is a gitignored build
* that `pnpm build` never produces — `pnpm objectui:build`, run at the
* root it was found under (it is a root script, so it does not resolve
* from the example directory `pnpm dev` runs in);
* - anywhere else, where the Console arrives prebuilt as
* `@objectstack/console`, a dependency of `@objectstack/cli` — so the
* remedy is that package install. (An installed `@object-ui/console`,
* which this text used to name, is never consulted: that name is matched
* only as the workspace package of a sibling `../objectui` checkout.)
*/
export function formatConsoleDistMissingWarning(consolePath: string): string {
const dist = path.join(consolePath, 'dist');
const buildRoot = findConsoleBuildRoot(consolePath);
if (buildRoot) {
return (
` ⚠ Console dist not found at ${path.relative(buildRoot, dist)} — \`pnpm build\` does not produce it. ` +
`Build it with \`pnpm ${CONSOLE_BUILD_SCRIPT}\` in ${buildRoot} ` +
`(uses a ../objectui checkout if present, else clones objectui at the pinned commit — needs network).`
);
}
return (
` ⚠ Console dist not found at ${dist} — the Console ships prebuilt in \`${CONSOLE_PACKAGE}\`, ` +
`a dependency of \`@objectstack/cli\`; reinstall it at the same version as the CLI.`
);
}

// ─── objectui-SHA Drift Guard (dev monorepo only) ───────────────────

/**
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/test/console-resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
isConsoleVersionCompatible,
detectConsoleShaDrift,
formatConsoleShaDriftWarning,
formatConsoleDistMissingWarning,
} from '../src/utils/console.js';

// resolveConsolePath() also discovers the real, version-locked workspace
Expand Down Expand Up @@ -223,3 +224,108 @@ describe('detectConsoleShaDrift', () => {
expect(warnings).toEqual([]);
});
});

describe('formatConsoleDistMissingWarning — the remedy named for where the server runs', () => {
/** The framework repo's remedy, and the one the warning used to name everywhere. */
const BUILD_REMEDY = 'pnpm objectui:build';
const RETIRED_REMEDY = '@object-ui/console';

function writeJson(file: string, value: unknown): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(value));
}

/** A console package with a manifest and no `dist/` — what the warning is printed for. */
function writeUnbuiltConsole(dir: string): string {
writeJson(path.join(dir, 'package.json'), { name: '@objectstack/console', version: '1.0.0' });
return dir;
}

function tmpRoot(tag: string): string {
return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `os-console-remedy-${tag}-`)));
}

/**
* The framework repo's shape: the root manifest declares the build script
* (or, for the control, does not), and `packages/console` is the workspace
* package `@objectstack/cli` resolves.
*/
function makeFrameworkTree(withScript: boolean): { root: string; consoleDir: string } {
const root = tmpRoot('repo');
writeJson(path.join(root, 'package.json'), {
name: 'framework-root',
private: true,
scripts: withScript
? { build: 'turbo run build', 'objectui:build': 'bash scripts/build-console.sh' }
: { build: 'turbo run build' },
});
const consoleDir = writeUnbuiltConsole(path.join(root, 'packages', 'console'));
return { root, consoleDir };
}

it('names `pnpm objectui:build` and the root to run it in, inside the framework repo', () => {
const { root, consoleDir } = makeFrameworkTree(true);

const message = formatConsoleDistMissingWarning(consoleDir);
expect(message).toContain(BUILD_REMEDY);
expect(message).toContain(` in ${root} `);
expect(message).toContain(path.join('packages', 'console', 'dist'));
expect(message).not.toContain('@objectstack/console');
expect(message).not.toContain(RETIRED_REMEDY);
});

it('control: the same layout without the script gets the install remedy — the script is the signal, not the layout', () => {
const { consoleDir } = makeFrameworkTree(false);

const message = formatConsoleDistMissingWarning(consoleDir);
expect(message).not.toContain(BUILD_REMEDY);
expect(message).toContain('@objectstack/console');
});

it('names the `@objectstack/console` install in a project that installed the CLI (hoisted and pnpm-store layouts)', () => {
const project = tmpRoot('app');
writeJson(path.join(project, 'package.json'), {
name: 'consumer-app',
scripts: { dev: 'objectstack dev' },
});
for (const consoleDir of [
writeUnbuiltConsole(path.join(project, 'node_modules', '@objectstack', 'console')),
writeUnbuiltConsole(
path.join(
project, 'node_modules', '.pnpm', '@objectstack+console@1.0.0',
'node_modules', '@objectstack', 'console',
),
),
]) {
const message = formatConsoleDistMissingWarning(consoleDir);
expect(message).toContain('@objectstack/console');
expect(message).toContain(path.join(consoleDir, 'dist'));
expect(message).not.toContain(BUILD_REMEDY);
expect(message).not.toContain(RETIRED_REMEDY);
}
});

it('stops at the consuming project\'s own manifest, even inside a framework checkout', () => {
// A project created inside a directory whose manifest declares the script:
// the console it resolved is ITS install, which `objectui:build` does not
// build, so the install remedy is the true one.
const outer = tmpRoot('outer');
writeJson(path.join(outer, 'package.json'), {
name: 'framework-root',
scripts: { 'objectui:build': 'bash scripts/build-console.sh' },
});
const project = path.join(outer, 'scratch', 'my-app');
const consoleDir = writeUnbuiltConsole(
path.join(project, 'node_modules', '@objectstack', 'console'),
);
writeJson(path.join(project, 'package.json'), { name: 'my-app' });
expect(formatConsoleDistMissingWarning(consoleDir)).not.toContain(BUILD_REMEDY);

// Control: without the project's own manifest the walk reaches the outer
// one — so the stop above is what kept the answer right.
fs.rmSync(path.join(project, 'package.json'));
const climbed = formatConsoleDistMissingWarning(consoleDir);
expect(climbed).toContain(BUILD_REMEDY);
expect(climbed).toContain(` in ${outer} `);
});
});
19 changes: 15 additions & 4 deletions packages/cli/test/published-subpath-console.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,15 @@ const RETIRED_RUNTIME_NAMES = [
'isConsoleVersionCompatible',
];

/**
* Exports `utils/console.ts` gained after the retirement above, internal from
* the day they landed. Listed so the partition below still accounts for every
* export of that module; the two "exactly three names" checks are what keep
* them off the published subpath. Publishing one is the same deliberate act as
* re-admitting a retired name.
*/
const INTERNAL_SINCE_RETIREMENT = ['formatConsoleDistMissingWarning'];

/**
* Every subpath the published package resolves, in full. A subpath removed here
* is a consumer broken in the exact shape of #15325 and #13662; a subpath added
Expand Down Expand Up @@ -682,16 +691,18 @@ describe('the public surface is exactly three names', () => {

it('retires exactly the names that are NOT public — the two lists partition the module, with nothing dropped', () => {
// The census is taken from the packed INTERNAL module rather than from a
// constant here, so a 14th export added to `utils/console.ts` lands in
// neither list and fails this — instead of silently being neither published
// nor recorded as retired.
// constant here, so a new export added to `utils/console.ts` lands in no
// list and fails this — instead of silently being neither published nor
// recorded as internal.
const internal = declaredExports(join(installedRoot, 'dist', 'utils', 'console.d.ts'));
// ⛔ First: the census is only a partition if every export was NAMEABLE. An
// export form this walk cannot name is missing from both lists, and the
// equality below would still hold — green, over a surface read short.
expectEveryExportNamed('the packed internal module `.d.ts`', internal);
expect(internal.starReExports).toBe(0);
expect(internal.names).toEqual([...PUBLIC_SURFACE, ...RETIRED_FROM_THIS_SUBPATH].sort());
expect(internal.names).toEqual(
[...PUBLIC_SURFACE, ...RETIRED_FROM_THIS_SUBPATH, ...INTERNAL_SINCE_RETIREMENT].sort(),
);
});
});

Expand Down
Loading