Skip to content

Commit 5be0c25

Browse files
committed
feat(cloud-connection,cli): install-local POST reports storageDir, CLI quotes it (#6721)
The install POST's `data` block now carries `storageDir: this.storageDir` — the same resolved ledger directory (`LocalManifestSource.dir`) the GET listing sibling already served. `os package install` runs on a different machine and never touches the runtime's disk, so before this the CLI could only describe the cache location by literal, and that literal was the plugin's default — wrong for every host that configures `storageDir`. The CLI now quotes the reported value, and prints NO directory sentence when the response lacks the field (an older host). No literal fallback: a `??` there would reinstate the defect PD #12 forbids and #5996 deleted. Fixes #6721 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
1 parent 1da1f32 commit 5be0c25

5 files changed

Lines changed: 331 additions & 36 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/cloud-connection": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(cloud-connection,cli): the install-local POST reports where it cached the manifest, and `os package install` quotes it (#6721)
7+
8+
The two endpoints of `MarketplaceInstallLocalPlugin` disagreed about one fact.
9+
`GET /api/v1/marketplace/install-local` — the console's Installed Apps list —
10+
served `storageDir: this.storageDir`, the ledger directory as resolved by
11+
`LocalManifestSource` (`config.storageDir` when the host set one, the
12+
`.objectstack/installed-packages` default when it did not). The `POST` that
13+
performs the install did not, so its `data` block described everything about
14+
the install except **where the install went**.
15+
16+
That gap was load-bearing for the one consumer of that response. `os package
17+
install` runs on a different machine from the runtime it installs into: it
18+
speaks HTTP and never touches the target's disk. With no directory in the
19+
response it could only describe the cache location by literal, and the literal
20+
it printed was the plugin's *default* — wrong for every host that configures
21+
`storageDir`, and wrong today rather than eventually. No consumer-side fix
22+
existed: a locally-resolved constant names the wrong machine, and importing it
23+
from `@objectstack/cloud-connection` would make a pure-HTTP command fail at
24+
module load wherever that package is absent. The producer is the contract, so
25+
the fix is there.
26+
27+
**`@objectstack/cloud-connection` (additive, no migration).** The install POST
28+
response's `data` now carries `storageDir`, read from the same
29+
`this.storageDir` field the GET listing already returns — one field, two
30+
endpoints, so they cannot drift apart again. No existing key changed, and
31+
nothing needs to read the new one.
32+
33+
**`@objectstack/cli`.** The post-install hint now quotes the directory the
34+
runtime reported:
35+
36+
```
37+
The manifest is cached on the runtime host and re-registers on every
38+
boot (survives restarts):
39+
/srv/objectstack/state/ledger-packages
40+
```
41+
42+
Against a runtime older than this release — one whose response has no
43+
`storageDir` — the CLI prints **no** directory sentence at all. It does not
44+
fall back to the old literal: a consumer stating a value the producer declined
45+
to state is the defect Prime Directive #12 forbids, and saying less is correct
46+
where guessing is not. Everything else the command prints is unchanged.

packages/cli/src/commands/package/install.ts

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -215,43 +215,32 @@ export default class PackageInstall extends Command {
215215
printKV(' Runtime', runtime);
216216
if (data.installedAt) printKV(' Installed', String(data.installedAt));
217217
console.log('');
218-
// ⚠️ This path is a DESCRIPTION OF THE DEFAULT CONVENTION, deliberately
219-
// left as a literal — not a consumer restating a value it could have read
220-
// (#6643, the sibling half of #5996). Do not "fix" it into a reference to
221-
// `DEFAULT_INSTALLED_PACKAGES_DIR`. Four measured reasons, in order:
218+
// #6721: the cache directory is quoted from the RESPONSE, never from a
219+
// literal or a locally-resolved constant. The directory lives on the
220+
// REMOTE host — everything above this line came back over HTTP from
221+
// `runtime`, and this command never touches the target's disk — so the
222+
// only truthful source is the host itself. #6643 documented at length why
223+
// no consumer-side answer works (a local constant describes the machine
224+
// typing the command; it is only the *default*, wrong the moment a host
225+
// configures `storageDir`; a static import of
226+
// `@objectstack/cloud-connection` would make a pure-HTTP command fail at
227+
// module load). The fix was upstream, and it landed: the POST response now
228+
// carries `storageDir: this.storageDir` — the same resolved value
229+
// (`ledger.dir`) its GET listing sibling already served.
222230
//
223-
// 1. **The directory is on the REMOTE host.** Everything above this
224-
// line came back over HTTP from `runtime`; this command never
225-
// touches the target's disk. A constant resolved HERE describes the
226-
// machine typing the command, not the one that stored the manifest.
227-
// 2. **The remote's directory is configurable, so the constant is only
228-
// its default.** `MarketplaceInstallLocalPlugin` builds its ledger as
229-
// `new LocalManifestSource(config.storageDir)` — the export is the
230-
// fallback that ctor applies when the host configured nothing.
231-
// Interpolating it would state a default as an observed fact.
232-
// 3. **The response we just read does not carry the real answer.** The
233-
// POST returns `{ manifestId, version, versionId, installedAt,
234-
// hotLoaded, upgradedFrom, translationsLoaded, seeded, note }` — no
235-
// `storageDir`. The GET listing endpoint does carry one; this one
236-
// does not, and asking for it would be an extra round-trip (and a
237-
// new failure mode) bolted onto a success hint.
238-
// 4. **Importing it would cost this command its independence.** Every
239-
// CLI reference to `@objectstack/cloud-connection` is a DYNAMIC load
240-
// behind `loadOptionalPackage()` (doctor.ts) or a guarded `import()`
241-
// (serve.ts), because the CLI must keep working where that package
242-
// is absent or unbuilt. A static import for one hint line would make
243-
// a pure-HTTP command fail at module load; a dynamic one needs a
244-
// literal fallback — the exact `??` Prime Directive #12 forbids and
245-
// #5996 deleted.
246-
//
247-
// So the divergence surface is accepted here, knowingly: if the constant
248-
// ever changes value, this sentence goes stale and no gate will say so.
249-
// The honest fix is upstream — the POST response carrying `storageDir`
250-
// like its GET sibling — which would let this line quote the real remote
251-
// directory. Tracked separately; `@objectstack/cloud-connection` is out
252-
// of scope for #6643.
253-
console.log(' The manifest is cached under .objectstack/installed-packages/ on the');
254-
console.log(' runtime host and re-registers on every boot (survives restarts).');
231+
// ⛔ No fallback. When the field is missing — an older host that predates
232+
// the producer half — this block prints NOTHING. A `??
233+
// '.objectstack/installed-packages/'` would re-introduce exactly the
234+
// defect Prime Directive #12 forbids and #5996 deleted: a consumer
235+
// inventing a value the producer declined to state. Saying less is
236+
// correct; guessing is not. An empty or non-string value is the same
237+
// "host did not state it" case, not a reason to print a blank path.
238+
const storageDir = typeof data.storageDir === 'string' ? data.storageDir.trim() : '';
239+
if (storageDir) {
240+
console.log(' The manifest is cached on the runtime host and re-registers on every');
241+
console.log(' boot (survives restarts):');
242+
console.log(` ${storageDir}`);
243+
}
255244
} catch (error) {
256245
printError((error as Error).message);
257246
this.exit(1);
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #6721 — `os package install`'s post-install directory hint quotes the
5+
* RESPONSE, and prints nothing when the response has no directory to quote.
6+
*
7+
* The directory lives on the runtime host, which is a different machine: this
8+
* command speaks only HTTP and never touches the target's disk. #6643 measured
9+
* that no consumer-side answer exists — a locally-resolved constant describes
10+
* the wrong machine and is in any case only the host's *default*, wrong for
11+
* every host that configures `storageDir` — and left the literal in place with
12+
* a comment saying the honest fix was upstream. It landed (POST now carries
13+
* `storageDir`), so the literal is gone.
14+
*
15+
* The absent case is the one that keeps it gone. A `?? '.objectstack/…'` bolted
16+
* onto the new reference would read as a harmless kindness to older hosts and
17+
* would reinstate precisely the defect Prime Directive #12 forbids and #5996
18+
* deleted: a consumer stating a value the producer declined to state. Only a
19+
* case that FAILS when the sentence appears anyway can hold that door shut —
20+
* a test covering just the happy path leaves it open.
21+
*/
22+
23+
import { describe, it, expect, afterEach, vi } from 'vitest';
24+
import PackageInstall from '../src/commands/package/install.js';
25+
26+
/** The host configured its ledger somewhere no default would ever guess. */
27+
const REMOTE_DIR = '/srv/objectstack/state/ledger-packages';
28+
29+
/** Stub the runtime's install POST with the given `data` block. */
30+
function stubRuntime(data: Record<string, unknown>): void {
31+
vi.stubGlobal('fetch', vi.fn(async () => ({
32+
ok: true,
33+
status: 200,
34+
statusText: 'OK',
35+
headers: { get: () => null },
36+
json: async () => ({ success: true, data }),
37+
}) as any));
38+
}
39+
40+
/** Run the command in catalog mode and return everything it printed. */
41+
async function runInstall(): Promise<string> {
42+
const lines: string[] = [];
43+
const capture = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); };
44+
vi.spyOn(console, 'log').mockImplementation(capture);
45+
vi.spyOn(console, 'error').mockImplementation(capture);
46+
await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test']);
47+
return lines.join('\n');
48+
}
49+
50+
const INSTALLED = {
51+
manifestId: 'com.acme.crm',
52+
version: '1.0.0',
53+
versionId: 'ver_1',
54+
installedAt: '2026-08-10T00:00:00.000Z',
55+
hotLoaded: true,
56+
};
57+
58+
describe('os package install — post-install directory hint', () => {
59+
afterEach(() => {
60+
vi.unstubAllGlobals();
61+
vi.restoreAllMocks();
62+
});
63+
64+
it('quotes the storageDir the runtime reported, verbatim', async () => {
65+
stubRuntime({ ...INSTALLED, storageDir: REMOTE_DIR });
66+
67+
const out = await runInstall();
68+
69+
expect(out).toContain('Package installed into the running kernel');
70+
expect(out).toContain(REMOTE_DIR);
71+
expect(out).toContain('re-registers on every');
72+
// The old literal is not printed alongside the real value — the point is
73+
// that the sentence has ONE source, and it is the response.
74+
expect(out).not.toContain('.objectstack/installed-packages');
75+
});
76+
77+
// The delivery criterion of this card: no fallback. An older host that
78+
// predates the producer half says nothing about its ledger, so neither do we.
79+
it('prints NO directory sentence when the response omits storageDir', async () => {
80+
stubRuntime({ ...INSTALLED });
81+
82+
const out = await runInstall();
83+
84+
// The install itself is still reported — only the directory claim is gone.
85+
expect(out).toContain('Package installed into the running kernel');
86+
expect(out).toContain('com.acme.crm');
87+
expect(out).not.toContain('cached');
88+
expect(out).not.toContain('survives restarts');
89+
expect(out).not.toContain('.objectstack/installed-packages');
90+
expect(out).not.toContain('undefined');
91+
});
92+
93+
// A host that answers with an empty/blank value has told us nothing either;
94+
// that is the same case, not a licence to print a blank path.
95+
it.each([
96+
['an empty string', ''],
97+
['whitespace', ' '],
98+
['null', null],
99+
])('prints no directory sentence when storageDir is %s', async (_label, value) => {
100+
stubRuntime({ ...INSTALLED, storageDir: value });
101+
102+
const out = await runInstall();
103+
104+
expect(out).toContain('Package installed into the running kernel');
105+
expect(out).not.toContain('cached');
106+
expect(out).not.toContain('survives restarts');
107+
});
108+
});

