busabase-sdk 0.17.3 → 0.19.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.
package/dist/airapp.js CHANGED
@@ -1,451 +1,610 @@
1
- // src/airapp.ts
1
+ import { z } from "zod";
2
+ const TemplateAirAppRefSchema = z.object({
3
+ /** Slug of the `content/<dir>` holding the AirApp. */
4
+ slug: z.string().min(1),
5
+ role: z.enum([
6
+ "primary",
7
+ "admin",
8
+ "public",
9
+ "tool"
10
+ ]),
11
+ label: z.string().optional()
12
+ });
13
+ /**
14
+ * Secrets the app expects to find in the Vault.
15
+ *
16
+ * DECLARED, never created: the package format has no slot for secret values and
17
+ * must not grow one (the same "you cannot leak what the format cannot express"
18
+ * rule the whole format is built on). Install surfaces these as a post-install
19
+ * prompt; the user fills them in the Vault themselves.
20
+ */
21
+ const TemplateSecretSchema = z.object({
22
+ key: z.string().min(1),
23
+ description: z.string().default(""),
24
+ required: z.boolean().default(true)
25
+ });
26
+ z.object({
27
+ /** Template Center category, e.g. `"crm"`, `"email"`, `"content"`. */
28
+ category: z.string().min(1),
29
+ tags: z.array(z.string()).default([]),
30
+ /** Card/detail screenshots, package-relative (`assets/screenshots/overview.webp`). */
31
+ screenshots: z.array(z.string()).default([]),
32
+ /**
33
+ * Ready-made prompts shown after install ("Ask agent" prefills the first).
34
+ *
35
+ * They are the difference between a folder of tables and something a user can
36
+ * *use*: the point of a template is that the agent already knows the job, and
37
+ * these are how that is made visible rather than left for the user to guess.
38
+ */
39
+ agentPrompts: z.array(z.string()).default([]),
40
+ /** Single-AirApp shorthand. Mutually exclusive with `airapps`. */
41
+ airapp: z.string().optional(),
42
+ /** Multi-AirApp form. Exactly one entry must have `role: "primary"`. */
43
+ airapps: z.array(TemplateAirAppRefSchema).optional(),
44
+ /**
45
+ * Bumped by the author when the declared resource shape changes.
46
+ *
47
+ * Part of the ownership stamp, so BOTH doors must agree on it: the installer
48
+ * writes it, and a skill's own `setup.mjs` compares against it to decide
49
+ * whether a node it finds is its own current shape or an older one to repair.
50
+ * Defaulted rather than required so an author who never versions their app
51
+ * still gets a stamp both sides recognise.
52
+ */
53
+ schemaVersion: z.number().int().nonnegative().default(1),
54
+ vaultNamespace: z.string().optional(),
55
+ secrets: z.array(TemplateSecretSchema).default([]),
56
+ requires: z.object({ airapp: z.boolean().optional() }).default({})
57
+ });
58
+ /**
59
+ * `metadata.busabase` inside the root `SKILL.md`'s YAML frontmatter.
60
+ *
61
+ * `template: true` is an EXPLICIT opt-in, not an inference from "this skill
62
+ * happens to contain a package". Publishing a template means accepting that
63
+ * installers will run its AirApp code and feed its SKILL.md to their agent; that
64
+ * deserves a deliberate flag rather than a side effect of directory shape.
65
+ */
66
+ const SkillBusabaseMetadataSchema = z.object({
67
+ template: z.boolean().default(false),
68
+ folderSlug: z.string().optional(),
69
+ /** Resource keys the manual talks about; each must exist under `content/`. */
70
+ resources: z.array(z.string()).default([]),
71
+ risk: z.string().optional()
72
+ });
73
+ z.object({
74
+ name: z.string().min(1),
75
+ description: z.string().default(""),
76
+ metadata: z.object({ busabase: SkillBusabaseMetadataSchema.optional() }).passthrough().optional()
77
+ });
78
+ /** Stamp on every resource node (Base, Drive, AirApp, …) an app owns. */
79
+ const AppResourceOwnershipSchema = z.object({
80
+ appId: z.string().min(1),
81
+ /** Stable internal handle (`"contacts"`), NOT the installed slug. */
82
+ resourceKey: z.string().min(1),
83
+ schemaVersion: z.number().int().nonnegative()
84
+ });
85
+ /**
86
+ * The `resourceKey` reserved for an app's root Folder.
87
+ *
88
+ * `busabase-sdk` recognises an app's own Folder by looking for exactly this
89
+ * value (`ownsAppRoot`), so the installer must write it too — a Folder stamped
90
+ * with anything else reads as a stranger's, and the skill's own `setup.mjs`
91
+ * then refuses to touch its own workspace with `SETUP_CONFLICT`. Exported so
92
+ * neither side carries the string literal privately.
93
+ */
94
+ const APP_ROOT_RESOURCE_KEY = "app-root";
95
+ AppResourceOwnershipSchema.extend({
96
+ resourceKey: z.literal(APP_ROOT_RESOURCE_KEY),
97
+ version: z.string().optional(),
98
+ source: z.object({
99
+ repo: z.string().optional(),
100
+ ref: z.string().optional(),
101
+ subdir: z.string().optional()
102
+ }).optional(),
103
+ installedAt: z.string().optional()
104
+ });
105
+ z.object({
106
+ appId: z.string().min(1),
107
+ ["isTemplateSkill"]: z.literal(true)
108
+ });
109
+ //#endregion
110
+ //#region src/airapp.ts
111
+ /**
112
+ * AirApp resource provisioning — how an app claims (or creates) the Folder and
113
+ * Bases it declares, exactly once, without ever taking over someone else's.
114
+ *
115
+ * Every App-in-Skill shipped a byte-identical copy of this module (280 lines ×
116
+ * 65 apps, two spellings). That is the wrong place for it: the rules encoded
117
+ * here are not app preferences, they are the safety boundary that keeps an app
118
+ * from adopting a Folder a human created for something else. A third party
119
+ * re-deriving them from scratch gets the happy path right and the conflict
120
+ * cases wrong, and the failure is silent — the app happily writes into data it
121
+ * does not own.
122
+ *
123
+ * The contract, in one line: **an app owns a node only if it stamped it.**
124
+ * Ownership lives in `node.metadata` as `{ appId, resourceKey, schemaVersion }`.
125
+ * Anything else is either a legacy node this app plausibly created before
126
+ * stamping existed (claimable *only* after a full structural fingerprint match)
127
+ * or someone else's (never touched, always a `SETUP_CONFLICT`).
128
+ *
129
+ * This module is isomorphic — browser and Node both — and holds no I/O beyond
130
+ * the passed-in client.
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * import { createBusabaseClient } from "busabase-sdk";
135
+ * import { inspectProvisionedResources, provisionDeclaredResources } from "busabase-sdk/airapp";
136
+ *
137
+ * const client = createBusabaseClient({ baseUrl: window.location.origin });
138
+ * const config = {
139
+ * appId: "kelly-crm",
140
+ * appName: "Kelly CRM",
141
+ * schemaVersion: 1,
142
+ * folder: { slug: "kelly-crm", name: "Kelly CRM", description: "CRM workspace" },
143
+ * bases: [{ key: "contacts", slug: "kelly-crm-contacts-v1", name: "Contacts", fields: [...] }],
144
+ * };
145
+ *
146
+ * let resources = await inspectProvisionedResources(client, config);
147
+ * if (!resources.folder || resources.missing.length) {
148
+ * resources = await provisionDeclaredResources(client, config); // one idempotent ChangeRequest
149
+ * }
150
+ * ```
151
+ */
152
+ /**
153
+ * A setup failure carrying its state as a `code`.
154
+ *
155
+ * `message` is deliberately kept in the historical `"CODE: detail"` shape: the
156
+ * generated apps parse the prefix off `error.message`, so an app can migrate to
157
+ * this class without touching its rendering code, then move to `error.code`.
158
+ */
2
159
  var AirAppSetupError = class extends Error {
3
- code;
4
- /** The human-readable half, without the `CODE: ` prefix. */
5
- detail;
6
- constructor(code, detail) {
7
- super(`${code}: ${detail}`);
8
- this.name = "AirAppSetupError";
9
- this.code = code;
10
- this.detail = detail;
11
- }
160
+ code;
161
+ /** The human-readable half, without the `CODE: ` prefix. */
162
+ detail;
163
+ constructor(code, detail) {
164
+ super(`${code}: ${detail}`);
165
+ this.name = "AirAppSetupError";
166
+ this.code = code;
167
+ this.detail = detail;
168
+ }
12
169
  };
