|
| 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 | +} |
0 commit comments