primitive-admin 1.0.54 → 1.0.56

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.
Files changed (110) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +230 -2
  2. package/dist/bin/primitive.js +3 -1
  3. package/dist/bin/primitive.js.map +1 -1
  4. package/dist/src/commands/apps.js +54 -2
  5. package/dist/src/commands/apps.js.map +1 -1
  6. package/dist/src/commands/database-types.js +4 -4
  7. package/dist/src/commands/database-types.js.map +1 -1
  8. package/dist/src/commands/databases.js +35 -14
  9. package/dist/src/commands/databases.js.map +1 -1
  10. package/dist/src/commands/documents.js +77 -0
  11. package/dist/src/commands/documents.js.map +1 -1
  12. package/dist/src/commands/guides.d.ts +15 -12
  13. package/dist/src/commands/guides.js +22 -15
  14. package/dist/src/commands/guides.js.map +1 -1
  15. package/dist/src/commands/metadata.js +48 -3
  16. package/dist/src/commands/metadata.js.map +1 -1
  17. package/dist/src/commands/scripts.d.ts +18 -0
  18. package/dist/src/commands/scripts.js +59 -5
  19. package/dist/src/commands/scripts.js.map +1 -1
  20. package/dist/src/commands/settings.d.ts +15 -0
  21. package/dist/src/commands/settings.js +102 -0
  22. package/dist/src/commands/settings.js.map +1 -0
  23. package/dist/src/commands/sync-app-settings.d.ts +115 -0
  24. package/dist/src/commands/sync-app-settings.js +379 -0
  25. package/dist/src/commands/sync-app-settings.js.map +1 -0
  26. package/dist/src/commands/sync.d.ts +134 -19
  27. package/dist/src/commands/sync.js +1949 -600
  28. package/dist/src/commands/sync.js.map +1 -1
  29. package/dist/src/commands/workflows.js +54 -15
  30. package/dist/src/commands/workflows.js.map +1 -1
  31. package/dist/src/lib/api-client.d.ts +29 -1
  32. package/dist/src/lib/api-client.js +31 -2
  33. package/dist/src/lib/api-client.js.map +1 -1
  34. package/dist/src/lib/app-settings-descriptor.d.ts +108 -0
  35. package/dist/src/lib/app-settings-descriptor.js +254 -0
  36. package/dist/src/lib/app-settings-descriptor.js.map +1 -0
  37. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +36 -0
  38. package/dist/src/lib/codegen-shared/generatedFiles.js +67 -10
  39. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  40. package/dist/src/lib/codegen-shared/prettierStable.d.ts +262 -0
  41. package/dist/src/lib/codegen-shared/prettierStable.js +610 -0
  42. package/dist/src/lib/codegen-shared/prettierStable.js.map +1 -0
  43. package/dist/src/lib/db-codegen/dbGenerator.d.ts +11 -83
  44. package/dist/src/lib/db-codegen/dbGenerator.js +96 -556
  45. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  46. package/dist/src/lib/db-codegen/dbNaming.d.ts +20 -7
  47. package/dist/src/lib/db-codegen/dbNaming.js +31 -13
  48. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  49. package/dist/src/lib/db-codegen/dbTemplates.d.ts +70 -4
  50. package/dist/src/lib/db-codegen/dbTemplates.js +244 -70
  51. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  52. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +13 -22
  53. package/dist/src/lib/db-codegen/dbTsTypes.js +33 -28
  54. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  55. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
  56. package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
  57. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
  58. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +112 -0
  59. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +211 -0
  60. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
  61. package/dist/src/lib/generated-allowlist.js +7 -0
  62. package/dist/src/lib/generated-allowlist.js.map +1 -1
  63. package/dist/src/lib/platform-owned.d.ts +37 -0
  64. package/dist/src/lib/platform-owned.js +46 -0
  65. package/dist/src/lib/platform-owned.js.map +1 -0
  66. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +100 -0
  67. package/dist/src/lib/swift-codegen/dbGenerator.js +682 -0
  68. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
  69. package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
  70. package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
  71. package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
  72. package/dist/src/lib/swift-codegen/generator.d.ts +85 -0
  73. package/dist/src/lib/swift-codegen/generator.js +266 -0
  74. package/dist/src/lib/swift-codegen/generator.js.map +1 -0
  75. package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
  76. package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
  77. package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
  78. package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +90 -0
  79. package/dist/src/lib/swift-codegen/siblingSymbols.js +152 -0
  80. package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -0
  81. package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
  82. package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
  83. package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
  84. package/dist/src/lib/sync-resource-types.d.ts +437 -0
  85. package/dist/src/lib/sync-resource-types.js +781 -0
  86. package/dist/src/lib/sync-resource-types.js.map +1 -0
  87. package/dist/src/lib/template.d.ts +1 -1
  88. package/dist/src/lib/template.js +6 -3
  89. package/dist/src/lib/template.js.map +1 -1
  90. package/dist/src/lib/toml-metadata-config.js +18 -0
  91. package/dist/src/lib/toml-metadata-config.js.map +1 -1
  92. package/dist/src/lib/workflow-apply.js +12 -2
  93. package/dist/src/lib/workflow-apply.js.map +1 -1
  94. package/dist/src/lib/workflow-codegen/generator.d.ts +27 -5
  95. package/dist/src/lib/workflow-codegen/generator.js +214 -68
  96. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  97. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +83 -0
  98. package/dist/src/lib/workflow-codegen/invokerIR.js +71 -0
  99. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
  100. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +17 -1
  101. package/dist/src/lib/workflow-codegen/schemaToTs.js +59 -38
  102. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -1
  103. package/dist/src/lib/workflow-payload.d.ts +19 -0
  104. package/dist/src/lib/workflow-payload.js +21 -0
  105. package/dist/src/lib/workflow-payload.js.map +1 -1
  106. package/dist/src/lib/workflow-toml-validator.d.ts +30 -0
  107. package/dist/src/lib/workflow-toml-validator.js +206 -0
  108. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  109. package/dist/src/types/index.d.ts +1 -0
  110. package/package.json +5 -2
