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
33 changes: 33 additions & 0 deletions .changeset/serve-organizations-prose-single-sourced.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
"@objectstack/cli": patch
---

chore(cli): spell the multi-org runtime from its one declaration in `serve`'s operator-facing prose (#12151)

`serve` prints `@objectstack/organizations` at operators in five places: the
two-branch install remedy, the ADR-0093 D5 fatal refusal when a walled tenancy
posture cannot load the runtime, the degraded-boot warning, the stage-2 mount
refusal, and the `isolated` posture description in the tenancy-posture fix list.
#11614 single-sourced the name `serve` RESOLVES onto
`Serve.ORGANIZATIONS_RUNTIME_PKG` and pinned that declaration against the
spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES` roster, but the sentences kept their
own copies — so a roster-key rename would leave operator instructions naming a
package that no longer exists while boot reached for the new one, with every gate
green (the roster pin only sees the declaration).

All five now interpolate the constant, and a new test asserts what they RENDER
rather than that the constant appears in the source — the affected line compared
whitespace-included against text built from the same declaration. The message
bodies moved into pure formatters (`formatOrganizationsInstallRemedy`,
`formatOrganizationsAbsentFatal`, `formatDegradedTenancyWarning`,
`formatOrganizationsMountFatal`), the seam shape `resolveTenancyPostureOrRefusal`
in the same file already uses, so the rendering is reachable without spawning a
boot; `chalk` and the `process.exit` stay at the call site.

**No behaviour change.** The declared value is byte-identical to the literal it
replaces, and the rendered output was verified byte-for-byte against the
pre-change expressions across every branch (both remedy kinds, both walled
postures, `mountCode` present and absent). The three comments in `serve.ts` that
legitimately name the package are untouched — the tempting "no bare literal
outside the declaration" source scan is deliberately not built, since it would
have to exclude comments and that shape is easy to get wrong.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The operator-facing prose that names the multi-org runtime spells it from the
* ONE declaration, and these pins read what it RENDERS (#12151).
*
* ── What this closes ─────────────────────────────────────────────────────
*
* #11614 moved the package name `serve` RESOLVES onto
* `Serve.ORGANIZATIONS_RUNTIME_PKG` and pinned that declaration against the
* spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES` roster
* (`serve-capability-vocabulary.test.ts`). It deliberately stopped there. The
* sentences an operator actually reads still carried their own copies of the
* literal, under no check at all — so a roster-key rename would leave the
* install remedy, the ADR-0093 D5 fatal refusal, the degraded-boot warning, the
* stage-2 mount refusal and the posture description naming a package that no
* longer exists, while boot reached for the new one. Every gate stays green
* through that: the roster pin only ever sees the declaration.
*
* ── Why it reads the RENDERED text, not the source ───────────────────────
*
* "No bare literal outside the declaration" is the tempting stronger form and
* is deliberately NOT built here: a source scan has to exclude comments — three
* comments in `serve.ts` legitimately name the package — and that shape is easy
* to get wrong. What matters is what reaches the operator, so each pin renders
* a message and compares the affected LINE, whitespace included, against text
* built from the constant. That is also the half that makes the interpolation
* safe: two of these five sit on a fatal path an operator reads at the worst
* possible moment, where a stray space or a lost backtick is a real regression,
* and a `toBe` on the rendered line is what turns "eyeballed" into "checked".
*
* The expectations are built from `Serve.ORGANIZATIONS_RUNTIME_PKG` rather than
* from a literal on purpose. Rename the roster key and these keep passing —
* the prose moved with it. Re-spell it inline in any of these messages and the
* rendered line stops matching the declaration, which is the exact drift this
* file exists to catch.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { HostDeclaration } from '@objectstack/types/node';

import Serve, {
formatDegradedTenancyWarning,
formatOrganizationsAbsentFatal,
formatOrganizationsInstallRemedy,
formatOrganizationsMountFatal,
resolveTenancyPostureOrRefusal,
} from './serve.js';

/** The one declaration. Every expectation below is built from THIS, never from a literal. */
const PKG = Serve.ORGANIZATIONS_RUNTIME_PKG;

