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
18 changes: 18 additions & 0 deletions .changeset/28914-package-export-whole-deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/runtime': patch
'@objectstack/client': patch
'@objectstack/cli': patch
'@objectstack/spec': patch
---

feat(runtime,client,cli): export a deployment's whole ontology as installable packages in one read (`GET /packages/export`, `os package export`)

Clause-②: yes (widening)
Metadata-protocol: none

A tenant could take its ontology out of a deployment only one package at a time (`GET /packages/:id/export`). The whole-deployment export declared in `@objectstack/spec` (`PackageExportBundleSchema`) is now served.

- **`@objectstack/runtime`**: `GET /api/v1/packages/export` answers `{ packages }`: every installed package whose registry row's `source` is not `code` (the marketplace and authored packages, each base's `.custom` pair among them), disabled ones included, each exactly as `GET /packages/:id/export` answers it, dependencies first, so a pair follows its base. It reads with the per-package export's gate (`studio.access` or `setup.access`), refuses any query parameter `400 VALIDATION_ERROR` naming it, and is whole or absent: a package whose export cannot be assembled fails the read, naming that package. No credential value is carried: credential positions are withheld as on every served metadata read, and a connector's `credentialRef` stays a reference.
- **`@objectstack/client`**: `client.packages.exportAll()` calls it.
- **`@objectstack/cli`**: `os package export [--out DIR]` writes one directory per package, numbered in install order (`DIR/01-ID/objectstack.json`), each installable with `os package install`. A refusal is printed with its status and code and nothing is written; an `--out` directory that already holds files is refused.
- **`@objectstack/spec`**: the `[PLANNED — not yet served]` marker comes off `PackageExportBundleSchema`'s description, which now names the route and the command.
4 changes: 3 additions & 1 deletion content/docs/concepts/metadata-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ document **whole** for every read that serves the base's name
layered read in `overlay` with `effective` = the base's document.
- **Export carries the declaration.** `GET /packages/hotcrm.custom/export` carries the
overriding item with its `overrides` block; `GET /packages/hotcrm/export` carries the
base unchanged.
base unchanged. `GET /packages/export` (`os package export`) answers the whole
deployment in one read: every installed package its own code does not register, the
pair as its own entry after its base, each entry exactly the per-package export.

## Overlay whitelist (shared-DB tenancy invariant)

Expand Down
49 changes: 49 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2968,6 +2968,7 @@ runtime**.
|---------|----------|-------------|
| `os package publish [artifact]` | ObjectOS Cloud | Upload a compiled artifact as a versioned package in your organization |
| `os package install <package>` | A running runtime | Install a package into a live kernel, from that runtime's catalog or from a local artifact |
| `os package export` | A running runtime | Export the deployment's whole ontology as installable packages, one directory per package, in install order |

For which one to reach for and the preview patterns around them, see
[Publish & preview](/docs/deployment/publish-and-preview). This section is the
Expand Down Expand Up @@ -3162,6 +3163,54 @@ composed without it will not accept installs.

In catalog mode, a runtime connected to an ObjectOS Cloud account presents its connection credential to the cloud catalog — see [Cloud Connection](/docs/deployment/cloud-connection).

#### `os package export`

Takes a running deployment's whole ontology out as packages you can install on
any runtime, the open Apache-2.0 one included. One read,
`GET /api/v1/packages/export`, answers every installed package the deployment's
own code does not register: the marketplace and authored packages, each base's
`.custom` customization package among them. Each comes exactly as
`GET /api/v1/packages/:id/export` answers it, with the namespace and
dependencies it was installed with, and dependencies come first, so a `.custom`
package follows its base. A package registered by the deployment's own code
(`defineStack()`, a plugin, the platform) is not exported: it travels with that
code.

```bash
os package export --out ./ontology --url https://acme.example.com
# ./ontology/01-com.acme.crm/objectstack.json
# ./ontology/02-com.acme.crm.custom/objectstack.json
# ./ontology/03-com.acme.billing/objectstack.json

# on the target runtime, in the numbered order:
os package install ./ontology/01-com.acme.crm/objectstack.json
os package install ./ontology/02-com.acme.crm.custom/objectstack.json
os package install ./ontology/03-com.acme.billing/objectstack.json
```