13
- var setupError = (code, detail) => new AirAppSetupError(code, detail);
14
- var isNotFound = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "NOT_FOUND" || "status" in error && error.status === 404);
15
- var isForbidden = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "FORBIDDEN" || "status" in error && error.status === 403);
16
- var ownsResource = (node, appId, resourceKey, schemaVersion) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey && node?.metadata?.schemaVersion === schemaVersion;
17
- var hasResourceIdentity = (node, appId, resourceKey) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey;
18
- var ownsAppRoot = (node, appId, schemaVersion) => hasResourceIdentity(node, appId, "app-root") && node?.metadata?.schemaVersion === schemaVersion;
19
- var hasEmptyMetadata = (node) => Object.keys(node?.metadata ?? {}).length === 0;
20
- var isUnclaimed = (node) => node?.metadata?.appId === void 0;
21
- var matchesDeclaration = (node, declaration, type) => node?.type === type && node?.slug === declaration.slug && node?.name === declaration.name && node?.description === (declaration.description ?? "");
22
- var matchesLegacyAirApp = (node, config) => isUnclaimed(node) && node?.type === "airapp" && node?.slug === config.airApp?.slug && node?.name === config.airApp?.name;
23
- var resourceMetadata = (config, resourceKey) => ({
24
- appId: config.appId,
25
- resourceKey,
26
- schemaVersion: config.schemaVersion
170
+ const setupError = (code, detail) => new AirAppSetupError(code, detail);
171
+ /** True for a 404 / NOT_FOUND from any of the client's transports. */
172
+ const isNotFound = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "NOT_FOUND" || "status" in error && error.status === 404);
173
+ const isForbidden = (error) => typeof error === "object" && error !== null && ("code" in error && error.code === "FORBIDDEN" || "status" in error && error.status === 403);
174
+ const ownsResource = (node, appId, resourceKey, schemaVersion) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey && node?.metadata?.schemaVersion === schemaVersion;
175
+ const hasResourceIdentity = (node, appId, resourceKey) => node?.metadata?.appId === appId && node?.metadata?.resourceKey === resourceKey;
176
+ const ownsAppRoot = (node, appId, schemaVersion) => hasResourceIdentity(node, appId, "app-root") && node?.metadata?.schemaVersion === schemaVersion;
177
+ const hasEmptyMetadata = (node) => Object.keys(node?.metadata ?? {}).length === 0;
178
+ /**
179
+ * Nobody has stamped ownership on this node — weaker than `hasEmptyMetadata`,
180
+ * and deliberately so: a file-tree node (Skill/Drive/AirApp) always carries a
181
+ * server-written `metadata.version`, even freshly created and never stamped
182
+ * by any app. Requiring literally-empty metadata there would mean a node
183
+ * `publishAirApp` itself just created is never recognized as ours on the very
184
+ * next read — confirmed against a live server, not assumed.
185
+ */
186
+ const isUnclaimed = (node) => node?.metadata?.appId === void 0;
187
+ /**
188
+ * The legacy-claim test. An unstamped node is adopted only when its every
189
+ * visible attribute still matches the declaration — a weaker test would let an
190
+ * app claim a same-slug Folder a human repurposed.
191
+ */
192
+ const matchesDeclaration = (node, declaration, type) => node?.type === type && node?.slug === declaration.slug && node?.name === declaration.name && node?.description === (declaration.description ?? "");
193
+ /**
194
+ * The AirApp equivalent of the legacy claim: an unclaimed `airapp` node counts
195
+ * as ours only when its slug and name still match what we declare.
196
+ */
197
+ const matchesLegacyAirApp = (node, config) => isUnclaimed(node) && node?.type === "airapp" && node?.slug === config.airApp?.slug && node?.name === config.airApp?.name;
198
+ const resourceMetadata = (config, resourceKey) => ({
199
+ appId: config.appId,
200
+ resourceKey,
201
+ schemaVersion: config.schemaVersion
27
202
  });
203
+ /**
204
+ * Decide, from one already-read Folder, what exists / is missing / needs
205
+ * re-stamping. Pure — no I/O — so the ownership rules are directly testable.
206
+ *
207
+ * @throws {AirAppSetupError} `SETUP_CONFLICT` when a node in the way is not
208
+ * this app's. Nothing is ever mutated on that path.
209
+ */
28
210
  function resolveProvisionedFolder(folder, config) {
29
- if (!folder) {
30
- return { folder: null, bases: [], missing: [...config.bases], repairs: [], airApp: null };
31
- }
32
- if (folder.node?.type !== "folder" || folder.node?.slug !== config.folder.slug) {
33
- throw setupError(
34
- "SETUP_CONFLICT",
35
- `A different Folder already uses the slug ${config.folder.slug}; nothing was changed`
36
- );
37
- }
38
- const rootOwned = hasResourceIdentity(folder.node, config.appId, "app-root");
39
- const legacyRoot = hasEmptyMetadata(folder.node) && matchesDeclaration(folder.node, config.folder, "folder");
40
- if (!rootOwned && !legacyRoot) {
41
- throw setupError(
42
- "SETUP_CONFLICT",
43
- `The Folder ${config.folder.slug} does not belong to this app; nothing was changed`
44
- );
45
- }
46
- const bases = [];
47
- const missing = [];
48
- const repairs = [];
49
- if (!ownsAppRoot(folder.node, config.appId, config.schemaVersion)) {
50
- repairs.push({
51
- nodeId: folder.node.id,
52
- resourceKey: "app-root",
53
- metadata: resourceMetadata(config, "app-root")
54
- });
55
- }
56
- for (const base of config.bases) {
57
- const matches = (folder.children ?? []).filter((node2) => node2.slug === base.slug);
58
- if (!matches.length) {
59
- if (legacyRoot) {
60
- throw setupError(
61
- "SETUP_CONFLICT",
62
- `The existing unstamped Folder is missing the resource ${base.slug}, so it cannot be claimed safely`
63
- );
64
- }
65
- missing.push(base);
66
- continue;
67
- }
68
- const node = matches[0];
69
- if (matches.length !== 1 || node.type !== "base" || !node.baseId) {
70
- throw setupError(
71
- "SETUP_CONFLICT",
72
- `The resource ${base.slug} does not match this app's declaration; nothing was changed`
73
- );
74
- }
75
- const owned = hasResourceIdentity(node, config.appId, base.key);
76
- const legacy = hasEmptyMetadata(node) && matchesDeclaration(node, base, "base");
77
- if (!owned && !legacy) {
78
- throw setupError(
79
- "SETUP_CONFLICT",
80
- `The resource ${base.slug} does not match this app's declaration; nothing was changed`
81
- );
82
- }
83
- if (!ownsResource(node, config.appId, base.key, config.schemaVersion)) {
84
- repairs.push({
85
- nodeId: node.id,
86
- baseId: node.baseId,
87
- resourceKey: base.key,
88
- metadata: resourceMetadata(config, base.key)
89
- });
90
- }
91
- bases.push({ ...base, nodeId: node.id, baseId: node.baseId });
92
- }
93
- const airAppNode = config.airApp ? (folder.children ?? []).find(
94
- (node) => hasResourceIdentity(
95
- node,
96
- config.appId,
97
- config.airApp.resourceKey
98
- ) || matchesLegacyAirApp(node, config)
99
- ) : void 0;
100
- if (config.airApp && airAppNode && !ownsResource(airAppNode, config.appId, config.airApp.resourceKey, config.schemaVersion)) {
101
- repairs.push({
102
- nodeId: airAppNode.id,
103
- resourceKey: config.airApp.resourceKey,
104
- metadata: resourceMetadata(config, config.airApp.resourceKey)
105
- });
106
- }
107
- if (legacyRoot) {
108
- const declaredSlugs = new Set(config.bases.map((base) => base.slug));
109
- const ambiguousExtra = (folder.children ?? []).find(
110
- (node) => !declaredSlugs.has(node.slug) && node.id !== airAppNode?.id && node?.metadata?.appId !== config.appId
111
- );
112
- if (ambiguousExtra) {
113
- throw setupError(
114
- "SETUP_CONFLICT",
115
- `The existing unstamped Folder holds an unattributable resource ${ambiguousExtra.slug}; nothing was changed`
116
- );
117
- }
118
- }
119
- return {
120
- folder: { ...config.folder, nodeId: folder.node.id },
121
- bases,
122
- missing,
123
- repairs,
124
- airApp: airAppNode ? { nodeId: airAppNode.id } : null
125
- };
211
+ if (!folder) return {
212
+ folder: null,
213
+ bases: [],
214
+ missing: [...config.bases],
215
+ repairs: [],
216
+ airApp: null
217
+ };
218
+ if (folder.node?.type !== "folder" || folder.node?.slug !== config.folder.slug) throw setupError("SETUP_CONFLICT", `A different Folder already uses the slug ${config.folder.slug}; nothing was changed`);
219
+ const rootOwned = hasResourceIdentity(folder.node, config.appId, APP_ROOT_RESOURCE_KEY);
220
+ const legacyRoot = hasEmptyMetadata(folder.node) && matchesDeclaration(folder.node, config.folder, "folder");
221
+ if (!rootOwned && !legacyRoot) throw setupError("SETUP_CONFLICT", `The Folder ${config.folder.slug} does not belong to this app; nothing was changed`);
222
+ const bases = [];
223
+ const missing = [];
224
+ const repairs = [];
225
+ if (!ownsAppRoot(folder.node, config.appId, config.schemaVersion)) repairs.push({
226
+ nodeId: folder.node.id,
227
+ resourceKey: APP_ROOT_RESOURCE_KEY,
228
+ metadata: resourceMetadata(config, APP_ROOT_RESOURCE_KEY)
229
+ });
230
+ for (const base of config.bases) {
231
+ const matches = (folder.children ?? []).filter((node) => node.slug === base.slug);
232
+ if (!matches.length) {
233
+ if (legacyRoot) throw setupError("SETUP_CONFLICT", `The existing unstamped Folder is missing the resource ${base.slug}, so it cannot be claimed safely`);
234
+ missing.push(base);
235
+ continue;
236
+ }
237
+ const node = matches[0];
238
+ if (matches.length !== 1 || node.type !== "base" || !node.baseId) throw setupError("SETUP_CONFLICT", `The resource ${base.slug} does not match this app's declaration; nothing was changed`);
239
+ const owned = hasResourceIdentity(node, config.appId, base.key);
240
+ const legacy = hasEmptyMetadata(node) && matchesDeclaration(node, base, "base");
241
+ if (!owned && !legacy) throw setupError("SETUP_CONFLICT", `The resource ${base.slug} does not match this app's declaration; nothing was changed`);
242
+ if (!ownsResource(node, config.appId, base.key, config.schemaVersion)) repairs.push({
243
+ nodeId: node.id,
244
+ baseId: node.baseId,
245
+ resourceKey: base.key,
246
+ metadata: resourceMetadata(config, base.key)
247
+ });
248
+ bases.push({
249
+ ...base,
250
+ nodeId: node.id,
251
+ baseId: node.baseId
252
+ });
253
+ }
254
+ const airAppNode = config.airApp ? (folder.children ?? []).find((node) => hasResourceIdentity(node, config.appId, config.airApp.resourceKey) || matchesLegacyAirApp(node, config)) : void 0;
255
+ if (config.airApp && airAppNode && !ownsResource(airAppNode, config.appId, config.airApp.resourceKey, config.schemaVersion)) repairs.push({
256
+ nodeId: airAppNode.id,
257
+ resourceKey: config.airApp.resourceKey,
258
+ metadata: resourceMetadata(config, config.airApp.resourceKey)
259
+ });
260
+ if (legacyRoot) {
261
+ const declaredSlugs = new Set(config.bases.map((base) => base.slug));
262
+ const ambiguousExtra = (folder.children ?? []).find((node) => !declaredSlugs.has(node.slug) && node.id !== airAppNode?.id && node?.metadata?.appId !== config.appId);
263
+ if (ambiguousExtra) throw setupError("SETUP_CONFLICT", `The existing unstamped Folder holds an unattributable resource ${ambiguousExtra.slug}; nothing was changed`);
264
+ }
265
+ return {
266
+ folder: {
267
+ ...config.folder,
268
+ nodeId: folder.node.id
269
+ },
270
+ bases,
271
+ missing,
272
+ repairs,
273
+ airApp: airAppNode ? { nodeId: airAppNode.id } : null
274
+ };
126
275
  }
276
+ /**
277
+ * The create operations for one idempotent ChangeRequest. Pure.
278
+ *
279
+ * When the Folder does not exist yet it is created under the temp `ref`
280
+ * `"app-root"` and the Bases nest under it via `parentNodeRef`, so the whole
281
+ * structure lands in a single reviewable change.
282
+ */
127
283
  function buildProvisionOperations(config, folder, missingBases) {
128
- const operations = [];
129
- if (!folder) {
130
- operations.push({
131
- kind: "create",
132
- ref: "app-root",
133
- nodeType: "folder",
134
- slug: config.folder.slug,
135
- name: config.folder.name,
136
- description: config.folder.description ?? "",
137
- metadata: resourceMetadata(config, "app-root")
138
- });
139
- }
140
- for (const base of missingBases) {
141
- operations.push({
142
- kind: "create",
143
- ...folder ? { parentNodeId: folder.nodeId } : { parentNodeRef: "app-root" },
144
- nodeType: "base",
145
- slug: base.slug,
146
- name: base.name,
147
- description: base.description ?? "",
148
- metadata: resourceMetadata(config, base.key),
149
- // The declaration's `type` is a plain `string` (see AirAppFieldDeclaration);
150
- // the server validates the real field-type enum on the wire.
151
- fields: base.fields
152
- });
153
- }
154
- return operations;
284
+ const operations = [];
285
+ if (!folder) operations.push({
286
+ kind: "create",
287
+ ref: "app-root",
288
+ nodeType: "folder",
289
+ slug: config.folder.slug,
290
+ name: config.folder.name,
291
+ description: config.folder.description ?? "",
292
+ metadata: resourceMetadata(config, APP_ROOT_RESOURCE_KEY)
293
+ });
294
+ for (const base of missingBases) operations.push({
295
+ kind: "create",
296
+ ...folder ? { parentNodeId: folder.nodeId } : { parentNodeRef: "app-root" },
297
+ nodeType: "base",
298
+ slug: base.slug,
299
+ name: base.name,
300
+ description: base.description ?? "",
301
+ metadata: resourceMetadata(config, base.key),
302
+ fields: base.fields
303
+ });
304
+ return operations;
155
305
  }
156
- var findTopLevelFolder = async (client, config) => {
157
- const roots = await client.nodes.list({ parentId: null, depth: 2 });
158
- const candidates = (roots ?? []).flatMap((node) => [node, ...node.children ?? []]).filter((node) => node.type === "folder" && node.slug === config.folder.slug);
159
- if (candidates.length > 1) {
160
- throw setupError(
161
- "SETUP_CONFLICT",
162
- `Found more than one Folder with the slug ${config.folder.slug}; nothing was changed`
163
- );
164
- }
165
- return candidates[0] ?? null;
306
+ const findTopLevelFolder = async (client, config) => {
307
+ const candidates = (await client.nodes.list({
308
+ parentId: null,
309
+ depth: 2
310
+ }) ?? []).flatMap((node) => [node, ...node.children ?? []]).filter((node) => node.type === "folder" && node.slug === config.folder.slug);
311
+ if (candidates.length > 1) throw setupError("SETUP_CONFLICT", `Found more than one Folder with the slug ${config.folder.slug}; nothing was changed`);
312
+ return candidates[0] ?? null;
166
313
  };
167
- var readFolder = async (client, config) => {
168
- let nodeId = config.folder.nodeId;
169
- if (!nodeId) nodeId = (await findTopLevelFolder(client, config))?.id;
170
- if (!nodeId) return null;
171
- try {
172
- return await client.nodes.get({ nodeId, type: "folder" });
173
- } catch (error) {
174
- if (isNotFound(error) && config.folder.nodeId) {
175
- const discovered = await findTopLevelFolder(client, config);
176
- return discovered ? await client.nodes.get({
177
- nodeId: discovered.id,
178
- type: "folder"
179
- }) : null;
180
- }
181
- if (isNotFound(error)) return null;
182
- throw error;
183
- }
314
+ const readFolder = async (client, config) => {
315
+ let nodeId = config.folder.nodeId;
316
+ if (!nodeId) nodeId = (await findTopLevelFolder(client, config))?.id;
317
+ if (!nodeId) return null;
318
+ try {
319
+ return await client.nodes.get({
320
+ nodeId,
321
+ type: "folder"
322
+ });
323
+ } catch (error) {
324
+ if (isNotFound(error) && config.folder.nodeId) {
325
+ const discovered = await findTopLevelFolder(client, config);
326
+ return discovered ? await client.nodes.get({
327
+ nodeId: discovered.id,
328
+ type: "folder"
329
+ }) : null;
330
+ }
331
+ if (isNotFound(error)) return null;
332
+ throw error;
333
+ }
184
334
  };
335
+ /** Read the current state of this app's declared resources. Never mutates. */
185
336
  async function inspectProvisionedResources(client, config) {
186
- return resolveProvisionedFolder(await readFolder(client, config), config);
337
+ return resolveProvisionedFolder(await readFolder(client, config), config);
187
338
  }
188
- var provisionStates = /* @__PURE__ */ new WeakMap();
189
- var stateFor = (client, appId) => {
190
- let byApp = provisionStates.get(client);
191
- if (!byApp) {
192
- byApp = /* @__PURE__ */ new Map();
193
- provisionStates.set(client, byApp);
194
- }
195
- let state = byApp.get(appId);
196
- if (!state) {
197
- state = { inFlight: null, metadataUpdatesSupported: void 0 };
198
- byApp.set(appId, state);
199
- }
200
- return state;
339
+ const provisionStates = /* @__PURE__ */ new WeakMap();
340
+ const stateFor = (client, appId) => {
341
+ let byApp = provisionStates.get(client);
342
+ if (!byApp) {
343
+ byApp = /* @__PURE__ */ new Map();
344
+ provisionStates.set(client, byApp);
345
+ }
346
+ let state = byApp.get(appId);
347
+ if (!state) {
348
+ state = {
349
+ inFlight: null,
350
+ metadataUpdatesSupported: void 0
351
+ };
352
+ byApp.set(appId, state);
353
+ }
354
+ return state;
201
355
  };
202
- var sameFieldName = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected);
203
- var fieldMatches = (actual, expected) => actual?.slug === expected.slug && actual?.type === expected.type && actual?.required === expected.required && sameFieldName(actual?.name, expected.name);
204
- var additiveFieldsFor = (actual, expected) => {
205
- const fields = actual?.fields ?? [];
206
- if (fields.length > expected.fields.length || !fields.every((field, index) => fieldMatches(field, expected.fields[index]))) {
207
- throw setupError(
208
- "SETUP_CONFLICT",
209
- `The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`
210
- );
211
- }
212
- return expected.fields.slice(fields.length);
356
+ const sameFieldName = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected);
357
+ const fieldMatches = (actual, expected) => actual?.slug === expected.slug && actual?.type === expected.type && actual?.required === expected.required && sameFieldName(actual?.name, expected.name);
358
+ /**
359
+ * How an app evolves a Base it already owns: the declared field list may grow,
360
+ * and only at the end.
361
+ *
362
+ * A live Base whose fields are a strict *prefix* of the declaration is an older
363
+ * schema of ours, and the missing suffix is added. Anything else — a field
364
+ * renamed, retyped, reordered, or removed — is not an upgrade this can reason
365
+ * about, so it refuses rather than guessing which of the two shapes is right.
366
+ *
367
+ * @throws {AirAppSetupError} `SETUP_CONFLICT` when the existing fields are not a
368
+ * prefix of the declared ones.
369
+ */
370
+ const additiveFieldsFor = (actual, expected) => {
371
+ const fields = actual?.fields ?? [];
372
+ if (fields.length > expected.fields.length || !fields.every((field, index) => fieldMatches(field, expected.fields[index]))) throw setupError("SETUP_CONFLICT", `The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`);
373
+ return expected.fields.slice(fields.length);
213
374
  };
214
- var validateRepairBase = (actual, expected, nodeId) => {
215
- if (!expected) {
216
- throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
217
- }
218
- const fields = actual?.fields ?? [];
219
- const exactFields = fields.length === expected.fields.length && fields.every((field, index) => fieldMatches(field, expected.fields[index]));
220
- if (actual?.nodeId !== nodeId || actual?.slug !== expected.slug || actual?.name !== expected.name || actual?.description !== (expected.description ?? "") || !exactFields) {
221
- throw setupError(
222
- "SETUP_CONFLICT",
223
- `The structure of ${expected.slug} does not match this app's declaration, so it cannot be claimed safely`
224
- );
225
- }
375
+ /**
376
+ * Before re-stamping an unstamped Base as ours, prove it is structurally the
377
+ * Base we declared — same slug, name, description, and exact field list in
378
+ * order. Without this, a stamp would launder a name collision into ownership.
379
+ */
380
+ const validateRepairBase = (actual, expected, nodeId) => {
381
+ if (!expected) throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
382
+ const fields = actual?.fields ?? [];
383
+ const exactFields = fields.length === expected.fields.length && fields.every((field, index) => fieldMatches(field, expected.fields[index]));
384
+ if (actual?.nodeId !== nodeId || actual?.slug !== expected.slug || actual?.name !== expected.name || actual?.description !== (expected.description ?? "") || !exactFields) throw setupError("SETUP_CONFLICT", `The structure of ${expected.slug} does not match this app's declaration, so it cannot be claimed safely`);
226
385
  };
227
386
  async function repairResourceOwnership(client, config, current) {
228
- if (!current.repairs.length) return current;
229
- const state = stateFor(client, config.appId);
230
- const baseRepairs = current.repairs.filter((repair) => repair.baseId);
231
- const baseByKey = new Map(config.bases.map((base) => [base.key, base]));
232
- const details = await Promise.all(
233
- baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId }))
234
- );
235
- const migrations = details.map((detail, index) => {
236
- const repair = baseRepairs[index];
237
- const expected = baseByKey.get(repair.resourceKey);
238
- if (!expected) {
239
- throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
240
- }
241
- if (detail?.nodeId !== repair.nodeId || detail?.slug !== expected.slug || detail?.name !== expected.name || detail?.description !== (expected.description ?? "")) {
242
- throw setupError(
243
- "SETUP_CONFLICT",
244
- `The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`
245
- );
246
- }
247
- return { repair, expected, fields: additiveFieldsFor(detail, expected) };
248
- });
249
- const pendingFieldRequests = [];
250
- for (const migration of migrations) {
251
- for (const field of migration.fields) {
252
- const changeRequest = await client.bases.fieldChangeRequest({
253
- operation: "create",
254
- baseId: migration.repair.baseId,
255
- slug: field.slug,
256
- name: field.name,
257
- // The declaration's `type` is a plain `string` (see AirAppFieldDeclaration);
258
- // the server validates the real field-type enum on the wire.
259
- type: field.type,
260
- required: field.required,
261
- message: `Upgrade ${config.appName}: add ${field.slug}`,
262
- submittedBy: config.appId
263
- });
264
- const merged = changeRequest?.status === "merged" || changeRequest?.materialized === true;
265
- if (!merged) pendingFieldRequests.push(changeRequest?.id ?? field.slug);
266
- }
267
- }
268
- if (pendingFieldRequests.length) {
269
- throw setupError(
270
- "SETUP_PENDING",
271
- `Submitted ${pendingFieldRequests.length} field upgrade request(s) awaiting Space admin approval: ${pendingFieldRequests.join(", ")}`
272
- );
273
- }
274
- const verified = migrations.some((migration) => migration.fields.length) ? await Promise.all(
275
- baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId }))
276
- ) : details;
277
- verified.forEach((detail, index) => {
278
- const repair = baseRepairs[index];
279
- validateRepairBase(detail, baseByKey.get(repair.resourceKey), repair.nodeId);
280
- });
281
- if (state.metadataUpdatesSupported === false) {
282
- return { ...current, repairs: [], compatibilityMode: "verified-legacy-fingerprint" };
283
- }
284
- try {
285
- for (const repair of current.repairs) {
286
- await client.nodes.updateMetadata({ nodeId: repair.nodeId, metadata: repair.metadata });
287
- state.metadataUpdatesSupported = true;
288
- }
289
- } catch (error) {
290
- if (isNotFound(error)) {
291
- state.metadataUpdatesSupported = false;
292
- return { ...current, repairs: [], compatibilityMode: "verified-legacy-fingerprint" };
293
- }
294
- if (isForbidden(error)) {
295
- throw setupError(
296
- "SETUP_PERMISSION",
297
- "This account may not repair resource ownership metadata for this app"
298
- );
299
- }
300
- throw error;
301
- }
302
- const repaired = await inspectProvisionedResources(client, config);
303
- if (repaired.repairs.length) {
304
- throw setupError("SCHEMA_INCOMPLETE", "Ownership was repaired but read back incomplete");
305
- }
306
- return repaired;
387
+ if (!current.repairs.length) return current;
388
+ const state = stateFor(client, config.appId);
389
+ const baseRepairs = current.repairs.filter((repair) => repair.baseId);
390
+ const baseByKey = new Map(config.bases.map((base) => [base.key, base]));
391
+ const details = await Promise.all(baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId })));
392
+ const migrations = details.map((detail, index) => {
393
+ const repair = baseRepairs[index];
394
+ const expected = baseByKey.get(repair.resourceKey);
395
+ if (!expected) throw setupError("SETUP_CONFLICT", "Cannot repair a resource this app does not declare");
396
+ if (detail?.nodeId !== repair.nodeId || detail?.slug !== expected.slug || detail?.name !== expected.name || detail?.description !== (expected.description ?? "")) throw setupError("SETUP_CONFLICT", `The structure of ${expected.slug} does not match this app's declaration, so it cannot be upgraded safely`);
397
+ return {
398
+ repair,
399
+ expected,
400
+ fields: additiveFieldsFor(detail, expected)
401
+ };
402
+ });
403
+ const pendingFieldRequests = [];
404
+ for (const migration of migrations) for (const field of migration.fields) {
405
+ const changeRequest = await client.bases.fieldChangeRequest({
406
+ operation: "create",
407
+ baseId: migration.repair.baseId,
408
+ slug: field.slug,
409
+ name: field.name,
410
+ type: field.type,
411
+ required: field.required,
412
+ message: `Upgrade ${config.appName}: add ${field.slug}`,
413
+ submittedBy: config.appId
414
+ });
415
+ if (!(changeRequest?.status === "merged" || changeRequest?.materialized === true)) pendingFieldRequests.push(changeRequest?.id ?? field.slug);
416
+ }
417
+ if (pendingFieldRequests.length) throw setupError("SETUP_PENDING", `Submitted ${pendingFieldRequests.length} field upgrade request(s) awaiting Space admin approval: ${pendingFieldRequests.join(", ")}`);
418
+ (migrations.some((migration) => migration.fields.length) ? await Promise.all(baseRepairs.map((repair) => client.bases.get({ baseId: repair.baseId }))) : details).forEach((detail, index) => {
419
+ const repair = baseRepairs[index];
420
+ validateRepairBase(detail, baseByKey.get(repair.resourceKey), repair.nodeId);
421
+ });
422
+ if (state.metadataUpdatesSupported === false) return {
423
+ ...current,
424
+ repairs: [],
425
+ compatibilityMode: "verified-legacy-fingerprint"
426
+ };
427
+ try {
428
+ for (const repair of current.repairs) {
429
+ await client.nodes.updateMetadata({
430
+ nodeId: repair.nodeId,
431
+ metadata: repair.metadata
432
+ });
433
+ state.metadataUpdatesSupported = true;
434
+ }
435
+ } catch (error) {
436
+ if (isNotFound(error)) {
437
+ state.metadataUpdatesSupported = false;
438
+ return {
439
+ ...current,
440
+ repairs: [],
441
+ compatibilityMode: "verified-legacy-fingerprint"
442
+ };
443
+ }
444
+ if (isForbidden(error)) throw setupError("SETUP_PERMISSION", "This account may not repair resource ownership metadata for this app");
445
+ throw error;
446
+ }
447
+ const repaired = await inspectProvisionedResources(client, config);
448
+ if (repaired.repairs.length) throw setupError("SCHEMA_INCOMPLETE", "Ownership was repaired but read back incomplete");
449
+ return repaired;
307
450
  }