/**
* `chalk` may or may not emit SGR codes depending on TTY detection.
*
* The escape is written as `\x1b`, never as the byte itself: one raw control
* character makes grep treat the whole file as binary, and a test file nobody's
* `git grep` can find is a test file that stops being maintained (#4890/#5157).
*/
const SGR = /\x1b\[[0-9;]*m/g;
const plain = (s: string) => s.replace(SGR, '');
const lines = (s: string) => plain(s).split('\n');

/**
* Every `@scope/name` the RENDERED text puts in front of an operator.
*
* Reading the rendering, not the source, is the whole point — a comment naming
* the package is invisible here, which is why this can be a total sweep instead
* of a scan with exclusions to get wrong.
*/
const scopedNamesIn = (rendered: string): string[] =>
plain(rendered).match(/@[a-z0-9-]+\/[a-z0-9._-]+/g) ?? [];

const DECLARED: HostDeclaration = {
packageName: PKG,
hostRoot: '/srv/app',
declared: true,
field: 'dependencies',
specifier: '^1.2.3',
};
const UNDECLARED: HostDeclaration = { packageName: PKG, hostRoot: '/srv/app', declared: false };

const remedyDeclared = () => formatOrganizationsInstallRemedy('declared-unresolvable', DECLARED, '/srv/app');
const remedyUndeclared = () => formatOrganizationsInstallRemedy('undeclared', UNDECLARED, '/srv/app');

describe('serve — the multi-org runtime name an operator READS comes from the declaration (#12151)', () => {
it('site 1 — the "install is broken" remedy names it, with the spacing intact', () => {
// The `declared-unresolvable` branch: the app's package.json IS correct and
// the install is what broke (#4719). One of the two an operator ACTS on.
expect(lines(remedyDeclared())[0]).toBe(
` • this app DECLARES ${PKG} (dependencies: "^1.2.3") — the`,
);
});

it('site 2 — the "add it to THIS APP" remedy names it, with the spacing intact', () => {
// The other instruction an operator acts on: the app never declared it.
expect(lines(remedyUndeclared())[0]).toBe(
` • add ${PKG} (the enterprise multi-org runtime) to THIS APP`,
);
});

it('site 3 — the ADR-0093 D5 fatal refusal names it', () => {
const fatal = formatOrganizationsAbsentFatal('isolated', remedyUndeclared(), 'Cannot find package');
// A leading blank line separates the refusal from whatever boot last printed.
expect(lines(fatal)[0]).toBe('');
expect(lines(fatal)[1]).toBe(
` ✖ FATAL: tenancy posture 'isolated' was requested but ${PKG} could not be loaded,`,
);
});

it('site 4 — the degraded-boot warning names it', () => {
expect(plain(formatDegradedTenancyWarning('isolated'))).toBe(
` ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): posture 'isolated' requested but `
+ `${PKG} is unavailable — booting with the organization wall INACTIVE. `
+ 'Organization boundaries are NOT enforced. (ADR-0093 D5)',
);
});

it('site 5 — the stage-2 mount refusal names it', () => {
const fatal = formatOrganizationsMountFatal('isolated', 'seat count exceeded', 'ORG_SEATS');
expect(lines(fatal)[1]).toBe(
` ✖ FATAL: tenancy posture 'isolated' was requested and ${PKG} WAS found and loaded,`,
);
// …and it stays the "present but declined" diagnosis, not an absence.
expect(plain(fatal)).toContain('This is NOT a missing-package problem');
});

it('no message an operator reads names any OTHER scoped package', () => {
// The sweep the excluded source-scan form was reaching for, done over the
// rendering instead — where comments cannot reach and no exclusion list is
// needed. `mountMessage` is the plugin's own words, so it is fed something
// neutral here; the framework never interprets it.
for (const [label, rendered] of [
['remedy (declared)', remedyDeclared()],
['remedy (undeclared)', remedyUndeclared()],
['stage-1 fatal', formatOrganizationsAbsentFatal('group', remedyUndeclared(), 'ERR_MODULE_NOT_FOUND')],
['degraded warning', formatDegradedTenancyWarning('group')],
['stage-2 fatal', formatOrganizationsMountFatal('group', 'refused', undefined)],
] as const) {
const names = scopedNamesIn(rendered);
expect(names.length, `${label} names no package at all — it stopped telling operators which one`)
.toBeGreaterThan(0);
for (const name of names) {
expect(name, `${label} names '${name}', which is not the runtime serve resolves`).toBe(PKG);
}
}
});
});

describe('serve — the posture description an operator reads names the declaration (#12151)', () => {
const TOUCHED = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED'] as const;
let saved: Record<string, string | undefined> = {};

beforeEach(() => {
saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]]));
for (const k of TOUCHED) delete process.env[k];
});

afterEach(() => {
for (const k of TOUCHED) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});

it('site 6 — the `isolated` fix-list bullet names it, through the real gate', () => {
// Rendered through `resolveTenancyPostureOrRefusal` rather than by reading
// the hint table: the bullet's assembly (`• set OS_TENANCY_POSTURE=<p> — `)
// is part of what the operator sees, so it is part of what is pinned.
process.env.OS_TENANCY_POSTURE = 'not-a-posture';
const verdict = resolveTenancyPostureOrRefusal();
expect(verdict.ok, 'the gate accepted a value that is not a posture').toBe(false);
if (verdict.ok) return;
expect(lines(verdict.fatal)).toContain(
` • set OS_TENANCY_POSTURE=isolated — organization wall + the enterprise ${PKG} runtime `
+ "(the legacy spelling 'multi' is accepted and normalizes to this)",
);
});
});

describe('#12151 CONTROL — these pins can say no', () => {
it('a lost space between the package name and what follows it fails the comparison', () => {
// The exact regression the card names: interpolating into a template is
// where a stray space or a lost backtick hides. If this instrument could
// not tell the two apart, every assertion above would be decorative.
const expected = ` • add ${PKG} (the enterprise multi-org runtime) to THIS APP`;
expect(` • add ${PKG}(the enterprise multi-org runtime) to THIS APP`).not.toBe(expected);
expect(` • add ${PKG} (the enterprise multi-org runtime) to THIS APP`).not.toBe(expected);
expect(lines(remedyUndeclared())[0]).toBe(expected);
});

it('the scoped-name reader returns a positive on a foreign package and empty on none', () => {
// Anchored on a term that is NOT a substring of the one under test: a
// zero-hit sweep is not a reading until the same instrument answers yes.
expect(scopedNamesIn('prose naming @objectstack/legacy-orgs and @acme/thing')).toEqual([
'@objectstack/legacy-orgs',
'@acme/thing',
]);
expect(scopedNamesIn('prose naming no scoped package at all')).toEqual([]);
expect(scopedNamesIn(`prose naming ${PKG}`)).toEqual([PKG]);
});
});
Loading
Loading