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
10 changes: 10 additions & 0 deletions .changeset/serve-names-unloaded-dev-plugin-by-its-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/cli': patch
---

fix(cli): `os serve` names an unloaded `devPlugins` entry by the list it was written in

Clause-②: no
Metadata-protocol: none

When `os serve --dev` refuses to boot because an entry that may provide the crypto provider (`crypto-provider`) did not load, a `devPlugins` entry is now named `devPlugins[i]` instead of by its position after the stack's `plugins`. Which entries are refused is unchanged: the refusal reads an entry's `providesServices` through the same predicate as the rest of the crypto-provider checks.
23 changes: 22 additions & 1 deletion packages/cli/src/commands/serve-host-crypto-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { maskComments } from '../../../../scripts/js-comment-mask.mjs';

import {
createHostCryptoProvider,
cryptoProviderPlugins,
HostCryptoProviderRefusal,
refuseTwoCryptoProviderPlugins,
refuseUnloadedCryptoProviderPlugins,
Expand Down Expand Up @@ -486,6 +487,26 @@ describe('a plugins entry that did not load refuses the boot unless it is ruled
expect(refusalOf([])).toBeUndefined();
});

it('a devPlugins entry is named by the list it was written in', () => {
const refusal = refusalOf([
{ list: 'devPlugins', index: 0, entry: '@acme/dev-kms', error: new Error('a') },
{ index: 1, entry: '@acme/kms', error: new Error('b') },
]);
expect(refusal!.message).toContain("devPlugins[0] ('@acme/dev-kms') did not load (a)");
expect(refusal!.message).toContain("plugins[1] ('@acme/kms') did not load (b)");
});

it('reads a declaration as cryptoProviderPlugins does: an entry it counts as the provider is refused, one it does not is ruled out', () => {
const declaring = providerPlugin('com.example.kms-crypto', kmsProvider().provider);
const other = { name: 'com.example.widgets', providesServices: ['widgets'], init: async () => {} };
const none = { name: 'com.example.plain', init: async () => {} };
for (const candidate of [declaring, other, none]) {
const counted = cryptoProviderPlugins([candidate]).length > 0;
const refused = refusalOf([{ index: 0, entry: candidate, error: new Error('x') }]) !== undefined;
expect(refused, (candidate as { name: string }).name).toBe(counted);
}
});

