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
61 changes: 61 additions & 0 deletions .changeset/cli-protocol-version-gap-advisory.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/cli": minor
---

fix(cli): the upgrade advisory reads `manifest.engines.protocol`, the axis that is actually declared (#13860)

`os validate`, `os doctor` and `os compile` all print a non-blocking advisory pointing
at the per-major migration guide when the installed platform has moved ahead of the app.
All three read it off `manifest.specVersion` — a key `ManifestSchema` does not declare.
`ManifestSchema` is not `.strict()`, so an author who wrote `specVersion` had it accepted
and dropped with nothing said, and the advisory could therefore only ever fire for a
manifest carrying a key the schema does not offer. It was dead for stack configs for its
whole life: the "breaking-change guidance the author should read before proceeding" that
its own header describes was never once delivered.

The advisory now reads `manifest.engines.protocol` — declared by `PluginEnginesSchema`,
stamped by every scaffold and example (`engines: { protocol: '^17' }`), and already
enforced at boot by the ADR-0087 handshake. One declared version axis instead of one
declared axis and one phantom.

`specVersion` is retired from the stack config's CLI vocabulary. It keeps its meaning on
the marketplace **template** manifest (`objectstack.manifest.json`, `cloud/TemplateManifest`),
which is a different surface and is untouched — the name means one thing in one place and
nothing in the other, which is the status quo stated honestly rather than a new debt.

## The verdict comes from the platform's own handshake

The range is judged by `checkProtocolCompat` from `@objectstack/metadata-core` rather
than by a leading-integer parse of the CLI's own. That module is the single reader of
this axis — it owns the source priority (`engines.protocol` → `engines.platform` →
legacy `engine.objectstack`) and the range grammar — and its header already records why:
two readers with two priority orders would be the "two opinions" defect. A private parse
would have been the third, and it would disagree exactly where it matters: `>=15 <18`
targets 15 but *admits* 17, so a naive reading advises an upgrade against a range that
already covers the installed platform. Delegating means the advisory fires precisely when
boot would refuse the app, which is what makes it guidance rather than noise. The
advisory names the key it actually read, so an author is never told to bump a key they
did not write.

Comparing a protocol range against the `@objectstack/spec` resolved from the app's
`node_modules` is sound because `PROTOCOL_VERSION` is held in lockstep with that
package's major (`protocol-version.test.ts` fails on drift), which is also what keeps
the `docs/releases/v<major>` link correct.

## What changes for you

Nothing is removed or renamed on a published surface, and no command's accept set or exit
status moves: the advisory is print-only, `os validate` keeps it outside `--strict` on
both faces, and `os doctor` never exited on warnings. The `--json` payload key stays
`specVersionGap` with its value shape unchanged.

`os doctor`'s row is renamed with the axis, from `Platform spec` to `Platform protocol`, and the
sentence it prints when a config cannot be loaded — the one enumerating which config-aware checks
were skipped — names the row by its new name too, so an operator reading either is pointed at a
row that exists.

What does change is that the advisory now **fires**. An app whose `engines.protocol` is
behind the installed platform will start seeing the migration-guide pointer from all three
commands, and `os doctor` will summarise that run as "functional but has some warnings"
rather than "healthy". That is the check finally doing its job; if it speaks up, the drift
it names was already there.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,7 @@
"@objectstack/lint": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:^",
"@objectstack/observability": "workspace:^",
Expand Down
18 changes: 10 additions & 8 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Compile extends Command {
static override description = 'Compile ObjectStack configuration to JSON artifact';
Expand DownExpand Up@@ -599,9 +599,9 @@ export default class Compile extends Command {
const sizeKB = (jsonContent.length / 1024).toFixed(1);
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): installed platform newer
// than the app declares → point at the migration guide.
const specGap = checkSpecVersionGap((config as { manifest?: { specVersion?: unknown } }).manifest);
// Protocol drift advisory (non-blocking): installed platform outside the
// app's declared `engines.protocol` range → point at the migration guide.
const protocolGap = checkProtocolVersionGap((config as { manifest?: unknown }).manifest);

if (flags.json) {
await emitJson({
Expand DownExpand Up@@ -680,7 +680,9 @@ export default class Compile extends Command {
// Same key `os validate --json` uses, so a CI consumer reads one shape
// from either command rather than learning two.
conversions: conversionNotices,
specVersionGap: specGap,
// Published key name kept; the axis behind it moved to
// `manifest.engines.protocol` (#13860). See validate.ts.
specVersionGap: protocolGap,
stats,
duration: timer.elapsed(),
}, 0, { compact: true });
Expand DownExpand Up@@ -711,10 +713,10 @@ export default class Compile extends Command {
`${path.join(path.dirname(output), runtimeBundle.outputFileName)} ${chalk.dim(`(${runtimeKB} KB, ${lowering.count} handler${lowering.count === 1 ? '' : 's'})`)}`,
);
}
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}
console.log('');

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -289,7 +289,14 @@ describe('os doctor, end to end, against a config that reads .env at top level',
// `.env` before bundling, boots this exact directory.
expect(run.out).not.toContain('Could not load config for analysis');
// The config checks did not merely stop failing — they RAN.
expect(run.out).toContain('Platform spec');
// Both strings below are ROW LABELS used as liveness evidence, not verdicts:
// the row is printed on either branch, so its presence proves the check
// executed whatever it concluded. That is why they must track `doctor.ts`'s
// labels through a rename rather than be relaxed — `Platform spec` became
// `Platform protocol` when the advisory moved onto `engines.protocol`
// (#13860), and this assertion is the one consumer that lived outside the
// suites that rename touched.
expect(run.out).toContain('Platform protocol');
expect(run.out).toContain('No circular references detected');
expect(run.exitCode).toBeUndefined();

Expand Down
22 changes: 11 additions & 11 deletions packages/cli/src/commands/doctor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@ import path from 'path';
import { normalizeStackInput } from '@objectstack/spec';
import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js';
import { loadConfig, configExists } from '../utils/config.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// #5644 — "the optional package is not installed" and "it is installed and
// will not load" are two facts, and one `catch` around `import()` cannot tell
// them apart. That classification lives in one place, with the measurements
Expand DownExpand Up@@ -1434,8 +1434,8 @@ export function configLoadFailureCheck(err: unknown): HealthCheckResult {
'`os serve` loads this same file the same way — bundle-require, under the `.env*`\n'
+ ' cascade named above (#5397) — and prints this error in full, so a config that\n'
+ ' lands here is one the server cannot boot either.\n'
+ ' The config-aware checks were SKIPPED, not passed: spec version, circular\n'
+ ' dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ' The config-aware checks were SKIPPED, not passed: platform protocol,\n'
+ ' circular dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ` cause: ${indentUnderGutter(cause)}`,
};
}
Expand DownExpand Up@@ -1927,7 +1927,7 @@ export default class Doctor extends Command {
// Here the honest report is no row at all. An application consumes
// `@objectstack/spec` from `node_modules`, where "built" is not a state it
// can be in — that dependency is covered by the `Dependencies` row above
// and by `checkSpecVersionGap()`. Inside the monorepo nothing changes: the
// and by `checkProtocolVersionGap()`. Inside the monorepo nothing changes: the
// workspace is present, and an unbuilt `dist/` is still the real warning
// it always was.
const specWorkspaceDir = path.join(cwd, 'packages/spec');
Expand DownExpand Up@@ -2097,15 +2097,15 @@ export default class Doctor extends Command {
const { config: rawConfig } = await withDotenvOverlayAsync(dotenvReading, () => loadConfig());
const config: any = normalizeStackInput(rawConfig as Record<string, unknown>);

// Spec-version drift: installed platform newer than the app declares.
printStep('Checking platform spec version...');
const specGap = checkSpecVersionGap(config.manifest);
if (specGap) {
// Protocol drift: installed platform outside the range the app declares.
printStep('Checking platform protocol version...');
const protocolGap = checkProtocolVersionGap(config.manifest);
if (protocolGap) {
hasWarnings = true;
printWarning(`Platform spec ${specGap.message}`);
console.log(chalk.dim(` → ${specGap.hint}`));
printWarning(`Platform protocol${protocolGap.message}`);
console.log(chalk.dim(` → ${protocolGap.hint}`));
} else {
printSuccess('Platform specDeclared specVersion is current with the installed platform');
printSuccess('Platform protocol Declared engines.protocol covers the installed platform');
}

// Circular dependency detection
Expand Down
22 changes: 14 additions & 8 deletions packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Validate extends Command {
static override description =
Expand DownExpand Up@@ -344,9 +344,10 @@ export default class Validate extends Command {
// 4. Collect and display stats
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): if the installed platform
// is a newer major than the app declares, point at the migration guide.
const specGap = checkSpecVersionGap(config.manifest);
// Protocol drift advisory (non-blocking): if the installed platform is a
// newer major than the app's declared `engines.protocol` range admits,
// point at the migration guide.
const protocolGap = checkProtocolVersionGap(config.manifest);

// 4b. Structural advisories (non-blocking) — computed HERE, above the
// `if (flags.json)` branch, for exactly the reason `unknownKeyWarnings`
Expand DownExpand Up@@ -443,7 +444,12 @@ export default class Validate extends Command {
// this one.
warnings: warningsSoFar(),
conversions: conversionNotices,
specVersionGap: specGap,
// The payload key keeps its published name. The AXIS it reports
// moved from the undeclared `manifest.specVersion` to
// `manifest.engines.protocol` (#13860), but this is a machine face
// with pinned consumers, and renaming it is a break nobody asked
// for. Its value shape is unchanged.
specVersionGap: protocolGap,
duration: timer.elapsed(),
},
// `--strict` means one thing — "treat warnings as errors" — and it now
Expand DownExpand Up@@ -507,10 +513,10 @@ export default class Validate extends Command {
}

// Non-blocking upgrade advisory — never gated by --strict.
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}

console.log('');
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
61 changes: 61 additions & 0 deletions .changeset/cli-protocol-version-gap-advisory.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/cli": minor
---

fix(cli): the upgrade advisory reads `manifest.engines.protocol`, the axis that is actually declared (#13860)

`os validate`, `os doctor` and `os compile` all print a non-blocking advisory pointing
at the per-major migration guide when the installed platform has moved ahead of the app.
All three read it off `manifest.specVersion` — a key `ManifestSchema` does not declare.
`ManifestSchema` is not `.strict()`, so an author who wrote `specVersion` had it accepted
and dropped with nothing said, and the advisory could therefore only ever fire for a
manifest carrying a key the schema does not offer. It was dead for stack configs for its
whole life: the "breaking-change guidance the author should read before proceeding" that
its own header describes was never once delivered.

The advisory now reads `manifest.engines.protocol` — declared by `PluginEnginesSchema`,
stamped by every scaffold and example (`engines: { protocol: '^17' }`), and already
enforced at boot by the ADR-0087 handshake. One declared version axis instead of one
declared axis and one phantom.

`specVersion` is retired from the stack config's CLI vocabulary. It keeps its meaning on
the marketplace **template** manifest (`objectstack.manifest.json`, `cloud/TemplateManifest`),
which is a different surface and is untouched — the name means one thing in one place and
nothing in the other, which is the status quo stated honestly rather than a new debt.

## The verdict comes from the platform's own handshake

The range is judged by `checkProtocolCompat` from `@objectstack/metadata-core` rather
than by a leading-integer parse of the CLI's own. That module is the single reader of
this axis — it owns the source priority (`engines.protocol` → `engines.platform` →
legacy `engine.objectstack`) and the range grammar — and its header already records why:
two readers with two priority orders would be the "two opinions" defect. A private parse
would have been the third, and it would disagree exactly where it matters: `>=15 <18`
targets 15 but *admits* 17, so a naive reading advises an upgrade against a range that
already covers the installed platform. Delegating means the advisory fires precisely when
boot would refuse the app, which is what makes it guidance rather than noise. The
advisory names the key it actually read, so an author is never told to bump a key they
did not write.

Comparing a protocol range against the `@objectstack/spec` resolved from the app's
`node_modules` is sound because `PROTOCOL_VERSION` is held in lockstep with that
package's major (`protocol-version.test.ts` fails on drift), which is also what keeps
the `docs/releases/v<major>` link correct.

## What changes for you

Nothing is removed or renamed on a published surface, and no command's accept set or exit
status moves: the advisory is print-only, `os validate` keeps it outside `--strict` on
both faces, and `os doctor` never exited on warnings. The `--json` payload key stays
`specVersionGap` with its value shape unchanged.

`os doctor`'s row is renamed with the axis, from `Platform spec` to `Platform protocol`, and the
sentence it prints when a config cannot be loaded — the one enumerating which config-aware checks
were skipped — names the row by its new name too, so an operator reading either is pointed at a
row that exists.

What does change is that the advisory now **fires**. An app whose `engines.protocol` is
behind the installed platform will start seeing the migration-guide pointer from all three
commands, and `os doctor` will summarise that run as "functional but has some warnings"
rather than "healthy". That is the check finally doing its job; if it speaks up, the drift
it names was already there.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,7 @@
"@objectstack/lint": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:^",
"@objectstack/observability": "workspace:^",
Expand Down
18 changes: 10 additions & 8 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Compile extends Command {
static override description = 'Compile ObjectStack configuration to JSON artifact';
Expand DownExpand Up@@ -599,9 +599,9 @@ export default class Compile extends Command {
const sizeKB = (jsonContent.length / 1024).toFixed(1);
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): installed platform newer
// than the app declares → point at the migration guide.
const specGap = checkSpecVersionGap((config as { manifest?: { specVersion?: unknown } }).manifest);
// Protocol drift advisory (non-blocking): installed platform outside the
// app's declared `engines.protocol` range → point at the migration guide.
const protocolGap = checkProtocolVersionGap((config as { manifest?: unknown }).manifest);

if (flags.json) {
await emitJson({
Expand DownExpand Up@@ -680,7 +680,9 @@ export default class Compile extends Command {
// Same key `os validate --json` uses, so a CI consumer reads one shape
// from either command rather than learning two.
conversions: conversionNotices,
specVersionGap: specGap,
// Published key name kept; the axis behind it moved to
// `manifest.engines.protocol` (#13860). See validate.ts.
specVersionGap: protocolGap,
stats,
duration: timer.elapsed(),
}, 0, { compact: true });
Expand DownExpand Up@@ -711,10 +713,10 @@ export default class Compile extends Command {
`${path.join(path.dirname(output), runtimeBundle.outputFileName)} ${chalk.dim(`(${runtimeKB} KB, ${lowering.count} handler${lowering.count === 1 ? '' : 's'})`)}`,
);
}
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}
console.log('');

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -289,7 +289,14 @@ describe('os doctor, end to end, against a config that reads .env at top level',
// `.env` before bundling, boots this exact directory.
expect(run.out).not.toContain('Could not load config for analysis');
// The config checks did not merely stop failing — they RAN.
expect(run.out).toContain('Platform spec');
// Both strings below are ROW LABELS used as liveness evidence, not verdicts:
// the row is printed on either branch, so its presence proves the check
// executed whatever it concluded. That is why they must track `doctor.ts`'s
// labels through a rename rather than be relaxed — `Platform spec` became
// `Platform protocol` when the advisory moved onto `engines.protocol`
// (#13860), and this assertion is the one consumer that lived outside the
// suites that rename touched.
expect(run.out).toContain('Platform protocol');
expect(run.out).toContain('No circular references detected');
expect(run.exitCode).toBeUndefined();

Expand Down
22 changes: 11 additions & 11 deletions packages/cli/src/commands/doctor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@ import path from 'path';
import { normalizeStackInput } from '@objectstack/spec';
import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js';
import { loadConfig, configExists } from '../utils/config.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// #5644 — "the optional package is not installed" and "it is installed and
// will not load" are two facts, and one `catch` around `import()` cannot tell
// them apart. That classification lives in one place, with the measurements
Expand DownExpand Up@@ -1434,8 +1434,8 @@ export function configLoadFailureCheck(err: unknown): HealthCheckResult {
'`os serve` loads this same file the same way — bundle-require, under the `.env*`\n'
+ ' cascade named above (#5397) — and prints this error in full, so a config that\n'
+ ' lands here is one the server cannot boot either.\n'
+ ' The config-aware checks were SKIPPED, not passed: spec version, circular\n'
+ ' dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ' The config-aware checks were SKIPPED, not passed: platform protocol,\n'
+ ' circular dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ` cause: ${indentUnderGutter(cause)}`,
};
}
Expand DownExpand Up@@ -1927,7 +1927,7 @@ export default class Doctor extends Command {
// Here the honest report is no row at all. An application consumes
// `@objectstack/spec` from `node_modules`, where "built" is not a state it
// can be in — that dependency is covered by the `Dependencies` row above
// and by `checkSpecVersionGap()`. Inside the monorepo nothing changes: the
// and by `checkProtocolVersionGap()`. Inside the monorepo nothing changes: the
// workspace is present, and an unbuilt `dist/` is still the real warning
// it always was.
const specWorkspaceDir = path.join(cwd, 'packages/spec');
Expand DownExpand Up@@ -2097,15 +2097,15 @@ export default class Doctor extends Command {
const { config: rawConfig } = await withDotenvOverlayAsync(dotenvReading, () => loadConfig());
const config: any = normalizeStackInput(rawConfig as Record<string, unknown>);

// Spec-version drift: installed platform newer than the app declares.
printStep('Checking platform spec version...');
const specGap = checkSpecVersionGap(config.manifest);
if (specGap) {
// Protocol drift: installed platform outside the range the app declares.
printStep('Checking platform protocol version...');
const protocolGap = checkProtocolVersionGap(config.manifest);
if (protocolGap) {
hasWarnings = true;
printWarning(`Platform spec ${specGap.message}`);
console.log(chalk.dim(` → ${specGap.hint}`));
printWarning(`Platform protocol${protocolGap.message}`);
console.log(chalk.dim(` → ${protocolGap.hint}`));
} else {
printSuccess('Platform specDeclared specVersion is current with the installed platform');
printSuccess('Platform protocol Declared engines.protocol covers the installed platform');
}

// Circular dependency detection
Expand Down
22 changes: 14 additions & 8 deletions packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Validate extends Command {
static override description =
Expand DownExpand Up@@ -344,9 +344,10 @@ export default class Validate extends Command {
// 4. Collect and display stats
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): if the installed platform
// is a newer major than the app declares, point at the migration guide.
const specGap = checkSpecVersionGap(config.manifest);
// Protocol drift advisory (non-blocking): if the installed platform is a
// newer major than the app's declared `engines.protocol` range admits,
// point at the migration guide.
const protocolGap = checkProtocolVersionGap(config.manifest);

// 4b. Structural advisories (non-blocking) — computed HERE, above the
// `if (flags.json)` branch, for exactly the reason `unknownKeyWarnings`
Expand DownExpand Up@@ -443,7 +444,12 @@ export default class Validate extends Command {
// this one.
warnings: warningsSoFar(),
conversions: conversionNotices,
specVersionGap: specGap,
// The payload key keeps its published name. The AXIS it reports
// moved from the undeclared `manifest.specVersion` to
// `manifest.engines.protocol` (#13860), but this is a machine face
// with pinned consumers, and renaming it is a break nobody asked
// for. Its value shape is unchanged.
specVersionGap: protocolGap,
duration: timer.elapsed(),
},
// `--strict` means one thing — "treat warnings as errors" — and it now
Expand DownExpand Up@@ -507,10 +513,10 @@ export default class Validate extends Command {
}

// Non-blocking upgrade advisory — never gated by --strict.
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}

console.log('');
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
61 changes: 61 additions & 0 deletions .changeset/cli-protocol-version-gap-advisory.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/cli": minor
---

fix(cli): the upgrade advisory reads `manifest.engines.protocol`, the axis that is actually declared (#13860)

`os validate`, `os doctor` and `os compile` all print a non-blocking advisory pointing
at the per-major migration guide when the installed platform has moved ahead of the app.
All three read it off `manifest.specVersion` — a key `ManifestSchema` does not declare.
`ManifestSchema` is not `.strict()`, so an author who wrote `specVersion` had it accepted
and dropped with nothing said, and the advisory could therefore only ever fire for a
manifest carrying a key the schema does not offer. It was dead for stack configs for its
whole life: the "breaking-change guidance the author should read before proceeding" that
its own header describes was never once delivered.

The advisory now reads `manifest.engines.protocol` — declared by `PluginEnginesSchema`,
stamped by every scaffold and example (`engines: { protocol: '^17' }`), and already
enforced at boot by the ADR-0087 handshake. One declared version axis instead of one
declared axis and one phantom.

`specVersion` is retired from the stack config's CLI vocabulary. It keeps its meaning on
the marketplace **template** manifest (`objectstack.manifest.json`, `cloud/TemplateManifest`),
which is a different surface and is untouched — the name means one thing in one place and
nothing in the other, which is the status quo stated honestly rather than a new debt.

## The verdict comes from the platform's own handshake

The range is judged by `checkProtocolCompat` from `@objectstack/metadata-core` rather
than by a leading-integer parse of the CLI's own. That module is the single reader of
this axis — it owns the source priority (`engines.protocol` → `engines.platform` →
legacy `engine.objectstack`) and the range grammar — and its header already records why:
two readers with two priority orders would be the "two opinions" defect. A private parse
would have been the third, and it would disagree exactly where it matters: `>=15 <18`
targets 15 but *admits* 17, so a naive reading advises an upgrade against a range that
already covers the installed platform. Delegating means the advisory fires precisely when
boot would refuse the app, which is what makes it guidance rather than noise. The
advisory names the key it actually read, so an author is never told to bump a key they
did not write.

Comparing a protocol range against the `@objectstack/spec` resolved from the app's
`node_modules` is sound because `PROTOCOL_VERSION` is held in lockstep with that
package's major (`protocol-version.test.ts` fails on drift), which is also what keeps
the `docs/releases/v<major>` link correct.

## What changes for you

Nothing is removed or renamed on a published surface, and no command's accept set or exit
status moves: the advisory is print-only, `os validate` keeps it outside `--strict` on
both faces, and `os doctor` never exited on warnings. The `--json` payload key stays
`specVersionGap` with its value shape unchanged.

`os doctor`'s row is renamed with the axis, from `Platform spec` to `Platform protocol`, and the
sentence it prints when a config cannot be loaded — the one enumerating which config-aware checks
were skipped — names the row by its new name too, so an operator reading either is pointed at a
row that exists.

What does change is that the advisory now **fires**. An app whose `engines.protocol` is
behind the installed platform will start seeing the migration-guide pointer from all three
commands, and `os doctor` will summarise that run as "functional but has some warnings"
rather than "healthy". That is the check finally doing its job; if it speaks up, the drift
it names was already there.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,7 @@
"@objectstack/lint": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:^",
"@objectstack/observability": "workspace:^",
Expand Down
18 changes: 10 additions & 8 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Compile extends Command {
static override description = 'Compile ObjectStack configuration to JSON artifact';
Expand DownExpand Up@@ -599,9 +599,9 @@ export default class Compile extends Command {
const sizeKB = (jsonContent.length / 1024).toFixed(1);
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): installed platform newer
// than the app declares → point at the migration guide.
const specGap = checkSpecVersionGap((config as { manifest?: { specVersion?: unknown } }).manifest);
// Protocol drift advisory (non-blocking): installed platform outside the
// app's declared `engines.protocol` range → point at the migration guide.
const protocolGap = checkProtocolVersionGap((config as { manifest?: unknown }).manifest);

if (flags.json) {
await emitJson({
Expand DownExpand Up@@ -680,7 +680,9 @@ export default class Compile extends Command {
// Same key `os validate --json` uses, so a CI consumer reads one shape
// from either command rather than learning two.
conversions: conversionNotices,
specVersionGap: specGap,
// Published key name kept; the axis behind it moved to
// `manifest.engines.protocol` (#13860). See validate.ts.
specVersionGap: protocolGap,
stats,
duration: timer.elapsed(),
}, 0, { compact: true });
Expand DownExpand Up@@ -711,10 +713,10 @@ export default class Compile extends Command {
`${path.join(path.dirname(output), runtimeBundle.outputFileName)} ${chalk.dim(`(${runtimeKB} KB, ${lowering.count} handler${lowering.count === 1 ? '' : 's'})`)}`,
);
}
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}
console.log('');

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -289,7 +289,14 @@ describe('os doctor, end to end, against a config that reads .env at top level',
// `.env` before bundling, boots this exact directory.
expect(run.out).not.toContain('Could not load config for analysis');
// The config checks did not merely stop failing — they RAN.
expect(run.out).toContain('Platform spec');
// Both strings below are ROW LABELS used as liveness evidence, not verdicts:
// the row is printed on either branch, so its presence proves the check
// executed whatever it concluded. That is why they must track `doctor.ts`'s
// labels through a rename rather than be relaxed — `Platform spec` became
// `Platform protocol` when the advisory moved onto `engines.protocol`
// (#13860), and this assertion is the one consumer that lived outside the
// suites that rename touched.
expect(run.out).toContain('Platform protocol');
expect(run.out).toContain('No circular references detected');
expect(run.exitCode).toBeUndefined();

Expand Down
22 changes: 11 additions & 11 deletions packages/cli/src/commands/doctor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@ import path from 'path';
import { normalizeStackInput } from '@objectstack/spec';
import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js';
import { loadConfig, configExists } from '../utils/config.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// #5644 — "the optional package is not installed" and "it is installed and
// will not load" are two facts, and one `catch` around `import()` cannot tell
// them apart. That classification lives in one place, with the measurements
Expand DownExpand Up@@ -1434,8 +1434,8 @@ export function configLoadFailureCheck(err: unknown): HealthCheckResult {
'`os serve` loads this same file the same way — bundle-require, under the `.env*`\n'
+ ' cascade named above (#5397) — and prints this error in full, so a config that\n'
+ ' lands here is one the server cannot boot either.\n'
+ ' The config-aware checks were SKIPPED, not passed: spec version, circular\n'
+ ' dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ' The config-aware checks were SKIPPED, not passed: platform protocol,\n'
+ ' circular dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ` cause: ${indentUnderGutter(cause)}`,
};
}
Expand DownExpand Up@@ -1927,7 +1927,7 @@ export default class Doctor extends Command {
// Here the honest report is no row at all. An application consumes
// `@objectstack/spec` from `node_modules`, where "built" is not a state it
// can be in — that dependency is covered by the `Dependencies` row above
// and by `checkSpecVersionGap()`. Inside the monorepo nothing changes: the
// and by `checkProtocolVersionGap()`. Inside the monorepo nothing changes: the
// workspace is present, and an unbuilt `dist/` is still the real warning
// it always was.
const specWorkspaceDir = path.join(cwd, 'packages/spec');
Expand DownExpand Up@@ -2097,15 +2097,15 @@ export default class Doctor extends Command {
const { config: rawConfig } = await withDotenvOverlayAsync(dotenvReading, () => loadConfig());
const config: any = normalizeStackInput(rawConfig as Record<string, unknown>);

// Spec-version drift: installed platform newer than the app declares.
printStep('Checking platform spec version...');
const specGap = checkSpecVersionGap(config.manifest);
if (specGap) {
// Protocol drift: installed platform outside the range the app declares.
printStep('Checking platform protocol version...');
const protocolGap = checkProtocolVersionGap(config.manifest);
if (protocolGap) {
hasWarnings = true;
printWarning(`Platform spec ${specGap.message}`);
console.log(chalk.dim(` → ${specGap.hint}`));
printWarning(`Platform protocol${protocolGap.message}`);
console.log(chalk.dim(` → ${protocolGap.hint}`));
} else {
printSuccess('Platform specDeclared specVersion is current with the installed platform');
printSuccess('Platform protocol Declared engines.protocol covers the installed platform');
}

// Circular dependency detection
Expand Down
22 changes: 14 additions & 8 deletions packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Validate extends Command {
static override description =
Expand DownExpand Up@@ -344,9 +344,10 @@ export default class Validate extends Command {
// 4. Collect and display stats
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): if the installed platform
// is a newer major than the app declares, point at the migration guide.
const specGap = checkSpecVersionGap(config.manifest);
// Protocol drift advisory (non-blocking): if the installed platform is a
// newer major than the app's declared `engines.protocol` range admits,
// point at the migration guide.
const protocolGap = checkProtocolVersionGap(config.manifest);

// 4b. Structural advisories (non-blocking) — computed HERE, above the
// `if (flags.json)` branch, for exactly the reason `unknownKeyWarnings`
Expand DownExpand Up@@ -443,7 +444,12 @@ export default class Validate extends Command {
// this one.
warnings: warningsSoFar(),
conversions: conversionNotices,
specVersionGap: specGap,
// The payload key keeps its published name. The AXIS it reports
// moved from the undeclared `manifest.specVersion` to
// `manifest.engines.protocol` (#13860), but this is a machine face
// with pinned consumers, and renaming it is a break nobody asked
// for. Its value shape is unchanged.
specVersionGap: protocolGap,
duration: timer.elapsed(),
},
// `--strict` means one thing — "treat warnings as errors" — and it now
Expand DownExpand Up@@ -507,10 +513,10 @@ export default class Validate extends Command {
}

// Non-blocking upgrade advisory — never gated by --strict.
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}

console.log('');
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
61 changes: 61 additions & 0 deletions .changeset/cli-protocol-version-gap-advisory.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/cli": minor
---

fix(cli): the upgrade advisory reads `manifest.engines.protocol`, the axis that is actually declared (#13860)

`os validate`, `os doctor` and `os compile` all print a non-blocking advisory pointing
at the per-major migration guide when the installed platform has moved ahead of the app.
All three read it off `manifest.specVersion` — a key `ManifestSchema` does not declare.
`ManifestSchema` is not `.strict()`, so an author who wrote `specVersion` had it accepted
and dropped with nothing said, and the advisory could therefore only ever fire for a
manifest carrying a key the schema does not offer. It was dead for stack configs for its
whole life: the "breaking-change guidance the author should read before proceeding" that
its own header describes was never once delivered.

The advisory now reads `manifest.engines.protocol` — declared by `PluginEnginesSchema`,
stamped by every scaffold and example (`engines: { protocol: '^17' }`), and already
enforced at boot by the ADR-0087 handshake. One declared version axis instead of one
declared axis and one phantom.

`specVersion` is retired from the stack config's CLI vocabulary. It keeps its meaning on
the marketplace **template** manifest (`objectstack.manifest.json`, `cloud/TemplateManifest`),
which is a different surface and is untouched — the name means one thing in one place and
nothing in the other, which is the status quo stated honestly rather than a new debt.

## The verdict comes from the platform's own handshake

The range is judged by `checkProtocolCompat` from `@objectstack/metadata-core` rather
than by a leading-integer parse of the CLI's own. That module is the single reader of
this axis — it owns the source priority (`engines.protocol` → `engines.platform` →
legacy `engine.objectstack`) and the range grammar — and its header already records why:
two readers with two priority orders would be the "two opinions" defect. A private parse
would have been the third, and it would disagree exactly where it matters: `>=15 <18`
targets 15 but *admits* 17, so a naive reading advises an upgrade against a range that
already covers the installed platform. Delegating means the advisory fires precisely when
boot would refuse the app, which is what makes it guidance rather than noise. The
advisory names the key it actually read, so an author is never told to bump a key they
did not write.

Comparing a protocol range against the `@objectstack/spec` resolved from the app's
`node_modules` is sound because `PROTOCOL_VERSION` is held in lockstep with that
package's major (`protocol-version.test.ts` fails on drift), which is also what keeps
the `docs/releases/v<major>` link correct.

## What changes for you

Nothing is removed or renamed on a published surface, and no command's accept set or exit
status moves: the advisory is print-only, `os validate` keeps it outside `--strict` on
both faces, and `os doctor` never exited on warnings. The `--json` payload key stays
`specVersionGap` with its value shape unchanged.

`os doctor`'s row is renamed with the axis, from `Platform spec` to `Platform protocol`, and the
sentence it prints when a config cannot be loaded — the one enumerating which config-aware checks
were skipped — names the row by its new name too, so an operator reading either is pointed at a
row that exists.

What does change is that the advisory now **fires**. An app whose `engines.protocol` is
behind the installed platform will start seeing the migration-guide pointer from all three
commands, and `os doctor` will summarise that run as "functional but has some warnings"
rather than "healthy". That is the check finally doing its job; if it speaks up, the drift
it names was already there.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,7 @@
"@objectstack/lint": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:^",
"@objectstack/observability": "workspace:^",
Expand Down
18 changes: 10 additions & 8 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Compile extends Command {
static override description = 'Compile ObjectStack configuration to JSON artifact';
Expand DownExpand Up@@ -599,9 +599,9 @@ export default class Compile extends Command {
const sizeKB = (jsonContent.length / 1024).toFixed(1);
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): installed platform newer
// than the app declares → point at the migration guide.
const specGap = checkSpecVersionGap((config as { manifest?: { specVersion?: unknown } }).manifest);
// Protocol drift advisory (non-blocking): installed platform outside the
// app's declared `engines.protocol` range → point at the migration guide.
const protocolGap = checkProtocolVersionGap((config as { manifest?: unknown }).manifest);

if (flags.json) {
await emitJson({
Expand DownExpand Up@@ -680,7 +680,9 @@ export default class Compile extends Command {
// Same key `os validate --json` uses, so a CI consumer reads one shape
// from either command rather than learning two.
conversions: conversionNotices,
specVersionGap: specGap,
// Published key name kept; the axis behind it moved to
// `manifest.engines.protocol` (#13860). See validate.ts.
specVersionGap: protocolGap,
stats,
duration: timer.elapsed(),
}, 0, { compact: true });
Expand DownExpand Up@@ -711,10 +713,10 @@ export default class Compile extends Command {
`${path.join(path.dirname(output), runtimeBundle.outputFileName)} ${chalk.dim(`(${runtimeKB} KB, ${lowering.count} handler${lowering.count === 1 ? '' : 's'})`)}`,
);
}
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}
console.log('');

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -289,7 +289,14 @@ describe('os doctor, end to end, against a config that reads .env at top level',
// `.env` before bundling, boots this exact directory.
expect(run.out).not.toContain('Could not load config for analysis');
// The config checks did not merely stop failing — they RAN.
expect(run.out).toContain('Platform spec');
// Both strings below are ROW LABELS used as liveness evidence, not verdicts:
// the row is printed on either branch, so its presence proves the check
// executed whatever it concluded. That is why they must track `doctor.ts`'s
// labels through a rename rather than be relaxed — `Platform spec` became
// `Platform protocol` when the advisory moved onto `engines.protocol`
// (#13860), and this assertion is the one consumer that lived outside the
// suites that rename touched.
expect(run.out).toContain('Platform protocol');
expect(run.out).toContain('No circular references detected');
expect(run.exitCode).toBeUndefined();

Expand Down
22 changes: 11 additions & 11 deletions packages/cli/src/commands/doctor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@ import path from 'path';
import { normalizeStackInput } from '@objectstack/spec';
import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js';
import { loadConfig, configExists } from '../utils/config.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// #5644 — "the optional package is not installed" and "it is installed and
// will not load" are two facts, and one `catch` around `import()` cannot tell
// them apart. That classification lives in one place, with the measurements
Expand DownExpand Up@@ -1434,8 +1434,8 @@ export function configLoadFailureCheck(err: unknown): HealthCheckResult {
'`os serve` loads this same file the same way — bundle-require, under the `.env*`\n'
+ ' cascade named above (#5397) — and prints this error in full, so a config that\n'
+ ' lands here is one the server cannot boot either.\n'
+ ' The config-aware checks were SKIPPED, not passed: spec version, circular\n'
+ ' dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ' The config-aware checks were SKIPPED, not passed: platform protocol,\n'
+ ' circular dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ` cause: ${indentUnderGutter(cause)}`,
};
}
Expand DownExpand Up@@ -1927,7 +1927,7 @@ export default class Doctor extends Command {
// Here the honest report is no row at all. An application consumes
// `@objectstack/spec` from `node_modules`, where "built" is not a state it
// can be in — that dependency is covered by the `Dependencies` row above
// and by `checkSpecVersionGap()`. Inside the monorepo nothing changes: the
// and by `checkProtocolVersionGap()`. Inside the monorepo nothing changes: the
// workspace is present, and an unbuilt `dist/` is still the real warning
// it always was.
const specWorkspaceDir = path.join(cwd, 'packages/spec');
Expand DownExpand Up@@ -2097,15 +2097,15 @@ export default class Doctor extends Command {
const { config: rawConfig } = await withDotenvOverlayAsync(dotenvReading, () => loadConfig());
const config: any = normalizeStackInput(rawConfig as Record<string, unknown>);

// Spec-version drift: installed platform newer than the app declares.
printStep('Checking platform spec version...');
const specGap = checkSpecVersionGap(config.manifest);
if (specGap) {
// Protocol drift: installed platform outside the range the app declares.
printStep('Checking platform protocol version...');
const protocolGap = checkProtocolVersionGap(config.manifest);
if (protocolGap) {
hasWarnings = true;
printWarning(`Platform spec ${specGap.message}`);
console.log(chalk.dim(` → ${specGap.hint}`));
printWarning(`Platform protocol${protocolGap.message}`);
console.log(chalk.dim(` → ${protocolGap.hint}`));
} else {
printSuccess('Platform specDeclared specVersion is current with the installed platform');
printSuccess('Platform protocol Declared engines.protocol covers the installed platform');
}

// Circular dependency detection
Expand Down
22 changes: 14 additions & 8 deletions packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Validate extends Command {
static override description =
Expand DownExpand Up@@ -344,9 +344,10 @@ export default class Validate extends Command {
// 4. Collect and display stats
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): if the installed platform
// is a newer major than the app declares, point at the migration guide.
const specGap = checkSpecVersionGap(config.manifest);
// Protocol drift advisory (non-blocking): if the installed platform is a
// newer major than the app's declared `engines.protocol` range admits,
// point at the migration guide.
const protocolGap = checkProtocolVersionGap(config.manifest);

// 4b. Structural advisories (non-blocking) — computed HERE, above the
// `if (flags.json)` branch, for exactly the reason `unknownKeyWarnings`
Expand DownExpand Up@@ -443,7 +444,12 @@ export default class Validate extends Command {
// this one.
warnings: warningsSoFar(),
conversions: conversionNotices,
specVersionGap: specGap,
// The payload key keeps its published name. The AXIS it reports
// moved from the undeclared `manifest.specVersion` to
// `manifest.engines.protocol` (#13860), but this is a machine face
// with pinned consumers, and renaming it is a break nobody asked
// for. Its value shape is unchanged.
specVersionGap: protocolGap,
duration: timer.elapsed(),
},
// `--strict` means one thing — "treat warnings as errors" — and it now
Expand DownExpand Up@@ -507,10 +513,10 @@ export default class Validate extends Command {
}

// Non-blocking upgrade advisory — never gated by --strict.
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}

console.log('');
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
61 changes: 61 additions & 0 deletions .changeset/cli-protocol-version-gap-advisory.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/cli": minor
---

fix(cli): the upgrade advisory reads `manifest.engines.protocol`, the axis that is actually declared (#13860)

`os validate`, `os doctor` and `os compile` all print a non-blocking advisory pointing
at the per-major migration guide when the installed platform has moved ahead of the app.
All three read it off `manifest.specVersion` — a key `ManifestSchema` does not declare.
`ManifestSchema` is not `.strict()`, so an author who wrote `specVersion` had it accepted
and dropped with nothing said, and the advisory could therefore only ever fire for a
manifest carrying a key the schema does not offer. It was dead for stack configs for its
whole life: the "breaking-change guidance the author should read before proceeding" that
its own header describes was never once delivered.

The advisory now reads `manifest.engines.protocol` — declared by `PluginEnginesSchema`,
stamped by every scaffold and example (`engines: { protocol: '^17' }`), and already
enforced at boot by the ADR-0087 handshake. One declared version axis instead of one
declared axis and one phantom.

`specVersion` is retired from the stack config's CLI vocabulary. It keeps its meaning on
the marketplace **template** manifest (`objectstack.manifest.json`, `cloud/TemplateManifest`),
which is a different surface and is untouched — the name means one thing in one place and
nothing in the other, which is the status quo stated honestly rather than a new debt.

## The verdict comes from the platform's own handshake

The range is judged by `checkProtocolCompat` from `@objectstack/metadata-core` rather
than by a leading-integer parse of the CLI's own. That module is the single reader of
this axis — it owns the source priority (`engines.protocol` → `engines.platform` →
legacy `engine.objectstack`) and the range grammar — and its header already records why:
two readers with two priority orders would be the "two opinions" defect. A private parse
would have been the third, and it would disagree exactly where it matters: `>=15 <18`
targets 15 but *admits* 17, so a naive reading advises an upgrade against a range that
already covers the installed platform. Delegating means the advisory fires precisely when
boot would refuse the app, which is what makes it guidance rather than noise. The
advisory names the key it actually read, so an author is never told to bump a key they
did not write.

Comparing a protocol range against the `@objectstack/spec` resolved from the app's
`node_modules` is sound because `PROTOCOL_VERSION` is held in lockstep with that
package's major (`protocol-version.test.ts` fails on drift), which is also what keeps
the `docs/releases/v<major>` link correct.

## What changes for you

Nothing is removed or renamed on a published surface, and no command's accept set or exit
status moves: the advisory is print-only, `os validate` keeps it outside `--strict` on
both faces, and `os doctor` never exited on warnings. The `--json` payload key stays
`specVersionGap` with its value shape unchanged.

`os doctor`'s row is renamed with the axis, from `Platform spec` to `Platform protocol`, and the
sentence it prints when a config cannot be loaded — the one enumerating which config-aware checks
were skipped — names the row by its new name too, so an operator reading either is pointed at a
row that exists.

What does change is that the advisory now **fires**. An app whose `engines.protocol` is
behind the installed platform will start seeing the migration-guide pointer from all three
commands, and `os doctor` will summarise that run as "functional but has some warnings"
rather than "healthy". That is the check finally doing its job; if it speaks up, the drift
it names was already there.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,7 @@
"@objectstack/lint": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:^",
"@objectstack/observability": "workspace:^",
Expand Down
18 changes: 10 additions & 8 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Compile extends Command {
static override description = 'Compile ObjectStack configuration to JSON artifact';
Expand DownExpand Up@@ -599,9 +599,9 @@ export default class Compile extends Command {
const sizeKB = (jsonContent.length / 1024).toFixed(1);
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): installed platform newer
// than the app declares → point at the migration guide.
const specGap = checkSpecVersionGap((config as { manifest?: { specVersion?: unknown } }).manifest);
// Protocol drift advisory (non-blocking): installed platform outside the
// app's declared `engines.protocol` range → point at the migration guide.
const protocolGap = checkProtocolVersionGap((config as { manifest?: unknown }).manifest);

if (flags.json) {
await emitJson({
Expand DownExpand Up@@ -680,7 +680,9 @@ export default class Compile extends Command {
// Same key `os validate --json` uses, so a CI consumer reads one shape
// from either command rather than learning two.
conversions: conversionNotices,
specVersionGap: specGap,
// Published key name kept; the axis behind it moved to
// `manifest.engines.protocol` (#13860). See validate.ts.
specVersionGap: protocolGap,
stats,
duration: timer.elapsed(),
}, 0, { compact: true });
Expand DownExpand Up@@ -711,10 +713,10 @@ export default class Compile extends Command {
`${path.join(path.dirname(output), runtimeBundle.outputFileName)} ${chalk.dim(`(${runtimeKB} KB, ${lowering.count} handler${lowering.count === 1 ? '' : 's'})`)}`,
);
}
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}
console.log('');

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -289,7 +289,14 @@ describe('os doctor, end to end, against a config that reads .env at top level',
// `.env` before bundling, boots this exact directory.
expect(run.out).not.toContain('Could not load config for analysis');
// The config checks did not merely stop failing — they RAN.
expect(run.out).toContain('Platform spec');
// Both strings below are ROW LABELS used as liveness evidence, not verdicts:
// the row is printed on either branch, so its presence proves the check
// executed whatever it concluded. That is why they must track `doctor.ts`'s
// labels through a rename rather than be relaxed — `Platform spec` became
// `Platform protocol` when the advisory moved onto `engines.protocol`
// (#13860), and this assertion is the one consumer that lived outside the
// suites that rename touched.
expect(run.out).toContain('Platform protocol');
expect(run.out).toContain('No circular references detected');
expect(run.exitCode).toBeUndefined();

Expand Down
22 changes: 11 additions & 11 deletions packages/cli/src/commands/doctor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@ import path from 'path';
import { normalizeStackInput } from '@objectstack/spec';
import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js';
import { loadConfig, configExists } from '../utils/config.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// #5644 — "the optional package is not installed" and "it is installed and
// will not load" are two facts, and one `catch` around `import()` cannot tell
// them apart. That classification lives in one place, with the measurements
Expand DownExpand Up@@ -1434,8 +1434,8 @@ export function configLoadFailureCheck(err: unknown): HealthCheckResult {
'`os serve` loads this same file the same way — bundle-require, under the `.env*`\n'
+ ' cascade named above (#5397) — and prints this error in full, so a config that\n'
+ ' lands here is one the server cannot boot either.\n'
+ ' The config-aware checks were SKIPPED, not passed: spec version, circular\n'
+ ' dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ' The config-aware checks were SKIPPED, not passed: platform protocol,\n'
+ ' circular dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ` cause: ${indentUnderGutter(cause)}`,
};
}
Expand DownExpand Up@@ -1927,7 +1927,7 @@ export default class Doctor extends Command {
// Here the honest report is no row at all. An application consumes
// `@objectstack/spec` from `node_modules`, where "built" is not a state it
// can be in — that dependency is covered by the `Dependencies` row above
// and by `checkSpecVersionGap()`. Inside the monorepo nothing changes: the
// and by `checkProtocolVersionGap()`. Inside the monorepo nothing changes: the
// workspace is present, and an unbuilt `dist/` is still the real warning
// it always was.
const specWorkspaceDir = path.join(cwd, 'packages/spec');
Expand DownExpand Up@@ -2097,15 +2097,15 @@ export default class Doctor extends Command {
const { config: rawConfig } = await withDotenvOverlayAsync(dotenvReading, () => loadConfig());
const config: any = normalizeStackInput(rawConfig as Record<string, unknown>);

// Spec-version drift: installed platform newer than the app declares.
printStep('Checking platform spec version...');
const specGap = checkSpecVersionGap(config.manifest);
if (specGap) {
// Protocol drift: installed platform outside the range the app declares.
printStep('Checking platform protocol version...');
const protocolGap = checkProtocolVersionGap(config.manifest);
if (protocolGap) {
hasWarnings = true;
printWarning(`Platform spec ${specGap.message}`);
console.log(chalk.dim(` → ${specGap.hint}`));
printWarning(`Platform protocol${protocolGap.message}`);
console.log(chalk.dim(` → ${protocolGap.hint}`));
} else {
printSuccess('Platform specDeclared specVersion is current with the installed platform');
printSuccess('Platform protocol Declared engines.protocol covers the installed platform');
}

// Circular dependency detection
Expand Down
22 changes: 14 additions & 8 deletions packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Validate extends Command {
static override description =
Expand DownExpand Up@@ -344,9 +344,10 @@ export default class Validate extends Command {
// 4. Collect and display stats
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): if the installed platform
// is a newer major than the app declares, point at the migration guide.
const specGap = checkSpecVersionGap(config.manifest);
// Protocol drift advisory (non-blocking): if the installed platform is a
// newer major than the app's declared `engines.protocol` range admits,
// point at the migration guide.
const protocolGap = checkProtocolVersionGap(config.manifest);

// 4b. Structural advisories (non-blocking) — computed HERE, above the
// `if (flags.json)` branch, for exactly the reason `unknownKeyWarnings`
Expand DownExpand Up@@ -443,7 +444,12 @@ export default class Validate extends Command {
// this one.
warnings: warningsSoFar(),
conversions: conversionNotices,
specVersionGap: specGap,
// The payload key keeps its published name. The AXIS it reports
// moved from the undeclared `manifest.specVersion` to
// `manifest.engines.protocol` (#13860), but this is a machine face
// with pinned consumers, and renaming it is a break nobody asked
// for. Its value shape is unchanged.
specVersionGap: protocolGap,
duration: timer.elapsed(),
},
// `--strict` means one thing — "treat warnings as errors" — and it now
Expand DownExpand Up@@ -507,10 +513,10 @@ export default class Validate extends Command {
}

// Non-blocking upgrade advisory — never gated by --strict.
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}

console.log('');
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
61 changes: 61 additions & 0 deletions .changeset/cli-protocol-version-gap-advisory.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/cli": minor
---

fix(cli): the upgrade advisory reads `manifest.engines.protocol`, the axis that is actually declared (#13860)

`os validate`, `os doctor` and `os compile` all print a non-blocking advisory pointing
at the per-major migration guide when the installed platform has moved ahead of the app.
All three read it off `manifest.specVersion` — a key `ManifestSchema` does not declare.
`ManifestSchema` is not `.strict()`, so an author who wrote `specVersion` had it accepted
and dropped with nothing said, and the advisory could therefore only ever fire for a
manifest carrying a key the schema does not offer. It was dead for stack configs for its
whole life: the "breaking-change guidance the author should read before proceeding" that
its own header describes was never once delivered.

The advisory now reads `manifest.engines.protocol` — declared by `PluginEnginesSchema`,
stamped by every scaffold and example (`engines: { protocol: '^17' }`), and already
enforced at boot by the ADR-0087 handshake. One declared version axis instead of one
declared axis and one phantom.

`specVersion` is retired from the stack config's CLI vocabulary. It keeps its meaning on
the marketplace **template** manifest (`objectstack.manifest.json`, `cloud/TemplateManifest`),
which is a different surface and is untouched — the name means one thing in one place and
nothing in the other, which is the status quo stated honestly rather than a new debt.

## The verdict comes from the platform's own handshake

The range is judged by `checkProtocolCompat` from `@objectstack/metadata-core` rather
than by a leading-integer parse of the CLI's own. That module is the single reader of
this axis — it owns the source priority (`engines.protocol` → `engines.platform` →
legacy `engine.objectstack`) and the range grammar — and its header already records why:
two readers with two priority orders would be the "two opinions" defect. A private parse
would have been the third, and it would disagree exactly where it matters: `>=15 <18`
targets 15 but *admits* 17, so a naive reading advises an upgrade against a range that
already covers the installed platform. Delegating means the advisory fires precisely when
boot would refuse the app, which is what makes it guidance rather than noise. The
advisory names the key it actually read, so an author is never told to bump a key they
did not write.

Comparing a protocol range against the `@objectstack/spec` resolved from the app's
`node_modules` is sound because `PROTOCOL_VERSION` is held in lockstep with that
package's major (`protocol-version.test.ts` fails on drift), which is also what keeps
the `docs/releases/v<major>` link correct.

## What changes for you

Nothing is removed or renamed on a published surface, and no command's accept set or exit
status moves: the advisory is print-only, `os validate` keeps it outside `--strict` on
both faces, and `os doctor` never exited on warnings. The `--json` payload key stays
`specVersionGap` with its value shape unchanged.

`os doctor`'s row is renamed with the axis, from `Platform spec` to `Platform protocol`, and the
sentence it prints when a config cannot be loaded — the one enumerating which config-aware checks
were skipped — names the row by its new name too, so an operator reading either is pointed at a
row that exists.

What does change is that the advisory now **fires**. An app whose `engines.protocol` is
behind the installed platform will start seeing the migration-guide pointer from all three
commands, and `os doctor` will summarise that run as "functional but has some warnings"
rather than "healthy". That is the check finally doing its job; if it speaks up, the drift
it names was already there.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,7 @@
"@objectstack/lint": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:^",
"@objectstack/observability": "workspace:^",
Expand Down
18 changes: 10 additions & 8 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Compile extends Command {
static override description = 'Compile ObjectStack configuration to JSON artifact';
Expand DownExpand Up@@ -599,9 +599,9 @@ export default class Compile extends Command {
const sizeKB = (jsonContent.length / 1024).toFixed(1);
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): installed platform newer
// than the app declares → point at the migration guide.
const specGap = checkSpecVersionGap((config as { manifest?: { specVersion?: unknown } }).manifest);
// Protocol drift advisory (non-blocking): installed platform outside the
// app's declared `engines.protocol` range → point at the migration guide.
const protocolGap = checkProtocolVersionGap((config as { manifest?: unknown }).manifest);

if (flags.json) {
await emitJson({
Expand DownExpand Up@@ -680,7 +680,9 @@ export default class Compile extends Command {
// Same key `os validate --json` uses, so a CI consumer reads one shape
// from either command rather than learning two.
conversions: conversionNotices,
specVersionGap: specGap,
// Published key name kept; the axis behind it moved to
// `manifest.engines.protocol` (#13860). See validate.ts.
specVersionGap: protocolGap,
stats,
duration: timer.elapsed(),
}, 0, { compact: true });
Expand DownExpand Up@@ -711,10 +713,10 @@ export default class Compile extends Command {
`${path.join(path.dirname(output), runtimeBundle.outputFileName)} ${chalk.dim(`(${runtimeKB} KB, ${lowering.count} handler${lowering.count === 1 ? '' : 's'})`)}`,
);
}
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}
console.log('');

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -289,7 +289,14 @@ describe('os doctor, end to end, against a config that reads .env at top level',
// `.env` before bundling, boots this exact directory.
expect(run.out).not.toContain('Could not load config for analysis');
// The config checks did not merely stop failing — they RAN.
expect(run.out).toContain('Platform spec');
// Both strings below are ROW LABELS used as liveness evidence, not verdicts:
// the row is printed on either branch, so its presence proves the check
// executed whatever it concluded. That is why they must track `doctor.ts`'s
// labels through a rename rather than be relaxed — `Platform spec` became
// `Platform protocol` when the advisory moved onto `engines.protocol`
// (#13860), and this assertion is the one consumer that lived outside the
// suites that rename touched.
expect(run.out).toContain('Platform protocol');
expect(run.out).toContain('No circular references detected');
expect(run.exitCode).toBeUndefined();

Expand Down
22 changes: 11 additions & 11 deletions packages/cli/src/commands/doctor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@ import path from 'path';
import { normalizeStackInput } from '@objectstack/spec';
import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js';
import { loadConfig, configExists } from '../utils/config.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// #5644 — "the optional package is not installed" and "it is installed and
// will not load" are two facts, and one `catch` around `import()` cannot tell
// them apart. That classification lives in one place, with the measurements
Expand DownExpand Up@@ -1434,8 +1434,8 @@ export function configLoadFailureCheck(err: unknown): HealthCheckResult {
'`os serve` loads this same file the same way — bundle-require, under the `.env*`\n'
+ ' cascade named above (#5397) — and prints this error in full, so a config that\n'
+ ' lands here is one the server cannot boot either.\n'
+ ' The config-aware checks were SKIPPED, not passed: spec version, circular\n'
+ ' dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ' The config-aware checks were SKIPPED, not passed: platform protocol,\n'
+ ' circular dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ` cause: ${indentUnderGutter(cause)}`,
};
}
Expand DownExpand Up@@ -1927,7 +1927,7 @@ export default class Doctor extends Command {
// Here the honest report is no row at all. An application consumes
// `@objectstack/spec` from `node_modules`, where "built" is not a state it
// can be in — that dependency is covered by the `Dependencies` row above
// and by `checkSpecVersionGap()`. Inside the monorepo nothing changes: the
// and by `checkProtocolVersionGap()`. Inside the monorepo nothing changes: the
// workspace is present, and an unbuilt `dist/` is still the real warning
// it always was.
const specWorkspaceDir = path.join(cwd, 'packages/spec');
Expand DownExpand Up@@ -2097,15 +2097,15 @@ export default class Doctor extends Command {
const { config: rawConfig } = await withDotenvOverlayAsync(dotenvReading, () => loadConfig());
const config: any = normalizeStackInput(rawConfig as Record<string, unknown>);

// Spec-version drift: installed platform newer than the app declares.
printStep('Checking platform spec version...');
const specGap = checkSpecVersionGap(config.manifest);
if (specGap) {
// Protocol drift: installed platform outside the range the app declares.
printStep('Checking platform protocol version...');
const protocolGap = checkProtocolVersionGap(config.manifest);
if (protocolGap) {
hasWarnings = true;
printWarning(`Platform spec ${specGap.message}`);
console.log(chalk.dim(` → ${specGap.hint}`));
printWarning(`Platform protocol${protocolGap.message}`);
console.log(chalk.dim(` → ${protocolGap.hint}`));
} else {
printSuccess('Platform specDeclared specVersion is current with the installed platform');
printSuccess('Platform protocol Declared engines.protocol covers the installed platform');
}

// Circular dependency detection
Expand Down
22 changes: 14 additions & 8 deletions packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Validate extends Command {
static override description =
Expand DownExpand Up@@ -344,9 +344,10 @@ export default class Validate extends Command {
// 4. Collect and display stats
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): if the installed platform
// is a newer major than the app declares, point at the migration guide.
const specGap = checkSpecVersionGap(config.manifest);
// Protocol drift advisory (non-blocking): if the installed platform is a
// newer major than the app's declared `engines.protocol` range admits,
// point at the migration guide.
const protocolGap = checkProtocolVersionGap(config.manifest);

// 4b. Structural advisories (non-blocking) — computed HERE, above the
// `if (flags.json)` branch, for exactly the reason `unknownKeyWarnings`
Expand DownExpand Up@@ -443,7 +444,12 @@ export default class Validate extends Command {
// this one.
warnings: warningsSoFar(),
conversions: conversionNotices,
specVersionGap: specGap,
// The payload key keeps its published name. The AXIS it reports
// moved from the undeclared `manifest.specVersion` to
// `manifest.engines.protocol` (#13860), but this is a machine face
// with pinned consumers, and renaming it is a break nobody asked
// for. Its value shape is unchanged.
specVersionGap: protocolGap,
duration: timer.elapsed(),
},
// `--strict` means one thing — "treat warnings as errors" — and it now
Expand DownExpand Up@@ -507,10 +513,10 @@ export default class Validate extends Command {
}

// Non-blocking upgrade advisory — never gated by --strict.
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}

console.log('');
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
61 changes: 61 additions & 0 deletions .changeset/cli-protocol-version-gap-advisory.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/cli": minor
---

fix(cli): the upgrade advisory reads `manifest.engines.protocol`, the axis that is actually declared (#13860)

`os validate`, `os doctor` and `os compile` all print a non-blocking advisory pointing
at the per-major migration guide when the installed platform has moved ahead of the app.
All three read it off `manifest.specVersion` — a key `ManifestSchema` does not declare.
`ManifestSchema` is not `.strict()`, so an author who wrote `specVersion` had it accepted
and dropped with nothing said, and the advisory could therefore only ever fire for a
manifest carrying a key the schema does not offer. It was dead for stack configs for its
whole life: the "breaking-change guidance the author should read before proceeding" that
its own header describes was never once delivered.

The advisory now reads `manifest.engines.protocol` — declared by `PluginEnginesSchema`,
stamped by every scaffold and example (`engines: { protocol: '^17' }`), and already
enforced at boot by the ADR-0087 handshake. One declared version axis instead of one
declared axis and one phantom.

`specVersion` is retired from the stack config's CLI vocabulary. It keeps its meaning on
the marketplace **template** manifest (`objectstack.manifest.json`, `cloud/TemplateManifest`),
which is a different surface and is untouched — the name means one thing in one place and
nothing in the other, which is the status quo stated honestly rather than a new debt.

## The verdict comes from the platform's own handshake

The range is judged by `checkProtocolCompat` from `@objectstack/metadata-core` rather
than by a leading-integer parse of the CLI's own. That module is the single reader of
this axis — it owns the source priority (`engines.protocol` → `engines.platform` →
legacy `engine.objectstack`) and the range grammar — and its header already records why:
two readers with two priority orders would be the "two opinions" defect. A private parse
would have been the third, and it would disagree exactly where it matters: `>=15 <18`
targets 15 but *admits* 17, so a naive reading advises an upgrade against a range that
already covers the installed platform. Delegating means the advisory fires precisely when
boot would refuse the app, which is what makes it guidance rather than noise. The
advisory names the key it actually read, so an author is never told to bump a key they
did not write.

Comparing a protocol range against the `@objectstack/spec` resolved from the app's
`node_modules` is sound because `PROTOCOL_VERSION` is held in lockstep with that
package's major (`protocol-version.test.ts` fails on drift), which is also what keeps
the `docs/releases/v<major>` link correct.

## What changes for you

Nothing is removed or renamed on a published surface, and no command's accept set or exit
status moves: the advisory is print-only, `os validate` keeps it outside `--strict` on
both faces, and `os doctor` never exited on warnings. The `--json` payload key stays
`specVersionGap` with its value shape unchanged.

`os doctor`'s row is renamed with the axis, from `Platform spec` to `Platform protocol`, and the
sentence it prints when a config cannot be loaded — the one enumerating which config-aware checks
were skipped — names the row by its new name too, so an operator reading either is pointed at a
row that exists.

What does change is that the advisory now **fires**. An app whose `engines.protocol` is
behind the installed platform will start seeing the migration-guide pointer from all three
commands, and `os doctor` will summarise that run as "functional but has some warnings"
rather than "healthy". That is the check finally doing its job; if it speaks up, the drift
it names was already there.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,7 @@
"@objectstack/lint": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:^",
"@objectstack/observability": "workspace:^",
Expand Down
18 changes: 10 additions & 8 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Compile extends Command {
static override description = 'Compile ObjectStack configuration to JSON artifact';
Expand DownExpand Up@@ -599,9 +599,9 @@ export default class Compile extends Command {
const sizeKB = (jsonContent.length / 1024).toFixed(1);
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): installed platform newer
// than the app declares → point at the migration guide.
const specGap = checkSpecVersionGap((config as { manifest?: { specVersion?: unknown } }).manifest);
// Protocol drift advisory (non-blocking): installed platform outside the
// app's declared `engines.protocol` range → point at the migration guide.
const protocolGap = checkProtocolVersionGap((config as { manifest?: unknown }).manifest);

if (flags.json) {
await emitJson({
Expand DownExpand Up@@ -680,7 +680,9 @@ export default class Compile extends Command {
// Same key `os validate --json` uses, so a CI consumer reads one shape
// from either command rather than learning two.
conversions: conversionNotices,
specVersionGap: specGap,
// Published key name kept; the axis behind it moved to
// `manifest.engines.protocol` (#13860). See validate.ts.
specVersionGap: protocolGap,
stats,
duration: timer.elapsed(),
}, 0, { compact: true });
Expand DownExpand Up@@ -711,10 +713,10 @@ export default class Compile extends Command {
`${path.join(path.dirname(output), runtimeBundle.outputFileName)} ${chalk.dim(`(${runtimeKB} KB, ${lowering.count} handler${lowering.count === 1 ? '' : 's'})`)}`,
);
}
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}
console.log('');

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -289,7 +289,14 @@ describe('os doctor, end to end, against a config that reads .env at top level',
// `.env` before bundling, boots this exact directory.
expect(run.out).not.toContain('Could not load config for analysis');
// The config checks did not merely stop failing — they RAN.
expect(run.out).toContain('Platform spec');
// Both strings below are ROW LABELS used as liveness evidence, not verdicts:
// the row is printed on either branch, so its presence proves the check
// executed whatever it concluded. That is why they must track `doctor.ts`'s
// labels through a rename rather than be relaxed — `Platform spec` became
// `Platform protocol` when the advisory moved onto `engines.protocol`
// (#13860), and this assertion is the one consumer that lived outside the
// suites that rename touched.
expect(run.out).toContain('Platform protocol');
expect(run.out).toContain('No circular references detected');
expect(run.exitCode).toBeUndefined();

Expand Down
22 changes: 11 additions & 11 deletions packages/cli/src/commands/doctor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@ import path from 'path';
import { normalizeStackInput } from '@objectstack/spec';
import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js';
import { loadConfig, configExists } from '../utils/config.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// #5644 — "the optional package is not installed" and "it is installed and
// will not load" are two facts, and one `catch` around `import()` cannot tell
// them apart. That classification lives in one place, with the measurements
Expand DownExpand Up@@ -1434,8 +1434,8 @@ export function configLoadFailureCheck(err: unknown): HealthCheckResult {
'`os serve` loads this same file the same way — bundle-require, under the `.env*`\n'
+ ' cascade named above (#5397) — and prints this error in full, so a config that\n'
+ ' lands here is one the server cannot boot either.\n'
+ ' The config-aware checks were SKIPPED, not passed: spec version, circular\n'
+ ' dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ' The config-aware checks were SKIPPED, not passed: platform protocol,\n'
+ ' circular dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ` cause: ${indentUnderGutter(cause)}`,
};
}
Expand DownExpand Up@@ -1927,7 +1927,7 @@ export default class Doctor extends Command {
// Here the honest report is no row at all. An application consumes
// `@objectstack/spec` from `node_modules`, where "built" is not a state it
// can be in — that dependency is covered by the `Dependencies` row above
// and by `checkSpecVersionGap()`. Inside the monorepo nothing changes: the
// and by `checkProtocolVersionGap()`. Inside the monorepo nothing changes: the
// workspace is present, and an unbuilt `dist/` is still the real warning
// it always was.
const specWorkspaceDir = path.join(cwd, 'packages/spec');
Expand DownExpand Up@@ -2097,15 +2097,15 @@ export default class Doctor extends Command {
const { config: rawConfig } = await withDotenvOverlayAsync(dotenvReading, () => loadConfig());
const config: any = normalizeStackInput(rawConfig as Record<string, unknown>);

// Spec-version drift: installed platform newer than the app declares.
printStep('Checking platform spec version...');
const specGap = checkSpecVersionGap(config.manifest);
if (specGap) {
// Protocol drift: installed platform outside the range the app declares.
printStep('Checking platform protocol version...');
const protocolGap = checkProtocolVersionGap(config.manifest);
if (protocolGap) {
hasWarnings = true;
printWarning(`Platform spec ${specGap.message}`);
console.log(chalk.dim(` → ${specGap.hint}`));
printWarning(`Platform protocol${protocolGap.message}`);
console.log(chalk.dim(` → ${protocolGap.hint}`));
} else {
printSuccess('Platform specDeclared specVersion is current with the installed platform');
printSuccess('Platform protocol Declared engines.protocol covers the installed platform');
}

// Circular dependency detection
Expand Down
22 changes: 14 additions & 8 deletions packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Validate extends Command {
static override description =
Expand DownExpand Up@@ -344,9 +344,10 @@ export default class Validate extends Command {
// 4. Collect and display stats
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): if the installed platform
// is a newer major than the app declares, point at the migration guide.
const specGap = checkSpecVersionGap(config.manifest);
// Protocol drift advisory (non-blocking): if the installed platform is a
// newer major than the app's declared `engines.protocol` range admits,
// point at the migration guide.
const protocolGap = checkProtocolVersionGap(config.manifest);

// 4b. Structural advisories (non-blocking) — computed HERE, above the
// `if (flags.json)` branch, for exactly the reason `unknownKeyWarnings`
Expand DownExpand Up@@ -443,7 +444,12 @@ export default class Validate extends Command {
// this one.
warnings: warningsSoFar(),
conversions: conversionNotices,
specVersionGap: specGap,
// The payload key keeps its published name. The AXIS it reports
// moved from the undeclared `manifest.specVersion` to
// `manifest.engines.protocol` (#13860), but this is a machine face
// with pinned consumers, and renaming it is a break nobody asked
// for. Its value shape is unchanged.
specVersionGap: protocolGap,
duration: timer.elapsed(),
},
// `--strict` means one thing — "treat warnings as errors" — and it now
Expand DownExpand Up@@ -507,10 +513,10 @@ export default class Validate extends Command {
}

// Non-blocking upgrade advisory — never gated by --strict.
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}

console.log('');
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
61 changes: 61 additions & 0 deletions .changeset/cli-protocol-version-gap-advisory.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/cli": minor
---

fix(cli): the upgrade advisory reads `manifest.engines.protocol`, the axis that is actually declared (#13860)

`os validate`, `os doctor` and `os compile` all print a non-blocking advisory pointing
at the per-major migration guide when the installed platform has moved ahead of the app.
All three read it off `manifest.specVersion` — a key `ManifestSchema` does not declare.
`ManifestSchema` is not `.strict()`, so an author who wrote `specVersion` had it accepted
and dropped with nothing said, and the advisory could therefore only ever fire for a
manifest carrying a key the schema does not offer. It was dead for stack configs for its
whole life: the "breaking-change guidance the author should read before proceeding" that
its own header describes was never once delivered.

The advisory now reads `manifest.engines.protocol` — declared by `PluginEnginesSchema`,
stamped by every scaffold and example (`engines: { protocol: '^17' }`), and already
enforced at boot by the ADR-0087 handshake. One declared version axis instead of one
declared axis and one phantom.

`specVersion` is retired from the stack config's CLI vocabulary. It keeps its meaning on
the marketplace **template** manifest (`objectstack.manifest.json`, `cloud/TemplateManifest`),
which is a different surface and is untouched — the name means one thing in one place and
nothing in the other, which is the status quo stated honestly rather than a new debt.

## The verdict comes from the platform's own handshake

The range is judged by `checkProtocolCompat` from `@objectstack/metadata-core` rather
than by a leading-integer parse of the CLI's own. That module is the single reader of
this axis — it owns the source priority (`engines.protocol` → `engines.platform` →
legacy `engine.objectstack`) and the range grammar — and its header already records why:
two readers with two priority orders would be the "two opinions" defect. A private parse
would have been the third, and it would disagree exactly where it matters: `>=15 <18`
targets 15 but *admits* 17, so a naive reading advises an upgrade against a range that
already covers the installed platform. Delegating means the advisory fires precisely when
boot would refuse the app, which is what makes it guidance rather than noise. The
advisory names the key it actually read, so an author is never told to bump a key they
did not write.

Comparing a protocol range against the `@objectstack/spec` resolved from the app's
`node_modules` is sound because `PROTOCOL_VERSION` is held in lockstep with that
package's major (`protocol-version.test.ts` fails on drift), which is also what keeps
the `docs/releases/v<major>` link correct.

## What changes for you

Nothing is removed or renamed on a published surface, and no command's accept set or exit
status moves: the advisory is print-only, `os validate` keeps it outside `--strict` on
both faces, and `os doctor` never exited on warnings. The `--json` payload key stays
`specVersionGap` with its value shape unchanged.

`os doctor`'s row is renamed with the axis, from `Platform spec` to `Platform protocol`, and the
sentence it prints when a config cannot be loaded — the one enumerating which config-aware checks
were skipped — names the row by its new name too, so an operator reading either is pointed at a
row that exists.

What does change is that the advisory now **fires**. An app whose `engines.protocol` is
behind the installed platform will start seeing the migration-guide pointer from all three
commands, and `os doctor` will summarise that run as "functional but has some warnings"
rather than "healthy". That is the check finally doing its job; if it speaks up, the drift
it names was already there.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,7 @@
"@objectstack/lint": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:^",
"@objectstack/observability": "workspace:^",
Expand Down
18 changes: 10 additions & 8 deletions packages/cli/src/commands/compile.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Compile extends Command {
static override description = 'Compile ObjectStack configuration to JSON artifact';
Expand DownExpand Up@@ -599,9 +599,9 @@ export default class Compile extends Command {
const sizeKB = (jsonContent.length / 1024).toFixed(1);
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): installed platform newer
// than the app declares → point at the migration guide.
const specGap = checkSpecVersionGap((config as { manifest?: { specVersion?: unknown } }).manifest);
// Protocol drift advisory (non-blocking): installed platform outside the
// app's declared `engines.protocol` range → point at the migration guide.
const protocolGap = checkProtocolVersionGap((config as { manifest?: unknown }).manifest);

if (flags.json) {
await emitJson({
Expand DownExpand Up@@ -680,7 +680,9 @@ export default class Compile extends Command {
// Same key `os validate --json` uses, so a CI consumer reads one shape
// from either command rather than learning two.
conversions: conversionNotices,
specVersionGap: specGap,
// Published key name kept; the axis behind it moved to
// `manifest.engines.protocol` (#13860). See validate.ts.
specVersionGap: protocolGap,
stats,
duration: timer.elapsed(),
}, 0, { compact: true });
Expand DownExpand Up@@ -711,10 +713,10 @@ export default class Compile extends Command {
`${path.join(path.dirname(output), runtimeBundle.outputFileName)} ${chalk.dim(`(${runtimeKB} KB, ${lowering.count} handler${lowering.count === 1 ? '' : 's'})`)}`,
);
}
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}
console.log('');

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -289,7 +289,14 @@ describe('os doctor, end to end, against a config that reads .env at top level',
// `.env` before bundling, boots this exact directory.
expect(run.out).not.toContain('Could not load config for analysis');
// The config checks did not merely stop failing — they RAN.
expect(run.out).toContain('Platform spec');
// Both strings below are ROW LABELS used as liveness evidence, not verdicts:
// the row is printed on either branch, so its presence proves the check
// executed whatever it concluded. That is why they must track `doctor.ts`'s
// labels through a rename rather than be relaxed — `Platform spec` became
// `Platform protocol` when the advisory moved onto `engines.protocol`
// (#13860), and this assertion is the one consumer that lived outside the
// suites that rename touched.
expect(run.out).toContain('Platform protocol');
expect(run.out).toContain('No circular references detected');
expect(run.exitCode).toBeUndefined();

Expand Down
22 changes: 11 additions & 11 deletions packages/cli/src/commands/doctor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@ import path from 'path';
import { normalizeStackInput } from '@objectstack/spec';
import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js';
import { loadConfig, configExists } from '../utils/config.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// #5644 — "the optional package is not installed" and "it is installed and
// will not load" are two facts, and one `catch` around `import()` cannot tell
// them apart. That classification lives in one place, with the measurements
Expand DownExpand Up@@ -1434,8 +1434,8 @@ export function configLoadFailureCheck(err: unknown): HealthCheckResult {
'`os serve` loads this same file the same way — bundle-require, under the `.env*`\n'
+ ' cascade named above (#5397) — and prints this error in full, so a config that\n'
+ ' lands here is one the server cannot boot either.\n'
+ ' The config-aware checks were SKIPPED, not passed: spec version, circular\n'
+ ' dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ' The config-aware checks were SKIPPED, not passed: platform protocol,\n'
+ ' circular dependencies, unused objects, orphan views, dashboard integrity.\n'
+ ` cause: ${indentUnderGutter(cause)}`,
};
}
Expand DownExpand Up@@ -1927,7 +1927,7 @@ export default class Doctor extends Command {
// Here the honest report is no row at all. An application consumes
// `@objectstack/spec` from `node_modules`, where "built" is not a state it
// can be in — that dependency is covered by the `Dependencies` row above
// and by `checkSpecVersionGap()`. Inside the monorepo nothing changes: the
// and by `checkProtocolVersionGap()`. Inside the monorepo nothing changes: the
// workspace is present, and an unbuilt `dist/` is still the real warning
// it always was.
const specWorkspaceDir = path.join(cwd, 'packages/spec');
Expand DownExpand Up@@ -2097,15 +2097,15 @@ export default class Doctor extends Command {
const { config: rawConfig } = await withDotenvOverlayAsync(dotenvReading, () => loadConfig());
const config: any = normalizeStackInput(rawConfig as Record<string, unknown>);

// Spec-version drift: installed platform newer than the app declares.
printStep('Checking platform spec version...');
const specGap = checkSpecVersionGap(config.manifest);
if (specGap) {
// Protocol drift: installed platform outside the range the app declares.
printStep('Checking platform protocol version...');
const protocolGap = checkProtocolVersionGap(config.manifest);
if (protocolGap) {
hasWarnings = true;
printWarning(`Platform spec ${specGap.message}`);
console.log(chalk.dim(` → ${specGap.hint}`));
printWarning(`Platform protocol${protocolGap.message}`);
console.log(chalk.dim(` → ${protocolGap.hint}`));
} else {
printSuccess('Platform specDeclared specVersion is current with the installed platform');
printSuccess('Platform protocol Declared engines.protocol covers the installed platform');
}

// Circular dependency detection
Expand Down
22 changes: 14 additions & 8 deletions packages/cli/src/commands/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ import {
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';

export default class Validate extends Command {
static override description =
Expand DownExpand Up@@ -344,9 +344,10 @@ export default class Validate extends Command {
// 4. Collect and display stats
const stats = collectMetadataStats(config);

// Spec-version drift advisory (non-blocking): if the installed platform
// is a newer major than the app declares, point at the migration guide.
const specGap = checkSpecVersionGap(config.manifest);
// Protocol drift advisory (non-blocking): if the installed platform is a
// newer major than the app's declared `engines.protocol` range admits,
// point at the migration guide.
const protocolGap = checkProtocolVersionGap(config.manifest);

// 4b. Structural advisories (non-blocking) — computed HERE, above the
// `if (flags.json)` branch, for exactly the reason `unknownKeyWarnings`
Expand DownExpand Up@@ -443,7 +444,12 @@ export default class Validate extends Command {
// this one.
warnings: warningsSoFar(),
conversions: conversionNotices,
specVersionGap: specGap,
// The payload key keeps its published name. The AXIS it reports
// moved from the undeclared `manifest.specVersion` to
// `manifest.engines.protocol` (#13860), but this is a machine face
// with pinned consumers, and renaming it is a break nobody asked
// for. Its value shape is unchanged.
specVersionGap: protocolGap,
duration: timer.elapsed(),
},
// `--strict` means one thing — "treat warnings as errors" — and it now
Expand DownExpand Up@@ -507,10 +513,10 @@ export default class Validate extends Command {
}

// Non-blocking upgrade advisory — never gated by --strict.
if (specGap) {
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
console.log(chalk.dim(` → ${specGap.hint}`));
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}

console.log('');
Expand Down
Loading
Loading