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
5 changes: 5 additions & 0 deletions .changeset/package-install-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@objectstack/cli": minor
---

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).
27 changes: 27 additions & 0 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string | undefined> };

// 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
Expand Down Expand Up @@ -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 <artifact.json>`).
// 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
Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
231 changes: 231 additions & 0 deletions packages/cli/src/commands/package/install.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, any>;
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);
}
}
}
1 change: 1 addition & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading