Skip to content

Commit 882f313

Browse files
os-zhuangclaude
andcommitted
feat(cli,showcase): ADR-0008 Phase 3 — os package install + marketplace in the open single-env example
- New `os package install <manifest-id|artifact.json>` CLI command: catalog mode (runtime resolves from its configured catalog) and air-gapped inline mode (compiled artifact sent in-body, no catalog). Signs in to the TARGET runtime via better-auth (--email/--password, Origin header for the CSRF check). - app-showcase wires @objectstack/cloud-connection explicitly (proxy + install-local + cloud-connection + runtime-config) — the reference wiring for an open single-environment deployment with a marketplace. OS_CLOUD_URL=off disables; air-gapped installs keep working. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ec6d7ae commit 882f313

5 files changed

Lines changed: 265 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
New `os package install <id|artifact.json>` command — install a package into a RUNNING runtime via its install-local endpoint. Catalog mode resolves from the runtime's configured catalog; passing a compiled artifact file installs inline (air-gapped, no catalog round-trip). Authenticates against the target runtime with --email/--password (better-auth session; Origin header included for the CSRF check).

examples/app-showcase/objectstack.config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
import { defineStack } from '@objectstack/spec';
44
import { ConnectorRestPlugin } from '@objectstack/connector-rest';
55
import { ConnectorSlackPlugin } from '@objectstack/connector-slack';
6+
import {
7+
MarketplaceProxyPlugin,
8+
MarketplaceInstallLocalPlugin,
9+
CloudConnectionPlugin,
10+
RuntimeConfigPlugin,
11+
resolveCloudUrl,
12+
} from '@objectstack/cloud-connection';
613

714
import * as objects from './src/objects/index.js';
815
import { TaskViews, ProjectViews } from './src/views/index.js';
@@ -33,6 +40,10 @@ import { ShowcaseSeedData } from './src/data/index.js';
3340
// runtime. Keeps `pnpm typecheck` green without widening the type surface.
3441
declare const process: { env: Record<string, string | undefined> };
3542

