busa-cli 0.30.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,17 +6,178 @@ import { DEFAULT_BASE_URL, cloudContract, createBusabaseClient, getRecordByField
6
6
  import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
7
7
  import { createInterface } from "node:readline/promises";
8
8
  import { homedir, tmpdir } from "node:os";
9
+ import { access, mkdir, mkdtemp, open, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
9
10
  import { PassThrough, Readable } from "node:stream";
10
11
  import { pipeline } from "node:stream/promises";
11
12
  import { ZstdCompress, ZstdDecompress } from "node:zlib";
12
13
  import * as tar from "tar-stream";
13
- import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
14
14
  import { spawn } from "node:child_process";
15
15
  import { createServer } from "node:http";
16
16
  import { parse, stringify } from "yaml";
17
17
  import { checkAirApp } from "busabase-sdk/airapp-check";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { Uint8ArrayReader, ZipReader } from "@zip.js/zip.js";
20
+ const LocaleSchema = z.enum({
21
+ defaultLocale: "en",
22
+ locales: [
23
+ "en",
24
+ "zh-CN",
25
+ "zh-TW",
26
+ "ja",
27
+ "ko",
28
+ "de",
29
+ "fr",
30
+ "es",
31
+ "pt"
32
+ ],
33
+ extendLocales: [
34
+ "en",
35
+ "zh-CN",
36
+ "zh-TW",
37
+ "ja",
38
+ "ko",
39
+ "fr",
40
+ "de",
41
+ "es",
42
+ "ru",
43
+ "it",
44
+ "vi",
45
+ "pt"
46
+ ]
47
+ }.locales);
48
+ //#endregion
49
+ //#region ../../packages/openlib/i18n/i-string.ts
50
+ /**
51
+ * i18n string utilities and types / i18n 字符串工具与类型
52
+ *
53
+ * Purpose / 作用
54
+ * - EN: Provide a lightweight, persistable representation for business strings that may be multilingual.
55
+ * Values can be plain strings or locale-keyed records, allowing storage and logic usage.
56
+ * - ZH: 为“可持久化且可多语言”的业务字符串提供轻量表示形式;可存纯文本,或按语言分组的对象,
57
+ * 方便在业务逻辑内读取、比较与分支判断。
58
+ *
59
+ * Model (iString) / 模型(iString)
60
+ * - EN: A value is either a string or a record like { en: "Hello", zh: "你好" }.
61
+ * - ZH: 值可以是纯字符串,或形如 { en: "Hello", zh: "你好" } 的按语言分布对象。
62
+ *
63
+ * Validation / 校验
64
+ * - EN: Zod schemas validate both the record shape and the union type.
65
+ * - ZH: 通过 Zod 对记录结构以及联合类型进行校验,Locale 受 ./types 中定义约束。
66
+ *
67
+ * Exports / 导出
68
+ * - iStringRecordSchema: zod schema for { [locale]: string } constrained by supported locales.
69
+ * - iStringSchema: union of string | iStringRecordSchema.
70
+ * - isIString(str):
71
+ * - EN: Quick check for JSON iString records; returns parsed record or false.
72
+ * - ZH: 快速检测 JSON 字符串是否为 iString 记录;成功返回解析对象,否则返回 false。
73
+ * - iStringMatch(str, compare, lang?):
74
+ * - EN: Compare by specified locale or any locale; supports plain string.
75
+ * - ZH: 支持按指定语言或任一语言进行等值比较;纯字符串直接比较。
76
+ * - iStringParse(str, lang='en'):
77
+ * - EN: Resolve to a display string with sensible fallback.
78
+ * - ZH: 解析为用于展示的字符串,包含合理的回退策略。
79
+ * - iStringify(str, lang='en'):
80
+ * - EN: Wrap a plain string into a record using the given locale.
81
+ * - ZH: 将纯字符串按给定语言包装为记录对象。
82
+ * - iStringConcat(str):
83
+ * - EN: Concatenate all locale values for display/debug (order not guaranteed).
84
+ * - ZH: 拼接所有语言值以便展示/调试(顺序不保证)。
85
+ *
86
+ * Fallback behavior (iStringParse) / 回退策略(iStringParse)
87
+ * - EN: If str is a string, return it. If it is a record: prefer requested locale; else any non-"en"; else "en".
88
+ * Warn and return empty string if no suitable value exists.
89
+ * - ZH: 若为纯字符串,原样返回;若为记录:优先使用请求的语言;否则使用任一非 "en";再退回 "en"。
90
+ * 若均不可用,会发出警告并返回空字符串。
91
+ *
92
+ * Examples / 示例
93
+ * - iStringify('Hello') => { en: 'Hello' }
94
+ * - iStringParse({ en: 'Hello', zh: '你好' }, 'zh') => '你好'
95
+ * - iStringMatch({ en: 'Hello' }, 'Hello') => true
96
+ *
97
+ * Notes / 说明
98
+ * - EN: Depends on zod and the Locale/LocaleSchema from ./types to keep locales consistent.
99
+ * - ZH: 依赖 zod 以及 ./types 中的 Locale/LocaleSchema,确保语言键一致与受控。
100
+ * - EN: isIString expects a JSON string that decodes to a locale-keyed object; plain strings return false.
101
+ * - ZH: isIString 接受 JSON 字符串并尝试解析为按语言分布的对象;纯字符串将返回 false。
102
+ */
103
+ const iStringRecordSchema = z.partialRecord(LocaleSchema, z.string());
104
+ const iStringSchema = z.union([z.string(), iStringRecordSchema]).describe("i18n string");
105
+ //#endregion
106
+ //#region ../../packages/busabase-contract/src/contract/node-agent-prompt-schemas.ts
107
+ /**
108
+ * Per-node custom scenario prompts (`node.metadata.agentPrompts`) — the single
109
+ * schema shared by BOTH write and read paths, per
110
+ * `apps/busabase-cloud/content/spec/node-agent-prompts-v2.md` §7.3:
111
+ *
112
+ * - Write: `busabase-cli nodes set-agent-prompts` validates a file against this
113
+ * schema client-side, before calling `nodes.updateMetadata` — a malformed file
114
+ * never reaches the network.
115
+ * - Read: `buildNodeAgentPrompts` (`packages/busabase-core/.../node-agent-prompts.ts`)
116
+ * `.safeParse`s whatever is stored in `metadata.agentPrompts` and falls back to
117
+ * the node type's default scenarios on failure, so a bad write (or a manual
118
+ * jsonb edit) can never crash the Agent Prompts dialog.
119
+ *
120
+ * One schema, one place to edit the limits — do not duplicate this validation on
121
+ * either side.
122
+ */
123
+ /** Recommended limits from the spec (§7.3) — kept as named constants so a limit
124
+ * change has exactly one place to edit, and so error messages can cite them. */
125
+ const CUSTOM_AGENT_PROMPT_LIMITS = {
126
+ /** Max custom prompts per node. */
127
+ maxPrompts: 50,
128
+ /** Max characters per localized `label` value. */
129
+ maxLabelChars: 80,
130
+ /** Max bytes (UTF-8) per localized `body` value. */
131
+ maxBodyBytes: 8192
132
+ };
133
+ /** Same intent vocabulary `PromptDef.intent` uses — `change` is the router-side
134
+ * default when omitted, matching the existing curated-prompt behavior. */
135
+ const customPromptIntentSchema = z.enum(["read-only", "change"]);
136
+ const iStringLocaleValues = (value) => typeof value === "string" ? [value] : Object.values(value);
137
+ /** UTF-8 byte length — `body` includes CJK/JA text, whose per-character byte
138
+ * cost is not 1, so a character-count limit alone would under-enforce §7.3's
139
+ * "8 KiB per localized body" budget. */
140
+ const utf8ByteLength = (value) => new TextEncoder().encode(value).length;
141
+ const customPromptLabelSchema = iStringSchema.refine((value) => iStringLocaleValues(value).every((v) => v.length <= CUSTOM_AGENT_PROMPT_LIMITS.maxLabelChars), { message: `label must be at most ${CUSTOM_AGENT_PROMPT_LIMITS.maxLabelChars} characters per locale` });
142
+ const customPromptBodySchema = iStringSchema.refine((value) => iStringLocaleValues(value).every((v) => utf8ByteLength(v) <= CUSTOM_AGENT_PROMPT_LIMITS.maxBodyBytes), { message: `body must be at most ${CUSTOM_AGENT_PROMPT_LIMITS.maxBodyBytes} bytes (UTF-8) per locale` });
143
+ /**
144
+ * One custom scenario prompt. `body`'s `{target}` placeholder is substituted at
145
+ * render time with the same target string `PromptDef.body(target)` receives
146
+ * today (see `node-agent-prompts.ts`) — this schema does not interpolate it.
147
+ */
148
+ const customPromptDefSchema = z.object({
149
+ /** Stable id, unique within this node's custom list. */
150
+ key: z.string().trim().min(1, { message: "key must not be empty" }),
151
+ /** Defaults to `change` (same default the curated prompts use) so a prompt
152
+ * cannot silently bypass the approval-first policy by omission. */
153
+ intent: customPromptIntentSchema.optional(),
154
+ /** Short title shown in the dialog's left list. */
155
+ label: customPromptLabelSchema,
156
+ /** Template text; "{target}" is substituted with the rendered target line. */
157
+ body: customPromptBodySchema
158
+ });
159
+ /**
160
+ * The full `metadata.agentPrompts` array. `.max` bounds the list; the
161
+ * `superRefine` catches duplicate keys with an issue path pointing at the
162
+ * offending entry, so a CLI/server validation error names which entry and
163
+ * which key collided instead of a bare "keys must be unique".
164
+ */
165
+ const customAgentPromptsSchema = z.array(customPromptDefSchema).max(CUSTOM_AGENT_PROMPT_LIMITS.maxPrompts, { message: `at most ${CUSTOM_AGENT_PROMPT_LIMITS.maxPrompts} custom prompts per node` }).superRefine((prompts, ctx) => {
166
+ const firstIndexByKey = /* @__PURE__ */ new Map();
167
+ prompts.forEach((prompt, index) => {
168
+ const firstIndex = firstIndexByKey.get(prompt.key);
169
+ if (firstIndex === void 0) {
170
+ firstIndexByKey.set(prompt.key, index);
171
+ return;
172
+ }
173
+ ctx.addIssue({
174
+ code: "custom",
175
+ path: [index, "key"],
176
+ message: `duplicate key "${prompt.key}" — already used at entry ${firstIndex + 1}`
177
+ });
178
+ });
179
+ });
180
+ //#endregion
20
181
  //#region ../../packages/busabase-contract/src/tasks/base-field.ts
21
182
  /**
22
183
  * Changing a Base's schema, as one task with an `operation` instead of six
@@ -2842,19 +3003,55 @@ z.object({
2842
3003
  textContentHash: z.string().nullable(),
2843
3004
  byteCount: z.number().int().nonnegative()
2844
3005
  });
3006
+ z.object({ nodeIds: z.array(z.string()).min(1).max(25) });
2845
3007
  z.object({
2846
3008
  /**
2847
- * The space id the archive was ORIGINALLY exported from (`manifest.spaceId`
2848
- * in the `.bbdump`, already integrity-verified before this is called).
2849
- * `importTableRows`'s "nodes" handling needs this to recognize the
2850
- * archive's own root-node row deterministically (`rootNodeIdForSpace`) —
2851
- * scanning each batch's rows for "the one with a null `parentId`" only
2852
- * works when that row happens to land in the SAME batch as its children,
2853
- * which cursor pagination (id-ordered, not tree-ordered) does not
2854
- * guarantee once a space has more nodes than one page. See the matching
2855
- * comment in `import-logic.ts`.
2856
- */
2857
- sourceSpaceId: z.string() });
3009
+ * One entry per requested node that is a Doc in this space. A node id that
3010
+ * does not resolve is simply absent (not an error): the caller asked for a
3011
+ * batch, and one bad id must not cost it the other 24. A Doc that exists but
3012
+ * has no body object yet yields `markdown: ""`, matching what a read through
3013
+ * the Doc domain would return.
3014
+ */
3015
+ bodies: z.array(z.object({
3016
+ nodeId: z.string(),
3017
+ markdown: z.string()
3018
+ })) });
3019
+ z.object({
3020
+ /**
3021
+ * The space id the archive was ORIGINALLY exported from (`manifest.spaceId`
3022
+ * in the `.bbdump`, already integrity-verified before this is called).
3023
+ * `importTableRows`'s "nodes" handling needs this to recognize the
3024
+ * archive's own root-node row deterministically (`rootNodeIdForSpace`) —
3025
+ * scanning each batch's rows for "the one with a null `parentId`" only
3026
+ * works when that row happens to land in the SAME batch as its children,
3027
+ * which cursor pagination (id-ordered, not tree-ordered) does not
3028
+ * guarantee once a space has more nodes than one page. See the matching
3029
+ * comment in `import-logic.ts`.
3030
+ */
3031
+ sourceSpaceId: z.string(),
3032
+ /**
3033
+ * Continue a restore that was interrupted partway through, instead of
3034
+ * requiring an empty space.
3035
+ *
3036
+ * A restore that FAILS rolls itself back (`importAbort`), so the target is
3037
+ * left clean and a plain re-run works. What cannot roll itself back is a
3038
+ * restore whose process died — Ctrl-C, OOM, a dropped connection, the
3039
+ * machine rebooting. That leaves the space holding however many of the
3040
+ * archive's rows had landed, and every subsequent attempt is refused
3041
+ * ("requires an empty target space") with no way forward except wiping it.
3042
+ *
3043
+ * In this mode the empty-space guard is skipped and inserts become
3044
+ * `ON CONFLICT DO NOTHING`, so replaying the same archive re-lands only what
3045
+ * is missing. Blobs and doc bodies are content-addressed writes to object
3046
+ * storage and were already idempotent.
3047
+ *
3048
+ * DANGEROUS if pointed at the wrong space: rows that collide are silently
3049
+ * skipped rather than reported, so restoring archive A into a space holding
3050
+ * archive B's data would interleave the two instead of refusing. Only pass
3051
+ * it to continue the SAME archive into the SAME space.
3052
+ */
3053
+ resume: z.boolean().optional().default(false)
3054
+ });
2858
3055
  z.object({ sessionId: z.string() });