packages/cloud-connection/src/marketplace-install-local-plugin.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,15 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
731731
upgradedFrom: conflict === 'marketplace' ? 'previous-marketplace-version' : null,
732732
translationsLoaded: seededSummary.translationsLoaded,
733733
seeded: seededSummary.seeded,
734+
// #6721: the RESOLVED ledger directory on THIS host — the same
735+
// value the GET listing serves (`handleList`), from the same
736+
// field. It is here because the installer is remote: `os package
737+
// install` never touches this machine's disk, so without it the
738+
// CLI can only describe the directory by literal, and that
739+
// literal is wrong the moment a host configures `storageDir`.
740+
// Keep the two endpoints reading `this.storageDir` — a second
741+
// derivation is how they diverged in the first place.
742+
storageDir: this.storageDir,
734743
note: 'App is now available in this runtime. Refresh the console to see it in the app switcher.',
735744
},
736745
}, 200);
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #6721 — the install POST reports WHERE it cached the manifest.
5+
*
6+
* The two endpoints of this plugin had diverged on one fact. `GET
7+
* /api/v1/marketplace/install-local` (the console's Installed Apps list) served
8+
* `storageDir: this.storageDir`; the `POST` that performs the install did not,
9+
* so its only consumer — `os package install`, which runs on a DIFFERENT machine
10+
* and never touches this host's disk — had no resolved value to quote and could
11+
* only describe the ledger directory by literal. That literal is the ctor's
12+
* default, i.e. wrong for every host that configures `storageDir`.
13+
*
14+
* The non-default-`storageDir` case below is therefore the point of this file,
15+
* not a variation on it: it is exactly the configuration the old literal
16+
* misreported, and the only one that can tell "quotes the resolved value" apart
17+
* from "restates the default".
18+
*
19+
* `this.storageDir` is `this.ledger.dir` — already resolved by
20+
* `LocalManifestSource`'s ctor — so both endpoints must keep reading that one
21+
* field. A second derivation is how they diverged in the first place, which is
22+
* why the parity case asserts POST and GET are the same string rather than
23+
* asserting each against a separately-computed expectation.
24+
*/
25+
26+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
27+
import { mkdtempSync, rmSync } from 'node:fs';
28+
import { join, resolve } from 'node:path';
29+
import { tmpdir } from 'node:os';
30+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
31+
import { DEFAULT_INSTALLED_PACKAGES_DIR } from './local-manifest-source.js';
32+
33+
type Handler = (c: any) => Promise<any>;
34+
35+
function makeRawApp() {
36+
const routes = new Map<string, Handler>();
37+
return {
38+
routes,
39+
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
40+
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
41+
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
42+
};
43+
}
44+
45+
function makeCtx(rawApp: any) {
46+
const hooks = new Map<string, any>();
47+
const services: Record<string, any> = {
48+
manifest: { register: vi.fn() },
49+
auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } },
50+
objectql: { syncSchemas: async () => undefined },
51+
};
52+
return {
53+
ctx: {
54+
hook: (e: string, h: any) => hooks.set(e, h),
55+
getService: (name: string) => {
56+
if (name === 'http-server') return { getRawApp: () => rawApp };
57+
const svc = services[name];
58+
if (svc === undefined) throw new Error(`no ${name}`);
59+
return svc;
60+
},
61+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
62+
},
63+
fire: async () => { await hooks.get('kernel:ready')?.(); },
64+
};
65+
}
66+
67+
function makeC(body: any) {
68+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
69+
return {
70+
req: {
71+
url: 'http://localhost:3000/api/v1/marketplace/install-local',
72+
raw: new Request('http://localhost:3000/x'),
73+
json: async () => body,
74+
param: () => undefined,
75+
},
76+
json,
77+
};
78+
}
79+
80+
/** Boot the plugin far enough that its routes are mounted. */
81+
async function mount(storageDir?: string) {
82+
const rawApp = makeRawApp();
83+
const { ctx, fire } = makeCtx(rawApp);
84+
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir });
85+
await plugin.start(ctx as any);
86+
await fire();
87+
return {
88+
install: async (manifest: any) =>
89+
rawApp.routes.get('POST /api/v1/marketplace/install-local')!(makeC({ manifest })),
90+
list: async () =>
91+
rawApp.routes.get('GET /api/v1/marketplace/install-local')!(makeC({})),
92+
};
93+
}
94+
95+
const MANIFEST = { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', objects: [] };
96+
97+
let dir: string;
98+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-storage-dir-')); });
99+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });
100+
101+
describe('install-local POST response — storageDir', () => {
102+
it('carries the RESOLVED ledger directory of a host that configured a non-default storageDir', async () => {
103+
const { install } = await mount(dir);
104+
105+
const res = await install(MANIFEST);
106+
107+
expect(res.payload?.success).toBe(true);
108+
// The configured directory, resolved — not the ctor default. Both
109+
// halves matter: the first is the value the CLI must be able to quote,
110+
// the second is the assertion the old literal would have failed.
111+
expect(res.payload.data.storageDir).toBe(resolve(dir));
112+
expect(res.payload.data.storageDir).not.toContain(DEFAULT_INSTALLED_PACKAGES_DIR);
113+
});
114+
115+
it('carries the ctor-default directory when the host configured nothing', async () => {
116+
// chdir into the temp dir first: with no `storageDir` the ledger
117+
// resolves against `process.cwd()`, and the install genuinely writes a
118+
// file there. Without this the case would litter the repo checkout.
119+
const prevCwd = process.cwd();
120+
process.chdir(dir);
121+
try {
122+
const { install } = await mount(undefined);
123+
124+
const res = await install(MANIFEST);
125+
126+
expect(res.payload?.success).toBe(true);
127+
expect(res.payload.data.storageDir).toBe(resolve(process.cwd(), DEFAULT_INSTALLED_PACKAGES_DIR));
128+
} finally {
129+
process.chdir(prevCwd);
130+
}
131+
});
132+
133+
it('reports the same directory as the GET listing — one field, two endpoints', async () => {
134+
const { install, list } = await mount(dir);
135+
136+
const installed = await install(MANIFEST);
137+
const listed = await list();
138+
139+
expect(installed.payload.data.storageDir).toBe(listed.payload.data.storageDir);
140+
expect(typeof installed.payload.data.storageDir).toBe('string');
141+
expect(installed.payload.data.storageDir.length).toBeGreaterThan(0);
142+
});
143+
});

0 commit comments

Comments
 (0)