308
- var waitForMaterializedResources = async (client, config, attempts = 20) => {
309
- let current;
310
- for (let attempt = 0; attempt < attempts; attempt += 1) {
311
- current = await inspectProvisionedResources(client, config);
312
- current = await repairResourceOwnership(client, config, current);
313
- if (current.folder && current.missing.length === 0) return current;
314
- if (attempt < attempts - 1) {
315
- await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
316
- }
317
- }
318
- throw setupError(
319
- "SCHEMA_INCOMPLETE",
320
- "Initialization merged but the resources read back incomplete"
321
- );
451
+ /**
452
+ * A merged ChangeRequest is not immediately readable — materialization is
453
+ * asynchronous — so poll briefly rather than reporting a successful setup as
454
+ * incomplete.
455
+ */
456
+ const waitForMaterializedResources = async (client, config, attempts = 20) => {
457
+ let current;
458
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
459
+ current = await inspectProvisionedResources(client, config);
460
+ current = await repairResourceOwnership(client, config, current);
461
+ if (current.folder && current.missing.length === 0) return current;
462
+ if (attempt < attempts - 1) await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
463
+ }
464
+ throw setupError("SCHEMA_INCOMPLETE", "Initialization merged but the resources read back incomplete");
322
465
  };
323
466
  async function provisionOnce(client, config) {
324
- let current = await inspectProvisionedResources(client, config);
325
- current = await repairResourceOwnership(client, config, current);
326
- if (current.folder && current.missing.length === 0) return current;
327
- const operations = buildProvisionOperations(config, current.folder, current.missing);
328
- let changeRequest;
329
- try {
330
- changeRequest = await client.nodes.createChangeRequest({
331
- message: `Initialize ${config.appName} workspace`,
332
- submittedBy: config.appId,
333
- autoMerge: true,
334
- operations
335
- });
336
- } catch (error) {
337
- if (isForbidden(error)) {
338
- throw setupError(
339
- "SETUP_PERMISSION",
340
- "This account may not create this app's resources in this Space"
341
- );
342
- }
343
- const concurrent = await inspectProvisionedResources(client, config).catch(() => null);
344
- if (concurrent?.folder && concurrent.missing.length === 0) return concurrent;
345
- throw error;
346
- }
347
- if (changeRequest?.status !== "merged") {
348
- throw setupError(
349
- "SETUP_PENDING",
350
- `Initialization request ${changeRequest?.id ?? ""} was submitted and awaits Space admin approval`.trim()
351
- );
352
- }
353
- return waitForMaterializedResources(client, config);
467
+ let current = await inspectProvisionedResources(client, config);
468
+ current = await repairResourceOwnership(client, config, current);
469
+ if (current.folder && current.missing.length === 0) return current;
470
+ const operations = buildProvisionOperations(config, current.folder, current.missing);
471
+ let changeRequest;
472
+ try {
473
+ changeRequest = await client.nodes.createChangeRequest({
474
+ message: `Initialize ${config.appName} workspace`,
475
+ submittedBy: config.appId,
476
+ autoMerge: true,
477
+ operations
478
+ });
479
+ } catch (error) {
480
+ if (isForbidden(error)) throw setupError("SETUP_PERMISSION", "This account may not create this app's resources in this Space");
481
+ const concurrent = await inspectProvisionedResources(client, config).catch(() => null);
482
+ if (concurrent?.folder && concurrent.missing.length === 0) return concurrent;
483
+ throw error;
484
+ }
485
+ if (changeRequest?.status !== "merged") throw setupError("SETUP_PENDING", `Initialization request ${changeRequest?.id ?? ""} was submitted and awaits Space admin approval`.trim());
486
+ return waitForMaterializedResources(client, config);
354
487
  }