43+
// Marketplace catalog URL: `OS_CLOUD_URL` → public ObjectStack catalog by
44+
// default; `OS_CLOUD_URL=off` returns '' and disables the marketplace plugins.
45+
const marketplaceUrl = resolveCloudUrl();
46+
3647
/**
3748
* Showcase — a kitchen-sink workspace that exercises every metadata type,
3849
* every view type, every chart type, and the major end-to-end capability
@@ -88,6 +99,22 @@ export default defineStack({
8899
new ConnectorSlackPlugin({
89100
token: process.env.SLACK_BOT_TOKEN ?? 'xoxb-showcase-demo-token',
90101
}),
102+
// App Marketplace for the open single-environment shape (ADR-0008).
103+
// Since ADR-0006 Phase 4 the CLI no longer auto-injects these — a host
104+
// that wants a marketplace wires @objectstack/cloud-connection explicitly.
105+
// Browse + install resolve against `OS_CLOUD_URL` (default: the public
106+
// ObjectStack catalog; set `OS_CLOUD_URL=off` for fully-offline runs —
107+
// air-gapped installs still work via `os package install <artifact.json>`).
108+
// install-local merges packages into THIS runtime's kernel: once
109+
// installed, nothing here depends on the cloud at runtime.
110+
...(marketplaceUrl
111+
? [
112+
new MarketplaceProxyPlugin({ controlPlaneUrl: marketplaceUrl }),
113+
new MarketplaceInstallLocalPlugin({ controlPlaneUrl: marketplaceUrl }),
114+
new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: marketplaceUrl }),
115+
]
116+
: []),
117+
new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, installLocal: true }),
91118
],
92119

93120
// Infrastructure

examples/app-showcase/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"verify": "pnpm typecheck && pnpm test"
2121
},
2222
"dependencies": {
23+
"@objectstack/cloud-connection": "workspace:*",
2324
"@objectstack/connector-rest": "workspace:*",
2425
"@objectstack/connector-slack": "workspace:*",
2526
"@objectstack/runtime": "workspace:*",
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `objectstack package install` — install a package into a RUNNING ObjectStack
5+
* runtime through its install-local endpoint (ADR-0008 Phase 3).
6+
*
7+
* Two modes, by argument shape:
8+
*
9+
* os package install com.acme.crm [--version 1.2.0]
10+
* Catalog mode: the runtime fetches the manifest snapshot from ITS
11+
* configured catalog (`OS_CLOUD_URL` of the runtime, public R2
12+
* fast-path first) and registers it into the live kernel.
13+
*
14+
* os package install ./dist/objectstack.json
15+
* Air-gapped mode: the compiled artifact is read locally and sent
16+
* inline — no catalog round-trip, works fully offline.
17+
*
18+
* This complements `os package publish` (which uploads to the CLOUD): the
19+
* pairing is publish-to-cloud / install-into-runtime. The target runtime
20+
* authenticates the call with its own better-auth session — pass
21+
* --email/--password (or OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) for an
22+
* account on the TARGET runtime, not your cloud login.
23+
*/
24+
25+
import { readFile } from 'node:fs/promises';
26+
import { existsSync } from 'node:fs';
27+
import { resolve as resolvePath } from 'node:path';
28+
import { Args, Command, Flags } from '@oclif/core';
29+
import { printHeader, printKV, printSuccess, printError, printStep } from '../../utils/format.js';
30+
31+
export default class PackageInstall extends Command {
32+
static override description =
33+
'Install a package into a running ObjectStack runtime (catalog id or local artifact file)';
34+
35+
static override examples = [
36+
'$ os package install com.acme.crm',
37+
'$ os package install com.acme.crm --version 1.2.0 --runtime http://localhost:3000',
38+
'$ os package install ./dist/objectstack.json # air-gapped, no catalog',
39+
'$ OS_RUNTIME_EMAIL=admin@local.test OS_RUNTIME_PASSWORD=… os package install com.acme.crm',
40+
];
41+
42+
static override args = {
43+
package: Args.string({
44+
description: 'Package manifest id (e.g. com.acme.crm) OR a path to a compiled artifact JSON',
45+
required: true,
46+
}),
47+
};
48+
49+
static override flags = {
50+
runtime: Flags.string({
51+
char: 'r',
52+
description: 'Target runtime base URL (the instance to install INTO)',
53+
env: 'OS_RUNTIME_URL',
54+
default: 'http://localhost:3000',
55+
}),
56+
version: Flags.string({
57+
char: 'v',
58+
description: "Version to install in catalog mode (default: 'latest')",
59+
default: 'latest',
60+
}),
61+
email: Flags.string({
62+
description: 'Runtime account email (better-auth session on the target runtime)',
63+
env: 'OS_RUNTIME_EMAIL',
64+
}),
65+
password: Flags.string({
66+
description: 'Runtime account password',
67+
env: 'OS_RUNTIME_PASSWORD',
68+
}),
69+
timeout: Flags.integer({
70+
description: 'HTTP timeout in milliseconds (0 disables)',
71+
env: 'OS_CLOUD_TIMEOUT_MS',
72+
default: 120_000,
73+
}),
74+
};
75+
76+
async run(): Promise<void> {
77+
const { args, flags } = await this.parse(PackageInstall);
78+
79+
printHeader('Install Package');
80+
const runtime = flags.runtime.replace(/\/+$/, '');
81+
82+
try {
83+
// ---- Resolve mode: inline artifact file vs catalog id ---------------
84+
const looksLikeFile = args.package.endsWith('.json')
85+
|| args.package.startsWith('./')
86+
|| args.package.startsWith('../')
87+
|| args.package.startsWith('/')
88+
|| existsSync(resolvePath(process.cwd(), args.package));
89+
90+
let body: Record<string, any>;
91+
let label: string;
92+
if (looksLikeFile) {
93+
const artifactPath = resolvePath(process.cwd(), args.package);
94+
printStep(`Loading artifact from ${artifactPath}...`);
95+
let raw: string;
96+
try {
97+
raw = await readFile(artifactPath, 'utf-8');
98+
} catch (err: any) {
99+
printError(`Cannot read artifact: ${err.message}. Run \`objectstack build\` first.`);
100+
this.exit(1);
101+
return;
102+
}
103+
let artifact: any;
104+
try {
105+
artifact = JSON.parse(raw);
106+
} catch (err: any) {
107+
printError(`Artifact is not valid JSON: ${err.message}`);
108+
this.exit(1);
109+
return;
110+
}
111+
// install-local's inline (air-gapped) path expects the manifest
112+
// object; a compiled artifact nests it under `manifest` alongside
113+
// the metadata payload — send the whole artifact so objects/views
114+
// travel with it, but make sure an id is present at the top level.
115+
const manifest = artifact?.manifest && typeof artifact.manifest === 'object'
116+
? { ...artifact, id: artifact.manifest.id ?? artifact.id, version: artifact.manifest.version ?? artifact.version }
117+
: artifact;
118+
body = { manifest };
119+
label = String(manifest.id ?? manifest.name ?? artifactPath);
120+
printSuccess(`Loaded artifact (${(raw.length / 1024).toFixed(1)} KB) — air-gapped inline install`);
121+
} else {
122+
body = { packageId: args.package, versionId: flags.version };
123+
label = `${args.package}@${flags.version}`;
124+
printStep(`Catalog install — the runtime resolves '${label}' from its configured catalog`);
125+
}
126+
127+
// ---- Authenticate against the TARGET runtime ------------------------
128+
let cookie: string | undefined;
129+
if (flags.email && flags.password) {
130+
printStep(`Signing in to ${runtime} as ${flags.email}...`);
131+
// `Origin` is required: better-auth's CSRF check 403s origin-less
132+
// non-browser POSTs to the sign-in route.
133+
const signIn = await this.request(`${runtime}/api/v1/auth/sign-in/email`, {
134+
method: 'POST',
135+
headers: { 'Content-Type': 'application/json', Origin: runtime },
136+
body: JSON.stringify({ email: flags.email, password: flags.password }),
137+
}, flags.timeout);
138+
if (!signIn.ok) {
139+
printError(`Runtime sign-in failed (${signIn.status}): ${signIn.error}`);
140+
this.exit(1);
141+
return;
142+
}
143+
cookie = signIn.setCookie;
144+
if (!cookie) {
145+
printError('Runtime sign-in returned no session cookie.');
146+
this.exit(1);
147+
return;
148+
}
149+
printSuccess('Signed in');
150+
}
151+
152+
// ---- Install ---------------------------------------------------------
153+
printStep(`Installing ${label} into ${runtime}...`);
154+
const res = await this.request(`${runtime}/api/v1/marketplace/install-local`, {
155+
method: 'POST',
156+
headers: {
157+
'Content-Type': 'application/json',
158+
...(cookie ? { Cookie: cookie } : {}),
159+
},
160+
body: JSON.stringify(body),
161+
}, flags.timeout);
162+
163+
if (!res.ok) {
164+
if (res.status === 401) {
165+
printError(
166+
'The runtime rejected the call as unauthenticated. Pass --email/--password ' +
167+
'(or set OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) for an account on the target runtime.',
168+
);
169+
} else if (res.status === 404) {
170+
printError(
171+
`install-local endpoint not found on ${runtime}. The target runtime must mount ` +
172+
'MarketplaceInstallLocalPlugin (see @objectstack/cloud-connection).',
173+
);
174+
} else {
175+
printError(`Install failed (${res.status}): ${res.error}`);
176+
}
177+
this.exit(1);
178+
return;
179+
}
180+
181+
const data = res.body?.data ?? res.body ?? {};
182+
console.log('');
183+
printSuccess('Package installed into the running kernel');
184+
printKV(' Package', String(data.manifestId ?? data.packageId ?? label));
185+
printKV(' Version', String(data.version ?? flags.version));
186+
printKV(' Runtime', runtime);
187+
if (data.installedAt) printKV(' Installed', String(data.installedAt));
188+
console.log('');
189+
console.log(' The manifest is cached under .objectstack/installed-packages/ on the');
190+
console.log(' runtime host and re-registers on every boot (survives restarts).');
191+
} catch (error) {
192+
printError((error as Error).message);
193+
this.exit(1);
194+
}
195+
}
196+
197+
/** Fetch wrapper: normalised envelope + captured Set-Cookie + timeout. */
198+
private async request(
199+
url: string,
200+
init: RequestInit,
201+
timeoutMs: number,
202+
): Promise<{ ok: boolean; status: number; body: any; setCookie?: string; error?: string }> {
203+
const controller = new AbortController();
204+
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
205+
try {
206+
const response = await fetch(url, { ...init, signal: controller.signal });
207+
let parsed: any = null;
208+
try { parsed = await response.json(); } catch { /* empty/non-json */ }
209+
// Session cookie: keep only the key=value pairs (drop attributes) so
210+
// it can be replayed as a request Cookie header.
211+
const rawSetCookie = response.headers.get('set-cookie') ?? undefined;
212+
const setCookie = rawSetCookie
213+
?.split(/,(?=[^;]+?=)/)
214+
.map((p) => p.split(';')[0].trim())
215+
.filter(Boolean)
216+
.join('; ');
217+
if (!response.ok) {
218+
const errMsg = parsed?.error?.message ?? parsed?.error ?? response.statusText ?? `HTTP ${response.status}`;
219+
return { ok: false, status: response.status, body: parsed, setCookie, error: typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg) };
220+
}
221+
return { ok: true, status: response.status, body: parsed, setCookie };
222+
} catch (err: any) {
223+
if (err?.name === 'AbortError') {
224+
return { ok: false, status: 0, body: null, error: `Request timed out after ${timeoutMs}ms.` };
225+
}
226+
return { ok: false, status: 0, body: null, error: err?.message ?? String(err) };
227+
} finally {
228+
if (timer) clearTimeout(timer);
229+
}
230+
}
231+
}

packages/cli/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@ export { default as CloudWhoamiCommand } from './commands/cloud/whoami.js';
3131

3232
// ─── Package topic subcommands ──────────────────────────────────────
3333
export { default as PackagePublishCommand } from './commands/package/publish.js';
34+
export { default as PackageInstallCommand } from './commands/package/install.js';

0 commit comments

Comments
 (0)