it('every refused entry is named, and the ruled-out ones are not', () => {
const refusal = refusalOf([
{ index: 0, entry: '@acme/one', error: new Error('a') },
Expand Down Expand Up @@ -663,7 +684,7 @@ describe('the wiring inside run()', () => {
});

it('refuses an unloaded entry that may provide the crypto provider right after the plugins loop, before the host provider is built', () => {
const record = SERVE.indexOf('unloadedStackPlugins.push({ index, entry: plugin, materialized, error: e });');
const record = SERVE.indexOf('unloadedStackPlugins.push(');
const check = SERVE.indexOf('refuseUnloadedCryptoProviderPlugins(unloadedStackPlugins);');
const host = SERVE.indexOf('hostCrypto = createHostCryptoProvider(() => localCryptoProvider.resolve());');
expect(record).toBeGreaterThan(0);
Expand Down
51 changes: 34 additions & 17 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3307,6 +3307,10 @@ export default class Serve extends Command {
// `stackBootPlugins` [#22371], the list `os migrate
// security-catalog-overlays --dev` composes too.
let plugins: any[] = stackBootPlugins(config, flags.dev);
// Where `devPlugins` sit in that list (after the stack's own `plugins`),
// so an entry that does not load is named by the list it was written in.
const authoredPluginCount = stackBootPlugins(config, false).length;
const bootPluginCount = plugins.length;

// 1. Auto-register ObjectQL Plugin if objects define but plugins missing
// [#15006] The whole gate — the `objects` read AND the already-composed
Expand Down Expand Up @@ -4765,7 +4769,11 @@ export default class Serve extends Command {
trackPlugin(pluginName);
} catch (e: any) {
console.error(chalk.red(` ✗ Failed to load plugin: ${e.message}`));
unloadedStackPlugins.push({ index, entry: plugin, materialized, error: e });
unloadedStackPlugins.push(
index >= authoredPluginCount && index < bootPluginCount
? { list: 'devPlugins', index: index - authoredPluginCount, entry: plugin, materialized, error: e }
: { index, entry: plugin, materialized, error: e },
);
}
}
}
Expand Down Expand Up @@ -6390,13 +6398,20 @@ export function cryptoProviderPlugins(composed: Iterable<unknown>): Array<{ name
if (typeof name === 'string') byName.set(name, candidate as object);
}
return [...byName]
.filter(([, plugin]) => {
const provides = (plugin as { providesServices?: unknown }).providesServices;
return Array.isArray(provides) && provides.includes(CRYPTO_PROVIDER_SERVICE);
})
.filter(([, plugin]) => declaresCryptoProvider(plugin))
.map(([name, plugin]) => ({ name, plugin }));
}

/**
* Does this plugin (or entry) declare, in `providesServices` (ADR-0116), that
* it registers {@link CRYPTO_PROVIDER_SERVICE}? The one predicate behind
* {@link cryptoProviderPlugins} and {@link refuseUnloadedCryptoProviderPlugins}.
*/
function declaresCryptoProvider(plugin: object): boolean {
const provides = (plugin as { providesServices?: unknown }).providesServices;
return Array.isArray(provides) && provides.includes(CRYPTO_PROVIDER_SERVICE);
}

/**
* Refuse a composition in which more than one plugin declares, in
* `providesServices` (ADR-0116), that it registers {@link CRYPTO_PROVIDER_SERVICE}.
Expand All @@ -6421,7 +6436,9 @@ export function refuseTwoCryptoProviderPlugins(composed: Iterable<unknown>): voi

/** A `plugins` entry `os serve` could not load: {@link refuseUnloadedCryptoProviderPlugins}. */
export interface UnloadedStackPlugin {
/** Its position in the stack's `plugins`. */
/** The list the entry was written in; `plugins` when absent. */
list?: 'plugins' | 'devPlugins';
/** Its position in that list. */
index: number;
/** The entry as the config wrote it. */
entry: unknown;
Expand All @@ -6438,12 +6455,13 @@ export interface UnloadedStackPlugin {
* setting, and the host chooses it once at `kernel:ready`
* ({@link createHostCryptoProvider}). A boot that goes on without the plugin
* that provides it would choose another one, so it does not go on. An entry is
* ruled out only by what it says it provides: the plugin it materialized to
* (or the entry itself, when it is an object) has a `providesServices`
* (ADR-0116) that does not name the provider. An entry that never
* materialized (a package specifier that does not resolve or import) says
* nothing, and is refused. Every other unloaded entry keeps the boot's
* existing handling: it is printed and the boot goes on without it.
* judged by what it says it provides, read as {@link cryptoProviderPlugins}
* reads it: the plugin it materialized to (or the entry itself, when it is an
* object) is refused when its `providesServices` (ADR-0116) names the
* provider, and ruled out when it declares other services or declares none.
* An entry that never materialized (a package specifier that does not resolve
* or import) says nothing, and is refused. Every ruled-out entry keeps the
* boot's existing handling: it is printed and the boot goes on without it.
*
* The one-shot secret commands keep the same rule over the same host config.
*/
Expand All @@ -6453,14 +6471,13 @@ export function refuseUnloadedCryptoProviderPlugins(unloaded: readonly UnloadedS
? materialized
: entry && typeof entry === 'object' ? entry : undefined;
if (!said) return true;
const provides = (said as { providesServices?: unknown }).providesServices;
return Array.isArray(provides) && provides.includes(CRYPTO_PROVIDER_SERVICE);
return declaresCryptoProvider(said);
});
if (refused.length === 0) return;
const label = ({ index, entry, materialized }: UnloadedStackPlugin): string => {
if (typeof entry === 'string') return `plugins[${index}] ('${entry}')`;
const label = ({ list = 'plugins', index, entry, materialized }: UnloadedStackPlugin): string => {
if (typeof entry === 'string') return `${list}[${index}] ('${entry}')`;
const name = (materialized as { name?: unknown } | null | undefined)?.name ?? (entry as { name?: unknown } | null)?.name;
return typeof name === 'string' ? `plugins[${index}] ('${name}')` : `plugins[${index}]`;
return typeof name === 'string' ? `${list}[${index}] ('${name}')` : `${list}[${index}]`;
};
const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error));
throw new HostCryptoProviderRefusal(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
// test-budget: 40 — spawns `os serve` (src, via runServe) once per single-cause fixture: four boots, two refused and two that reach the ready banner; budget to be re-measured
// test-budget: 40 — spawns `os serve` (src, via runServe) once per single-cause fixture: five boots, three refused and two that reach the ready banner; budget to be re-measured

/**
* `os serve` refuses to boot when a `plugins` entry fails to load and it cannot
Expand All @@ -14,6 +14,8 @@
* nothing says what it provides, so the boot is refused;
* - an entry that materializes, declares the crypto provider in
* `providesServices`, and is refused by the kernel: refused;
* - under `--dev`, a `devPlugins` entry that does not materialize: refused,
* named by the list it was written in (`devPlugins[0]`);
* - CONTROL: an entry that materializes, declares nothing of the kind, and is
* refused by the kernel: the boot goes on, as before, and says so;
* - CONTROL: a config whose every entry loads boots.
Expand Down Expand Up @@ -62,6 +64,12 @@ const CONFIGS = {
}],
${OBJECT}
};
`,
devUnresolvable: `export default {
plugins: [{ name: 'com.acme.host', version: '1.0.0', init: async () => {} }, '@acme/not-installed-crypto-provider'],
devPlugins: ['@acme/not-installed-dev-crypto-provider'],
${OBJECT}
};
`,
allLoad: `export default {
plugins: [{ name: 'com.acme.fine', version: '1.0.0', init: async () => {} }],
Expand All @@ -84,8 +92,8 @@ afterAll(() => {
for (const dir of Object.values(dirs)) if (dir) rmSync(dir, { recursive: true, force: true });
});

const boot = async (key: keyof typeof CONFIGS) => {
const run = await runServe(dirs[key]!, [], { waitFor: SETTLED, timeoutMs: 150_000 });
const boot = async (key: keyof typeof CONFIGS, args: string[] = []) => {
const run = await runServe(dirs[key]!, args, { waitFor: SETTLED, timeoutMs: 150_000 });
return { ...run, output: run.stdout + run.stderr };
};

Expand All @@ -107,6 +115,14 @@ describe('os serve — a plugins entry that fails to load and may provide the cr
expect(port).toBeUndefined();
}, 180_000);

it('under --dev, a devPlugins entry that does not materialize is refused, named by its own list', async () => {
const { output, port } = await boot('devUnresolvable', ['--dev']);
expect(output, output).toMatch(REFUSED);
expect(output).toContain("devPlugins[0] ('@acme/not-installed-dev-crypto-provider') did not load");
expect(output).toContain("plugins[1] ('@acme/not-installed-crypto-provider') did not load");
expect(port).toBeUndefined();
}, 180_000);

it('CONTROL: an entry that declares no crypto provider and is refused by the kernel: the boot goes on', async () => {
const { output, port } = await boot('nonDeclaringRefused');
expect(output).toContain('Failed to load plugin');
Expand Down
Loading