Each file is one package's export, installable as it stands with
[`os package install`](#os-package-install). Install them in the numbered order;
the command prints that order too, and never re-sorts what the read answered.

- **No secret leaves.** Credential positions are withheld, as on every served
metadata read, and a connector's `auth.credentialRef` stays the reference it
is: fill the secret it names on the target runtime.
- **No enabled state.** Every package installs enabled, including one that was
switched off where it was exported.
- **Whole or nothing.** A package whose export cannot be assembled fails the
read, and the command prints the runtime's refusal, which names the package,
and writes nothing. It refuses an `--out` directory that already holds files.
- **Who can export.** The same callers who can read a single package's export:
`studio.access` or `setup.access`.

**Options:**

| Flag | Env equivalent | Purpose |
|---|---|---|
| `-o, --out <dir>` | — | Directory to write into (default `./objectstack-export`); created, and refused when it already holds files |
| `-u, --url <url>` | `OS_CLOUD_URL` | Server URL of the deployment to export (default: the stored `os login` session's, else `http://localhost:3000`) |
| `-t, --token <token>` | `OS_TOKEN` | Authentication token (default: the stored `os login` session's) |

## Configuration

The CLI looks for `objectstack.config.ts` (or `.js`, `.mjs`) in the current directory:
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/package-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ A field or flow another package contributes to one of the base's objects.

## PackageExportBundle

[PLANNED — not yet served] Whole-deployment export: every installed package the deployment's own code does not register (registry `source` other than `code`), as installable packages in dependency order. Carries no credential value and no enabled state. Declared ahead of its producer (ADR-0049): no runtime serves `GET /packages/export` yet. The server half of the whole-deployment package export mounts it, together with a future CLI command that calls it; until then this is the contract that door must answer, not a read a client can make.
Whole-deployment export, the answer of `GET /packages/export` (`os package export`): every installed package the deployment's own code does not register (registry `source` other than `code`), as installable packages in dependency order. Carries no credential value and no enabled state. A package whose export cannot be assembled fails the whole read, naming it.

### Properties

Expand Down
156 changes: 156 additions & 0 deletions packages/cli/src/commands/package/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `objectstack package export` — take a running deployment's whole ontology
* out as installable packages (#28914).
*
* os package export [--out DIR]
*
* One read, `GET /api/v1/packages/export` (`client.packages.exportAll()`,
* `PackageExportBundleSchema`): every installed package the deployment's own
* code does not register — the marketplace and authored packages, each base's
* `.custom` pair among them — each exactly as `GET /packages/:id/export`
* answers it, dependencies first. This command writes them as they came, one
* directory per package, numbered in that order:
*
* DIR/01-com.acme.crm/objectstack.json
* DIR/02-com.acme.crm.custom/objectstack.json
*
* Each file is the package's export, and each is installable as it stands with
* `os package install DIR/NN-ID/objectstack.json` on an open runtime. Install
* them in the numbered order: the order is the read's, and this command never
* re-sorts it.
*
* Loud, never partial: the read is whole or absent (a package whose export
* cannot be assembled fails it, and the runtime's refusal names that package),
* and this command prints that refusal with its status and code and writes
* nothing. It refuses an `--out` directory that already holds files, so an
* export is never mixed into an older one.
*
* No credential value travels: the runtime withholds credential positions on
* this read as on every served metadata read, and a connector's
* `credentialRef` stays the reference it is.
*/

import { mkdir, readdir, writeFile } from 'node:fs/promises';
import { join, resolve as resolvePath } from 'node:path';
import { Command, Flags } from '@oclif/core';
import { printHeader, printKV, printSuccess, printWarning, printError, printStep, isExitSignal } from '../../utils/format.js';
import { createApiClient, requireAuth } from '../../utils/api-client.js';

/** The file each package directory holds: the name `os package install` is shown first in its examples. */
export const EXPORTED_PACKAGE_FILE = 'objectstack.json';

/** `NN-ID`: the package's place in the read's order, zero-padded so a listing sorts in install order. */
export function exportedPackageDirectory(index: number, total: number, id: string): string {
const width = Math.max(2, String(total).length);
return `${String(index + 1).padStart(width, '0')}-${id}`;
}

export default class PackageExport extends Command {
static override description =
"Export a running deployment's whole ontology as installable packages (one directory per package, in install order)";

static override examples = [
'$ os package export',
'$ os package export --out ./ontology --url https://acme.objectos.app',
'$ os package install ./ontology/01-com.acme.crm/objectstack.json # then each directory, in order',
];

static override flags = {
out: Flags.string({
char: 'o',
description: 'Directory to write the packages into (created; refused when it already holds files)',
default: './objectstack-export',
}),
url: Flags.string({
char: 'u',
description: 'Server URL of the deployment to export',
env: 'OS_CLOUD_URL',
}),
token: Flags.string({
char: 't',
description: 'Authentication token',
env: 'OS_TOKEN',
}),
};

async run(): Promise<void> {
const { flags } = await this.parse(PackageExport);

printHeader('Export Packages');
const outDir = resolvePath(process.cwd(), flags.out);

try {
const existing = await readdir(outDir).catch((err: NodeJS.ErrnoException) => {
if (err?.code === 'ENOENT') return [] as string[];
throw err;
});
if (existing.length > 0) {
printError(`${outDir} already holds files. Export into a new or empty directory (--out DIR).`);
this.exit(1);
return;
}

const { client, token, baseUrl } = await createApiClient({ url: flags.url, token: flags.token });
requireAuth(token);

printStep(`Reading the whole-deployment export from ${baseUrl}...`);
let bundle: unknown;
try {
bundle = await client.packages.exportAll();
} catch (err: any) {
const status = typeof err?.httpStatus === 'number' ? err.httpStatus : undefined;
const code = typeof err?.code === 'string' ? err.code : undefined;
const tag = [status, code].filter((v) => v !== undefined).join(' ');
printError(`Export refused${tag ? ` (${tag})` : ''}: ${err?.message ?? String(err)}. Nothing was written.`);
this.exit(1);
return;
}

const packages = (bundle as { packages?: unknown } | null | undefined)?.packages;
if (!Array.isArray(packages)) {
printError('The runtime answered no `packages` list: it does not serve the whole-deployment export. Nothing was written.');
this.exit(1);
return;
}
const nameless = packages.findIndex(
(entry) => typeof (entry as { id?: unknown } | null)?.id !== 'string' || (entry as { id: string }).id === '',
);
if (nameless !== -1) {
printError(`Entry ${nameless + 1} of the export names no package id, so it cannot be installed. Nothing was written.`);
this.exit(1);
return;
}
if (packages.length === 0) {
printWarning(
'Nothing to export: every package of this deployment is registered by its own code, which carries it. Nothing was written.',
);
return;
}

await mkdir(outDir, { recursive: true });
const written: string[] = [];
for (const [index, entry] of packages.entries()) {
const id = (entry as { id: string }).id;
const dir = join(outDir, exportedPackageDirectory(index, packages.length, id));
await mkdir(dir, { recursive: true });
const file = join(dir, EXPORTED_PACKAGE_FILE);
await writeFile(file, `${JSON.stringify(entry, null, 2)}\n`, 'utf-8');
written.push(file);
}

console.log('');
printSuccess(`Exported ${packages.length} package${packages.length === 1 ? '' : 's'}`);
printKV(' Directory', outDir);
console.log('');
console.log(' Install them on an open runtime in this order:');
for (const file of written) console.log(` os package install ${file}`);
console.log('');
} catch (error) {
if (isExitSignal(error)) throw error;
printError((error as Error).message);
this.exit(1);
}
}
}
1 change: 1 addition & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ 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';
export { default as PackageExportCommand } from './commands/package/export.js';

// ─── Entry-point guard (#10111) ─────────────────────────────────────
// This file is the package `main`. It is a LIBRARY entry — a barrel of
Expand Down
Loading
Loading