488
+ /**
489
+ * Ensure the declared Folder and Bases exist, as one idempotent ChangeRequest.
490
+ *
491
+ * Safe to call concurrently: calls for the same client + `appId` share one
492
+ * in-flight promise, so a multi-pane app cannot submit the structure twice.
493
+ *
494
+ * @throws {AirAppSetupError} with a `code` describing which screen to show.
495
+ */
355
496
  function provisionDeclaredResources(client, config) {
356
- const state = stateFor(client, config.appId);
357
- if (!state.inFlight) {
358
- state.inFlight = provisionOnce(client, config).finally(() => {
359
- state.inFlight = null;
360
- });
361
- }
362
- return state.inFlight;
497
+ const state = stateFor(client, config.appId);
498
+ if (!state.inFlight) state.inFlight = provisionOnce(client, config).finally(() => {
499
+ state.inFlight = null;
500
+ });
501
+ return state.inFlight;
363
502
  }
503
+ /**
504
+ * The create-vs-update operation list for one AirApp publish. Pure — no I/O —
505
+ * so the decision is directly testable: a local path already present on the
506
+ * deployed node updates it, anything else is a new file. Never deletes a
507
+ * remote-only path — a file this bundle stopped shipping is left alone rather
508
+ * than assumed stale, the same conservative choice the rest of this module
509
+ * makes for a Base's fields (`additiveFieldsFor` only ever appends).
510
+ */
364
511
  function buildAirAppFileOperations(localFiles, deployedPaths) {
365
- const deployed = new Set(deployedPaths);
366
- return localFiles.map(
367
- (file) => ({
368
- kind: deployed.has(file.path) ? "update" : "create",
369
- path: file.path,
370
- content: file.content,
371
- ...file.mimeType ? { mimeType: file.mimeType } : {}
372
- })
373
- );
512
+ const deployed = new Set(deployedPaths);
513
+ return localFiles.map((file) => ({
514
+ kind: deployed.has(file.path) ? "update" : "create",
515
+ path: file.path,
516
+ content: file.content,
517
+ ...file.mimeType ? { mimeType: file.mimeType } : {}
518
+ }));
374
519
  }
