Skip to content

Commit b3d1415

Browse files
feat(runtime,client,cli): export a deployment's whole ontology as installable packages in one read (GET /packages/export, os package export) (#29755)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8HPBiw3dHprSTrka5xiidsK)_ Fixes #28914 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:运行时新开 `GET /api/v1/packages/export`,一次导出整个部署的本体:所有不是由部署自身代码注册的已安装包(市场安装的、库里创作的,含 `.custom` 配对包,停用的也算),每一项就是现有单包导出 `GET /packages/:id/export` 的原样回答,按依赖顺序排(基础包在前)。配套 SDK 方法 `client.packages.exportAll()` 和 CLI 命令 `os package export [--out DIR]`(每个包写一个带序号的目录,可直接 `os package install`);spec 去掉 `[PLANNED — not yet served]` 标记。 - 为什么改:官网承诺「本体是你的,可以导出到开源运行时跑」,今天只能一个包一个包导;spec 形状已由 #29100 落地,阻塞的 #29617(导出带 namespace / dependencies)已由 #29651 落地,本 PR 是最后的服务端切片。 - 风险与回滚:纯新增路由和命令,不改任何现有接口的回答;读门与单包导出相同(`studio.access` / `setup.access`)。回滚即撤回本 PR。 - 先看:`packages/runtime/src/domains/packages.ts` 的 `assemblePackageExportBundle` 与新路由分支(在 `GET /packages/:id` 之前);`packages/cli/src/commands/package/export.ts`;`packages/spec/src/api/package-lifecycle.zod.ts` 的 docblock 与 describe(去标记的公开面)。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-export-bundle.test.ts`,预期 10 条全绿(选包、依赖序、单一生产者、读门、查询参数拒绝、整体或不答)。 - 需要你做的:无,席位做完契约复审后落地。 ## What changes - **Runtime (`packages/runtime/src/domains/packages.ts`).** A new branch answers `GET /packages/export`, matched ahead of `GET /packages/:id` (`export` is never a package id: `MANIFEST_ID_PATTERN` needs two dot-separated segments). It asks the per-package export's read gate first (`requireReadCapability`: `studio.access` or `setup.access`, or a system context), then refuses any query parameter `400 VALIDATION_ERROR` naming it (`unknownQueryParamRefusalMessage(query, [])`, the same closed-set refusal `GET /packages/:id/customizations` uses). `assemblePackageExportBundle` then: - selects every registry row whose `source` is not `code`, disabled rows included, each id once (the row's `source`, never `manifest.scope` or `writable`, as `PackageExportBundleSchema` states); - orders them with the boot's own sorter, `orderHydrationBatch` from `@objectstack/core` (ADR-0141 D5), over `declaredDependencyIds(row.manifest)`, so a `.custom` pair follows its base whatever order the registry lists them in; - composes each entry with `assemblePackageManifest`, the per-package export's producer, and nothing else: no second clean or redaction rule; - is whole or absent: a thrown assembly answers through `errorFromThrown` (its status kept) with the message prefixed `The export of package 'ID' could not be assembled, so no bundle is answered:`; a `null` assembly answers 500 naming the package. - **Mount (`packages/runtime/src/dispatcher-plugin.ts`).** `mountPackagesRoute('get', '/export', …)`, registered before `/:id` so the literal segment wins. - **Route ledger (`packages/runtime/src/route-ledger.ts`).** Row `GET /packages/export` → `packages.exportAll`, `responseSchema: 'PackageExportBundleSchema'`; the census sentence regenerated (116 → 117) by `check-route-ledger-census.mjs --fix`. - **Client (`packages/client/src/index.ts`).** `client.packages.exportAll()`, answering a `PackageExportBundle`; its row in `unwrap-misfire.pin.test.ts`'s roster (`PackageExportBundleSchema` declares `packages` only, so the `success`+`data` heuristic cannot fire). - **CLI (`packages/cli/src/commands/package/export.ts`, exported from `src/index.ts`).** `os package export [--out DIR] [--url] [--token]`, through `createApiClient` like the `os meta` commands. It writes `DIR/NN-ID/objectstack.json`, one per entry, numbered in the order served (zero-padded so a listing sorts in install order), and prints the `os package install` commands in that order. Loud and never partial: a refusal prints with status and code and writes nothing; an entry with no id is refused naming its position; an `--out` directory that already holds files is refused before the read; an empty bundle writes nothing and says why. - **Spec (`packages/spec/src/api/package-lifecycle.zod.ts`).** The `[PLANNED — not yet served]` marker comes off `PackageExportBundleSchema`'s describe, which now names the route and `os package export`; the docblock's "MUST do / not served today" prose now states what the door does. No key, type or accept set changes. The test that pinned the marker is flipped (no `PLANNED`, names the route and the command, no tracker id); the regenerated `content/docs/references/api/package-lifecycle.mdx` follows (`check:generated --fix`). - **Docs.** `content/docs/deployment/cli.mdx`: a row in the package command table and an `os package export` section (what is and is not exported, the numbered install order, no secret, no enabled state, whole or nothing, who can export, options). `content/docs/concepts/metadata-lifecycle.mdx`: the export paragraph now names the whole-deployment read beside the per-package one. - **Changeset** `.changeset/28914-package-export-whole-deployment.md`: runtime, client, cli, spec all `patch` (kind `none`, a non-protocol widening per AGENTS.md's level rule). ## Why The card's server slice: the maintainer's ruling on docs.objectos.ai #334 (record 6568804196) approved building the whole-ontology export; the spec slice (#29100) declared the answer and the blocker #29617 (#29651) made each per-package export carry what a reinstall reads. This PR serves the read and the command and meets the card's two Done tests. ## Tests - **Door pins** (new `packages/runtime/src/domains/packages-export-bundle.test.ts`, real dispatcher over a registry and protocol double, 10 tests): selection (`code` excluded, disabled included, each once, parses as `PackageExportBundleSchema`); dependency order with the registry listing both dependents ahead of the base (control asserted); each entry `toEqual` the `GET /packages/:id/export` answer for the same id; empty when every row is `code`; the read gate (`studio.access` and `setup.access` admitted, `manage_metadata` alone 403 with the registry and protocol never read); `?format=zip` → 400 `VALIDATION_ERROR` naming `format`, registry never read; whole or absent twice (no protocol → 500 naming the first package; a throw on the second package's assembly → its 503, message naming that package, no `data`); `export` never reaches `getPackage`; the ledger row. - **CLI pins** (new `packages/cli/test/package-export.test.ts`, the real command and the real `ObjectStackClient`, only `createApiClient` stubbed, 6 tests): numbered directories with each entry as served and the install commands in order; **each written file is the `manifest` the real `os package install FILE` posts, unchanged** (install driven per file, its POST captured); a 503 refusal printed with status and code, exit 1, nothing written; a non-empty `--out` refused before any request; an empty bundle writes nothing; an id-less entry refused naming its position. - **Round trip and credential** (new `packages/qa/dogfood/test/package-export-bundle-round-trip.dogfood.test.ts`, two real showcase boots with install-local mounted, 5 tests). Source: a base installed through install-local (object, app, dashboard, flow, permission set, and a connector with `auth: { type: 'bearer', credentialRef }`), its `.custom` pair with a published dashboard override, and a third package depending on the base and extending its object. The bundle is exactly those three ids (the showcase's own `code` package absent), the base first, the pair carrying `namespace` `exportbase_custom`, its base edge and its override; the connector carries its `credentialRef` and the secret the reference resolves to on the source (control: set in the process env) appears nowhere in the response bytes. Target: each entry installs 200 through `POST /marketplace/install-local` with `{ manifest: entry }` (the body `os package install FILE` sends), in the order served, and the target's own bundle answers the same ids in the same order with the same namespace, dependencies and per-collection item names and counts; the pair's override comes back with its `overrides` block. - **Client**: `packages-lifecycle.test.ts` pins `exportAll` → `GET /api/v1/packages/export` and the order served. - **Spec**: the flipped marker test. <details><summary>Ablation (one-time, runtime tests resolve packages.ts from source; scripts/ablation-replace.mjs)</summary> On `699399a4`, fix committed first: - `orderHydrationBatch(nodes).ordered` → `nodes.map((n) => n.item)`: `Tests 2 failed | 8 passed (10)` (dependency order; and the null-protocol case, which names the first package and so reads the order). - `row?.source === 'code'` → `row?.source === 'never'`: `Tests 2 failed | 8 passed (10)` (selection; empty bundle). - Both restored: `blob after restore == blob at HEAD (8a46b0c)`, `git diff HEAD` empty. </details> ## Acceptance notes - **The door's status on a failed assembly.** The declaration says only "fails the read, naming that package". A thrown assembly keeps the status `errorFromThrown` resolves for it; a `null` one (no metadata protocol) answers 500. No new error code. - **`Clause-②: yes (a new served route)`** from the claim is written here as `yes (widening)`: AGENTS.md's closed arm set is `(widening)` / `(narrowing)`, and a new route that changes no existing answer is a widening. `Metadata-protocol: none`: nothing a metadata author writes changes. - **No new spec key**, and the spec diff is describe text and docblock only; the marker drop is the published-schema face the seat's contract review reads. - `assemblePackageManifest` swallows a per-type read error as "type unsupported here" (`continue`), as before; the bundle inherits that. Not changed here. - docs.objectos.ai #334's "export your ontology" page is the docs lane's follow-up, as the card says. ## Verification Head `43a7c550` (merge base `c9636f1e`); every heavy run under `os-verify-lock`. - `pnpm --filter @objectstack/spec test`: `Test Files 803 passed (803)`, `Tests 23792 passed | 1 todo`; typecheck exit 0. - `pnpm --filter @objectstack/client test`: `Test Files 54 passed (54)`, `Tests 695 passed`; typecheck exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: `Test Files 376 passed (376)`, `Tests 5295 passed`; typecheck exit 0. The `integration` tier is CI's (the diff touches no integration-tier file or spawn entry). - `pnpm --filter @objectstack/runtime test`: `Test Files 532 passed (532)`, `Tests 8535 passed | 19 skipped`; typecheck exit 0. - `pnpm --filter @objectstack/dogfood typecheck` exit 0; the round-trip file `Tests 5 passed (5)` after building the dogfood closure. - `pnpm --filter @objectstack/spec check:generated --fix`: 1 stale (`content/docs/references/**`), regenerated and re-checked green. - These suites ran on `699399a4`; `43a7c550` changes one comment digit in `route-ledger.ts` (the census sentence). <details><summary>dispatch-gates --commands and the ran list</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 111 derived, the same list on `699399a4` and `43a7c550`. First pass on `699399a4`: 107 exit 0; `check:route-ledger-census` exit 1 (the census sentence read 116 against 117 rows: fixed with its own `--fix` in `43a7c550`); exit 3 (prerequisite not met) for `check:skill-examples` (client-react unbuilt) and `check:dual-build-cjs-loads` (seven packages without `dist/`), both exit 0 on `43a7c550` after building those packages; `check-plugin-teardown-shape.mjs --self-test` exit 3 on both heads (its fixture commit is outside the shallow clone): NOT MEASURED. Re-run on `43a7c550`, exit 0 each: `check:route-ledger-census`, `check:skill-examples`, `check:dual-build-cjs-loads`, `check:type-check-debt`, `check:query-options-erasure`, `check:route-envelope`, `check:dispatcher-error-vocabulary`, `check:system-context-census`, `check:spec-docblock-symbol-anchors`, `check:nul-bytes`, and repo-wide `pnpm lint` (`eslint . --no-inline-config`). `dispatch-gates --ran`: "111 derived famil(ies) accounted for — 110 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)", exit 0. CI runs every family. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (exit 0 on `43a7c550`). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [ ] The *Before Submitting* list in CONTRIBUTING.md is done (not walked item by item in this run). --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWrCtyQtTGt4iYTTvX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2545021 commit b3d1415

17 files changed

Lines changed: 975 additions & 42 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/client': patch
4+
'@objectstack/cli': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
feat(runtime,client,cli): export a deployment's whole ontology as installable packages in one read (`GET /packages/export`, `os package export`)
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
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.
14+
15+
- **`@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.
16+
- **`@objectstack/client`**: `client.packages.exportAll()` calls it.
17+
- **`@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.
18+
- **`@objectstack/spec`**: the `[PLANNED — not yet served]` marker comes off `PackageExportBundleSchema`'s description, which now names the route and the command.

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,9 @@ document **whole** for every read that serves the base's name
158158
layered read in `overlay` with `effective` = the base's document.
159159
- **Export carries the declaration.** `GET /packages/hotcrm.custom/export` carries the
160160
overriding item with its `overrides` block; `GET /packages/hotcrm/export` carries the
161-
base unchanged.
161+
base unchanged. `GET /packages/export` (`os package export`) answers the whole
162+
deployment in one read: every installed package its own code does not register, the
163+
pair as its own entry after its base, each entry exactly the per-package export.
162164

163165
## Overlay whitelist (shared-DB tenancy invariant)
164166

‎content/docs/deployment/cli.mdx‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2995,6 +2995,7 @@ runtime**.
29952995
|---------|----------|-------------|
29962996
| `os package publish [artifact]` | ObjectOS Cloud | Upload a compiled artifact as a versioned package in your organization |
29972997
| `os package install <package>` | A running runtime | Install a package into a live kernel, from that runtime's catalog or from a local artifact |
2998+
| `os package export` | A running runtime | Export the deployment's whole ontology as installable packages, one directory per package, in install order |
29982999

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

31903191
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).
31913192

3193+
#### `os package export`
3194+
3195+
Takes a running deployment's whole ontology out as packages you can install on
3196+
any runtime, the open Apache-2.0 one included. One read,
3197+
`GET /api/v1/packages/export`, answers every installed package the deployment's
3198+
own code does not register: the marketplace and authored packages, each base's
3199+
`.custom` customization package among them. Each comes exactly as
3200+
`GET /api/v1/packages/:id/export` answers it, with the namespace and
3201+
dependencies it was installed with, and dependencies come first, so a `.custom`
3202+
package follows its base. A package registered by the deployment's own code
3203+
(`defineStack()`, a plugin, the platform) is not exported: it travels with that
3204+
code.
3205+
3206+
```bash
3207+
os package export --out ./ontology --url https://acme.example.com
3208+
# ./ontology/01-com.acme.crm/objectstack.json
3209+
# ./ontology/02-com.acme.crm.custom/objectstack.json
3210+
# ./ontology/03-com.acme.billing/objectstack.json
3211+
3212+
# on the target runtime, in the numbered order:
3213+
os package install ./ontology/01-com.acme.crm/objectstack.json
3214+
os package install ./ontology/02-com.acme.crm.custom/objectstack.json
3215+
os package install ./ontology/03-com.acme.billing/objectstack.json
3216+
```
3217+
3218+
Each file is one package's export, installable as it stands with
3219+
[`os package install`](#os-package-install). Install them in the numbered order;
3220+
the command prints that order too, and never re-sorts what the read answered.
3221+
3222+
- **No secret leaves.** Credential positions are withheld, as on every served
3223+
metadata read, and a connector's `auth.credentialRef` stays the reference it
3224+
is: fill the secret it names on the target runtime.
3225+
- **No enabled state.** Every package installs enabled, including one that was
3226+
switched off where it was exported.
3227+
- **Whole or nothing.** A package whose export cannot be assembled fails the
3228+
read, and the command prints the runtime's refusal, which names the package,
3229+
and writes nothing. It refuses an `--out` directory that already holds files.
3230+
- **Who can export.** The same callers who can read a single package's export:
3231+
`studio.access` or `setup.access`.
3232+
3233+
**Options:**
3234+
3235+
| Flag | Env equivalent | Purpose |
3236+
|---|---|---|
3237+
| `-o, --out <dir>` | — | Directory to write into (default `./objectstack-export`); created, and refused when it already holds files |
3238+
| `-u, --url <url>` | `OS_CLOUD_URL` | Server URL of the deployment to export (default: the stored `os login` session's, else `http://localhost:3000`) |
3239+
| `-t, --token <token>` | `OS_TOKEN` | Authentication token (default: the stored `os login` session's) |
3240+
31923241
## Configuration
31933242

31943243
The CLI looks for `objectstack.config.ts` (or `.js`, `.mjs`) in the current directory:

‎content/docs/references/api/package-lifecycle.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ A navigation item another installed package contributes to an app the base owns,
317317

318318
## PackageExportBundle
319319

320-
[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.
320+
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.
321321

322322
### Properties
323323

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `objectstack package export` — take a running deployment's whole ontology
5+
* out as installable packages (#28914).
6+
*
7+
* os package export [--out DIR]
8+
*
9+
* One read, `GET /api/v1/packages/export` (`client.packages.exportAll()`,
10+
* `PackageExportBundleSchema`): every installed package the deployment's own
11+
* code does not register — the marketplace and authored packages, each base's
12+
* `.custom` pair among them — each exactly as `GET /packages/:id/export`
13+
* answers it, dependencies first. This command writes them as they came, one
14+
* directory per package, numbered in that order:
15+
*
16+
* DIR/01-com.acme.crm/objectstack.json
17+
* DIR/02-com.acme.crm.custom/objectstack.json
18+
*
19+
* Each file is the package's export, and each is installable as it stands with
20+
* `os package install DIR/NN-ID/objectstack.json` on an open runtime. Install
21+
* them in the numbered order: the order is the read's, and this command never
22+
* re-sorts it.
23+
*
24+
* Loud, never partial: the read is whole or absent (a package whose export
25+
* cannot be assembled fails it, and the runtime's refusal names that package),
26+
* and this command prints that refusal with its status and code and writes
27+
* nothing. It refuses an `--out` directory that already holds files, so an
28+
* export is never mixed into an older one.
29+
*
30+
* No credential value travels: the runtime withholds credential positions on
31+
* this read as on every served metadata read, and a connector's
32+
* `credentialRef` stays the reference it is.
33+
*/
34+
35+
import { mkdir, readdir, writeFile } from 'node:fs/promises';
36+
import { join, resolve as resolvePath } from 'node:path';
37+
import { Command, Flags } from '@oclif/core';
38+
import { printHeader, printKV, printSuccess, printWarning, printError, printStep, isExitSignal } from '../../utils/format.js';
39+
import { createApiClient, requireAuth } from '../../utils/api-client.js';
40+
41+
/** The file each package directory holds: the name `os package install` is shown first in its examples. */
42+
export const EXPORTED_PACKAGE_FILE = 'objectstack.json';
43+
44+
/** `NN-ID`: the package's place in the read's order, zero-padded so a listing sorts in install order. */
45+
export function exportedPackageDirectory(index: number, total: number, id: string): string {
46+
const width = Math.max(2, String(total).length);
47+
return `${String(index + 1).padStart(width, '0')}-${id}`;
48+
}
49+
50+
export default class PackageExport extends Command {
51+
static override description =
52+
"Export a running deployment's whole ontology as installable packages (one directory per package, in install order)";
53+
54+
static override examples = [
55+
'$ os package export',
56+
'$ os package export --out ./ontology --url https://acme.objectos.app',
57+
'$ os package install ./ontology/01-com.acme.crm/objectstack.json # then each directory, in order',
58+
];
59+
60+
static override flags = {
61+
out: Flags.string({
62+
char: 'o',
63+
description: 'Directory to write the packages into (created; refused when it already holds files)',
64+
default: './objectstack-export',
65+
}),
66+
url: Flags.string({
67+
char: 'u',
68+
description: 'Server URL of the deployment to export',
69+
env: 'OS_CLOUD_URL',
70+
}),
71+
token: Flags.string({
72+
char: 't',
73+
description: 'Authentication token',
74+
env: 'OS_TOKEN',
75+
}),
76+
};
77+
78+
async run(): Promise<void> {
79+
const { flags } = await this.parse(PackageExport);
80+
81+
printHeader('Export Packages');
82+
const outDir = resolvePath(process.cwd(), flags.out);
83+
84+
try {
85+
const existing = await readdir(outDir).catch((err: NodeJS.ErrnoException) => {
86+
if (err?.code === 'ENOENT') return [] as string[];
87+
throw err;
88+
});
89+
if (existing.length > 0) {
90+
printError(`${outDir} already holds files. Export into a new or empty directory (--out DIR).`);
91+
this.exit(1);
92+
return;
93+
}
94+
95+
const { client, token, baseUrl } = await createApiClient({ url: flags.url, token: flags.token });
96+
requireAuth(token);
97+
98+
printStep(`Reading the whole-deployment export from ${baseUrl}...`);
99+
let bundle: unknown;
100+
try {
101+
bundle = await client.packages.exportAll();
102+
} catch (err: any) {
103+
const status = typeof err?.httpStatus === 'number' ? err.httpStatus : undefined;
104+
const code = typeof err?.code === 'string' ? err.code : undefined;
105+
const tag = [status, code].filter((v) => v !== undefined).join(' ');
106+
printError(`Export refused${tag ? ` (${tag})` : ''}: ${err?.message ?? String(err)}. Nothing was written.`);
107+
this.exit(1);
108+
return;
109+
}
110+
111+
const packages = (bundle as { packages?: unknown } | null | undefined)?.packages;
112+
if (!Array.isArray(packages)) {
113+
printError('The runtime answered no `packages` list: it does not serve the whole-deployment export. Nothing was written.');
114+
this.exit(1);
115+
return;
116+
}
117+
const nameless = packages.findIndex(
118+
(entry) => typeof (entry as { id?: unknown } | null)?.id !== 'string' || (entry as { id: string }).id === '',
119+
);
120+
if (nameless !== -1) {
121+
printError(`Entry ${nameless + 1} of the export names no package id, so it cannot be installed. Nothing was written.`);
122+
this.exit(1);
123+
return;
124+
}
125+
if (packages.length === 0) {
126+
printWarning(
127+
'Nothing to export: every package of this deployment is registered by its own code, which carries it. Nothing was written.',
128+
);
129+
return;
130+
}
131+
132+
await mkdir(outDir, { recursive: true });
133+
const written: string[] = [];
134+
for (const [index, entry] of packages.entries()) {
135+
const id = (entry as { id: string }).id;
136+
const dir = join(outDir, exportedPackageDirectory(index, packages.length, id));
137+
await mkdir(dir, { recursive: true });
138+
const file = join(dir, EXPORTED_PACKAGE_FILE);
139+
await writeFile(file, `${JSON.stringify(entry, null, 2)}\n`, 'utf-8');
140+
written.push(file);
141+
}
142+
143+
console.log('');
144+
printSuccess(`Exported ${packages.length} package${packages.length === 1 ? '' : 's'}`);
145+
printKV(' Directory', outDir);
146+
console.log('');
147+
console.log(' Install them on an open runtime in this order:');
148+
for (const file of written) console.log(` os package install ${file}`);
149+
console.log('');
150+
} catch (error) {
151+
if (isExitSignal(error)) throw error;
152+
printError((error as Error).message);
153+
this.exit(1);
154+
}
155+
}
156+
}

‎packages/cli/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export { default as CloudWhoamiCommand } from './commands/cloud/whoami.js';
5555
// ─── Package topic subcommands ──────────────────────────────────────
5656
export { default as PackagePublishCommand } from './commands/package/publish.js';
5757
export { default as PackageInstallCommand } from './commands/package/install.js';
58+
export { default as PackageExportCommand } from './commands/package/export.js';
5859

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

0 commit comments

Comments
 (0)