2859
3056
  z.object({
2860
3057
  sessionId: z.string(),
@@ -2873,6 +3070,85 @@ z.object({
2873
3070
  warnings: z.array(z.string())
2874
3071
  });
2875
3072
  z.object({ ok: z.boolean() });
3073
+ //#endregion
3074
+ //#region src/retry.ts
3075
+ const BASE_DELAY_MS = 500;
3076
+ const MAX_DELAY_MS = 2e4;
3077
+ const READ_METHODS = /* @__PURE__ */ new Set([
3078
+ "GET",
3079
+ "HEAD",
3080
+ "OPTIONS"
3081
+ ]);
3082
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3083
+ const isNetworkError = (error) => error instanceof TypeError || /fetch failed|econnreset|econnrefused|etimedout|socket hang up/i.test(error instanceof Error ? error.message : String(error));
3084
+ /**
3085
+ * Whether an already-thrown error is worth another attempt.
3086
+ *
3087
+ * `shouldRetry` below decides from a `Response`; this decides from an `Error`,
3088
+ * for callers that retry ABOVE the fetch layer (see `backup`'s exporter, which
3089
+ * has to retry oRPC reads that `withRetry` refuses to repeat because they are
3090
+ * POSTs). The distinction that matters is transient-vs-deterministic: a dropped
3091
+ * socket or a 502 may answer differently next time, while a 404, a validation
3092
+ * refusal or a shape mismatch answers identically every time — retrying those
3093
+ * only burns the backoff, which at scale (one per attachment, on a space with
3094
+ * tens of thousands) is the difference between a slow backup and a stalled one.
3095
+ */
3096
+ function isTransientError(error) {
3097
+ if (isNetworkError(error)) return true;
3098
+ const status = error?.status;
3099
+ if (typeof status === "number") return status === 429 || status >= 500;
3100
+ return /\bHTTP (429|5\d\d)\b/.test(error instanceof Error ? error.message : String(error));
3101
+ }
3102
+ /** `Retry-After` is either delay-seconds or an HTTP-date; both are legal. */
3103
+ function parseRetryAfter(header, now = Date.now()) {
3104
+ if (!header) return void 0;
3105
+ const seconds = Number(header.trim());
3106
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, MAX_DELAY_MS);
3107
+ const at = Date.parse(header);
3108
+ if (Number.isNaN(at)) return void 0;
3109
+ return Math.min(Math.max(at - now, 0), MAX_DELAY_MS);
3110
+ }
3111
+ /** Whether this outcome is worth another attempt for a request using `method`. */
3112
+ function shouldRetry(method, status) {
3113
+ if (status === 429) return true;
3114
+ if (!READ_METHODS.has(method.toUpperCase())) return false;
3115
+ return status === void 0 || status >= 500;
3116
+ }
3117
+ /**
3118
+ * Wrap a fetch so transient failures are retried with exponential backoff.
3119
+ * Returns `base` unchanged when `retries` is 0, so the no-retry path adds no
3120
+ * wrapper and no behaviour.
3121
+ */
3122
+ function withRetry(base, options) {
3123
+ const { retries } = options;
3124
+ if (retries <= 0) return base;
3125
+ const sleep = options.sleep ?? defaultSleep;
3126
+ const notify = options.notify ?? ((message) => console.error(message));
3127
+ const jitter = options.jitter ?? Math.random;
3128
+ return (async (input, init) => {
3129
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
3130
+ for (let attempt = 0;; attempt += 1) {
3131
+ let response;
3132
+ let failure;
3133
+ try {
3134
+ response = await base(input, init);
3135
+ } catch (error) {
3136
+ if (!isNetworkError(error)) throw error;
3137
+ failure = error;
3138
+ }
3139
+ const status = response?.status;
3140
+ const last = attempt >= retries;
3141
+ if (!shouldRetry(method, status) || last) {
3142
+ if (response) return response;
3143
+ throw failure;
3144
+ }
3145
+ const backoff = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
3146
+ const delay = parseRetryAfter(response?.headers.get("retry-after") ?? null) ?? Math.round(backoff * (.5 + jitter() * .5));
3147
+ notify(`[busabase-cli] ${status ? `HTTP ${status}` : "connection failed"} — retrying in ${Math.round(delay / 1e3)}s (attempt ${attempt + 2} of ${retries + 1})`);
3148
+ await sleep(delay);
3149
+ }
3150
+ });
3151
+ }
2876
3152
  /**
2877
3153
  * `.bbdump` manifest — the archive's own table of contents + integrity
2878
3154
  * record. Written last (after every entry's bytes/checksums are known) so it
@@ -3263,9 +3539,12 @@ async function streamNdjsonRows(body, send) {
3263
3539
  * integrity-verified (`verifyArchive`) before this is called.
3264
3540
  */
3265
3541
  async function importFull(options) {
3266
- const { client, archivePath, sourceSpaceId, onProgress } = options;
3542
+ const { client, archivePath, sourceSpaceId, resume = false, onProgress } = options;
3267
3543
  const log = onProgress ?? (() => {});
3268
- const { sessionId } = await client.dump.importBegin({ sourceSpaceId });
3544
+ const { sessionId } = await client.dump.importBegin({
3545
+ sourceSpaceId,
3546
+ resume
3547
+ });
3269
3548
  try {
3270
3549
  const docBodyRows = [];
3271
3550
  await streamArchive(archivePath, async (entry) => {
@@ -3322,7 +3601,7 @@ async function importFull(options) {
3322
3601
  }
3323
3602
  return await client.dump.importCommit({ sessionId });
3324
3603
  } catch (error) {
3325
- await client.dump.importAbort({ sessionId }).catch(() => void 0);
3604
+ if (!resume) await client.dump.importAbort({ sessionId }).catch(() => void 0);
3326
3605
  throw error;
3327
3606
  }
3328
3607
  }
@@ -3339,33 +3618,285 @@ async function importFull(options) {
3339
3618
  * the one page here, close to the failure, rather than widening the shared
3340
3619
  * fetch-level wrapper (which would change retry behavior for every command).
3341
3620
  */
3342
- const PAGE_FETCH_RETRIES = 3;
3621
+ const DEFAULT_PAGE_FETCH_RETRIES = 3;
3343
3622
  const PAGE_FETCH_RETRY_DELAY_MS = 1e3;
3344
- const fetchPageWithRetry = async (fetchPage, onRetry) => {
3623
+ const fetchPageWithRetry = async (fetchPage, onRetry, shouldRetryError = () => true, retries = DEFAULT_PAGE_FETCH_RETRIES) => {
3345
3624
  for (let attempt = 0;; attempt += 1) try {
3346
3625
  return await fetchPage();
3347
3626
  } catch (error) {
3348
- if (attempt >= PAGE_FETCH_RETRIES) throw error;
3627
+ if (!shouldRetryError(error)) throw error;
3628
+ if (attempt >= retries) throw error;
3349
3629
  onRetry?.(attempt + 1, error);
3350
3630
  await new Promise((resolve) => setTimeout(resolve, PAGE_FETCH_RETRY_DELAY_MS * (attempt + 1)));
3351
3631
  }
3352
3632
  };
3353
3633
  /**
3634
+ * A per-item loop (one doc body, one attachment blob, one asset-text blob —
3635
+ * always exactly one request each, no server-side batching available) can run
3636
+ * for many minutes on a real space with no output between its start and its
3637
+ * own final summary line, which reads exactly like a hang. Call this once per
3638
+ * iteration; it logs `message()` only every `PROGRESS_INTERVAL_MS`, not on
3639
+ * every single item.
3640
+ */
3641
+ const PROGRESS_INTERVAL_MS = 5e3;
3642
+ const throttledProgress = (log, message) => {
3643
+ let lastAt = Date.now();
3644
+ return () => {
3645
+ const now = Date.now();
3646
+ if (now - lastAt < PROGRESS_INTERVAL_MS) return;
3647
+ lastAt = now;
3648
+ log(message());
3649
+ };
3650
+ };
3651
+ /**
3652
+ * How many blob downloads may be in flight at once.
3653
+ *
3654
+ * The export used to be strictly one-request-at-a-time. On the real Vika Team
3655
+ * space that is 10,486 attachments x 2 sequential round trips each (resolve a
3656
+ * download URL, then fetch the bytes) — ~21k serialized requests, which is
3657
+ * where the bulk of a multi-HOUR backup went. Latency, not bandwidth, was the
3658
+ * limit: a single in-flight download never comes close to saturating the link.
3659
+ *
3660
+ * 8 is deliberately modest. These run against a live production server that is
3661
+ * also serving real users, and each in-flight download holds its whole body in
3662
+ * memory (see the buffer in the attachment loop), so the ceiling also bounds
3663
+ * peak heap at roughly 8 x the largest file rather than the whole set.
3664
+ */
3665
+ const BLOB_DOWNLOAD_CONCURRENCY = 8;
3666
+ /**
3667
+ * Run `work` over `items` with at most `limit` in flight, handing each result
3668
+ * to `consume` strictly IN ORDER and one at a time.
3669
+ *
3670
+ * The ordering guarantee is what makes this safe to drop into the existing
3671
+ * loops: `consume` keeps doing exactly what the sequential body did (append to
3672
+ * a spool file, bump counters, dedupe against a Set), with no concurrent
3673
+ * access to any of it — only the network wait overlaps. Ordered output also
3674
+ * keeps an archive's bytes reproducible for a given space rather than varying
3675
+ * with which download happened to finish first.
3676
+ *
3677
+ * A rejected `work` is captured rather than thrown, so one bad item still
3678
+ * reaches `consume` as a failure and the run continues — the loops below
3679
+ * already treat a single unreachable blob as a warning, not a reason to lose
3680
+ * the other 10,485.
3681
+ */
3682
+ const forEachConcurrent = async (items, limit, work, consume) => {
3683
+ const inFlight = /* @__PURE__ */ new Map();
3684
+ let started = 0;
3685
+ for (let emitted = 0; emitted < items.length; emitted += 1) {
3686
+ while (inFlight.size < limit && started < items.length) {
3687
+ const index = started;
3688
+ started += 1;
3689
+ inFlight.set(index, work(items[index]).then((value) => ({
3690
+ ok: true,
3691
+ value
3692
+ }), (error) => ({
3693
+ ok: false,
3694
+ error
3695
+ })));
3696
+ }
3697
+ const pending = inFlight.get(emitted);
3698
+ inFlight.delete(emitted);
3699
+ await consume(await pending, items[emitted]);
3700
+ }
3701
+ };
3702
+ /**
3354
3703
  * Write an NDJSON archive entry from already-collected rows without ever
3355
3704
  * concatenating them into a single JS string first. A real production table
3356
3705
  * (e.g. `fieldValues` holding long CMS/article-body content) can produce an
3357
3706
  * NDJSON blob well past V8's max string length — `"".join("\n")` on such a
3358
3707
  * table throws `RangeError: Invalid string length` and aborts the whole
3359
- * backup. Building one `Buffer` per line and streaming them through
3360
- * `ArchiveWriter.addStream` (already built for exactly this — see its own
3361
- * doc comment) avoids ever holding one oversized string/buffer.
3708
+ * backup. Streaming line-sized buffers through `ArchiveWriter.addStream`
3709
+ * (already built for exactly this — see its own doc comment) avoids ever
3710
+ * holding one oversized string/buffer.
3711
+ *
3712
+ * `addStream` needs the entry's total byte size up front (a tar constraint),
3713
+ * which used to be met by materializing every line as a `Buffer` in an array
3714
+ * and summing it. On `fieldValues` that is 1.1M `Buffer`s alive at once while
3715
+ * the 1.1M row objects they came from are also still live — the same
3716
+ * peak-heap shape `SpooledNdjsonEntry` was written to avoid, on the one table
3717
+ * that dwarfs the rest. Measure the size in a first pass (keeping only a
3718
+ * running total), then re-serialize lazily from a generator so at most one
3719
+ * line exists at a time. The cost is `JSON.stringify` twice per row, which is
3720
+ * CPU against a backup whose real bottleneck is the network.
3362
3721
  */
3363
3722
  const writeNdjsonEntry = async (writer, path, rows) => {
3364
- const lineBuffers = rows.map((row) => Buffer.from(`${JSON.stringify(row)}\n`, "utf8"));
3365
- const totalSize = lineBuffers.reduce((sum, buf) => sum + buf.length, 0);
3366
- await writer.addStream(path, totalSize, Readable.from(lineBuffers));
3723
+ let totalSize = 0;
3724
+ for (const row of rows) totalSize += Buffer.byteLength(`${JSON.stringify(row)}\n`, "utf8");
3725
+ const lines = function* () {
3726
+ for (const row of rows) yield Buffer.from(`${JSON.stringify(row)}\n`, "utf8");
3727
+ };
3728
+ await writer.addStream(path, totalSize, Readable.from(lines()));
3729
+ };
3730
+ /**
3731
+ * Same NDJSON-entry shape as {@link writeNdjsonEntry}, but for a table built
3732
+ * one row at a time over a long fetch loop (attachment blobs, asset-text
3733
+ * blobs) instead of already sitting in memory. `ArchiveWriter.addStream`
3734
+ * requires the entry's total byte size up front (a tar constraint), which is
3735
+ * why `writeNdjsonEntry` collects every row into an array first — fine for
3736
+ * table rows, but a real space's attachment blobs (base64-encoded, one row
3737
+ * per file) can total many GB. Held as an array of Buffers so their combined
3738
+ * size could be summed, that blew Node's default heap outright — confirmed
3739
+ * live: "JavaScript heap out of memory" partway through a ~5.7GB attachment
3740
+ * set. Spooling each row to a temp file as it's produced, then streaming
3741
+ * that file into the archive once its final size is known from disk, keeps
3742
+ * peak memory bounded by one row at a time rather than the whole table.
3743
+ */
3744
+ var SpooledNdjsonEntry = class {
3745
+ path;
3746
+ stream;
3747
+ /**
3748
+ * `spoolPath` is deterministic (derived from the archive path) rather than a
3749
+ * random temp name, so a killed backup leaves a file the next run can find
3750
+ * and continue from — see `recoverCompleteRows`. A random name in `tmpdir()`
3751
+ * left nothing but an orphan nobody could match back to its archive.
3752
+ */
3753
+ constructor(spoolPath, append) {
3754
+ this.path = spoolPath;
3755
+ this.stream = createWriteStream(this.path, append ? { flags: "a" } : {});
3756
+ }
3757
+ async writeRow(row) {
3758
+ const buf = Buffer.from(`${JSON.stringify(row)}\n`, "utf8");
3759
+ if (!this.stream.write(buf)) await new Promise((resolve) => this.stream.once("drain", resolve));
3760
+ }
3761
+ async finish(writer, archivePath) {
3762
+ await new Promise((resolve, reject) => {
3763
+ this.stream.end((error) => error ? reject(error) : resolve());
3764
+ });
3765
+ const { size } = await stat(this.path);
3766
+ await writer.addStream(archivePath, size, createReadStream(this.path));
3767
+ await unlink(this.path);
3768
+ }
3769
+ };
3770
+ /**
3771
+ * What a previous, killed run already spooled: the identifying key of every
3772
+ * COMPLETE row, plus the totals those rows represent.
3773
+ *
3774
+ * "Complete" is load-bearing. A process killed mid-write leaves a final line
3775
+ * with no trailing newline — valid-looking JSON prefix, truncated content. The
3776
+ * file is therefore truncated back to its last newline before anything is
3777
+ * appended, so a resumed run can never splice new output onto half a row.
3778
+ *
3779
+ * Byte totals are recomputed from the base64 lengths WITHOUT decoding: a real
3780
+ * space's spool is ~5.7GB, and decoding it just to re-derive a counter would
3781
+ * cost more than re-downloading. base64 encodes 3 bytes per 4 chars, minus one
3782
+ * byte per `=` of padding.
3783
+ */
3784
+ /**
3785
+ * Suffixes of the two spool files a backup writes next to its archive, in the
3786
+ * order `exportFull` derives them (attachment blobs, then asset-text blobs).
3787
+ * Exported so `commands.ts` can detect leftovers from an interrupted run
3788
+ * without duplicating the naming.
3789
+ */
3790
+ const SPOOL_SUFFIXES = [".attachment-blobs.part", ".asset-text-blobs.part"];
3791
+ const decodedBase64Bytes = (base64) => {
3792
+ const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
3793
+ return Math.max(0, base64.length * 3 / 4 - padding);
3794
+ };
3795
+ const recoverCompleteRows = async (spoolPath, keyField) => {
3796
+ const keys = /* @__PURE__ */ new Set();
3797
+ let count = 0;
3798
+ let bytes = 0;
3799
+ let completeBytes = 0;
3800
+ let handle;
3801
+ try {
3802
+ handle = await open(spoolPath, "r+");
3803
+ } catch {
3804
+ return {
3805
+ keys,
3806
+ count,
3807
+ bytes
3808
+ };
3809
+ }
3810
+ try {
3811
+ let carry = "";
3812
+ for await (const chunk of handle.createReadStream({
3813
+ encoding: "utf8",
3814
+ autoClose: false
3815
+ })) {
3816
+ carry += chunk;
3817
+ let newlineIndex;
3818
+ while ((newlineIndex = carry.indexOf("\n")) !== -1) {
3819
+ const line = carry.slice(0, newlineIndex);
3820
+ carry = carry.slice(newlineIndex + 1);
3821
+ completeBytes += Buffer.byteLength(line, "utf8") + 1;
3822
+ if (!line) continue;
3823
+ let row;
3824
+ try {
3825
+ row = JSON.parse(line);
3826
+ } catch {
3827
+ completeBytes -= Buffer.byteLength(line, "utf8") + 1;
3828
+ carry = "";
3829
+ break;
3830
+ }
3831
+ const key = row[keyField];
3832
+ if (typeof key !== "string") continue;
3833
+ keys.add(key);
3834
+ count += 1;
3835
+ bytes += decodedBase64Bytes(typeof row.base64 === "string" ? row.base64 : "");
3836
+ }
3837
+ }
3838
+ await handle.truncate(completeBytes);
3839
+ } finally {
3840
+ await handle.close();
3841
+ }
3842
+ return {
3843
+ keys,
3844
+ count,
3845
+ bytes
3846
+ };
3367
3847
  };
3368
3848
  const EXPORT_TABLES = [...DumpTableSchema.options];
3849
+ /**
3850
+ * Which dump tables reference which OTHER dump tables, column by column —
3851
+ * mirrors the real `.references()` declarations in busabase-core's schema
3852
+ * (`domains/base/schema.ts`, `domains/assets/schema/assets.ts`,
3853
+ * `db/schema.ts`). Only cross-table FKs matter here: a column with no
3854
+ * `.references()` (e.g. `fieldValues.fieldId`, a loose text ref by design, or
3855
+ * `assetUsages.recordId`, which defaults to `""`) cannot throw a violation and
3856
+ * must NOT be listed, or the drift check would drop rows Postgres would have
3857
+ * accepted.
3858
+ *
3859
+ * `nodes.parentId` is deliberately absent despite being a real self-FK: the
3860
+ * importer rewrites it (dropping the source root and remapping its children
3861
+ * onto the target's own root — see `import-logic.ts`), so a parentId that
3862
+ * looks dangling here is resolved server-side.
3863
+ */
3864
+ const DUMP_FOREIGN_KEYS = {
3865
+ bases: { nodeId: "nodes" },
3866
+ baseFields: { baseId: "bases" },
3867
+ views: { baseId: "bases" },
3868
+ records: {
3869
+ baseId: "bases",
3870
+ headCommitId: "commits"
3871
+ },
3872
+ fieldValues: {
3873
+ baseId: "bases",
3874
+ recordId: "records",
3875
+ changeRequestId: "changeRequests",
3876
+ operationId: "operations",
3877
+ commitId: "commits"
3878
+ },
3879
+ recordLinks: {
3880
+ baseId: "bases",
3881
+ fieldId: "baseFields",
3882
+ sourceRecordId: "records",
3883
+ targetBaseId: "bases",
3884
+ targetRecordId: "records",
3885
+ commitId: "commits"
3886
+ },
3887
+ assetUsages: {
3888
+ assetId: "assets",
3889
+ nodeId: "nodes"
3890
+ },
3891
+ assetTexts: { assetId: "assets" },
3892
+ auditEvents: {
3893
+ baseId: "bases",
3894
+ recordId: "records",
3895
+ changeRequestId: "changeRequests",
3896
+ operationId: "operations",
3897
+ commitId: "commits"
3898
+ }
3899
+ };
3369
3900
  const HISTORY_TABLES = [
3370
3901
  "commits",
3371
3902
  "changeRequests",
@@ -3375,6 +3906,84 @@ const HISTORY_TABLES = [
3375
3906
  "auditEvents"
3376
3907
  ];
3377
3908
  /**
3909
+ * A change request in one of these states is FINISHED — its content is history.
3910
+ * Anything else (`in_review`, `approved`, `conflict`, …) is work somebody has
3911
+ * not finished yet, and a backup that silently dropped it would lose a
3912
+ * colleague's in-flight work, not history. On the validated production space
3913
+ * that is 492 unfinished out of 22,828.
3914
+ */
3915
+ const FINISHED_CR_STATUSES = /* @__PURE__ */ new Set([
3916
+ "merged",
3917
+ "closed",
3918
+ "rejected"
3919
+ ]);
3920
+ /**
3921
+ * Reduce the history tables to what the CURRENT state (plus unfinished work)
3922
+ * genuinely needs, for `--no-history`.
3923
+ *
3924
+ * The flag used to just omit the history tables wholesale, which produced an
3925
+ * archive that could never be restored: `records.head_commit_id` is a NOT NULL
3926
+ * FK into `commits`, so every record dangled. Verified end-to-end — the
3927
+ * restore died on the first `records` batch. Dropping a table a live table
3928
+ * still points at is not "less history", it is a broken archive.
3929
+ *
3930
+ * What the shape of the data actually allows (measured on the real Vika Team
3931
+ * space, 1,130,898 fieldValues rows):
3932
+ * - Every (record, field) cell holds exactly ONE row — there are no
3933
+ * per-field historical versions to drop. 499,186 rows carry a `recordId`
3934
+ * and ARE the current values.
3935
+ * - The other 631,712 rows carry no `recordId` at all: they are values staged
3936
+ * inside change requests ("this CR proposes setting X to Y"). Those are the
3937
+ * history, and they are 56% of the table.
3938
+ * - 97.8% of change requests are already merged.
3939
+ *
3940
+ * So: keep every current value, keep unfinished change requests whole, and
3941
+ * drop the finished ones' contents. Commits are then kept transitively —
3942
+ * whatever the surviving rows still reference — rather than by any rule about
3943
+ * what a commit "is", so a NOT NULL `commitId` can never be left dangling.
3944
+ */
3945
+ const pruneToCurrentState = (tableRows, tableCounts, log) => {
3946
+ const before = Object.fromEntries(HISTORY_TABLES.map((t) => [t, (tableRows[t] ?? []).length]));
3947
+ const beforeFieldValues = (tableRows.fieldValues ?? []).length;
3948
+ const keptCrIds = new Set((tableRows.changeRequests ?? []).filter((cr) => !FINISHED_CR_STATUSES.has(cr.status)).map((cr) => cr.id));
3949
+ const keptOperations = (tableRows.operations ?? []).filter((op) => keptCrIds.has(op.changeRequestId));
3950
+ const keptOpIds = new Set(keptOperations.map((op) => op.id));
3951
+ const keptFieldValues = (tableRows.fieldValues ?? []).filter((fv) => {
3952
+ if (fv.recordId) return true;
3953
+ if (fv.changeRequestId && keptCrIds.has(fv.changeRequestId)) return true;
3954
+ return Boolean(fv.operationId) && keptOpIds.has(fv.operationId);
3955
+ });
3956
+ for (const fv of keptFieldValues) {
3957
+ if (fv.changeRequestId && !keptCrIds.has(fv.changeRequestId)) fv.changeRequestId = null;
3958
+ if (fv.operationId && !keptOpIds.has(fv.operationId)) fv.operationId = null;
3959
+ }
3960
+ const keptReviews = (tableRows.reviews ?? []).filter((r) => keptCrIds.has(r.changeRequestId));
3961
+ const keptComments = (tableRows.comments ?? []).filter((c) => !c.changeRequestId || keptCrIds.has(c.changeRequestId));
3962
+ const neededCommitIds = /* @__PURE__ */ new Set();
3963
+ const need = (id) => {
3964
+ if (typeof id === "string" && id) neededCommitIds.add(id);
3965
+ };
3966
+ for (const r of tableRows.records ?? []) need(r.headCommitId);
3967
+ for (const rl of tableRows.recordLinks ?? []) need(rl.commitId);
3968
+ for (const fv of keptFieldValues) need(fv.commitId);
3969
+ for (const op of keptOperations) need(op.headCommitId);
3970
+ for (const c of keptComments) need(c.commitId);
3971
+ const keptCommits = (tableRows.commits ?? []).filter((c) => neededCommitIds.has(c.id));
3972
+ const assign = (table, rows) => {
3973
+ tableRows[table] = rows;
3974
+ tableCounts[table] = rows.length;
3975
+ };
3976
+ assign("fieldValues", keptFieldValues);
3977
+ assign("changeRequests", (tableRows.changeRequests ?? []).filter((cr) => keptCrIds.has(cr.id)));
3978
+ assign("operations", keptOperations);
3979
+ assign("reviews", keptReviews);
3980
+ assign("comments", keptComments);
3981
+ assign("commits", keptCommits);
3982
+ assign("auditEvents", []);
3983
+ const dropped = (table) => before[table] - (tableRows[table] ?? []).length;
3984
+ log(` --no-history: kept ${keptFieldValues.length}/${beforeFieldValues} fieldValues (dropped ${beforeFieldValues - keptFieldValues.length} staged in finished change requests), ${keptCrIds.size} unfinished change request(s); dropped ${dropped("commits")} commits, ${dropped("operations")} operations, ${dropped("reviews")} reviews, ${dropped("auditEvents")} audit events.`);
3985
+ };
3986
+ /**
3378
3987
  * Full-fidelity export: pulls every dump-eligible table via the `dump.exportTables`
3379
3988
  * endpoint (cursor-paginated, raw rows with original ids), doc bodies for
3380
3989
  * every `doc` node via the public `docs.body` read, every asset's bytes via
@@ -3383,16 +3992,18 @@ const HISTORY_TABLES = [
3383
3992
  * keeps the format uniform regardless of table size).
3384
3993
  */
3385
3994
  async function exportFull(options) {
3386
- const { client, outPath, spaceId, sourceHost, includeHistory, toolVersion, onProgress } = options;
3387
- const pageLimit = options.pageLimit ?? 500;
3995
+ const { client, outPath, spaceId, sourceHost, includeHistory, toolVersion, resume = false, retries = DEFAULT_PAGE_FETCH_RETRIES, onProgress } = options;
3996
+ const [attachmentSpoolPath, assetTextSpoolPath] = SPOOL_SUFFIXES.map((suffix) => `${outPath}${suffix}`);
3997
+ const pageLimit = options.pageLimit ?? 2e3;
3388
3998
  const log = onProgress ?? (() => {});
3389
3999
  const writer = ArchiveWriter.create(outPath);
3390
4000
  const tableCounts = {};
3391
4001
  const tableRows = {};
3392
- const tables = includeHistory ? EXPORT_TABLES : EXPORT_TABLES.filter((t) => !HISTORY_TABLES.includes(t));
4002
+ const tables = EXPORT_TABLES;
3393
4003
  for (const table of tables) {
3394
4004
  const rows = [];
3395
4005
  let cursor;
4006
+ const reportProgress = throttledProgress(log, () => ` ...${table}: ${rows.length} rows so far`);
3396
4007
  do {
3397
4008
  const page = await fetchPageWithRetry(() => client.dump.exportTables({
3398
4009
  table,
@@ -3400,101 +4011,207 @@ async function exportFull(options) {
3400
4011
  limit: pageLimit
3401
4012
  }), (attempt, error) => {
3402
4013
  const message = error instanceof Error ? error.message : String(error);
3403
- log(` retrying ${table} page (attempt ${attempt}/${PAGE_FETCH_RETRIES}): ${message}`);
3404
- });
4014
+ log(` retrying ${table} page (attempt ${attempt}/${retries}): ${message}`);
4015
+ }, void 0, retries);
3405
4016
  rows.push(...page.rows);
3406
4017
  cursor = page.nextCursor ?? void 0;
4018
+ if (cursor) reportProgress();
3407
4019
  } while (cursor);
3408
4020
  tableRows[table] = rows;
3409
4021
  tableCounts[table] = rows.length;
3410
4022
  log(`exported ${table}: ${rows.length} rows`);
3411
4023
  }
4024
+ if (!includeHistory) pruneToCurrentState(tableRows, tableCounts, log);
4025
+ {
4026
+ const exported = new Set(tables);
4027
+ const idsOf = (table) => exported.has(table) ? new Set((tableRows[table] ?? []).map((row) => row.id)) : null;
4028
+ const validIds = /* @__PURE__ */ new Map();
4029
+ const validIdsFor = (table) => {
4030
+ if (!validIds.has(table)) validIds.set(table, idsOf(table));
4031
+ return validIds.get(table) ?? null;
4032
+ };
4033
+ for (const table of IMPORT_ORDER) {
4034
+ const foreignKeys = DUMP_FOREIGN_KEYS[table];
4035
+ if (!foreignKeys || !exported.has(table)) continue;
4036
+ const rows = tableRows[table] ?? [];
4037
+ if (rows.length === 0) continue;
4038
+ const kept = rows.filter((row) => Object.entries(foreignKeys).every(([column, targetTable]) => {
4039
+ const id = row[column];
4040
+ if (id === null || id === void 0 || id === "") return true;
4041
+ const valid = validIdsFor(targetTable);
4042
+ return valid === null || valid.has(id);
4043
+ }));
4044
+ const dropped = rows.length - kept.length;
4045
+ if (dropped === 0) continue;
4046
+ log(` WARNING: dropped ${dropped} ${table} row(s) referencing a ${Object.values(foreignKeys).join("/")} that this archive does not carry — a long backup of an actively-used space can drift out of sync with itself. Re-run the backup for a tighter snapshot if these matter.`);
4047
+ tableRows[table] = kept;
4048
+ tableCounts[table] = kept.length;
4049
+ validIds.set(table, new Set(kept.map((row) => row.id)));
4050
+ }
4051
+ }
3412
4052
  const docNodes = (tableRows.nodes ?? []).filter((n) => n.type === "doc");
3413
4053
  const docBodyEntries = [];
3414
4054
  const skippedDocBodies = [];
3415
- for (const node of docNodes) {
3416
- const nodeId = node.id;
3417
- try {
3418
- const detail = await client.nodes.get({
3419
- nodeId,
3420
- type: "doc"
3421
- });
3422
- if (detail.type !== "doc") throw new Error(`Expected a doc node for ${nodeId}, got "${detail.type}"`);
3423
- docBodyEntries.push({
3424
- nodeId,
3425
- body: detail.body ?? ""
3426
- });
4055
+ const reportDocProgress = throttledProgress(log, () => ` ...doc bodies: ${docBodyEntries.length}/${docNodes.length} fetched`);
4056
+ const isMissingRoute = (error) => /not found|404|no procedure|unknown procedure/i.test(error instanceof Error ? error.message : String(error));
4057
+ const readOneDocBodyLegacy = async (nodeId) => await fetchPageWithRetry(async () => {
4058
+ const detail = await client.nodes.get({
4059
+ nodeId,
4060
+ type: "doc"
4061
+ });
4062
+ if (detail.type !== "doc") throw new Error(`Expected a doc node for ${nodeId}, got "${detail.type}"`);
4063
+ return detail.body ?? "";
4064
+ }, (attempt, error) => log(` retrying doc body ${nodeId} (attempt ${attempt}/${retries}): ${error instanceof Error ? error.message : String(error)}`), isTransientError, retries);
4065
+ const applyLegacyDocBody = (nodeId, body) => {
4066
+ docBodyEntries.push({
4067
+ nodeId,
4068
+ body
4069
+ });
4070
+ };
4071
+ let batchRouteAvailable = true;
4072
+ for (let i = 0; i < docNodes.length; i += 25) {
4073
+ reportDocProgress();
4074
+ const nodeIds = docNodes.slice(i, i + 25).map((node) => node.id);
4075
+ if (batchRouteAvailable) try {
4076
+ const { bodies } = await fetchPageWithRetry(() => client.dump.exportDocBodies({ nodeIds }), (attempt, error) => log(` retrying doc bodies batch (attempt ${attempt}): ${error instanceof Error ? error.message : String(error)}`), (error) => !isMissingRoute(error), retries);
4077
+ const byId = new Map(bodies.map((entry) => [entry.nodeId, entry.markdown]));
4078
+ for (const nodeId of nodeIds) {
4079
+ const markdown = byId.get(nodeId);
4080
+ if (markdown === void 0) {
4081
+ skippedDocBodies.push(nodeId);
4082
+ log(` WARNING: server returned no body for doc node ${nodeId}, skipping it`);
4083
+ continue;
4084
+ }
4085
+ docBodyEntries.push({
4086
+ nodeId,
4087
+ body: markdown
4088
+ });
4089
+ }
4090
+ continue;
3427
4091
  } catch (error) {
4092
+ if (!isMissingRoute(error)) {
4093
+ const message = error instanceof Error ? error.message : String(error);
4094
+ skippedDocBodies.push(...nodeIds);
4095
+ log(` WARNING: could not read doc bodies for ${nodeIds.length} node(s), skipping them (${message})`);
4096
+ continue;
4097
+ }
4098
+ batchRouteAvailable = false;
4099
+ log(" NOTE: this server predates `dump.exportDocBodies`; falling back to one request per Doc. Archived Docs cannot be read that way, so their bodies will be missing from this archive — upgrade the server to capture them.");
4100
+ }
4101
+ await forEachConcurrent(nodeIds, BLOB_DOWNLOAD_CONCURRENCY, (nodeId) => readOneDocBodyLegacy(nodeId), (settled, nodeId) => {
4102
+ if (settled.ok) {
4103
+ applyLegacyDocBody(nodeId, settled.value);
4104
+ return;
4105
+ }
4106
+ const { error } = settled;
3428
4107
  const message = error instanceof Error ? error.message : String(error);
3429
4108
  skippedDocBodies.push(nodeId);
3430
4109
  log(` WARNING: could not read doc body for node ${nodeId}, skipping it (${message})`);
3431
- }
4110
+ });
3432
4111
  }
3433
4112
  log(`fetched ${docBodyEntries.length} doc bodies, skipped ${skippedDocBodies.length}`);
3434
4113
  const attachmentsById = new Map((tableRows.attachments ?? []).map((a) => [a.id, a]));
3435
- const attachmentBlobRows = [];
3436
- let blobCount = 0;
3437
- let blobBytes = 0;
4114
+ const recoveredBlobs = resume ? await recoverCompleteRows(attachmentSpoolPath, "storageKey") : {
4115
+ keys: /* @__PURE__ */ new Set(),
4116
+ count: 0,
4117
+ bytes: 0
4118
+ };
4119
+ const attachmentBlobs = new SpooledNdjsonEntry(attachmentSpoolPath, resume);
4120
+ let blobCount = recoveredBlobs.count;
4121
+ let blobBytes = recoveredBlobs.bytes;
3438
4122
  const skippedAssets = [];
3439
- for (const asset of tableRows.assets ?? []) {
3440
- const assetId = asset.id;
4123
+ const assetRows = tableRows.assets ?? [];
4124
+ if (recoveredBlobs.count > 0) log(` resuming: ${recoveredBlobs.count} attachment blob(s) already downloaded (${recoveredBlobs.bytes} bytes), continuing with the rest`);
4125
+ const reportBlobProgress = throttledProgress(log, () => ` ...attachment blobs: ${blobCount}/${assetRows.length} fetched`);
4126
+ await forEachConcurrent(assetRows, BLOB_DOWNLOAD_CONCURRENCY, async (asset) => {
3441
4127
  const attachment = attachmentsById.get(asset.attachmentId);
3442
- try {
3443
- if (!attachment) throw new Error("no matching attachments row (orphaned asset)");
3444
- const download = await client.assets.download({ assetId });
4128
+ if (!attachment) throw new Error("no matching attachments row (orphaned asset)");
4129
+ if (recoveredBlobs.keys.has(attachment.storageKey)) return null;
4130
+ return await fetchPageWithRetry(async () => {
4131
+ const download = await client.assets.download({ assetId: asset.id });
3445
4132
  const resolvedUrl = new URL(download.downloadUrl, sourceHost).toString();
3446
4133
  const res = await fetch(resolvedUrl);
3447
4134
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
3448
- const buf = Buffer.from(await res.arrayBuffer());
3449
- attachmentBlobRows.push({
4135
+ return {
3450
4136
  storageKey: attachment.storageKey,
3451
4137
  mimeType: download.mimeType,
3452
- base64: buf.toString("base64")
3453
- });
3454
- blobCount += 1;
3455
- blobBytes += buf.length;
3456
- } catch (error) {
4138
+ buf: Buffer.from(await res.arrayBuffer())
4139
+ };
4140
+ }, (attempt, error) => log(` retrying asset ${asset.id} (attempt ${attempt}/${retries}): ${error instanceof Error ? error.message : String(error)}`), isTransientError, retries);
4141
+ }, async (settled, asset) => {
4142
+ reportBlobProgress();
4143
+ const assetId = asset.id;
4144
+ if (!settled.ok) {
4145
+ const { error } = settled;
3457
4146
  const message = error instanceof Error ? error.message : String(error);
3458
4147
  skippedAssets.push(assetId);
3459
4148
  log(` WARNING: could not download asset ${assetId}, skipping its blob (${message})`);
4149
+ return;
3460
4150
  }
3461
- }
4151
+ if (!settled.value) return;
4152
+ const { storageKey, mimeType, buf } = settled.value;
4153
+ await attachmentBlobs.writeRow({
4154
+ storageKey,
4155
+ mimeType,
4156
+ base64: buf.toString("base64")
4157
+ });
4158
+ blobCount += 1;
4159
+ blobBytes += buf.length;
4160
+ });
3462
4161
  log(`fetched ${blobCount} attachment blobs (${blobBytes} bytes), skipped ${skippedAssets.length}`);
3463
- const textBlobRows = [];
3464
- const seenTextKeys = /* @__PURE__ */ new Set();
3465
- let textBlobCount = 0;
3466
- let textBlobBytes = 0;
4162
+ const recoveredTexts = resume ? await recoverCompleteRows(assetTextSpoolPath, "textStorageKey") : {
4163
+ keys: /* @__PURE__ */ new Set(),
4164
+ count: 0,
4165
+ bytes: 0
4166
+ };
4167
+ const textBlobs = new SpooledNdjsonEntry(assetTextSpoolPath, resume);
4168
+ const seenTextKeys = new Set(recoveredTexts.keys);
4169
+ let textBlobCount = recoveredTexts.count;
4170
+ let textBlobBytes = recoveredTexts.bytes;
3467
4171
  const skippedAssetTexts = [];
3468
- for (const assetText of tableRows.assetTexts ?? []) {
4172
+ const assetTextRows = tableRows.assetTexts ?? [];
4173
+ if (recoveredTexts.count > 0) log(` resuming: ${recoveredTexts.count} asset-text blob(s) already downloaded`);
4174
+ const reportTextProgress = throttledProgress(log, () => ` ...asset-text blobs: ${textBlobCount}/${assetTextRows.length} fetched`);
4175
+ await forEachConcurrent(assetTextRows, BLOB_DOWNLOAD_CONCURRENCY, async (assetText) => await fetchPageWithRetry(async () => {
4176
+ const info = await client.dump.exportAssetText({ assetId: assetText.assetId });
4177
+ if (!info.downloadUrl) return null;
4178
+ if (recoveredTexts.keys.has(info.textStorageKey)) return null;
4179
+ const resolvedUrl = new URL(info.downloadUrl, sourceHost).toString();
4180
+ const res = await fetch(resolvedUrl);
4181
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
4182
+ const buf = Buffer.from(await res.arrayBuffer());
4183
+ if (info.textContentHash) {
4184
+ const actual = `sha256:${createHash("sha256").update(buf).digest("hex")}`;
4185
+ if (actual !== info.textContentHash) throw new Error(`content hash mismatch (expected ${info.textContentHash}, downloaded ${actual})`);
4186
+ }
4187
+ return {
4188
+ textStorageKey: info.textStorageKey,
4189
+ buf
4190
+ };
4191
+ }, (attempt, error) => log(` retrying asset text ${assetText.assetId} (attempt ${attempt}/${retries}): ${error instanceof Error ? error.message : String(error)}`), isTransientError, retries), async (settled, assetText) => {
4192
+ reportTextProgress();
3469
4193
  const assetId = assetText.assetId;
3470
- try {
3471
- const info = await client.dump.exportAssetText({ assetId });
3472
- if (!info.downloadUrl) continue;
3473
- if (seenTextKeys.has(info.textStorageKey)) continue;
3474
- const resolvedUrl = new URL(info.downloadUrl, sourceHost).toString();
3475
- const res = await fetch(resolvedUrl);
3476
- if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
3477
- const buf = Buffer.from(await res.arrayBuffer());
3478
- if (info.textContentHash) {
3479
- const actual = `sha256:${createHash("sha256").update(buf).digest("hex")}`;
3480
- if (actual !== info.textContentHash) throw new Error(`content hash mismatch (expected ${info.textContentHash}, downloaded ${actual})`);
3481
- }
3482
- seenTextKeys.add(info.textStorageKey);
3483
- textBlobRows.push({
3484
- textStorageKey: info.textStorageKey,
3485
- base64: buf.toString("base64")
3486
- });
3487
- textBlobCount += 1;
3488
- textBlobBytes += buf.length;
3489
- } catch (error) {
4194
+ if (!settled.ok) {
4195
+ const { error } = settled;
3490
4196
  const message = error instanceof Error ? error.message : String(error);
3491
4197
  skippedAssetTexts.push(assetId);
3492
4198
  log(` WARNING: could not download text for asset ${assetId}, skipping its blob (${message})`);
4199
+ return;
3493
4200
  }
3494
- }
4201
+ if (!settled.value) return;
4202
+ const { textStorageKey, buf } = settled.value;
4203
+ if (seenTextKeys.has(textStorageKey)) return;
4204
+ seenTextKeys.add(textStorageKey);
4205
+ await textBlobs.writeRow({
4206
+ textStorageKey,
4207
+ base64: buf.toString("base64")
4208
+ });
4209
+ textBlobCount += 1;
4210
+ textBlobBytes += buf.length;
4211
+ });
3495
4212
  log(`fetched ${textBlobCount} asset-text blobs (${textBlobBytes} bytes), skipped ${skippedAssetTexts.length}`);
3496
- await writeNdjsonEntry(writer, "tree/attachmentBlobs.ndjson", attachmentBlobRows);
3497
- await writeNdjsonEntry(writer, "tree/assetTextBlobs.ndjson", textBlobRows);
4213
+ await attachmentBlobs.finish(writer, "tree/attachmentBlobs.ndjson");
4214
+ await textBlobs.finish(writer, "tree/assetTextBlobs.ndjson");
3498
4215
  for (const table of IMPORT_ORDER) {
3499
4216
  if (!tables.includes(table)) continue;
3500
4217
  await writeNdjsonEntry(writer, `tree/${table}.ndjson`, tableRows[table] ?? []);
@@ -3519,10 +4236,24 @@ async function exportFull(options) {
3519
4236
  }
3520
4237
  //#endregion
3521
4238
  //#region src/backup/commands.ts
4239
+ /**
4240
+ * `backup` and `restore` — the terminal-facing halves of the full-fidelity
4241
+ * `.bbdump` archive format. Thin orchestration: drain every dump-eligible table
4242
+ * into an archive, or verify an archive and replay it into an empty space.
4243
+ */
3522
4244
  /** Progress is diagnostics, not data — it must never pollute `--output json` on stdout. */
3523
4245
  const reportProgress$1 = (message) => {
3524
4246
  console.error(message);
3525
4247
  };
4248
+ /** Which of this archive's spool files a previous, interrupted run left behind. */
4249
+ const findSpoolFiles = async (outFile) => {
4250
+ const present = [];
4251
+ for (const suffix of SPOOL_SUFFIXES) try {
4252
+ await access(`${outFile}${suffix}`);
4253
+ present.push(`${outFile}${suffix}`);
4254
+ } catch {}
4255
+ return present;
4256
+ };
3526
4257
  /**
3527
4258
  * Deliberately not implemented: `export` already writes the portable current-state
3528
4259
  * representation as a readable, diffable directory. The two formats stay separate
@@ -3557,7 +4288,11 @@ const runBackup = async (client, options) => {
3557
4288
  if (options.stateOnly) throw new Error(STATE_ONLY_BACKUP_MESSAGE);
3558
4289
  const { spaceId } = options;
3559
4290
  if (!spaceId) throw new Error("No space configured. Pass --space-id <id>, export BUSABASE_SPACE_ID, or run `busabase-cli login`.");
3560
- reportProgress$1(`Backing up space ${spaceId} → ${options.outFile} (full fidelity, history=${options.includeHistory})`);
4291
+ if (!options.resume) {
4292
+ const orphaned = await findSpoolFiles(options.outFile);
4293
+ if (orphaned.length > 0) reportProgress$1(` NOTE: found ${orphaned.length} partial file(s) from an interrupted backup (${orphaned.join(", ")}). Re-run with --resume to continue from them; this run will start over and overwrite them.`);
4294
+ }
4295
+ reportProgress$1(`Backing up space ${spaceId} → ${options.outFile} (full fidelity, history=${options.includeHistory}${options.resume ? ", resuming" : ""})`);
3561
4296
  const manifest = await exportFull({
3562
4297
  client,
3563
4298
  outPath: options.outFile,
@@ -3565,6 +4300,8 @@ const runBackup = async (client, options) => {
3565
4300
  sourceHost: options.baseUrl,
3566
4301
  includeHistory: options.includeHistory,
3567
4302
  toolVersion: options.toolVersion,
4303
+ resume: Boolean(options.resume),
4304
+ retries: options.retries,
3568
4305
  onProgress: (message) => reportProgress$1(` ${message}`)
3569
4306
  });
3570
4307
  const rows = Object.values(manifest.tables).reduce((total, count) => total + count, 0);
@@ -3583,7 +4320,7 @@ const runBackup = async (client, options) => {
3583
4320
  ].join("\n");
3584
4321
  };
3585
4322
  const runRestore = async (client, options) => {
3586
- if (options.resume) throw new Error("--resume is not implemented yet in this version.");
4323
+ if (options.resume) reportProgress$1("Resuming: rows already present are left alone, and this run will NOT roll back on failure. Only do this to continue THIS archive into THIS space.");
3587
4324
  reportProgress$1(`Reading ${options.file} …`);
3588
4325
  const manifest = await verifyArchive(options.file);
3589
4326
  reportProgress$1(`Archive: fidelity=${manifest.fidelity} space=${manifest.spaceId} exportedAt=${manifest.exportedAt}`);
@@ -3593,6 +4330,7 @@ const runRestore = async (client, options) => {
3593
4330
  client,
3594
4331
  archivePath: options.file,
3595
4332
  sourceSpaceId: manifest.spaceId,
4333
+ resume: Boolean(options.resume),
3596
4334
  onProgress: (message) => reportProgress$1(` ${message}`)
3597
4335
  });
3598
4336
  if (options.json) return {
@@ -4910,138 +5648,53 @@ const auditPackage = (tree, files, options = {}) => {
4910
5648
  *
4911
5649
  * Runs for any directory carrying a root `SKILL.md` — package or not, template or not.
4912
5650
  * It reports nothing at all when there is no manual: a package without one is a
4913
- * perfectly legitimate thing to publish, not a Skill that failed.
4914
- *
4915
- * The honest limit: a checker can tell you the frontmatter parses and the files the
4916
- * manual points at exist. It cannot tell you the manual is *true*. Everything in
4917
- * `SKILL.md` becomes instructions an agent acts on, and no static rule catches a
4918
- * confident, wrong sentence about what a table means.
4919
- */
4920
- const auditSkill = (files, options = {}) => {
4921
- const findings = [];
4922
- const error = (rule, message) => findings.push({
4923
- severity: "error",
4924
- rule,
4925
- message
4926
- });
4927
- const warn = (rule, message) => findings.push({
4928
- severity: "warning",
4929
- rule,
4930
- message
4931
- });
4932
- const root = options.root ?? "";
4933
- const source = readText(files, root, PACKAGE_SKILL_ENTRY);
4934
- if (source === void 0) return findings;
4935
- let body = "";
4936
- try {
4937
- const parsed = parseFrontmatter(source, PACKAGE_SKILL_ENTRY);
4938
- body = parsed.body;
4939
- const frontmatter = SkillFrontmatterSchema$1.safeParse(parsed.data);
4940
- if (!frontmatter.success) error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY} frontmatter is not a valid Skill declaration: ${frontmatter.error.issues.map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`).join("; ")}`);
4941
- else if (frontmatter.data.description.trim() === "") error("skill/description", `${PACKAGE_SKILL_ENTRY} has no description. An agent chooses whether to reach for a Skill by reading it, so an empty one is not cosmetic — it is a Skill that never gets picked.`);
4942
- } catch (cause) {
4943
- error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY}: ${cause.message}`);
4944
- return findings;
4945
- }
4946
- const todos = (body.match(/^.*\bTODO\b.*$/gm) ?? []).length;
4947
- if (todos > 0) error("skill/todo", `${PACKAGE_SKILL_ENTRY} still carries ${todos} TODO line(s). The export draft leaves them on purpose — an agent acts on what this file says, and a blank the author was meant to fill in is worse than nothing once it ships.`);
4948
- const present = new Set(relativePaths(files, root));
4949
- const sidecar = new RegExp(`\\]\\((\\.?/?(?:${PACKAGE_SIDECARS})/[^)\\s]+)\\)|\`((?:${PACKAGE_SIDECARS})/[^\`\\s]+)\``, "g");
4950
- const seen = /* @__PURE__ */ new Set();
4951
- for (const match of body.matchAll(sidecar)) {
4952
- const target = (match[1] ?? match[2]).replace(/^\.?\//, "");
4953
- if (seen.has(target)) continue;
4954
- seen.add(target);
4955
- if (!present.has(target)) warn("skill/missing-reference", `${PACKAGE_SKILL_ENTRY} points at "${target}", which the package does not ship. An agent told to read it has an instruction it cannot satisfy.`);
4956
- }
4957
- return findings;
4958
- };
4959
- const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
4960
- const LocaleSchema = z.enum({
4961
- defaultLocale: "en",
4962
- locales: [
4963
- "en",
4964
- "zh-CN",
4965
- "zh-TW",
4966
- "ja",
4967
- "ko",
4968
- "de",
4969
- "fr",
4970
- "es",
4971
- "pt"
4972
- ],
4973
- extendLocales: [
4974
- "en",
4975
- "zh-CN",
4976
- "zh-TW",
4977
- "ja",
4978
- "ko",
4979
- "fr",
4980
- "de",
4981
- "es",
4982
- "ru",
4983
- "it",
4984
- "vi",
4985
- "pt"
4986
- ]
4987
- }.locales);
4988
- //#endregion
4989
- //#region ../../packages/openlib/i18n/i-string.ts
4990
- /**
4991
- * i18n string utilities and types / i18n 字符串工具与类型
4992
- *
4993
- * Purpose / 作用
4994
- * - EN: Provide a lightweight, persistable representation for business strings that may be multilingual.
4995
- * Values can be plain strings or locale-keyed records, allowing storage and logic usage.
4996
- * - ZH: 为“可持久化且可多语言”的业务字符串提供轻量表示形式;可存纯文本,或按语言分组的对象,
4997
- * 方便在业务逻辑内读取、比较与分支判断。
4998
- *
4999
- * Model (iString) / 模型(iString)
5000
- * - EN: A value is either a string or a record like { en: "Hello", zh: "你好" }.
5001
- * - ZH: 值可以是纯字符串,或形如 { en: "Hello", zh: "你好" } 的按语言分布对象。
5002
- *
5003
- * Validation / 校验
5004
- * - EN: Zod schemas validate both the record shape and the union type.
5005
- * - ZH: 通过 Zod 对记录结构以及联合类型进行校验,Locale 受 ./types 中定义约束。
5006
- *
5007
- * Exports / 导出
5008
- * - iStringRecordSchema: zod schema for { [locale]: string } constrained by supported locales.
5009
- * - iStringSchema: union of string | iStringRecordSchema.
5010
- * - isIString(str):
5011
- * - EN: Quick check for JSON iString records; returns parsed record or false.
5012
- * - ZH: 快速检测 JSON 字符串是否为 iString 记录;成功返回解析对象,否则返回 false。
5013
- * - iStringMatch(str, compare, lang?):
5014
- * - EN: Compare by specified locale or any locale; supports plain string.
5015
- * - ZH: 支持按指定语言或任一语言进行等值比较;纯字符串直接比较。
5016
- * - iStringParse(str, lang='en'):
5017
- * - EN: Resolve to a display string with sensible fallback.
5018
- * - ZH: 解析为用于展示的字符串,包含合理的回退策略。
5019
- * - iStringify(str, lang='en'):
5020
- * - EN: Wrap a plain string into a record using the given locale.
5021
- * - ZH: 将纯字符串按给定语言包装为记录对象。
5022
- * - iStringConcat(str):
5023
- * - EN: Concatenate all locale values for display/debug (order not guaranteed).
5024
- * - ZH: 拼接所有语言值以便展示/调试(顺序不保证)。
5025
- *
5026
- * Fallback behavior (iStringParse) / 回退策略(iStringParse)
5027
- * - EN: If str is a string, return it. If it is a record: prefer requested locale; else any non-"en"; else "en".
5028
- * Warn and return empty string if no suitable value exists.
5029
- * - ZH: 若为纯字符串,原样返回;若为记录:优先使用请求的语言;否则使用任一非 "en";再退回 "en"。
5030
- * 若均不可用,会发出警告并返回空字符串。
5031
- *
5032
- * Examples / 示例
5033
- * - iStringify('Hello') => { en: 'Hello' }
5034
- * - iStringParse({ en: 'Hello', zh: '你好' }, 'zh') => '你好'
5035
- * - iStringMatch({ en: 'Hello' }, 'Hello') => true
5651
+ * perfectly legitimate thing to publish, not a Skill that failed.
5036
5652
  *
5037
- * Notes / 说明
5038
- * - EN: Depends on zod and the Locale/LocaleSchema from ./types to keep locales consistent.
5039
- * - ZH: 依赖 zod 以及 ./types 中的 Locale/LocaleSchema,确保语言键一致与受控。
5040
- * - EN: isIString expects a JSON string that decodes to a locale-keyed object; plain strings return false.
5041
- * - ZH: isIString 接受 JSON 字符串并尝试解析为按语言分布的对象;纯字符串将返回 false。
5653
+ * The honest limit: a checker can tell you the frontmatter parses and the files the
5654
+ * manual points at exist. It cannot tell you the manual is *true*. Everything in
5655
+ * `SKILL.md` becomes instructions an agent acts on, and no static rule catches a
5656
+ * confident, wrong sentence about what a table means.
5042
5657
  */
5043
- const iStringRecordSchema = z.partialRecord(LocaleSchema, z.string());
5044
- z.union([z.string(), iStringRecordSchema]).describe("i18n string");
5658
+ const auditSkill = (files, options = {}) => {
5659
+ const findings = [];
5660
+ const error = (rule, message) => findings.push({
5661
+ severity: "error",
5662
+ rule,
5663
+ message
5664
+ });
5665
+ const warn = (rule, message) => findings.push({
5666
+ severity: "warning",
5667
+ rule,
5668
+ message
5669
+ });
5670
+ const root = options.root ?? "";
5671
+ const source = readText(files, root, PACKAGE_SKILL_ENTRY);
5672
+ if (source === void 0) return findings;
5673
+ let body = "";
5674
+ try {
5675
+ const parsed = parseFrontmatter(source, PACKAGE_SKILL_ENTRY);
5676
+ body = parsed.body;
5677
+ const frontmatter = SkillFrontmatterSchema$1.safeParse(parsed.data);
5678
+ if (!frontmatter.success) error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY} frontmatter is not a valid Skill declaration: ${frontmatter.error.issues.map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`).join("; ")}`);
5679
+ else if (frontmatter.data.description.trim() === "") error("skill/description", `${PACKAGE_SKILL_ENTRY} has no description. An agent chooses whether to reach for a Skill by reading it, so an empty one is not cosmetic — it is a Skill that never gets picked.`);
5680
+ } catch (cause) {
5681
+ error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY}: ${cause.message}`);
5682
+ return findings;
5683
+ }
5684
+ const todos = (body.match(/^.*\bTODO\b.*$/gm) ?? []).length;
5685
+ if (todos > 0) error("skill/todo", `${PACKAGE_SKILL_ENTRY} still carries ${todos} TODO line(s). The export draft leaves them on purpose — an agent acts on what this file says, and a blank the author was meant to fill in is worse than nothing once it ships.`);
5686
+ const present = new Set(relativePaths(files, root));
5687
+ const sidecar = new RegExp(`\\]\\((\\.?/?(?:${PACKAGE_SIDECARS})/[^)\\s]+)\\)|\`((?:${PACKAGE_SIDECARS})/[^\`\\s]+)\``, "g");
5688
+ const seen = /* @__PURE__ */ new Set();
5689
+ for (const match of body.matchAll(sidecar)) {
5690
+ const target = (match[1] ?? match[2]).replace(/^\.?\//, "");
5691
+ if (seen.has(target)) continue;
5692
+ seen.add(target);
5693
+ if (!present.has(target)) warn("skill/missing-reference", `${PACKAGE_SKILL_ENTRY} points at "${target}", which the package does not ship. An agent told to read it has an instruction it cannot satisfy.`);
5694
+ }
5695
+ return findings;
5696
+ };
5697
+ const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
5045
5698
  //#endregion
5046
5699
  //#region ../../packages/busabase-contract/src/contract/auto-merge.ts
5047
5700
  /**
@@ -5187,7 +5840,8 @@ const baseSchema = z.object({
5187
5840
  requiredApprovals: z.number()
5188
5841
  }),
5189
5842
  createdAt: z.string(),
5190
- fields: z.array(baseFieldSchema)
5843
+ fields: z.array(baseFieldSchema),
5844
+ metadata: z.record(z.string(), z.unknown()).default({})
5191
5845
  });
5192
5846
  z.object({
5193
5847
  parentNodeId: z.string().optional().describe("Parent node id. Must be a folder or the space root; container-incapable node types (Base, Doc, AirApp, etc.) cannot hold children."),
@@ -5371,6 +6025,7 @@ const nodeSchema = z.lazy(() => z.object({
5371
6025
  name: z.string(),
5372
6026
  description: z.string(),
5373
6027
  metadata: z.object({ version: z.string().optional() }).catchall(z.unknown()).default({}),
6028
+ settings: nodeSettingsSchema.default({}),
5374
6029
  explicitVisibility: z.enum([
5375
6030
  "private",
5376
6031
  "workspace",
@@ -5454,6 +6109,53 @@ z.object({
5454
6109
  nodeId: z.string(),
5455
6110
  metadata: z.record(z.string(), z.unknown())
5456
6111
  });
6112
+ /**
6113
+ * System settings Busabase itself acts on, per node.
6114
+ *
6115
+ * A CLOSED object, unlike `metadata`'s `z.record(z.string(), z.unknown())`.
6116
+ * That difference is the point: `metadata` is the open bag callers put their
6117
+ * own keys in, and its endpoint merges whatever it is handed. Anything the
6118
+ * product *reads and acts on* must not live there — `metadata.visibility` had
6119
+ * to be pulled out into its own column precisely because the generic endpoint
6120
+ * handed a `write`-level actor a door into an ACL input.
6121
+ *
6122
+ * Adding a key here is a deliberate change to this schema, which is what keeps
6123
+ * `PATCH /nodes/{nodeId}/settings` from becoming a second free-form bag.
6124
+ *
6125
+ * `strictObject`, not `object`: zod's default is to STRIP an unknown key, which
6126
+ * would hand a client that misspelled `airappEngine` a 200 and no effect —
6127
+ * exactly the silent failure a closed schema exists to prevent. Verified
6128
+ * against a running server, where the permissive version returned 200.
6129
+ */
6130
+ const nodeSettingsSchema = z.strictObject({
6131
+ /**
6132
+ * Which engine an AirApp runs on, when a human has chosen.
6133
+ *
6134
+ * `undefined` means "follow the app" — `airapp.json`'s `preferredEngine`
6135
+ * decides, or the default does. A value means somebody overrode it in the
6136
+ * node settings dialog, and it outranks the manifest from then on. So absence
6137
+ * must stay distinguishable from any particular value; `null` clears an
6138
+ * override and returns the node to following the app.
6139
+ */
6140
+ airappEngine: z.enum([
6141
+ "browser",
6142
+ "local",
6143
+ "remote"
6144
+ ]).nullish() });
6145
+ z.object({
6146
+ nodeId: z.string(),
6147
+ settings: nodeSettingsSchema
6148
+ });
6149
+ z.object({ nodeId: z.string() });
6150
+ z.object({
6151
+ nodeId: z.string(),
6152
+ agentPrompts: customAgentPromptsSchema.nullable()
6153
+ });
6154
+ z.object({
6155
+ nodeId: z.string(),
6156
+ /** Replaces the whole list — this is not a merge. Send `null` to clear. */
6157
+ agentPrompts: customAgentPromptsSchema.nullable()
6158
+ });
5457
6159
  z.object({
5458
6160
  query: z.string().min(1),
5459
6161
  limit: z.coerce.number().int().min(1).max(50).optional().default(20)
@@ -5649,7 +6351,8 @@ z.object({
5649
6351
  "change_request.reviewed",
5650
6352
  "change_request.merged",
5651
6353
  "change_request.pending_review",
5652
- "node.metadata_updated"
6354
+ "node.metadata_updated",
6355
+ "node.settings_updated"
5653
6356
  ]),
5654
6357
  spaceId: z.string(),
5655
6358
  actorId: z.string(),
@@ -5680,6 +6383,8 @@ const auditActionSchema = z.enum([
5680
6383
  "asset.text_written",
5681
6384
  "asset.text_marked_none",
5682
6385
  "node.metadata_updated",
6386
+ "node.settings_updated",
6387
+ "node.agent_prompts_updated",
5683
6388
  "node.purged"
5684
6389
  ]);
5685
6390
  z.object({
@@ -6528,6 +7233,38 @@ const decodeText = (bytes, filePath) => {
6528
7233
  if (text.includes("�")) throw new Error(`${filePath} is not valid UTF-8 text.`);
6529
7234
  return text;
6530
7235
  };
7236
+ const AIRAPP_MANIFEST_FILENAME = "airapp.json";
7237
+ const PYTHON_RUNTIME_MARKERS = ["requirements.txt", "pyproject.toml"];
7238
+ /**
7239
+ * Resolve only the lifecycle facts the package format needs to validate.
7240
+ * Keep the same explicit -> inferred -> default order as Busabase's runtime
7241
+ * descriptor without making busabase-package depend on busabase-core (core
7242
+ * already depends on this package).
7243
+ */
7244
+ const resolveAirAppPackageLifecycle = (files, slug) => {
7245
+ const paths = new Set(files.map((file) => file.path));
7246
+ const manifestFile = files.find((file) => file.path === AIRAPP_MANIFEST_FILENAME);
7247
+ let manifest = {};
7248
+ if (manifestFile) {
7249
+ let parsed;
7250
+ try {
7251
+ parsed = JSON.parse(decodeText(manifestFile.bytes, AIRAPP_MANIFEST_FILENAME));
7252
+ } catch (error) {
7253
+ throw new Error(`AirApp "${slug}" has an invalid ${AIRAPP_MANIFEST_FILENAME}: ${error.message}`);
7254
+ }
7255
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`AirApp "${slug}" has an invalid ${AIRAPP_MANIFEST_FILENAME}: expected an object.`);
7256
+ manifest = parsed;
7257
+ }
7258
+ const declaredRuntime = manifest.runtime;
7259
+ if (declaredRuntime !== void 0 && declaredRuntime !== "node" && declaredRuntime !== "python") throw new Error(`AirApp "${slug}" declares unknown runtime ${JSON.stringify(declaredRuntime)} in ${AIRAPP_MANIFEST_FILENAME}.`);
7260
+ const runtime = declaredRuntime === "node" || declaredRuntime === "python" ? declaredRuntime : PYTHON_RUNTIME_MARKERS.some((path) => paths.has(path)) ? "python" : "node";
7261
+ const hasCustomCommand = (key) => typeof manifest[key] === "string" && manifest[key].trim().length > 0;
7262
+ return {
7263
+ runtime,
7264
+ usesDefaultInstall: !hasCustomCommand("install"),
7265
+ usesDefaultStart: !hasCustomCommand("start")
7266
+ };
7267
+ };
6531
7268
  const parseJsonFile = (files, filePath) => {
6532
7269
  const bytes = files.get(filePath);
6533
7270
  if (!bytes) throw new Error(`Missing ${filePath}`);
@@ -6707,7 +7444,10 @@ const readDirNode = (files, dir, slug) => {
6707
7444
  const ignore = readIgnoreFile(files, dir);
6708
7445
  const entries = collectSubtree(files, dir).filter((entry) => entry.path !== "_node.json" && entry.path !== ".busabaseignore" && !ignore.ignores(entry.path));
6709
7446
  assertNoReservedNames(entries.map((entry) => entry.path), `${meta.type} "${slug}"`);
6710
- if (meta.type === "airapp" && !entries.some((entry) => entry.path === "package.json")) throw new Error(`AirApp "${slug}" has no package.json to install${ignore.isEmpty ? "" : ` — check ${dir}${PACKAGE_AIRAPP_IGNORE}, which must never exclude it`}. Nothing was installed.`);
7447
+ if (meta.type === "airapp") {
7448
+ const lifecycle = resolveAirAppPackageLifecycle(entries, slug);
7449
+ if (lifecycle.runtime === "node" && (lifecycle.usesDefaultInstall || lifecycle.usesDefaultStart) && !entries.some((entry) => entry.path === "package.json")) throw new Error(`AirApp "${slug}" has no package.json required by its default Node lifecycle${ignore.isEmpty ? "" : ` — check ${dir}${PACKAGE_AIRAPP_IGNORE}, which must never exclude it`}. Nothing was installed.`);
7450
+ }
6711
7451
  return {
6712
7452
  type: meta.type,
6713
7453
  slug,
@@ -6904,6 +7644,8 @@ const validateTemplate = (tree) => {
6904
7644
  else if (airapps.length > 0 || template?.airapp || template?.airapps?.length) errors.push(resolved.error);
6905
7645
  for (const node of walkNodes(tree.nodes)) {
6906
7646
  if (!isAirApp(node)) continue;
7647
+ const lifecycle = resolveAirAppPackageLifecycle(node.files, node.slug);
7648
+ if (lifecycle.runtime !== "node" || !lifecycle.usesDefaultStart) continue;
6907
7649
  const manifestFile = node.files.find((file) => file.path === "package.json");
6908
7650
  if (!manifestFile) continue;
6909
7651
  let scripts;
@@ -6913,7 +7655,7 @@ const validateTemplate = (tree) => {
6913
7655
  errors.push(`AirApp "${node.slug}": package.json is not valid JSON (${error.message}).`);
6914
7656
  continue;
6915
7657
  }
6916
- if (typeof scripts?.dev !== "string") errors.push(`AirApp "${node.slug}": package.json has no \`dev\` script. Busabase starts an AirApp with \`npm run dev\`, so without it the app installs and then never boots.`);
7658
+ if (typeof scripts?.dev !== "string") errors.push(`AirApp "${node.slug}": package.json has no \`dev\` script. This AirApp uses the default Node start command \`npm run dev\`, so without it the app installs and then never boots.`);
6917
7659
  }
6918
7660
  if (airapps.length === 0) warnings.push("No AirApp — this template installs data only. Users will see tables, not an app.");
6919
7661
  if (template?.requires?.airapp && airapps.length === 0) errors.push("template.requires.airapp is true, but content/ contains no AirApp.");
@@ -7418,6 +8160,7 @@ const applyInstall = async (client, plan, options) => {
7418
8160
  pendingChangeRequests: 0,
7419
8161
  warnings: [...plan.warnings]
7420
8162
  };
8163
+ const createdRootNodeIds = [];
7421
8164
  const fieldIds = /* @__PURE__ */ new Map();
7422
8165
  const bases = [];
7423
8166
  const progress = options.onProgress ?? (() => {});
@@ -7437,6 +8180,7 @@ const applyInstall = async (client, plan, options) => {
7437
8180
  metadata: app?.rootMetadata
7438
8181
  });
7439
8182
  state.created.folders++;
8183
+ createdRootNodeIds.push(state.targetFolderNodeId);
7440
8184
  }
7441
8185
  await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds, app);
7442
8186
  if (app?.rootSkill) {
@@ -7528,8 +8272,16 @@ const applyInstall = async (client, plan, options) => {
7528
8272
  const created = Object.entries(state.created).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`).join(", ");
7529
8273
  const partial = created ? `Created before failure: ${created}. Target folder: ${plan.targetFolderSlug}.` : "No package resources were created.";
7530
8274
  const pending = state.pendingChangeRequests ? ` ${state.pendingChangeRequests} ChangeRequest(s) may still be pending.` : "";
8275
+ const rollback = await rollbackPartialInstall(client, {
8276
+ createdRootNodeIds,
8277
+ enabled: options.rollbackOnFailure !== false,
8278
+ installedIntoExistingFolder: Boolean(plan.existingTargetFolderNodeId),
8279
+ anythingCreated: Object.values(state.created).some((count) => count > 0),
8280
+ submittedBy: options.submittedBy ?? "busabase-cli install",
8281
+ progress
8282
+ });
7531
8283
  const reason = error instanceof Error ? error.message : String(error);
7532
- const message = `Install stopped during ${phase}. ${partial}${pending} Cause: ${reason}`;
8284
+ const message = `Install stopped during ${phase}. ${partial}${pending}${rollback} Cause: ${reason}`;
7533
8285
  const details = {
7534
8286
  phase,
7535
8287
  targetFolderSlug: plan.targetFolderSlug,
@@ -7544,6 +8296,47 @@ const applyInstall = async (client, plan, options) => {
7544
8296
  }
7545
8297
  };
7546
8298
  /**
8299
+ * Undo a failed install by moving what it created to Trash.
8300
+ *
8301
+ * Deleting a node takes its whole subtree — Bases, their records, nested
8302
+ * folders — so undoing an install is a matter of deleting the roots it made,
8303
+ * not walking everything it touched. It moves them to Trash rather than
8304
+ * purging: purge refuses a subtree containing a Base (which every real package
8305
+ * has), and Trash is reversible, which matters for an automatic action taken
8306
+ * while something has already gone wrong.
8307
+ *
8308
+ * Only ever deletes a target folder THIS install created. Installing into a
8309
+ * folder the user already had is reported instead of guessed at: the new nodes
8310
+ * sit among the user's own, and there is no reliable way here to tell which
8311
+ * were ours. Deleting the wrong thing is far worse than leaving a mess.
8312
+ *
8313
+ * Best-effort: a rollback runs after a failure, so it can hit the same broken
8314
+ * condition. Whatever happens is reported, never thrown — the install error is
8315
+ * the one the caller needs.
8316
+ */
8317
+ const rollbackPartialInstall = async (client, args) => {
8318
+ if (!args.anythingCreated) return "";
8319
+ if (!args.enabled) return " Left in place (--no-rollback).";
8320
+ if (args.createdRootNodeIds.length === 0) return args.installedIntoExistingFolder ? " Not rolled back: this installed into a folder that already existed, so its new items sit among yours and cannot be told apart safely — remove them by hand." : " Nothing to roll back.";
8321
+ args.progress("Install failed — moving what it created to Trash…");
8322
+ const failures = [];
8323
+ for (const nodeId of args.createdRootNodeIds) try {
8324
+ await client.nodes.createChangeRequest({
8325
+ operations: [{
8326
+ kind: "delete",
8327
+ nodeId
8328
+ }],
8329
+ message: "Roll back a failed install",
8330
+ submittedBy: args.submittedBy,
8331
+ autoMerge: true
8332
+ });
8333
+ } catch (rollbackError) {
8334
+ failures.push(`${nodeId} (${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)})`);
8335
+ }
8336
+ if (failures.length === 0) return " Rolled back: everything it created was moved to Trash.";
8337
+ return ` Rollback incomplete — could not remove ${failures.join("; ")}. Remove them by hand.`;
8338
+ };
8339
+ /**
7547
8340
  * A property rather than a subclass: the whole point of the rethrow above is to
7548
8341
  * keep the original error's prototype (an `ORPCError`'s `code`/`status`/`data`
7549
8342
  * are what make a permission or quota failure actionable), so the diagnostics
@@ -8052,21 +8845,26 @@ const findSourceNode = (nodes, slugOrId) => {
8052
8845
  const collectPackageTree = async (client, root, options) => {
8053
8846
  const children = root.type === "folder" ? root.children ?? [] : [root];
8054
8847
  const { skillSource, rest } = partitionTemplateSkill(children);
8055
- const nodes = await collectNodes(client, rest, options);
8848
+ const collectOptions = {
8849
+ ...options,
8850
+ baseResourceKeys: collectBaseResourceKeys(children)
8851
+ };
8852
+ const nodes = await collectNodes(client, rest, collectOptions);
8056
8853
  const tree = {
8057
8854
  manifest: {
8058
8855
  ...options.manifest,
8059
8856
  format: PACKAGE_FORMAT
8060
8857
  },
8061
- nodes: restorePackageSlugs(nodes, children),
8062
- ...skillSource ? { rootSkill: await collectRootSkill(client, skillSource, options) } : {},
8063
- assets: await collectDocAssets(client, nodes, options)
8858
+ nodes,
8859
+ ...skillSource ? { rootSkill: await collectRootSkill(client, skillSource, collectOptions) } : {},
8860
+ assets: await collectDocAssets(client, nodes, collectOptions)
8064
8861
  };
8065
- assertSelfContained(tree, options);
8862
+ assertSelfContained(tree, collectOptions);
8066
8863
  return tree;
8067
8864
  };
8068
8865
  /**
8069
- * Undo the slugs INSTALL chose, restoring the ones the package declared.
8866
+ * Undo the slug INSTALL chose for one node, restoring the one the package
8867
+ * declared.
8070
8868
  *
8071
8869
  * Install prefixes a template's Base slugs with the target folder (`contacts` →
8072
8870
  * `kelly-crm-contacts`) and records the original as the node's `resourceKey`.
@@ -8076,53 +8874,48 @@ const collectPackageTree = async (client, root, options) => {
8076
8874
  * which is the property that lets a user install a template, adjust it, and
8077
8875
  * publish the result without the names drifting a little further each time.
8078
8876
  *
8079
- * The root folder's own stamp is skipped: its `resourceKey` is the reserved
8080
- * `app-root` marker, not a slug anything should be named after.
8081
- */
8082
- const restorePackageSlugs = (nodes, sources) => {
8083
- const renames = /* @__PURE__ */ new Map();
8084
- const index = (list) => {
8877
+ * The app-root stamp is skipped: `resourceKey` is the reserved `app-root`
8878
+ * marker there, not a slug anything should be named after.
8879
+ *
8880
+ * Applied to the ONE node `source` describes — never through a space-wide
8881
+ * slug→slug rename table. An earlier version built exactly that table (keyed
8882
+ * by each node's *current* slug) and applied it by re-matching slugs across
8883
+ * the whole exported tree; that broke the moment two unrelated nodes shared a
8884
+ * slug anywhere in the space — observed live: an AirApp installed into a
8885
+ * folder auto-named after it end up BOTH called "social-media" pre-restore
8886
+ * (the folder, and the app before its own stamp renamed it back to "airapp"),
8887
+ * so the app's rename bled onto its own parent folder on export.
8888
+ */
8889
+ const restoredNodeSlug = (source) => {
8890
+ const key = source.metadata?.resourceKey;
8891
+ if (typeof key === "string" && key !== "app-root" && key !== source.slug && NODE_SLUG_PATTERN.test(key)) return key;
8892
+ return source.slug;
8893
+ };
8894
+ /**
8895
+ * The other half of slug restoration: a relation field's `targetBaseSlug` (set
8896
+ * from `context.baseSlugById` in {@link toPackageField}, itself built by
8897
+ * {@link buildIndexes} from each Base's raw current server slug) must resolve
8898
+ * to whatever THAT target Base's own node slug gets restored to, or the
8899
+ * package would ship a relation pointing at a name it never actually wrote.
8900
+ *
8901
+ * Keyed by Base id (globally unique, unlike slug — see {@link restoredNodeSlug}'s
8902
+ * doc) so `buildIndexes` can look a target base's restored slug up without
8903
+ * re-deriving it from a name that might collide with something else in the
8904
+ * space.
8905
+ */
8906
+ const collectBaseResourceKeys = (sources) => {
8907
+ const byBaseId = /* @__PURE__ */ new Map();
8908
+ const walk = (list) => {
8085
8909
  for (const source of list) {
8086
- const key = source.metadata?.resourceKey;
8087
- if (typeof key === "string" && key !== "app-root" && key !== source.slug && NODE_SLUG_PATTERN.test(key)) renames.set(source.slug, key);
8088
- if (source.children) index(source.children);
8089
- }
8090
- };
8091
- index(sources);
8092
- if (renames.size === 0) return [...nodes];
8093
- const rewriteField = (field) => {
8094
- const target = field.options.targetBaseSlug;
8095
- if (!target) return field;
8096
- const renamed = renames.get(target);
8097
- return renamed ? {
8098
- ...field,
8099
- options: {
8100
- ...field.options,
8101
- targetBaseSlug: renamed
8102
- }
8103
- } : field;
8104
- };
8105
- const rewrite = (node) => {
8106
- const slug = renames.get(node.slug) ?? node.slug;
8107
- if (node.type === "folder") return {
8108
- ...node,
8109
- slug,
8110
- children: node.children.map(rewrite)
8111
- };
8112
- if (node.type === "base") return {
8113
- ...node,
8114
- slug,
8115
- base: {
8116
- ...node.base,
8117
- fields: node.base.fields.map(rewriteField)
8910
+ if (source.type === "base") {
8911
+ const restored = restoredNodeSlug(source);
8912
+ if (restored !== source.slug) byBaseId.set(source.baseId ?? source.slug, restored);
8118
8913
  }
8119
- };
8120
- return {
8121
- ...node,
8122
- slug
8123
- };
8914
+ if (source.children) walk(source.children);
8915
+ }
8124
8916
  };
8125
- return nodes.map(rewrite);
8917
+ walk(sources);
8918
+ return byBaseId;
8126
8919
  };
8127
8920
  /**
8128
8921
  * Separate the app's manual from its resources.
@@ -8200,7 +8993,7 @@ const collectNodes = async (client, sources, options) => {
8200
8993
  };
8201
8994
  const collectNode = async (client, source, options) => {
8202
8995
  const common = {
8203
- slug: source.slug,
8996
+ slug: restoredNodeSlug(source),
8204
8997
  name: source.name,
8205
8998
  description: source.description ?? "",
8206
8999
  position: source.position
@@ -8237,12 +9030,14 @@ const collectBase = async (client, source, common, options) => {
8237
9030
  const baseId = source.baseId ?? source.slug;
8238
9031
  const base = await client.bases.get({ baseId });
8239
9032
  if (!base) throw new Error(`Base node "${source.slug}" has no Base behind it (looked up ${baseId}). The space may be mid-migration; re-run export.`);
8240
- const views = await client.bases.listViews({ baseId: base.id });
8241
- const { baseSlugById, fieldSlugById } = await buildIndexes(client);
9033
+ const allViews = await client.bases.listViews({ baseId: base.id });
9034
+ const views = allViews.filter((view) => view.slug.length > 0);
9035
+ if (views.length !== allViews.length) options.warn(`Base "${common.slug}" has ${allViews.length - views.length} view(s) with no slug — they were dropped (predates the slug requirement; rename it in the UI to keep it).`);
9036
+ const { baseSlugById, fieldSlugById } = await buildIndexes(client, options.baseResourceKeys ?? /* @__PURE__ */ new Map());
8242
9037
  const fields = [...base.fields].sort((a, b) => a.position - b.position).map((field) => toPackageField(field, {
8243
9038
  fieldSlugById,
8244
9039
  baseSlugById,
8245
- baseSlug: base.slug,
9040
+ baseSlug: common.slug,
8246
9041
  warn: options.warn
8247
9042
  }));
8248
9043
  const records = await collectRecords(client, base.id, base.fields, options);
@@ -8272,12 +9067,12 @@ const collectBase = async (client, source, common, options) => {
8272
9067
  *
8273
9068
  * `bases.list()` already hydrates each base's fields, so one call builds both.
8274
9069
  */
8275
- const buildIndexes = async (client) => {
9070
+ const buildIndexes = async (client, baseResourceKeys) => {
8276
9071
  const bases = await client.bases.list({});
8277
9072
  const baseSlugById = /* @__PURE__ */ new Map();
8278
9073
  const fieldSlugById = /* @__PURE__ */ new Map();
8279
9074
  for (const base of bases) {
8280
- baseSlugById.set(base.id, base.slug);
9075
+ baseSlugById.set(base.id, baseResourceKeys.get(base.id) ?? base.slug);
8281
9076
  for (const field of base.fields ?? []) fieldSlugById.set(field.id, field.slug);
8282
9077
  }
8283
9078
  return {
@@ -8464,12 +9259,18 @@ const assertSelfContained = (tree, options) => {
8464
9259
  const packagedRecordKeys = new Set(baseNodes.flatMap((node) => node.type === "base" ? node.records.map((record) => record.key) : []));
8465
9260
  for (const node of baseNodes) {
8466
9261
  if (node.type !== "base") continue;
9262
+ for (const field of node.base.fields) {
9263
+ if (field.type !== "attachment" || !field.required) continue;
9264
+ field.required = false;
9265
+ options.warn(`Base "${node.slug}": attachment field "${field.slug}" is required in the source, but ${PACKAGE_FORMAT} never carries attachment values — the field would be empty in every installed record and the install would fail. It is marked NOT required in this package.`);
9266
+ }
8467
9267
  for (const field of node.base.fields) {
8468
9268
  const targetBaseSlug = field.options.targetBaseSlug;
8469
9269
  if (field.type !== "relation" || !targetBaseSlug) continue;
8470
9270
  if (!packagedBaseSlugs.has(targetBaseSlug)) throw new Error(`Field "${node.slug}.${field.slug}" relates to Base "${targetBaseSlug}", which is outside the subtree being exported. A package must be self-contained — export a subtree that includes both Bases, or remove the field.`);
8471
9271
  }
8472
9272
  let dangling = 0;
9273
+ const emptiedRequired = /* @__PURE__ */ new Map();
8473
9274
  for (const record of node.records) for (const field of node.base.fields) {
8474
9275
  if (field.type !== "relation") continue;
8475
9276
  const value = record.fields[field.slug];
@@ -8477,9 +9278,18 @@ const assertSelfContained = (tree, options) => {
8477
9278
  if (keys.length === 0) continue;
8478
9279
  const kept = keys.filter((key) => typeof key !== "string" || packagedRecordKeys.has(key));
8479
9280
  dangling += keys.length - kept.length;
8480
- if (kept.length !== keys.length) record.fields[field.slug] = kept;
9281
+ if (kept.length !== keys.length) {
9282
+ record.fields[field.slug] = kept;
9283
+ if (kept.length === 0 && field.required) emptiedRequired.set(field.slug, (emptiedRequired.get(field.slug) ?? 0) + 1);
9284
+ }
8481
9285
  }
8482
9286
  if (dangling > 0) options.warn(`Base "${node.slug}" has ${dangling} relation value(s) pointing at records outside the exported subtree — they were dropped.`);
9287
+ for (const [slug, count] of emptiedRequired) {
9288
+ const field = node.base.fields.find((candidate) => candidate.slug === slug);
9289
+ if (!field) continue;
9290
+ field.required = false;
9291
+ options.warn(`Base "${node.slug}": relation field "${slug}" is required in the source, but ${count} record(s) point only at records outside the export (typically archived ones, which are never exported). It is marked NOT required in this package so the package can be installed; those records install with that field empty.`);
9292
+ }
8483
9293
  }
8484
9294
  };
8485
9295
  //#endregion
@@ -8818,15 +9628,59 @@ const renderNodeTree = (nodes, indent) => {
8818
9628
  return lines;
8819
9629
  };
8820
9630
  /**
8821
- * §4 + §12: without `--auto-merge` an unmerged base has no field ids to patch and no
8822
- * records to link, so passes 2-5 cannot run. Fail fast and say exactly why.
9631
+ * A required field the package cannot satisfy for at least one record. The
9632
+ * server rejects these — but only once it is already writing records, deep into
9633
+ * pass 4 of 5, having created everything before them.
9634
+ *
9635
+ * Real occurrence: a package whose `content-artifacts` Base declared a REQUIRED
9636
+ * attachment field (`busabase-package@1` never carries attachment bytes, so the
9637
+ * column is empty in every record) failed with `Invalid field value: 文件 is
9638
+ * required` **after creating 2,903 records**. `export` no longer produces such
9639
+ * packages, but one written by hand — or exported before that fix — still can,
9640
+ * and `install` should say so before touching the target.
8823
9641
  */
8824
- const assertPlanIsApplicable = (plan, autoMerge) => {
9642
+ const findUnsatisfiableRequiredFields = (plan) => {
9643
+ const problems = [];
9644
+ for (const node of walkNodes(plan.tree.nodes)) {
9645
+ if (node.type !== "base") continue;
9646
+ for (const field of node.base.fields) {
9647
+ if (!field.required) continue;
9648
+ const missing = node.records.filter((record) => {
9649
+ const value = record.fields[field.slug];
9650
+ if (value === null || value === void 0 || value === "") return true;
9651
+ return Array.isArray(value) && value.length === 0;
9652
+ }).length;
9653
+ if (missing === 0) continue;
9654
+ problems.push(` • Base "${node.slug}", field "${field.slug}" (${field.type}) is required, but ${missing} of ${node.records.length} record(s) carry no value for it.`);
9655
+ }
9656
+ }
9657
+ return problems;
9658
+ };
9659
+ /**
9660
+ * Everything that would stop this plan from applying, as messages — so a dry
9661
+ * run can REPORT them and a real install can throw on them, from one source of
9662
+ * truth. Previously the checks lived only inside `assertPlanIsApplicable`,
9663
+ * which `--dry-run` returned before ever reaching: a dry run therefore printed
9664
+ * a clean-looking plan for a package that could not install at all.
9665
+ */
9666
+ const findPlanBlockers = (plan, autoMerge) => {
9667
+ const blockers = [];
8825
9668
  if (plan.collisions.some((collision) => !collision.renamedTo)) {
8826
9669
  const list = plan.collisions.filter((collision) => !collision.renamedTo).map((collision) => ` • ${collision.kind} "${collision.slug}" (${collision.path})`).join("\n");
8827
- throw new Error(`Install would collide with content that already exists:\n${list}\n\nNothing was created. Re-run with --rename to install the colliding items under suffixed slugs (e.g. "-2"), or pick a different --into-folder.`);
9670
+ blockers.push(`Install would collide with content that already exists:\n${list}\n\nNothing was created. Re-run with --rename to install the colliding items under suffixed slugs (e.g. "-2"), or pick a different --into-folder.`);
8828
9671
  }
8829
- if (plan.requiresAutoMerge && !autoMerge) throw new Error("This package's records carry relation values, and a relation stores the ids of the records it points at — which only exist once the records are merged. Installing review-first would leave every relation empty.\n\nRe-run with --auto-merge to install it (this trusts the package author: records are created immediately instead of landing as change requests you review first).");
9672
+ if (plan.requiresAutoMerge && !autoMerge) blockers.push("This package's records carry relation values, and a relation stores the ids of the records it points at — which only exist once the records are merged. Installing review-first would leave every relation empty.\n\nRe-run with --auto-merge to install it (this trusts the package author: records are created immediately instead of landing as change requests you review first).");
9673
+ const unsatisfiable = findUnsatisfiableRequiredFields(plan);
9674
+ if (unsatisfiable.length > 0) blockers.push(`This package cannot satisfy its own required fields:\n${unsatisfiable.join("\n")}\n\nThe server rejects these while writing records, partway through the install. Re-export the package (export relaxes fields it cannot fill), or edit the Base definitions in the package to make them optional.`);
9675
+ return blockers;
9676
+ };
9677
+ /**
9678
+ * §4 + §12: without `--auto-merge` an unmerged base has no field ids to patch and no
9679
+ * records to link, so passes 2-5 cannot run. Fail fast and say exactly why.
9680
+ */
9681
+ const assertPlanIsApplicable = (plan, autoMerge) => {
9682
+ const blockers = findPlanBlockers(plan, autoMerge);
9683
+ if (blockers.length > 0) throw new Error(blockers.join("\n\n"));
8830
9684
  };
8831
9685
  //#endregion
8832
9686
  //#region ../../packages/busabase-package/src/discover.ts
@@ -9464,9 +10318,12 @@ const runInstall = async (client, repoUrl, options) => {
9464
10318
  installSampleRecords: !options.noSampleRecords
9465
10319
  });
9466
10320
  if (options.dryRun) {
9467
- const report = `${renderPlan(plan)}\n\nDry run — nothing was created.`;
10321
+ const blockers = findPlanBlockers(plan, Boolean(options.autoMerge));
10322
+ const verdict = blockers.length === 0 ? "Dry run — nothing was created. No blockers found." : `Dry run — nothing was created.\n\nThis package would NOT install as-is:\n\n${blockers.join("\n\n")}`;
10323
+ const report = `${renderPlan(plan)}\n\n${verdict}`;
9468
10324
  return options.json ? {
9469
10325
  dryRun: true,
10326
+ blockers,
9470
10327
  ...toPlanSummary(plan)
9471
10328
  } : report;
9472
10329
  }
@@ -9477,6 +10334,7 @@ const runInstall = async (client, repoUrl, options) => {
9477
10334
  onProgress: reportProgress,
9478
10335
  serverUrl: options.serverUrl,
9479
10336
  installSampleRecords: !options.noSampleRecords,
10337
+ rollbackOnFailure: !options.noRollback,
9480
10338
  source: {
9481
10339
  ...github ? {
9482
10340
  repo: `${github.owner}/${github.repo}`,
@@ -9788,67 +10646,6 @@ async function paginateAll(options) {
9788
10646
  return written;
9789
10647
  }
9790
10648
  //#endregion
9791
- //#region src/retry.ts
9792
- const BASE_DELAY_MS = 500;
9793
- const MAX_DELAY_MS = 2e4;
9794
- const READ_METHODS = /* @__PURE__ */ new Set([
9795
- "GET",
9796
- "HEAD",
9797
- "OPTIONS"
9798
- ]);
9799
- const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9800
- const isNetworkError = (error) => error instanceof TypeError || /fetch failed|econnreset|econnrefused|etimedout|socket hang up/i.test(error instanceof Error ? error.message : String(error));
9801
- /** `Retry-After` is either delay-seconds or an HTTP-date; both are legal. */
9802
- function parseRetryAfter(header, now = Date.now()) {
9803
- if (!header) return void 0;
9804
- const seconds = Number(header.trim());
9805
- if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, MAX_DELAY_MS);
9806
- const at = Date.parse(header);
9807
- if (Number.isNaN(at)) return void 0;
9808
- return Math.min(Math.max(at - now, 0), MAX_DELAY_MS);
9809
- }
9810
- /** Whether this outcome is worth another attempt for a request using `method`. */
9811
- function shouldRetry(method, status) {
9812
- if (status === 429) return true;
9813
- if (!READ_METHODS.has(method.toUpperCase())) return false;
9814
- return status === void 0 || status >= 500;
9815
- }
9816
- /**
9817
- * Wrap a fetch so transient failures are retried with exponential backoff.
9818
- * Returns `base` unchanged when `retries` is 0, so the no-retry path adds no
9819
- * wrapper and no behaviour.
9820
- */
9821
- function withRetry(base, options) {
9822
- const { retries } = options;
9823
- if (retries <= 0) return base;
9824
- const sleep = options.sleep ?? defaultSleep;
9825
- const notify = options.notify ?? ((message) => console.error(message));
9826
- const jitter = options.jitter ?? Math.random;
9827
- return (async (input, init) => {
9828
- const method = init?.method ?? (input instanceof Request ? input.method : "GET");
9829
- for (let attempt = 0;; attempt += 1) {
9830
- let response;
9831
- let failure;
9832
- try {
9833
- response = await base(input, init);
9834
- } catch (error) {
9835
- if (!isNetworkError(error)) throw error;
9836
- failure = error;
9837
- }
9838
- const status = response?.status;
9839
- const last = attempt >= retries;
9840
- if (!shouldRetry(method, status) || last) {
9841
- if (response) return response;
9842
- throw failure;
9843
- }
9844
- const backoff = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
9845
- const delay = parseRetryAfter(response?.headers.get("retry-after") ?? null) ?? Math.round(backoff * (.5 + jitter() * .5));
9846
- notify(`[busabase-cli] ${status ? `HTTP ${status}` : "connection failed"} — retrying in ${Math.round(delay / 1e3)}s (attempt ${attempt + 2} of ${retries + 1})`);
9847
- await sleep(delay);
9848
- }
9849
- });
9850
- }
9851
- //#endregion
9852
10649
  //#region src/task-command.ts
9853
10650
  const collect = (value, previous = []) => [...previous, value];
9854
10651
  /** Range-checks before the request goes out, so a cap surfaces as a usage error. */
@@ -10079,14 +10876,22 @@ function resolveConfig(opts) {
10079
10876
  }));
10080
10877
  const file = loadDotEnvFile();
10081
10878
  const baseUrl = opts.baseUrl ?? process.env.BUSABASE_BASE_URL ?? file.BUSABASE_BASE_URL ?? DEFAULT_BASE_URL;
10879
+ const retries = opts.retries ?? DEFAULT_RETRIES;
10082
10880
  return {
10083
10881
  baseUrl,
10882
+ /**
10883
+ * The number itself, not just the `fetch` built from it. `backup` retries
10884
+ * at a level this wrapper cannot reach — see `FullExportOptions.retries` —
10885
+ * and has to honour the same flag, or `--retries` silently means nothing
10886
+ * for the one command whose failures cost hours.
10887
+ */
10888
+ retries,
10084
10889
  webUrl: process.env.BUSABASE_WEB_URL ?? file.BUSABASE_WEB_URL ?? baseUrl,
10085
10890
  apiKey: opts.apiKey ?? process.env.BUSABASE_API_KEY ?? file.BUSABASE_API_KEY,
10086
10891
  spaceId: opts.spaceId ?? process.env.BUSABASE_SPACE_ID ?? file.BUSABASE_SPACE_ID,
10087
10892
  sourceChannel: "cli",
10088
10893
  output: opts.output ?? configuredOutput() ?? "text",
10089
- fetch: withRetry(fetch, { retries: opts.retries ?? DEFAULT_RETRIES })
10894
+ fetch: withRetry(fetch, { retries })
10090
10895
  };
10091
10896
  }
10092
10897
  /** Enough to ride out a rate limiter or a proxy blip; not enough to hide a real outage. */
@@ -10337,6 +11142,24 @@ async function putTextCommand(client, opts) {
10337
11142
  });
10338
11143
  }
10339
11144
  /** Wrap a leaf-command handler: resolve config, build the client, render the result. */
11145
+ /**
11146
+ * Is this a 404 — either "this server has no such route" or "no such node"?
11147
+ *
11148
+ * Duck-typed rather than an `instanceof ORPCError` check, because this package
11149
+ * deliberately depends on no `@orpc/*` package; the SDK builds the error and
11150
+ * only its `status` is needed here.
11151
+ *
11152
+ * Not distinguishing the two 404s is safe for the one thing this gates: a
11153
+ * version fallback whose other branch targets the SAME node. An old server
11154
+ * answers 404 because the route is missing and the fallback then succeeds; a
11155
+ * current server answers 404 because the node does not exist, the fallback
11156
+ * asks about the same missing node and 404s too, and the caller still sees a
11157
+ * "not found" error. The cost of guessing wrong is one extra request, not a
11158
+ * write landing somewhere unintended.
11159
+ */
11160
+ function isNotFound(error) {
11161
+ return typeof error === "object" && error !== null && "status" in error && error.status === 404;
11162
+ }
10340
11163
  function runAction(state, handler) {
10341
11164
  return async (_opts, cmd) => {
10342
11165
  const opts = cmd.optsWithGlobals();
@@ -10901,6 +11724,48 @@ Examples:
10901
11724
  message: opts.message,
10902
11725
  submittedBy: opts.submittedBy
10903
11726
  })));
11727
+ addGlobalFlags(nodes.command("set-agent-prompts")).description("Set a node's custom scenario prompts, shown in the Agent Prompts dialog instead of the node type's generic ones. Capability prompts (what the node type can actually do) are unaffected.").requiredOption("--node-id <id>", "node id to customize").requiredOption("--file <path>", "JSON file: an array of { key, intent?, label, body } — see the help text below").addHelpText("after", `
11728
+ Each entry: { key: string (unique per node), intent?: "read-only" | "change",
11729
+ label: string | { <locale>: string }, body: string | { <locale>: string } }.
11730
+ Locale keys (when using the object form): en, zh-CN, zh-TW, ja, ko, de, fr, es,
11731
+ pt — anything else fails validation as "Invalid input" on that field.
11732
+ "body" may contain a literal "{target}" placeholder, substituted at render time
11733
+ with the same node/space description every built-in prompt uses. At most 50
11734
+ entries per node, 80 characters per localized label, 8 KiB per localized body.
11735
+
11736
+ The file is validated against this exact schema BEFORE any network request —
11737
+ a malformed file (duplicate key, an over-limit label/body, an unrecognized
11738
+ locale) is reported entry-by-entry and nothing is written. \`--file\`'s
11739
+ contents ARE the array to store; this command does not reshape it.
11740
+
11741
+ Examples:
11742
+ busabase-cli nodes set-agent-prompts --node-id nod_123 --file prompts.json`).action(runAction(state, async (client, opts) => {
11743
+ const filePath = opts.file;
11744
+ let raw;
11745
+ try {
11746
+ raw = JSON.parse(readFileSync(filePath, "utf8"));
11747
+ } catch (error) {
11748
+ throw new CliOutcomeError("VALIDATION", `Could not read/parse ${filePath} as JSON: ${error instanceof Error ? error.message : String(error)}`);
11749
+ }
11750
+ const parsed = customAgentPromptsSchema.safeParse(raw);
11751
+ if (!parsed.success) {
11752
+ const detail = formatIssues(parsed.error.issues);
11753
+ throw new CliOutcomeError("VALIDATION", `Invalid agent prompts in ${filePath}${detail ? ` — ${detail}` : ""}`);
11754
+ }
11755
+ const nodeId = opts.nodeId;
11756
+ try {
11757
+ return await client.nodes.updateAgentPrompts({
11758
+ nodeId,
11759
+ agentPrompts: parsed.data
11760
+ });
11761
+ } catch (error) {
11762
+ if (!isNotFound(error)) throw error;
11763
+ return await client.nodes.updateMetadata({
11764
+ nodeId,
11765
+ metadata: { agentPrompts: parsed.data }
11766
+ });
11767
+ }
11768
+ }));
10904
11769
  const bases = program.command("bases").description("Bases (structured tables)");
10905
11770
  addGlobalFlags(bases.command("list")).description("List Bases in the active space").addOption(new Option("--status <active|archived>", "live Bases (default) or archived ones").choices(["active", "archived"])).action(runAction(state, (client, opts) => client.bases.list({ status: opts.status ?? "active" })));
10906
11771
  addGlobalFlags(bases.command("get")).description("Get one Base by slug").requiredOption("--slug <slug>", "Base slug").action(runAction(state, async (client, opts) => {
@@ -11024,7 +11889,7 @@ Examples:
11024
11889
  startLine: opts.startLine,
11025
11890
  endLine: opts.endLine
11026
11891
  })));
11027
- addGlobalFlags(program.command("install")).description("Install a Busabase package from a GitHub repo or a local directory into this space").argument("<source>", "GitHub repo URL (optionally /tree/<ref>[/<subdir>]), or a local directory / file:// URL written by `busabase-cli export`").option("--into-folder <name>", "target folder slug (default: the package's manifest name)").option("--dry-run", "print the plan (tree, record counts, collisions) and create nothing").option("--auto-merge", "merge the package's records and docs on the spot instead of leaving them as change requests to review — this TRUSTS THE PACKAGE AUTHOR, since skills and AirApps carry code your agents will run (default: review first)").option("--rename", "install colliding items under suffixed slugs (-2, -3, …) instead of failing").option("--skill <name>", "which package to install when the URL is a repository holding several (e.g. busabase/skills)").option("--no-sample-records", "propose a template's sample rows for review instead of merging them (default: merged, so the app is not empty on first open)").addHelpText("after", `
11892
+ addGlobalFlags(program.command("install")).description("Install a Busabase package from a GitHub repo or a local directory into this space").argument("<source>", "GitHub repo URL (optionally /tree/<ref>[/<subdir>]), or a local directory / file:// URL written by `busabase-cli export`").option("--into-folder <name>", "target folder slug (default: the package's manifest name)").option("--dry-run", "print the plan (tree, record counts, collisions) and create nothing").option("--auto-merge", "merge the package's records and docs on the spot instead of leaving them as change requests to review — this TRUSTS THE PACKAGE AUTHOR, since skills and AirApps carry code your agents will run (default: review first)").option("--rename", "install colliding items under suffixed slugs (-2, -3, …) instead of failing").option("--skill <name>", "which package to install when the URL is a repository holding several (e.g. busabase/skills)").option("--no-sample-records", "propose a template's sample rows for review instead of merging them (default: merged, so the app is not empty on first open)").option("--no-rollback", "keep whatever a failed install created (default: it is moved to Trash)").addHelpText("after", `
11028
11893
  The URL's git ref is the version pin — a tag installs that tag's content forever,
11029
11894
  even after the branch moves on. GITHUB_TOKEN is honored for private repos.
11030
11895
 
@@ -11064,6 +11929,7 @@ field with nothing linked yet does not trigger this.`).action(runArgAction(state
11064
11929
  rename: Boolean(opts.rename),
11065
11930
  skill: opts.skill,
11066
11931
  noSampleRecords: opts.sampleRecords === false,
11932
+ noRollback: opts.rollback === false,
11067
11933
  json: config.output === "json",
11068
11934
  githubToken: process.env.GITHUB_TOKEN,
11069
11935
  serverUrl: config.baseUrl
@@ -11126,7 +11992,7 @@ and as something \`busabase-cli install\` can install.`).action(runArgAction(sta
11126
11992
  json: config.output === "json",
11127
11993
  baseUrl: config.baseUrl
11128
11994
  })));
11129
- addGlobalFlags(program.command("backup")).description("Back up the active space to a full-fidelity .bbdump archive").option("-o, --out-file <file>", "output archive path", "space.bbdump").option("--no-history", "full-fidelity rows, but omit the history tables (commits, change requests, operations, comments, reviews, audit events)").option("--state-only", "superseded by `busabase-cli export` — see the error text").addHelpText("after", `
11995
+ addGlobalFlags(program.command("backup")).description("Back up the active space to a full-fidelity .bbdump archive").option("-o, --out-file <file>", "output archive path", "space.bbdump").option("--no-history", "drop finished change requests, reviews, comments and audit events (unfinished work is kept)").option("--state-only", "superseded by `busabase-cli export` — see the error text").option("--resume", "continue an interrupted backup, reusing the attachment bytes it already downloaded").addHelpText("after", `
11130
11996
  A backup is full fidelity: raw rows with their original ids, every doc body, and
11131
11997
  every attachment's bytes, in one integrity-checked archive. Vault items and
11132
11998
  webhook signing secrets are never written to it.
@@ -11134,19 +12000,27 @@ webhook signing secrets are never written to it.
11134
12000
  Examples:
11135
12001
  busabase-cli backup # → ./space.bbdump
11136
12002
  busabase-cli backup -o ./backups/acme-2026-07-19.bbdump
11137
- busabase-cli backup --no-history # smaller: current state only
12003
+ busabase-cli backup --no-history # current state + unfinished work
12004
+
12005
+ \`--no-history\` still restores completely: every record, field value, file and
12006
+ permission is there. What it leaves out is the paper trail — change requests
12007
+ that are already finished, their reviews and comments, and the audit log of who
12008
+ did what when. Change requests still in review (or approved but unmerged) are
12009
+ KEPT, because those are unfinished work rather than history.
11138
12010
 
11139
12011
  To hand content to someone else rather than back it up, use \`busabase-cli export\`,
11140
12012
  which writes a readable, diffable package directory you can push to GitHub.`).action(runAction(state, (client, opts, config) => runBackup(client, {
11141
12013
  outFile: opts.outFile,
11142
12014
  includeHistory: opts.history !== false,
12015
+ resume: Boolean(opts.resume),
11143
12016
  stateOnly: Boolean(opts.stateOnly),
11144
12017
  json: config.output === "json",
11145
12018
  baseUrl: config.baseUrl,
11146
12019
  spaceId: config.spaceId,
12020
+ retries: config.retries,
11147
12021
  toolVersion: pkgVersion()
11148
12022
  })));
11149
- addGlobalFlags(program.command("restore")).description("Restore a .bbdump archive into an EMPTY space, preserving original ids").argument("<file>", "path to the .bbdump archive").option("--resume", "resume a previously interrupted restore (not yet implemented)").option("--into-folder <name>", "state-only archives only — see the error text").addHelpText("after", `
12023
+ addGlobalFlags(program.command("restore")).description("Restore a .bbdump archive into an EMPTY space, preserving original ids").argument("<file>", "path to the .bbdump archive").option("--resume", "continue a restore whose process was killed partway through (same archive, same space)").option("--into-folder <name>", "state-only archives only — see the error text").addHelpText("after", `
11150
12024
  The archive's checksum is verified before a single row is written, and the target
11151
12025
  space must be EMPTY — a restore replays original ids, so it cannot merge into a
11152
12026
  space that already holds content. Those ids are also unique across the whole
@@ -11327,4 +12201,90 @@ function explainError(error, config) {
11327
12201
  return `${body}\n → Docs: ${DOCS_TROUBLESHOOTING}`;
11328
12202
  }
11329
12203
  //#endregion
11330
- export { runCli as n, render as r, HELP as t };
12204
+ //#region src/update-check.ts
12205
+ /** 24h — frequent enough to notice a new release, rare enough to never matter for latency. */
12206
+ const CHECK_INTERVAL_MS = 864e5;
12207
+ /**
12208
+ * Only paid on the (at most once per 24h, per package) cache-refresh path, and
12209
+ * only for a real network round trip — not on every command. A live check
12210
+ * against the real npm registry measured a cold DNS+TLS handshake at ~800ms
12211
+ * on its own, so anything near that cuts it too close and turns "occasionally
12212
+ * shows the notice a run late" into "usually misses it on the first try
12213
+ * after 24h" — 1500ms leaves real headroom while still being far short of
12214
+ * anything a human would perceive as the command hanging.
12215
+ */
12216
+ const FETCH_TIMEOUT_MS = 1500;
12217
+ function cacheDir() {
12218
+ return process.env.BUSABASE_UPDATE_CACHE_DIR || join(homedir(), ".cache", "busabase");
12219
+ }
12220
+ function cacheFile() {
12221
+ return join(cacheDir(), "update-check.json");
12222
+ }
12223
+ async function readCache() {
12224
+ try {
12225
+ const raw = await readFile(cacheFile(), "utf8");
12226
+ const parsed = JSON.parse(raw);
12227
+ return parsed && typeof parsed === "object" ? parsed : {};
12228
+ } catch {
12229
+ return {};
12230
+ }
12231
+ }
12232
+ async function writeCacheEntry(packageName, entry) {
12233
+ try {
12234
+ await mkdir(cacheDir(), { recursive: true });
12235
+ const all = await readCache();
12236
+ all[packageName] = entry;
12237
+ await writeFile(cacheFile(), JSON.stringify(all), "utf8");
12238
+ } catch {}
12239
+ }
12240
+ async function fetchLatestVersion(packageName) {
12241
+ const controller = new AbortController();
12242
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
12243
+ try {
12244
+ const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`, { signal: controller.signal });
12245
+ if (!res.ok) return null;
12246
+ const data = await res.json();
12247
+ return typeof data.version === "string" ? data.version : null;
12248
+ } catch {
12249
+ return null;
12250
+ } finally {
12251
+ clearTimeout(timeout);
12252
+ }
12253
+ }
12254
+ /** Opted out via env, CI, or a non-interactive terminal — a notice nobody will read. */
12255
+ function checksDisabled() {
12256
+ if (process.env.BUSABASE_NO_UPDATE_CHECK) return true;
12257
+ if (process.env.CI === "true") return true;
12258
+ if (!process.stdout.isTTY) return true;
12259
+ return false;
12260
+ }
12261
+ function formatNotice(packageName, currentVersion, latestVersion) {
12262
+ return `\nA new version of ${packageName} is available: ${currentVersion} → ${latestVersion}\nRun \`npm i -g ${packageName}@latest\` to update.\n`;
12263
+ }
12264
+ /**
12265
+ * Returns an update notice string if a newer version is known, else `null` —
12266
+ * caller decides where that goes (stderr for `busabase-cli`, the server boot
12267
+ * banner for `busabase`). Never throws.
12268
+ *
12269
+ * Cache hit (checked within the last 24h): synchronous-fast, no network call.
12270
+ * Cache miss/stale: pays for ONE bounded (`FETCH_TIMEOUT_MS`) registry round
12271
+ * trip so this run can already show the notice if a new version just shipped;
12272
+ * a genuinely slow/dead network just means this run stays silent and the next
12273
+ * stale check tries again — the timeout caps the worst case, it doesn't
12274
+ * silently retry in the background past process exit (a short-lived CLI
12275
+ * process can't rely on a detached background task actually finishing).
12276
+ */
12277
+ async function checkForUpdate(packageName, currentVersion) {
12278
+ if (checksDisabled()) return null;
12279
+ const cached = (await readCache())[packageName];
12280
+ if (!(!cached || Date.now() - cached.lastCheckedAt > CHECK_INTERVAL_MS)) return cached.latestVersion !== currentVersion ? formatNotice(packageName, currentVersion, cached.latestVersion) : null;
12281
+ const latest = await fetchLatestVersion(packageName);
12282
+ if (!latest) return null;
12283
+ await writeCacheEntry(packageName, {
12284
+ lastCheckedAt: Date.now(),
12285
+ latestVersion: latest
12286
+ });
12287
+ return latest !== currentVersion ? formatNotice(packageName, currentVersion, latest) : null;
12288
+ }
12289
+ //#endregion
12290
+ export { render as a, runCli as i, HELP as n, pkgVersion as r, checkForUpdate as t };