@@ -0,0 +1,781 @@
1
+ /**
2
+ * Shared layout metadata for the per-entity resource types `sync pull` writes
3
+ * into a sync slot (issue #1659).
4
+ *
5
+ * `sync pull` writes one file per server entity and records what it wrote in
6
+ * `.primitive-sync.json` (`SyncState.entities[<slot>]`). Until #1659 it never
7
+ * removed a local file whose entity had disappeared server-side, so the next
8
+ * `sync push` — which treats local TOML as the source of truth — silently
9
+ * re-created the just-deleted entity.
10
+ *
11
+ * This module holds the narrow descriptor the prune pass and `sync diff` share:
12
+ * directory, extension, state slot, key→filename derivation, and the sidecar
13
+ * test-directory block type. The pull write loops deliberately stay as they are
14
+ * — this table is layout metadata, not a write framework.
15
+ */
16
+ import { existsSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "fs";
17
+ import { basename, dirname, join } from "path";
18
+ import { parseConfigToml } from "./config-toml.js";
19
+ const tomlFile = (key) => `${key}.toml`;
20
+ /**
21
+ * Rule sets are keyed on disk (and in sync state) by their name with
22
+ * filesystem-hostile characters replaced. Shared by the pull write loop and the
23
+ * prune pass so the two can never derive a different key for the same rule set.
24
+ */
25
+ export function ruleSetFileKey(ruleSet) {
26
+ return String(ruleSet.name || ruleSet.ruleSetId || "").replace(/[/\\:*?"<>|]/g, "_");
27
+ }
28
+ /**
29
+ * Every per-entity resource type in a sync slot. `app.toml` and `vars.toml` are
30
+ * root singletons, not per-entity directories, so they are not listed here —
31
+ * var removal is already handled within-file by `sync push` (`planVarsPush`).
32
+ */
33
+ export const SYNC_RESOURCE_TYPES = [
34
+ {
35
+ label: "integration",
36
+ dir: "integrations",
37
+ ext: ".toml",
38
+ stateSlot: "integrations",
39
+ fileFromKey: tomlFile,
40
+ // `/admin/api/apps/{id}/integrations` paginates; pull drains it with fetchAll.
41
+ listingComplete: true,
42
+ },
43
+ {
44
+ label: "webhook",
45
+ dir: "webhooks",
46
+ ext: ".toml",
47
+ stateSlot: "webhooks",
48
+ fileFromKey: tomlFile,
49
+ // `/admin/api/apps/{id}/webhooks` paginates; pull drains it with fetchAll.
50
+ listingComplete: true,
51
+ },
52
+ {
53
+ label: "cron-trigger",
54
+ dir: "cron-triggers",
55
+ ext: ".toml",
56
+ stateSlot: "cronTriggers",
57
+ fileFromKey: tomlFile,
58
+ // Server drains `triggersByApp` and filters archived across the full set (#1668).
59
+ listingComplete: true,
60
+ },
61
+ {
62
+ label: "blob-bucket",
63
+ dir: "blob-buckets",
64
+ ext: ".toml",
65
+ stateSlot: "blobBuckets",
66
+ fileFromKey: tomlFile,
67
+ // Server drains `bucketsForApp` across all pages (#1668).
68
+ listingComplete: true,
69
+ },
70
+ {
71
+ label: "prompt",
72
+ dir: "prompts",
73
+ ext: ".toml",
74
+ stateSlot: "prompts",
75
+ fileFromKey: tomlFile,
76
+ testBlockType: "prompt",
77
+ // `/admin/api/apps/{id}/prompts` paginates; pull drains it with fetchAll.
78
+ listingComplete: true,
79
+ },
80
+ {
81
+ label: "workflow",
82
+ dir: "workflows",
83
+ ext: ".toml",
84
+ stateSlot: "workflows",
85
+ fileFromKey: tomlFile,
86
+ testBlockType: "workflow",
87
+ // `/admin/api/apps/{id}/workflows` paginates; pull drains it with fetchAll.
88
+ listingComplete: true,
89
+ },
90
+ {
91
+ // Rhai scripts: `.rhai` bodies, not TOML, with tests at
92
+ // `transforms/<name>.tests/`.
93
+ label: "transform",
94
+ dir: "transforms",
95
+ ext: ".rhai",
96
+ stateSlot: "scripts",
97
+ fileFromKey: (key) => `${key}.rhai`,
98
+ testBlockType: "script",
99
+ // Server drains `scriptsByApp` across all pages (#1668).
100
+ listingComplete: true,
101
+ },
102
+ {
103
+ label: "database-type",
104
+ dir: "database-types",
105
+ ext: ".toml",
106
+ stateSlot: "databaseTypes",
107
+ fileFromKey: tomlFile,
108
+ // Server drains `databaseTypeConfigsForApp` across all pages (#1668).
109
+ listingComplete: true,
110
+ },
111
+ {
112
+ // The state key is already the sanitized filename the write loop derives
113
+ // from the rule set's name (`name.replace(/[/\\:*?"<>|]/g, "_")`), so the
114
+ // filename is the key plus the extension.
115
+ label: "rule-set",
116
+ dir: "rule-sets",
117
+ ext: ".toml",
118
+ stateSlot: "ruleSets",
119
+ fileFromKey: tomlFile,
120
+ // Server drains `ruleSetsForApp` across all pages (#1668).
121
+ listingComplete: true,
122
+ },
123
+ {
124
+ label: "group-type-config",
125
+ dir: "group-type-configs",
126
+ ext: ".toml",
127
+ stateSlot: "groupTypeConfigs",
128
+ fileFromKey: tomlFile,
129
+ // Server drains `configsForApp` across all pages (#1668).
130
+ listingComplete: true,
131
+ },
132
+ {
133
+ label: "collection-type-config",
134
+ dir: "collection-type-configs",
135
+ ext: ".toml",
136
+ stateSlot: "collectionTypeConfigs",
137
+ fileFromKey: tomlFile,
138
+ // Server drains `collectionConfigsForApp` across all pages (#1668).
139
+ listingComplete: true,
140
+ },
141
+ {
142
+ // State key is the `resourceType#category` pair; the file on disk is
143
+ // `resourceType.category.toml`.
144
+ label: "metadata-category-config",
145
+ dir: "metadata-category-configs",
146
+ ext: ".toml",
147
+ stateSlot: "metadataCategoryConfigs",
148
+ fileFromKey: (key) => `${key.replace("#", ".")}.toml`,
149
+ // Server drains `metadataCategoriesForApp` across all pages (#1668).
150
+ listingComplete: true,
151
+ },
152
+ {
153
+ label: "email-template",
154
+ dir: "email-templates",
155
+ ext: ".toml",
156
+ stateSlot: "emailTemplates",
157
+ fileFromKey: tomlFile,
158
+ // Server drains `templatesByApp` across all pages (#1668). Independently
159
+ // safe (#1684): the email-template list is the fixed, bounded `EMAIL_TYPES`
160
+ // enum (`src/admin-api.ts`), fully returned every call with no pagination —
161
+ // so there is no version-skew hazard even against a pre-#1668 server.
162
+ // `planPrune` still prunes only MANAGED keys, so hand-authored files that
163
+ // were never pulled are left alone.
164
+ listingComplete: true,
165
+ },
166
+ ];
167
+ /** Generic try/catch wrapper for a server fetch, in the ok/error shape. */
168
+ export async function attemptFetch(fn) {
169
+ try {
170
+ return { ok: true, value: await fn() };
171
+ }
172
+ catch (error) {
173
+ return { ok: false, error };
174
+ }
175
+ }
176
+ /**
177
+ * A filename must stay inside its type's directory. State is read from a file
178
+ * on disk, so a corrupt or hand-edited `.primitive-sync.json` must never be
179
+ * able to steer an `unlinkSync` outside the sync slot.
180
+ */
181
+ export function isSafeSyncFilename(filename) {
182
+ if (!filename || filename === "." || filename === "..")
183
+ return false;
184
+ if (filename.includes("/") || filename.includes("\\"))
185
+ return false;
186
+ if (filename.includes("\0"))
187
+ return false;
188
+ return true;
189
+ }
190
+ /**
191
+ * Decide which of a type's managed entity keys should have their local file
192
+ * removed, given the prior sync state and the outcome of this pull's fetch.
193
+ *
194
+ * A key is pruned iff it was **managed** (present in the prior
195
+ * `SyncState.entities[<slot>]`, so a previous pull wrote its file) and is
196
+ * **absent from the server keys** this pull fetched. A local file that was
197
+ * never in prior state was hand-authored — it is left alone and reported by
198
+ * `sync diff` as new.
199
+ *
200
+ * Pure and side-effect-free (the caller owns the `unlinkSync`) so the safety
201
+ * rules are unit-testable without a live server.
202
+ */
203
+ export function planPrune(input) {
204
+ const { resourceType, priorKeys, presence, enabled } = input;
205
+ // Which managed keys the server did not list. Only meaningful when the fetch
206
+ // succeeded; on a failed fetch we know nothing and must not act.
207
+ const absentKeys = presence.ok
208
+ ? priorKeys.filter((key) => !new Set(presence.serverKeys).has(key))
209
+ : [];
210
+ if (!enabled) {
211
+ return {
212
+ keys: [],
213
+ skipped: true,
214
+ skipReason: "disabled",
215
+ unsafeKeys: [],
216
+ unprunedAbsentKeys: absentKeys,
217
+ };
218
+ }
219
+ if (!presence.ok) {
220
+ return {
221
+ keys: [],
222
+ skipped: true,
223
+ skipReason: "fetch-failed",
224
+ unsafeKeys: [],
225
+ unprunedAbsentKeys: [],
226
+ };
227
+ }
228
+ // A 200 is not a complete list. Deleting a file because a truncated listing
229
+ // omitted its entity is unrecoverable-looking to the operator (the entity is
230
+ // still live, so no later pull rewrites the file), which is worse than
231
+ // leaving a stale file behind. See `listingComplete`.
232
+ if (!resourceType.listingComplete) {
233
+ return {
234
+ keys: [],
235
+ skipped: true,
236
+ skipReason: "listing-incomplete",
237
+ unsafeKeys: [],
238
+ unprunedAbsentKeys: absentKeys,
239
+ };
240
+ }
241
+ const keys = [];
242
+ const unsafeKeys = [];
243
+ for (const key of absentKeys) {
244
+ if (!isSafeSyncFilename(resourceType.fileFromKey(key))) {
245
+ unsafeKeys.push(key);
246
+ continue;
247
+ }
248
+ keys.push(key);
249
+ }
250
+ return { keys, skipped: false, unsafeKeys, unprunedAbsentKeys: [] };
251
+ }
252
+ /**
253
+ * Copy prior state entries for entities the server listed but this pull did not
254
+ * write (e.g. a script with no active config, or an email template whose detail
255
+ * fetch failed). Without this the entity would drop out of the state slot and
256
+ * look hand-authored to the next pull, so its stale file could never be pruned.
257
+ */
258
+ export function preserveUnwrittenEntries(written, prior, serverKeys) {
259
+ if (!prior)
260
+ return written;
261
+ for (const key of serverKeys) {
262
+ if (!(key in written) && key in prior) {
263
+ written[key] = prior[key];
264
+ }
265
+ }
266
+ return written;
267
+ }
268
+ /**
269
+ * Best-effort check for uncommitted git changes at `path` (a file or a
270
+ * directory). Returns false when git isn't available, the path isn't in a
271
+ * repository, or anything else goes wrong — the caller treats "unknown" as
272
+ * "no local edits", so this never blocks on a non-git checkout.
273
+ *
274
+ * `path` is passed as an argument, never interpolated into a shell string:
275
+ * on the prune path its basename derives from a server entity name, and entity
276
+ * names are free-form (a rule set may legitimately be named ``a`b`` or `a$b`).
277
+ * `execFileSync` runs git directly with no shell, so no name can be read as
278
+ * a command.
279
+ *
280
+ * `includeUntracked` decides whether an untracked file counts as a change:
281
+ * - `true` (default) suits "am I about to overwrite something?" — `sync revert`
282
+ * would clobber an untracked file just the same.
283
+ * - `false` suits "did the operator edit this file?" — an untracked file is the
284
+ * ordinary state of a pulled file in a sync dir that was never committed, and
285
+ * is not evidence of a local edit.
286
+ */
287
+ export async function hasUncommittedChanges(path, options = {}) {
288
+ const { includeUntracked = true } = options;
289
+ if (!existsSync(path))
290
+ return false;
291
+ try {
292
+ const { execFileSync } = await import("child_process");
293
+ // Run git from the path's own location, not the process cwd: the sync slot
294
+ // can sit in a different repository than the one the CLI was invoked from
295
+ // (or in none at all).
296
+ const cwd = statSync(path).isDirectory() ? path : dirname(path);
297
+ const args = ["status", "--porcelain"];
298
+ if (!includeUntracked)
299
+ args.push("-uno");
300
+ args.push("--", path);
301
+ const out = execFileSync("git", args, {
302
+ cwd,
303
+ stdio: ["ignore", "pipe", "ignore"],
304
+ encoding: "utf-8",
305
+ });
306
+ return out.trim().length > 0;
307
+ }
308
+ catch {
309
+ return false;
310
+ }
311
+ }
312
+ /**
313
+ * The phrase pull and diff both use for an entity that is no longer exported.
314
+ *
315
+ * The plan of record asked for "archived or deleted" on the assumption that
316
+ * archiving drops an entity from the export. It does not: the list endpoints
317
+ * filter by status only when `?status=` is passed and pull never passes it
318
+ * (`listAppPrompts`/`listAppIntegrations` in `src/admin-api.ts`; `fetchAll` in
319
+ * `cli/src/commands/sync.ts`). An archived entity stays in the export and its
320
+ * TOML is rewritten with `status = "archived"`, so it is never pruned. The only
321
+ * way to be absent from the export is to have been deleted.
322
+ */
323
+ export const ABSENT_FROM_EXPORT = "absent from sync export (deleted on server)";
324
+ /** `sync diff` status for a local file whose managed entity is gone. */
325
+ export const LOCAL_ONLY_ABSENT = "local only (absent from export)";
326
+ /** `sync diff` status for a local file the operator authored themselves. */
327
+ export const LOCAL_ONLY_NEW = "local only";
328
+ /**
329
+ * Classify a local file that has no matching server entity (issue #1659).
330
+ *
331
+ * The two cases look identical on disk but mean opposite things. A key a prior
332
+ * pull managed means the entity was deleted server-side and this stale file
333
+ * will RE-create it on the next push. A key never pulled means the operator
334
+ * authored the file and push creating it is the intent.
335
+ */
336
+ export function classifyLocalOnly(priorEntities, slot, key) {
337
+ const managed = (priorEntities?.[slot] ?? {});
338
+ return key in managed ? LOCAL_ONLY_ABSENT : LOCAL_ONLY_NEW;
339
+ }
340
+ /**
341
+ * Remove the local files for `keys` (as decided by `planPrune`), along with any
342
+ * sidecar `<key>.tests/` directory.
343
+ *
344
+ * A file with uncommitted git edits is kept and reported instead: the operator
345
+ * may be mid-way through authoring a change they meant to push. Everything
346
+ * removed here is recoverable from the pre-pull snapshot via `sync revert`.
347
+ */
348
+ export async function applyPrune(options) {
349
+ const { configDir, resourceType, keys, testCaseEntities, logger } = options;
350
+ const log = logger ?? (() => { });
351
+ const result = {
352
+ prunedKeys: [],
353
+ removed: [],
354
+ keptWithLocalEdits: [],
355
+ removedTestCaseKeys: [],
356
+ };
357
+ for (const key of keys) {
358
+ const filename = resourceType.fileFromKey(key);
359
+ const relativePath = `${resourceType.dir}/${filename}`;
360
+ const filePath = join(configDir, resourceType.dir, filename);
361
+ if (!existsSync(filePath))
362
+ continue;
363
+ // Untracked does not mean edited: in a sync dir that was never committed
364
+ // (or in no repository at all) every pulled file is untracked, and counting
365
+ // that as a local edit would keep every stale file and leave #1659 unfixed.
366
+ if (await hasUncommittedChanges(filePath, { includeUntracked: false })) {
367
+ result.keptWithLocalEdits.push(key);
368
+ log(` Kept ${relativePath} — ${ABSENT_FROM_EXPORT} but the file has ` +
369
+ "uncommitted local edits; remove it yourself or re-create the entity on the server");
370
+ continue;
371
+ }
372
+ unlinkSync(filePath);
373
+ result.prunedKeys.push(key);
374
+ result.removed.push(relativePath);
375
+ log(` Removed ${relativePath} — ${ABSENT_FROM_EXPORT}`);
376
+ if (!resourceType.testBlockType)
377
+ continue;
378
+ // Sidecar tests: `<key>.tests/` holds the block's test-case TOMLs and their
379
+ // attachment directories. With the block gone there is nothing to run them
380
+ // against, so they go too — along with their sync-state records.
381
+ const testsDirName = `${key}.tests`;
382
+ const testsDir = join(configDir, resourceType.dir, testsDirName);
383
+ if (existsSync(testsDir) && isSafeSyncFilename(testsDirName)) {
384
+ rmSync(testsDir, { recursive: true, force: true });
385
+ result.removed.push(`${resourceType.dir}/${testsDirName}`);
386
+ log(` Removed ${resourceType.dir}/${testsDirName} — ${ABSENT_FROM_EXPORT}`);
387
+ }
388
+ if (testCaseEntities) {
389
+ for (const [stateKey, entry] of Object.entries(testCaseEntities)) {
390
+ if (entry?.blockType === resourceType.testBlockType &&
391
+ entry?.blockKey === key) {
392
+ delete testCaseEntities[stateKey];
393
+ result.removedTestCaseKeys.push(stateKey);
394
+ }
395
+ }
396
+ }
397
+ }
398
+ return result;
399
+ }
400
+ /** Read `id` from an id-addressed slot's state entry. */
401
+ export const addressById = (_key, entry) => entry && typeof entry.id === "string" && entry.id ? entry.id : undefined;
402
+ /** Key-addressed slots are addressed by the state key itself. */
403
+ export const addressByKey = (key) => key || undefined;
404
+ /**
405
+ * Live modified timestamp used by the drift check. Most admin/app detail
406
+ * endpoints serialize `modifiedAt` at the top level (the same field `sync pull`
407
+ * records into state), with `updatedAt` as a defensive fallback. Two detail
408
+ * endpoints nest the entity under a wrapper key and need their own extractors
409
+ * below (`workflowPruneModifiedAt`, `emailTemplatePruneModifiedAt`) so the
410
+ * drift check reads the real timestamp instead of `undefined` — a top-level
411
+ * read there always looks like drift and blocks `--prune` without `--force`.
412
+ */
413
+ export const pruneModifiedAt = (detail) => (detail && (detail.modifiedAt ?? detail.updatedAt)) || undefined;
414
+ /**
415
+ * Workflow detail-read extractor. `getWorkflow` (server `getAppWorkflow`,
416
+ * src/admin-api.ts) returns `{ workflow, draft, revisions, configs }` — the
417
+ * entity and its `modifiedAt` are nested under `workflow`, unlike every other
418
+ * detail endpoint. Read there, not the top level.
419
+ */
420
+ export const workflowPruneModifiedAt = (detail) => pruneModifiedAt(detail && detail.workflow);
421
+ /**
422
+ * Email-template detail-read extractor. `getEmailTemplate` returns
423
+ * `{ emailType, label, hasOverride, override, default, variables }`; the
424
+ * editable copy `sync` manages — and its `modifiedAt` — live under `override`
425
+ * (null when there is no override). `sync pull` likewise records
426
+ * `template.override?.modifiedAt`, so the drift check must read the same path.
427
+ */
428
+ export const emailTemplatePruneModifiedAt = (detail) => pruneModifiedAt(detail && detail.override);
429
+ /**
430
+ * Read a declared identity field (`[<section>].<field>`) from a managed TOML
431
+ * file. Returns `undefined` when the field is absent, empty, or the file cannot
432
+ * be parsed — callers fall back to the filename-derived key, matching the
433
+ * `<field> || basename(file)` convention every `sync push` parser uses.
434
+ *
435
+ * Parsed via `parseConfigToml` (not `parseTomlFile`): the identity fields
436
+ * pruning reads are author-declared top-level values, unaffected by the
437
+ * workflow-fragment expander, so this avoids depending on `commands/sync.ts`
438
+ * (which would be a circular import) while producing the same key push does.
439
+ */
440
+ function declaredTomlKey(filePath, section, field) {
441
+ let parsed;
442
+ try {
443
+ parsed = parseConfigToml(readFileSync(filePath, "utf-8"));
444
+ }
445
+ catch {
446
+ return undefined;
447
+ }
448
+ const value = parsed && parsed[section] && parsed[section][field];
449
+ return typeof value === "string" && value ? value : undefined;
450
+ }
451
+ /**
452
+ * Build a `keyFromFile` for a TOML type whose entity key is declared at
453
+ * `[<section>].<field>`, falling back to the file basename (the exact key the
454
+ * matching `sync push` loop derives). This is what lets prune recognize an
455
+ * entity declared inside a **renamed** file and keep it out of the prune set,
456
+ * instead of deleting a remote entity that push just updated (issue #1655).
457
+ */
458
+ function tomlDeclaredKeyFromFile(section, field) {
459
+ return (filePath) => declaredTomlKey(filePath, section, field) ?? basename(filePath, ".toml");
460
+ }
461
+ /**
462
+ * `keyFromFile` for types keyed purely by filename — Rhai transforms (the
463
+ * script `name` is the `.rhai` basename) and rule-sets (the state key is the
464
+ * sanitized basename). These have no in-file identity, so a rename genuinely
465
+ * changes the entity and the basename is the correct key.
466
+ */
467
+ function basenameKeyFromFile(ext) {
468
+ return (filePath) => basename(filePath, ext);
469
+ }
470
+ /**
471
+ * `keyFromFile` for metadata-category-configs, whose key is the
472
+ * `resourceType#category` pair. Push reads `[metadataCategoryConfig]`
473
+ * `resourceType`/`category` and falls back to the filename
474
+ * (`resourceType.category.toml`, split on the first `.`).
475
+ */
476
+ function metadataCategoryKeyFromFile(filePath) {
477
+ const resourceType = declaredTomlKey(filePath, "metadataCategoryConfig", "resourceType");
478
+ const category = declaredTomlKey(filePath, "metadataCategoryConfig", "category");
479
+ if (resourceType && category)
480
+ return `${resourceType}#${category}`;
481
+ const base = basename(filePath, ".toml");
482
+ const dot = base.indexOf(".");
483
+ if (dot > 0) {
484
+ return `${base.slice(0, dot)}#${base.slice(dot + 1)}`;
485
+ }
486
+ return base;
487
+ }
488
+ function pruneTypeFor(label) {
489
+ const found = SYNC_RESOURCE_TYPES.find((t) => t.label === label);
490
+ if (!found)
491
+ throw new Error(`no sync resource type '${label}'`);
492
+ return found;
493
+ }
494
+ /**
495
+ * Every resource type push-prune can retire. Id-addressed types (integrations,
496
+ * webhooks, cron-triggers, blob-buckets, prompts, workflows, transforms,
497
+ * rule-sets) fetch/delete by the stored id; key-addressed types (database-types,
498
+ * group-type-configs, collection-type-configs, email-templates) by the key.
499
+ *
500
+ * `--force` (the CLI's existing "override baseline drift" flag) only relaxes the
501
+ * drift check — it deliberately does NOT pass a server-side force flag on the
502
+ * delete, so each type's existing 409 dangling-ref guard stays active and a
503
+ * still-referenced entity is reported "blocked", not force-deleted.
504
+ */
505
+ export const PUSH_PRUNE_SPECS = [
506
+ {
507
+ resourceType: pruneTypeFor("integration"),
508
+ keyFromFile: tomlDeclaredKeyFromFile("integration", "key"),
509
+ addressOf: addressById,
510
+ fetchDetail: (c, a, id) => c.getIntegration(a, id),
511
+ modifiedAtOf: pruneModifiedAt,
512
+ deleteEntity: async (c, a, id) => {
513
+ await c.deleteIntegration(a, id);
514
+ },
515
+ },
516
+ {
517
+ resourceType: pruneTypeFor("webhook"),
518
+ keyFromFile: tomlDeclaredKeyFromFile("webhook", "key"),
519
+ addressOf: addressById,
520
+ fetchDetail: (c, a, id) => c.getWebhook(a, id),
521
+ modifiedAtOf: pruneModifiedAt,
522
+ deleteEntity: async (c, a, id) => {
523
+ await c.deleteWebhook(a, id);
524
+ },
525
+ },
526
+ {
527
+ resourceType: pruneTypeFor("cron-trigger"),
528
+ keyFromFile: tomlDeclaredKeyFromFile("cronTrigger", "key"),
529
+ addressOf: addressById,
530
+ fetchDetail: (c, a, id) => c.getCronTrigger(a, id),
531
+ modifiedAtOf: pruneModifiedAt,
532
+ deleteEntity: async (c, a, id) => {
533
+ await c.deleteCronTrigger(a, id);
534
+ },
535
+ },
536
+ {
537
+ resourceType: pruneTypeFor("blob-bucket"),
538
+ keyFromFile: tomlDeclaredKeyFromFile("bucket", "key"),
539
+ addressOf: addressById,
540
+ fetchDetail: (c, a, id) => c.getBlobBucket(a, id),
541
+ modifiedAtOf: pruneModifiedAt,
542
+ deleteEntity: async (c, a, id) => {
543
+ await c.deleteBlobBucket(a, id);
544
+ },
545
+ },
546
+ {
547
+ resourceType: pruneTypeFor("prompt"),
548
+ keyFromFile: tomlDeclaredKeyFromFile("prompt", "key"),
549
+ addressOf: addressById,
550
+ fetchDetail: (c, a, id) => c.getPrompt(a, id),
551
+ modifiedAtOf: pruneModifiedAt,
552
+ deleteEntity: async (c, a, id) => {
553
+ await c.deletePrompt(a, id);
554
+ },
555
+ },
556
+ {
557
+ resourceType: pruneTypeFor("workflow"),
558
+ keyFromFile: tomlDeclaredKeyFromFile("workflow", "key"),
559
+ addressOf: addressById,
560
+ fetchDetail: (c, a, id) => c.getWorkflow(a, id),
561
+ // `getWorkflow` nests the entity (and modifiedAt) under `workflow`.
562
+ modifiedAtOf: workflowPruneModifiedAt,
563
+ deleteEntity: async (c, a, id) => {
564
+ await c.deleteWorkflow(a, id);
565
+ },
566
+ },
567
+ {
568
+ resourceType: pruneTypeFor("transform"),
569
+ // Rhai scripts are keyed by the `.rhai` basename — no in-file identity.
570
+ keyFromFile: basenameKeyFromFile(".rhai"),
571
+ addressOf: addressById,
572
+ fetchDetail: (c, a, id) => c.getScript(a, id),
573
+ modifiedAtOf: pruneModifiedAt,
574
+ deleteEntity: async (c, a, id) => {
575
+ await c.deleteScript(a, id);
576
+ },
577
+ },
578
+ {
579
+ resourceType: pruneTypeFor("rule-set"),
580
+ // Rule sets are keyed by the sanitized basename — no in-file identity.
581
+ keyFromFile: basenameKeyFromFile(".toml"),
582
+ addressOf: addressById,
583
+ fetchDetail: (c, a, id) => c.getRuleSet(a, id),
584
+ modifiedAtOf: pruneModifiedAt,
585
+ deleteEntity: async (c, a, id) => {
586
+ await c.deleteRuleSet(a, id);
587
+ },
588
+ },
589
+ {
590
+ resourceType: pruneTypeFor("database-type"),
591
+ keyFromFile: tomlDeclaredKeyFromFile("type", "databaseType"),
592
+ addressOf: addressByKey,
593
+ fetchDetail: (c, a, key) => c.getDatabaseTypeConfig(a, key),
594
+ modifiedAtOf: pruneModifiedAt,
595
+ // No server-side force: the type's 409 dangling-ref guard must stay active.
596
+ deleteEntity: async (c, a, key) => {
597
+ await c.deleteDatabaseTypeConfig(a, key);
598
+ },
599
+ },
600
+ {
601
+ resourceType: pruneTypeFor("group-type-config"),
602
+ keyFromFile: tomlDeclaredKeyFromFile("groupTypeConfig", "groupType"),
603
+ addressOf: addressByKey,
604
+ fetchDetail: (c, a, key) => c.getGroupTypeConfig(a, key),
605
+ modifiedAtOf: pruneModifiedAt,
606
+ deleteEntity: async (c, a, key) => {
607
+ await c.deleteGroupTypeConfig(a, key);
608
+ },
609
+ },
610
+ {
611
+ resourceType: pruneTypeFor("collection-type-config"),
612
+ keyFromFile: tomlDeclaredKeyFromFile("collectionTypeConfig", "collectionType"),
613
+ addressOf: addressByKey,
614
+ fetchDetail: (c, a, key) => c.getCollectionTypeConfig(a, key),
615
+ modifiedAtOf: pruneModifiedAt,
616
+ deleteEntity: async (c, a, key) => {
617
+ await c.deleteCollectionTypeConfig(a, key);
618
+ },
619
+ },
620
+ {
621
+ resourceType: pruneTypeFor("email-template"),
622
+ keyFromFile: tomlDeclaredKeyFromFile("template", "emailType"),
623
+ addressOf: addressByKey,
624
+ fetchDetail: (c, a, key) => c.getEmailTemplate(a, key),
625
+ // `getEmailTemplate` nests the managed override (and modifiedAt) under `override`.
626
+ modifiedAtOf: emailTemplatePruneModifiedAt,
627
+ deleteEntity: async (c, a, key) => {
628
+ await c.deleteEmailTemplate(a, key);
629
+ },
630
+ },
631
+ {
632
+ // #1426 owns the config-delete admin-client wrapper; `deleteResourceMetadata`
633
+ // is a metadata-*value* delete, not a config delete. Until then a candidate
634
+ // of this type is reported "no delete surface yet (#1426)", never deleted.
635
+ resourceType: pruneTypeFor("metadata-category-config"),
636
+ keyFromFile: metadataCategoryKeyFromFile,
637
+ addressOf: addressByKey,
638
+ fetchDetail: (c, a, key) => {
639
+ // The state key is the `resourceType#category` pair; the getter is
640
+ // path-addressed by both. (Never actually called — `deleteEntity` is null,
641
+ // so a candidate short-circuits to "unsupported" before any fetch.)
642
+ const [resourceType, category] = key.split("#");
643
+ return c.getMetadataCategoryConfig(a, resourceType, category);
644
+ },
645
+ modifiedAtOf: pruneModifiedAt,
646
+ deleteEntity: null,
647
+ },
648
+ ];
649
+ /**
650
+ * The set of entity keys the local managed files of a type declare — collected
651
+ * the way `sync push` derives keys: parse every file in the type's directory
652
+ * and read its declared identity (`<in-file key> || basename`), NOT by assuming
653
+ * the file lives at its canonical `<key>.toml` path.
654
+ *
655
+ * This is the crux of the #1655 destructive-delete fix. `sync push` accepts a
656
+ * managed file at a **noncanonical filename** (e.g. `prompts/custom.toml` with
657
+ * `prompt.key = "greeting"`) and still updates the `greeting` entity. Collecting
658
+ * declared keys — rather than checking whether `greeting.toml` exists — means a
659
+ * renamed-but-same-key file keeps its entity out of the prune set, so prune
660
+ * never deletes an entity push just updated.
661
+ */
662
+ export function collectLocalDeclaredKeys(input) {
663
+ const { spec, configDir } = input;
664
+ const dirPath = join(configDir, spec.resourceType.dir);
665
+ const keys = new Set();
666
+ if (!existsSync(dirPath))
667
+ return keys;
668
+ for (const file of readdirSync(dirPath)) {
669
+ if (!file.endsWith(spec.resourceType.ext))
670
+ continue;
671
+ const key = spec.keyFromFile(join(dirPath, file));
672
+ if (key)
673
+ keys.add(key);
674
+ }
675
+ return keys;
676
+ }
677
+ /**
678
+ * A managed entity whose local declaration is gone — a candidate for
679
+ * server-side deletion. Pure and side-effect-free: computed from the prior sync
680
+ * state and the filesystem only (no server call), so it is immune to listing
681
+ * truncation.
682
+ *
683
+ * A key is a candidate iff it was in the prior state slot (a prior pull managed
684
+ * it) AND **no** local file in the type's directory declares that key (via
685
+ * {@link collectLocalDeclaredKeys}, mirroring how `sync push` derives keys). A
686
+ * key still declared by some file — even one renamed away from the canonical
687
+ * `<key>.toml` path — is left alone, since `sync push` would update it, not
688
+ * delete it (issue #1655). A file whose key was never in prior state was
689
+ * hand-authored and is left for `sync push` to create; it is never a candidate.
690
+ */
691
+ export function collectPushPruneCandidates(input) {
692
+ const { spec, configDir, priorEntities } = input;
693
+ if (!priorEntities)
694
+ return [];
695
+ const localKeys = collectLocalDeclaredKeys({ spec, configDir });
696
+ const candidates = [];
697
+ for (const [key, entry] of Object.entries(priorEntities)) {
698
+ if (!localKeys.has(key)) {
699
+ candidates.push({ key, entry });
700
+ }
701
+ }
702
+ return candidates;
703
+ }
704
+ /**
705
+ * Decide what to do with one prune candidate, given the point read's outcome.
706
+ * Pure so the safety rules are unit-testable without a live server.
707
+ *
708
+ * Fail-closed throughout: a candidate is deleted ONLY on a successful read whose
709
+ * live `modifiedAt` matches prior state (or under `--force`). A 404 means the
710
+ * work is already done (drop the stale state). Any other read failure, a missing
711
+ * address, or a type without a delete surface leaves everything untouched.
712
+ */
713
+ export function decidePushPruneCandidate(input) {
714
+ const { spec, address, priorModifiedAt, fetch: fetchOutcome, force } = input;
715
+ if (!address)
716
+ return { action: "skip", reason: "no-id" };
717
+ if (!spec.deleteEntity)
718
+ return { action: "skip", reason: "unsupported" };
719
+ if (fetchOutcome.ok) {
720
+ const serverModifiedAt = spec.modifiedAtOf(fetchOutcome.detail);
721
+ const unchanged = !!priorModifiedAt &&
722
+ !!serverModifiedAt &&
723
+ serverModifiedAt === priorModifiedAt;
724
+ if (force || unchanged) {
725
+ return { action: "delete", address, serverModifiedAt };
726
+ }
727
+ return { action: "skip", reason: "drift", serverModifiedAt };
728
+ }
729
+ // Fetch failed: a 404 means already gone (drop stale state); any other error
730
+ // means presence could not be confirmed — leave everything intact.
731
+ if (fetchOutcome.status === 404)
732
+ return { action: "drop-state" };
733
+ return { action: "skip", reason: "unconfirmed" };
734
+ }
735
+ /**
736
+ * Remove a pruned block's sidecar `<key>.tests/` directory and its
737
+ * `entities.testCases` state records. The main file is already gone (that is
738
+ * what made this a prune candidate), so `applyPrune` above — which bails when
739
+ * the main file is absent — does not clean the sidecar; this does.
740
+ */
741
+ export function removePrunedSidecar(options) {
742
+ const { configDir, resourceType, key, testCaseEntities, logger } = options;
743
+ const log = logger ?? (() => { });
744
+ const removed = [];
745
+ const removedTestCaseKeys = [];
746
+ if (!resourceType.testBlockType)
747
+ return { removed, removedTestCaseKeys };
748
+ const testsDirName = `${key}.tests`;
749
+ const testsDir = join(configDir, resourceType.dir, testsDirName);
750
+ if (existsSync(testsDir) && isSafeSyncFilename(testsDirName)) {
751
+ rmSync(testsDir, { recursive: true, force: true });
752
+ removed.push(`${resourceType.dir}/${testsDirName}`);
753
+ log(` Removed orphaned ${resourceType.dir}/${testsDirName}`);
754
+ }
755
+ if (testCaseEntities) {
756
+ for (const [stateKey, entry] of Object.entries(testCaseEntities)) {
757
+ if (entry?.blockType === resourceType.testBlockType &&
758
+ entry?.blockKey === key) {
759
+ delete testCaseEntities[stateKey];
760
+ removedTestCaseKeys.push(stateKey);
761
+ }
762
+ }
763
+ }
764
+ return { removed, removedTestCaseKeys };
765
+ }
766
+ /** `sync diff` status for a managed remote entity whose local file is gone. */
767
+ export const REMOTE_ONLY_WILL_DELETE = "remote only (will delete with push --prune)";
768
+ /** `sync diff` status for a server entity that was never synced (hand-created). */
769
+ export const REMOTE_ONLY_UNMANAGED = "remote only (left alone)";
770
+ /**
771
+ * Classify a "remote only" entity (present server-side, no local file) for
772
+ * `sync diff` (issue #1655). A key the prior pull managed means the operator
773
+ * deleted its local file and `push --prune` will delete the remote entity; a key
774
+ * never synced was authored on the server and is left alone. Prior sync state is
775
+ * the discriminator, exactly as it is for the local-only / pull-prune split.
776
+ */
777
+ export function classifyRemoteOnly(priorEntities, slot, key) {
778
+ const managed = (priorEntities?.[slot] ?? {});
779
+ return key in managed ? REMOTE_ONLY_WILL_DELETE : REMOTE_ONLY_UNMANAGED;
780
+ }
781
+ //# sourceMappingURL=sync-resource-types.js.map