diff --git a/.changeset/package-install-command.md b/.changeset/package-install-command.md new file mode 100644 index 0000000000..e89130af57 --- /dev/null +++ b/.changeset/package-install-command.md @@ -0,0 +1,5 @@ +--- +"@objectstack/cli": minor +--- + +New `os package install ` 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). diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 527fbb9479..e82187a552 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -3,6 +3,13 @@ import { defineStack } from '@objectstack/spec'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorSlackPlugin } from '@objectstack/connector-slack'; +import { + MarketplaceProxyPlugin, + MarketplaceInstallLocalPlugin, + CloudConnectionPlugin, + RuntimeConfigPlugin, + resolveCloudUrl, +} from '@objectstack/cloud-connection'; import * as objects from './src/objects/index.js'; import { TaskViews, ProjectViews } from './src/views/index.js'; @@ -33,6 +40,10 @@ import { ShowcaseSeedData } from './src/data/index.js'; // runtime. Keeps `pnpm typecheck` green without widening the type surface. declare const process: { env: Record }; +// Marketplace catalog URL: `OS_CLOUD_URL` → public ObjectStack catalog by +// default; `OS_CLOUD_URL=off` returns '' and disables the marketplace plugins. +const marketplaceUrl = resolveCloudUrl(); + /** * Showcase — a kitchen-sink workspace that exercises every metadata type, * every view type, every chart type, and the major end-to-end capability @@ -88,6 +99,22 @@ export default defineStack({ new ConnectorSlackPlugin({ token: process.env.SLACK_BOT_TOKEN ?? 'xoxb-showcase-demo-token', }), + // App Marketplace for the open single-environment shape (ADR-0008). + // Since ADR-0006 Phase 4 the CLI no longer auto-injects these — a host + // that wants a marketplace wires @objectstack/cloud-connection explicitly. + // Browse + install resolve against `OS_CLOUD_URL` (default: the public + // ObjectStack catalog; set `OS_CLOUD_URL=off` for fully-offline runs — + // air-gapped installs still work via `os package install `). + // install-local merges packages into THIS runtime's kernel: once + // installed, nothing here depends on the cloud at runtime. + ...(marketplaceUrl + ? [ + new MarketplaceProxyPlugin({ controlPlaneUrl: marketplaceUrl }), + new MarketplaceInstallLocalPlugin({ controlPlaneUrl: marketplaceUrl }), + new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: marketplaceUrl }), + ] + : []), + new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, installLocal: true }), ], // Infrastructure diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index e60fe283e6..15e3081f39 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -20,6 +20,7 @@ "verify": "pnpm typecheck && pnpm test" }, "dependencies": { + "@objectstack/cloud-connection": "workspace:*", "@objectstack/connector-rest": "workspace:*", "@objectstack/connector-slack": "workspace:*", "@objectstack/runtime": "workspace:*", diff --git a/packages/cli/src/commands/package/install.ts b/packages/cli/src/commands/package/install.ts new file mode 100644 index 0000000000..b150547aea --- /dev/null +++ b/packages/cli/src/commands/package/install.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `objectstack package install` — install a package into a RUNNING ObjectStack + * runtime through its install-local endpoint (ADR-0008 Phase 3). + * + * Two modes, by argument shape: + * + * os package install com.acme.crm [--version 1.2.0] + * Catalog mode: the runtime fetches the manifest snapshot from ITS + * configured catalog (`OS_CLOUD_URL` of the runtime, public R2 + * fast-path first) and registers it into the live kernel. + * + * os package install ./dist/objectstack.json + * Air-gapped mode: the compiled artifact is read locally and sent + * inline — no catalog round-trip, works fully offline. + * + * This complements `os package publish` (which uploads to the CLOUD): the + * pairing is publish-to-cloud / install-into-runtime. The target runtime + * authenticates the call with its own better-auth session — pass + * --email/--password (or OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) for an + * account on the TARGET runtime, not your cloud login. + */ + +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; +import { Args, Command, Flags } from '@oclif/core'; +import { printHeader, printKV, printSuccess, printError, printStep } from '../../utils/format.js'; + +export default class PackageInstall extends Command { + static override description = + 'Install a package into a running ObjectStack runtime (catalog id or local artifact file)'; + + static override examples = [ + '$ os package install com.acme.crm', + '$ os package install com.acme.crm --version 1.2.0 --runtime http://localhost:3000', + '$ os package install ./dist/objectstack.json # air-gapped, no catalog', + '$ OS_RUNTIME_EMAIL=admin@local.test OS_RUNTIME_PASSWORD=… os package install com.acme.crm', + ]; + + static override args = { + package: Args.string({ + description: 'Package manifest id (e.g. com.acme.crm) OR a path to a compiled artifact JSON', + required: true, + }), + }; + + static override flags = { + runtime: Flags.string({ + char: 'r', + description: 'Target runtime base URL (the instance to install INTO)', + env: 'OS_RUNTIME_URL', + default: 'http://localhost:3000', + }), + version: Flags.string({ + char: 'v', + description: "Version to install in catalog mode (default: 'latest')", + default: 'latest', + }), + email: Flags.string({ + description: 'Runtime account email (better-auth session on the target runtime)', + env: 'OS_RUNTIME_EMAIL', + }), + password: Flags.string({ + description: 'Runtime account password', + env: 'OS_RUNTIME_PASSWORD', + }), + timeout: Flags.integer({ + description: 'HTTP timeout in milliseconds (0 disables)', + env: 'OS_CLOUD_TIMEOUT_MS', + default: 120_000, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PackageInstall); + + printHeader('Install Package'); + const runtime = flags.runtime.replace(/\/+$/, ''); + + try { + // ---- Resolve mode: inline artifact file vs catalog id --------------- + const looksLikeFile = args.package.endsWith('.json') + || args.package.startsWith('./') + || args.package.startsWith('../') + || args.package.startsWith('/') + || existsSync(resolvePath(process.cwd(), args.package)); + + let body: Record; + let label: string; + if (looksLikeFile) { + const artifactPath = resolvePath(process.cwd(), args.package); + printStep(`Loading artifact from ${artifactPath}...`); + let raw: string; + try { + raw = await readFile(artifactPath, 'utf-8'); + } catch (err: any) { + printError(`Cannot read artifact: ${err.message}. Run \`objectstack build\` first.`); + this.exit(1); + return; + } + let artifact: any; + try { + artifact = JSON.parse(raw); + } catch (err: any) { + printError(`Artifact is not valid JSON: ${err.message}`); + this.exit(1); + return; + } + // install-local's inline (air-gapped) path expects the manifest + // object; a compiled artifact nests it under `manifest` alongside + // the metadata payload — send the whole artifact so objects/views + // travel with it, but make sure an id is present at the top level. + const manifest = artifact?.manifest && typeof artifact.manifest === 'object' + ? { ...artifact, id: artifact.manifest.id ?? artifact.id, version: artifact.manifest.version ?? artifact.version } + : artifact; + body = { manifest }; + label = String(manifest.id ?? manifest.name ?? artifactPath); + printSuccess(`Loaded artifact (${(raw.length / 1024).toFixed(1)} KB) — air-gapped inline install`); + } else { + body = { packageId: args.package, versionId: flags.version }; + label = `${args.package}@${flags.version}`; + printStep(`Catalog install — the runtime resolves '${label}' from its configured catalog`); + } + + // ---- Authenticate against the TARGET runtime ------------------------ + let cookie: string | undefined; + if (flags.email && flags.password) { + printStep(`Signing in to ${runtime} as ${flags.email}...`); + // `Origin` is required: better-auth's CSRF check 403s origin-less + // non-browser POSTs to the sign-in route. + const signIn = await this.request(`${runtime}/api/v1/auth/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: runtime }, + body: JSON.stringify({ email: flags.email, password: flags.password }), + }, flags.timeout); + if (!signIn.ok) { + printError(`Runtime sign-in failed (${signIn.status}): ${signIn.error}`); + this.exit(1); + return; + } + cookie = signIn.setCookie; + if (!cookie) { + printError('Runtime sign-in returned no session cookie.'); + this.exit(1); + return; + } + printSuccess('Signed in'); + } + + // ---- Install --------------------------------------------------------- + printStep(`Installing ${label} into ${runtime}...`); + const res = await this.request(`${runtime}/api/v1/marketplace/install-local`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify(body), + }, flags.timeout); + + if (!res.ok) { + if (res.status === 401) { + printError( + 'The runtime rejected the call as unauthenticated. Pass --email/--password ' + + '(or set OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) for an account on the target runtime.', + ); + } else if (res.status === 404) { + printError( + `install-local endpoint not found on ${runtime}. The target runtime must mount ` + + 'MarketplaceInstallLocalPlugin (see @objectstack/cloud-connection).', + ); + } else { + printError(`Install failed (${res.status}): ${res.error}`); + } + this.exit(1); + return; + } + + const data = res.body?.data ?? res.body ?? {}; + console.log(''); + printSuccess('Package installed into the running kernel'); + printKV(' Package', String(data.manifestId ?? data.packageId ?? label)); + printKV(' Version', String(data.version ?? flags.version)); + printKV(' Runtime', runtime); + if (data.installedAt) printKV(' Installed', String(data.installedAt)); + console.log(''); + console.log(' The manifest is cached under .objectstack/installed-packages/ on the'); + console.log(' runtime host and re-registers on every boot (survives restarts).'); + } catch (error) { + printError((error as Error).message); + this.exit(1); + } + } + + /** Fetch wrapper: normalised envelope + captured Set-Cookie + timeout. */ + private async request( + url: string, + init: RequestInit, + timeoutMs: number, + ): Promise<{ ok: boolean; status: number; body: any; setCookie?: string; error?: string }> { + const controller = new AbortController(); + const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined; + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + let parsed: any = null; + try { parsed = await response.json(); } catch { /* empty/non-json */ } + // Session cookie: keep only the key=value pairs (drop attributes) so + // it can be replayed as a request Cookie header. + const rawSetCookie = response.headers.get('set-cookie') ?? undefined; + const setCookie = rawSetCookie + ?.split(/,(?=[^;]+?=)/) + .map((p) => p.split(';')[0].trim()) + .filter(Boolean) + .join('; '); + if (!response.ok) { + const errMsg = parsed?.error?.message ?? parsed?.error ?? response.statusText ?? `HTTP ${response.status}`; + return { ok: false, status: response.status, body: parsed, setCookie, error: typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg) }; + } + return { ok: true, status: response.status, body: parsed, setCookie }; + } catch (err: any) { + if (err?.name === 'AbortError') { + return { ok: false, status: 0, body: null, error: `Request timed out after ${timeoutMs}ms.` }; + } + return { ok: false, status: 0, body: null, error: err?.message ?? String(err) }; + } finally { + if (timer) clearTimeout(timer); + } + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3ca2a17172..74bf4b1b57 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -31,3 +31,4 @@ export { default as CloudWhoamiCommand } from './commands/cloud/whoami.js'; // ─── Package topic subcommands ────────────────────────────────────── export { default as PackagePublishCommand } from './commands/package/publish.js'; +export { default as PackageInstallCommand } from './commands/package/install.js';