520
+ /**
521
+ * Find a still-pending ChangeRequest that already proposes creating this
522
+ * declared AirApp, so a second `publishAirApp` call before the first is
523
+ * reviewed does not propose a second, duplicate create for the same slug.
524
+ *
525
+ * Scoped to the first 500 in-review ChangeRequests (10 pages of 50) — a
526
+ * Space with more open reviews than that has bigger problems than this
527
+ * scan not reaching the one it is looking for, and a missed match only
528
+ * costs an extra (harmless, reviewer-visible) duplicate proposal, never a
529
+ * wrong merge.
530
+ */
375
531
  async function findPendingAirAppCreate(client, slug) {
376
- let cursor;
377
- for (let page = 0; page < 10; page += 1) {
378
- const result = await client.changeRequests.list({
379
- status: ["in_review"],
380
- ...cursor ? { cursor } : {}
381
- });
382
- for (const changeRequest of result.changeRequests) {
383
- const matches = (changeRequest.operations ?? []).some((operation) => {
384
- const payload = operation.headCommit?.payload;
385
- return payload?.kind === "create" && payload?.nodeType === "airapp" && payload?.slug === slug;
386
- });
387
- if (matches) return changeRequest.id;
388
- }
389
- if (!result.nextCursor) return null;
390
- cursor = result.nextCursor;
391
- }
392
- return null;
532
+ let cursor;
533
+ for (let page = 0; page < 10; page += 1) {
534
+ const result = await client.changeRequests.list({
535
+ status: ["in_review"],
536
+ ...cursor ? { cursor } : {}
537
+ });
538
+ for (const changeRequest of result.changeRequests) if ((changeRequest.operations ?? []).some((operation) => {
539
+ const payload = operation.headCommit?.payload;
540
+ return payload?.kind === "create" && payload?.nodeType === "airapp" && payload?.slug === slug;
541
+ })) return changeRequest.id;
542
+ if (!result.nextCursor) return null;
543
+ cursor = result.nextCursor;
544
+ }
545
+ return null;
393
546
  }
547
+ /**
548
+ * Publish the app's own AirApp bundle: create it under the Folder when this
549
+ * Space has never had it, or propose the local files as an update when it
550
+ * already exists. Always a separate, always-review-first ChangeRequest from
551
+ * the data layer's `provisionDeclaredResources` — see the note on
552
+ * `AirAppNodeDeclaration` for why the two must never share a request.
553
+ *
554
+ * Call after `provisionDeclaredResources` has confirmed the Folder exists.
555
+ * Every call proposes the full local file list, even when nothing actually
556
+ * changed — this module has no access to the deployed content hashes
557
+ * (`fileTrees.listFiles` reports paths, not hashes; only a per-file
558
+ * `readFile` does, and fetching one per file to skip a no-op publish is not
559
+ * worth the round trips a normal publish cadence would spend on it). A
560
+ * reviewer sees an empty diff and merges or ignores it; this is a cost in
561
+ * review noise, not correctness.
562
+ *
563
+ * @throws {AirAppSetupError} `SETUP_CONFLICT` when `config` declares no
564
+ * `airApp`; `SETUP_REQUIRED` when the Folder does not exist yet.
565
+ */
394
566
  async function publishAirApp(client, config, files) {
395
- const airApp = config.airApp;
396
- if (!airApp) {
397
- throw setupError("SETUP_CONFLICT", "This app does not declare an airApp to publish");
398
- }
399
- const current = await inspectProvisionedResources(client, config);
400
- if (!current.folder) {
401
- throw setupError(
402
- "SETUP_REQUIRED",
403
- "Provision the Folder and Bases with provisionDeclaredResources before publishing the AirApp"
404
- );
405
- }
406
- if (!current.airApp) {
407
- const pendingChangeRequestId = await findPendingAirAppCreate(client, airApp.slug);
408
- if (pendingChangeRequestId) {
409
- return { status: "pending", changeRequestId: pendingChangeRequestId };
410
- }
411
- const changeRequest2 = await client.fileTrees.create({
412
- type: "airapp",
413
- parentNodeId: current.folder.nodeId,
414
- slug: airApp.slug,
415
- name: airApp.name,
416
- description: airApp.description ?? "",
417
- files,
418
- mergeMode: "replace",
419
- // Explicit even though this app's write-permission credential would
420
- // otherwise auto-merge it: executable AirApp code always gets human
421
- // review before it runs in a viewer's browser, no exceptions.
422
- autoMerge: false
423
- });
424
- if (changeRequest2.materialized) {
425
- throw setupError(
426
- "SCHEMA_INCOMPLETE",
427
- "AirApp create unexpectedly materialized despite autoMerge: false"
428
- );
429
- }
430
- return { status: "created", changeRequestId: changeRequest2.id };
431
- }
432
- const deployedFiles = await client.fileTrees.listFiles({
433
- nodeId: current.airApp.nodeId,
434
- type: "airapp"
435
- });
436
- const operations = buildAirAppFileOperations(
437
- files,
438
- deployedFiles.map((file) => file.path)
439
- );
440
- const changeRequest = await client.fileTrees.createChangeRequest({
441
- nodeId: current.airApp.nodeId,
442
- type: "airapp",
443
- operations,
444
- message: `Publish ${config.appName} AirApp`,
445
- submittedBy: config.appId,
446
- autoMerge: false
447
- });
448
- return { status: "updated", changeRequestId: changeRequest.id };
567
+ const airApp = config.airApp;
568
+ if (!airApp) throw setupError("SETUP_CONFLICT", "This app does not declare an airApp to publish");
569
+ const current = await inspectProvisionedResources(client, config);
570
+ if (!current.folder) throw setupError("SETUP_REQUIRED", "Provision the Folder and Bases with provisionDeclaredResources before publishing the AirApp");
571
+ if (!current.airApp) {
572
+ const pendingChangeRequestId = await findPendingAirAppCreate(client, airApp.slug);
573
+ if (pendingChangeRequestId) return {
574
+ status: "pending",
575
+ changeRequestId: pendingChangeRequestId
576
+ };
577
+ const changeRequest = await client.fileTrees.create({
578
+ type: "airapp",
579
+ parentNodeId: current.folder.nodeId,
580
+ slug: airApp.slug,
581
+ name: airApp.name,
582
+ description: airApp.description ?? "",
583
+ files,
584
+ mergeMode: "replace",
585
+ autoMerge: false
586
+ });
587
+ if (changeRequest.materialized) throw setupError("SCHEMA_INCOMPLETE", "AirApp create unexpectedly materialized despite autoMerge: false");
588
+ return {
589
+ status: "created",
590
+ changeRequestId: changeRequest.id
591
+ };
592
+ }
593
+ const operations = buildAirAppFileOperations(files, (await client.fileTrees.listFiles({
594
+ nodeId: current.airApp.nodeId,
595
+ type: "airapp"
596
+ })).map((file) => file.path));
597
+ return {
598
+ status: "updated",
599
+ changeRequestId: (await client.fileTrees.createChangeRequest({
600
+ nodeId: current.airApp.nodeId,
601
+ type: "airapp",
602
+ operations,
603
+ message: `Publish ${config.appName} AirApp`,
604
+ submittedBy: config.appId,
605
+ autoMerge: false
606
+ })).id
607
+ };
449
608
  }
450
-
609
+ //#endregion
451
610
  export { AirAppSetupError, buildAirAppFileOperations, buildProvisionOperations, inspectProvisionedResources, isNotFound, provisionDeclaredResources, publishAirApp, resolveProvisionedFolder };