primitive-admin 1.0.52 → 1.0.54

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 (141) hide show
  1. package/README.md +38 -10
  2. package/dist/bin/primitive.js +6 -0
  3. package/dist/bin/primitive.js.map +1 -1
  4. package/dist/src/commands/auth.js +16 -6
  5. package/dist/src/commands/auth.js.map +1 -1
  6. package/dist/src/commands/blob-buckets.js +84 -7
  7. package/dist/src/commands/blob-buckets.js.map +1 -1
  8. package/dist/src/commands/catalog.js +3 -3
  9. package/dist/src/commands/catalog.js.map +1 -1
  10. package/dist/src/commands/collections.js +12 -9
  11. package/dist/src/commands/collections.js.map +1 -1
  12. package/dist/src/commands/database-types.js +11 -1
  13. package/dist/src/commands/database-types.js.map +1 -1
  14. package/dist/src/commands/databases.js +197 -74
  15. package/dist/src/commands/databases.js.map +1 -1
  16. package/dist/src/commands/group-type-configs.js +1 -9
  17. package/dist/src/commands/group-type-configs.js.map +1 -1
  18. package/dist/src/commands/groups.js +12 -6
  19. package/dist/src/commands/groups.js.map +1 -1
  20. package/dist/src/commands/guides.d.ts +161 -0
  21. package/dist/src/commands/guides.js +502 -84
  22. package/dist/src/commands/guides.js.map +1 -1
  23. package/dist/src/commands/init.js +77 -45
  24. package/dist/src/commands/init.js.map +1 -1
  25. package/dist/src/commands/integrations.js +2 -2
  26. package/dist/src/commands/integrations.js.map +1 -1
  27. package/dist/src/commands/metadata.d.ts +2 -0
  28. package/dist/src/commands/metadata.js +344 -0
  29. package/dist/src/commands/metadata.js.map +1 -0
  30. package/dist/src/commands/prompts.js +2 -2
  31. package/dist/src/commands/prompts.js.map +1 -1
  32. package/dist/src/commands/rule-sets.d.ts +1 -0
  33. package/dist/src/commands/rule-sets.js +25 -3
  34. package/dist/src/commands/rule-sets.js.map +1 -1
  35. package/dist/src/commands/scripts.d.ts +2 -0
  36. package/dist/src/commands/scripts.js +667 -0
  37. package/dist/src/commands/scripts.js.map +1 -0
  38. package/dist/src/commands/sync.d.ts +212 -16
  39. package/dist/src/commands/sync.js +1688 -287
  40. package/dist/src/commands/sync.js.map +1 -1
  41. package/dist/src/commands/vars.d.ts +8 -0
  42. package/dist/src/commands/vars.js +110 -0
  43. package/dist/src/commands/vars.js.map +1 -0
  44. package/dist/src/commands/webhooks.js +17 -3
  45. package/dist/src/commands/webhooks.js.map +1 -1
  46. package/dist/src/commands/workflows.d.ts +30 -0
  47. package/dist/src/commands/workflows.js +651 -52
  48. package/dist/src/commands/workflows.js.map +1 -1
  49. package/dist/src/lib/api-client.d.ts +123 -19
  50. package/dist/src/lib/api-client.js +128 -4
  51. package/dist/src/lib/api-client.js.map +1 -1
  52. package/dist/src/lib/batch.d.ts +26 -0
  53. package/dist/src/lib/batch.js +32 -0
  54. package/dist/src/lib/batch.js.map +1 -0
  55. package/dist/src/lib/block-layout.d.ts +160 -0
  56. package/dist/src/lib/block-layout.js +451 -0
  57. package/dist/src/lib/block-layout.js.map +1 -0
  58. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +61 -0
  59. package/dist/src/lib/codegen-shared/generatedFiles.js +127 -0
  60. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
  61. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +68 -0
  62. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +168 -0
  63. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
  64. package/dist/src/lib/config-toml.d.ts +10 -0
  65. package/dist/src/lib/config-toml.js +42 -0
  66. package/dist/src/lib/config-toml.js.map +1 -0
  67. package/dist/src/lib/config.d.ts +16 -0
  68. package/dist/src/lib/config.js +41 -0
  69. package/dist/src/lib/config.js.map +1 -1
  70. package/dist/src/lib/constants.d.ts +9 -0
  71. package/dist/src/lib/constants.js +9 -0
  72. package/dist/src/lib/constants.js.map +1 -1
  73. package/dist/src/lib/csv.d.ts +1 -2
  74. package/dist/src/lib/csv.js +3 -8
  75. package/dist/src/lib/csv.js.map +1 -1
  76. package/dist/src/lib/db-codegen/dbGenerator.d.ts +35 -7
  77. package/dist/src/lib/db-codegen/dbGenerator.js +384 -125
  78. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  79. package/dist/src/lib/db-codegen/dbNaming.d.ts +29 -0
  80. package/dist/src/lib/db-codegen/dbNaming.js +58 -0
  81. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  82. package/dist/src/lib/db-codegen/dbTemplates.d.ts +114 -5
  83. package/dist/src/lib/db-codegen/dbTemplates.js +163 -16
  84. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  85. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +7 -3
  86. package/dist/src/lib/db-codegen/dbTsTypes.js +23 -1
  87. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  88. package/dist/src/lib/generated-allowlist.js +52 -0
  89. package/dist/src/lib/generated-allowlist.js.map +1 -1
  90. package/dist/src/lib/init-config.d.ts +13 -0
  91. package/dist/src/lib/init-config.js +21 -2
  92. package/dist/src/lib/init-config.js.map +1 -1
  93. package/dist/src/lib/output.d.ts +42 -5
  94. package/dist/src/lib/output.js +70 -4
  95. package/dist/src/lib/output.js.map +1 -1
  96. package/dist/src/lib/query-operators.d.ts +43 -0
  97. package/dist/src/lib/query-operators.js +80 -0
  98. package/dist/src/lib/query-operators.js.map +1 -0
  99. package/dist/src/lib/template.d.ts +9 -5
  100. package/dist/src/lib/template.js +58 -45
  101. package/dist/src/lib/template.js.map +1 -1
  102. package/dist/src/lib/toml-database-config.d.ts +10 -19
  103. package/dist/src/lib/toml-database-config.js +28 -59
  104. package/dist/src/lib/toml-database-config.js.map +1 -1
  105. package/dist/src/lib/toml-metadata-config.d.ts +147 -0
  106. package/dist/src/lib/toml-metadata-config.js +463 -0
  107. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  108. package/dist/src/lib/toml-native-form.d.ts +46 -0
  109. package/dist/src/lib/toml-native-form.js +78 -0
  110. package/dist/src/lib/toml-native-form.js.map +1 -0
  111. package/dist/src/lib/toml-params-validator.d.ts +34 -0
  112. package/dist/src/lib/toml-params-validator.js +118 -3
  113. package/dist/src/lib/toml-params-validator.js.map +1 -1
  114. package/dist/src/lib/workflow-apply.d.ts +86 -0
  115. package/dist/src/lib/workflow-apply.js +127 -0
  116. package/dist/src/lib/workflow-apply.js.map +1 -0
  117. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
  118. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
  119. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
  120. package/dist/src/lib/workflow-codegen/generator.d.ts +74 -0
  121. package/dist/src/lib/workflow-codegen/generator.js +215 -0
  122. package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
  123. package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
  124. package/dist/src/lib/workflow-codegen/naming.js +81 -0
  125. package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
  126. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +64 -0
  127. package/dist/src/lib/workflow-codegen/schemaToTs.js +282 -0
  128. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
  129. package/dist/src/lib/workflow-fragments.js +3 -3
  130. package/dist/src/lib/workflow-fragments.js.map +1 -1
  131. package/dist/src/lib/workflow-payload.d.ts +68 -0
  132. package/dist/src/lib/workflow-payload.js +150 -0
  133. package/dist/src/lib/workflow-payload.js.map +1 -0
  134. package/dist/src/lib/workflow-toml-validator.d.ts +1 -1
  135. package/dist/src/lib/workflow-toml-validator.js +37 -1
  136. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  137. package/dist/src/types/index.d.ts +11 -0
  138. package/dist/src/validators.d.ts +4 -3
  139. package/dist/src/validators.js +6 -5
  140. package/dist/src/validators.js.map +1 -1
  141. package/package.json +5 -3
@@ -1,17 +1,22 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
2
2
  import { join, basename } from "path";
3
3
  import { createHash } from "crypto";
4
- import * as TOML from "@iarna/toml";
5
4
  import { lookup as mimeLookup } from "mime-types";
6
5
  import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
7
6
  import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
7
+ import { canEmitNative } from "../lib/toml-native-form.js";
8
+ import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMetadataCategoryConfig, parseDeclaredAccessManifestToml, validateDeclaredAccessManifest, serializeDeclaredAccessManifest, } from "../lib/toml-metadata-config.js";
8
9
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
9
10
  import { getServerUrl, resolveAppId } from "../lib/config.js";
10
11
  import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
11
12
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
12
13
  import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
13
14
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
14
- import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
15
+ import { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
16
+ import { applyWorkflowBody } from "../lib/workflow-apply.js";
17
+ import { buildWorkflowPayloadFromToml } from "../lib/workflow-payload.js";
18
+ import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
19
+ import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
15
20
  import { confirmPrompt } from "../lib/confirm-prompt.js";
16
21
  import chalk from "chalk";
17
22
  function ensureDir(dirPath) {
@@ -132,30 +137,43 @@ export function shouldPushExpandedFile(parsed, storedHash) {
132
137
  return computeExpandedContentHash(parsed) !== storedHash;
133
138
  }
134
139
  /**
135
- * Model defaults for the *exact five* workflow-level fields that
136
- * `serializeWorkflow` emits unconditionally (`sync.ts` workflow serializer:
137
- * `perUserMaxRunning`, `perUserMaxQueued`, `dequeueOrder`,
138
- * `requiresClientApply`, `syncCallable`). These mirror `models.yaml`
139
- * (`WorkflowDefinition`): `perUserMaxRunning=4`, `perUserMaxQueued=100`,
140
- * `dequeueOrder="fifo"`, `requiresClientApply=true`, `syncCallable=false`.
140
+ * Model defaults for the workflow-level fields that `serializeWorkflow` emits
141
+ * with a concrete server default the *local* file may omit (`sync.ts` workflow
142
+ * serializer): `perUserMaxRunning`, `perUserMaxQueued`, `perAppMaxRunning`,
143
+ * `perAppMaxQueued`, `queueTtlSeconds`, `dequeueOrder`, `requiresClientApply`,
144
+ * `syncCallable`. These mirror `models.yaml` (`WorkflowDefinition`):
145
+ * `perUserMaxRunning=4`, `perUserMaxQueued=100`, `perAppMaxRunning=25`,
146
+ * `perAppMaxQueued=10000`, `queueTtlSeconds=43200`, `dequeueOrder="fifo"`,
147
+ * `requiresClientApply=true`, `syncCallable=false`.
148
+ *
149
+ * `perAppMax*` / `queueTtlSeconds` were added here for #1177: once the pull
150
+ * serializer began emitting them (they carry a non-null model default, so a GET
151
+ * always returns them), an unchanged workflow using the server defaults hashed
152
+ * unequal to a local TOML that omits them — a false `modified` diff that made
153
+ * `sync pull` rewrite the file just to inject defaults. Normalizing both sides
154
+ * to the model default here closes that gap the same way the per-user limits do.
141
155
  *
142
156
  * This is deliberately NOT a generic models.yaml default importer — the model
143
- * carries many defaults the serializer never writes (`perAppMax*`,
144
- * `queueTtlSeconds`, …). Only the serializer-emitted surface matters for a
145
- * content comparison, because the remote side is hashed via `serializeWorkflow`
146
- * and therefore only ever contains these five.
157
+ * carries defaults the serializer never writes. Only the serializer-emitted
158
+ * surface with a concrete default matters for a content comparison, because the
159
+ * remote side is hashed via `serializeWorkflow`. Emit-when-set fields whose GET
160
+ * value is null/empty when unset (`accessRule`, `runAs`, `capabilities`) are
161
+ * omitted on both sides and so need no normalization here.
147
162
  */
148
163
  const WORKFLOW_SERIALIZER_DEFAULTS = {
149
164
  perUserMaxRunning: 4,
150
165
  perUserMaxQueued: 100,
166
+ perAppMaxRunning: 25,
167
+ perAppMaxQueued: 10000,
168
+ queueTtlSeconds: 43200,
151
169
  dequeueOrder: "fifo",
152
170
  requiresClientApply: true,
153
171
  syncCallable: false,
154
172
  };
155
173
  /**
156
- * Normalize a parsed workflow TOML object so that any of the five
157
- * serializer-emitted fields the *local* file omits are filled in with the
158
- * model default. Used by `diff` so a hand-authored workflow that omits e.g.
174
+ * Normalize a parsed workflow TOML object so that any of the
175
+ * serializer-emitted default fields the *local* file omits are filled in with
176
+ * the model default. Used by `diff` so a hand-authored workflow that omits e.g.
159
177
  * `perUserMaxRunning` compares equal to a server that defaulted it to 4
160
178
  * (the #1175 false-positive guard) — while a server value that was
161
179
  * *explicitly set* to a non-default (e.g. 8) still shows as Modified.
@@ -173,6 +191,25 @@ export function normalizeWorkflowTomlDefaults(parsed) {
173
191
  workflow[field] = defaultValue;
174
192
  }
175
193
  }
194
+ // Schema-form canonicalization (issue #1446): a legacy file carries
195
+ // `inputSchema` / `outputSchema` as a JSON string, while the native form (and
196
+ // the remote serialization) carries a parsed object. Parse the string form to
197
+ // its object here so the two forms hash equal — `sync diff` stays
198
+ // form-independent and an unchanged schema never shows a false `modified`
199
+ // during the transition. This runs only on the diff-hash path
200
+ // (`hashWorkflowTomlForDiff`); the stored-state hash path is untouched, so
201
+ // there is no forced one-time re-push after upgrade.
202
+ for (const field of ["inputSchema", "outputSchema"]) {
203
+ if (typeof workflow[field] === "string") {
204
+ try {
205
+ workflow[field] = JSON.parse(workflow[field]);
206
+ }
207
+ catch {
208
+ // Not valid JSON — leave the raw string so a genuinely malformed value
209
+ // still compares by its literal content.
210
+ }
211
+ }
212
+ }
176
213
  return { ...parsed, workflow };
177
214
  }
178
215
  /**
@@ -191,14 +228,97 @@ export function hashWorkflowTomlForDiff(parsed) {
191
228
  * `getWorkflow` + active `getWorkflowConfig`), mirroring exactly what a fresh
192
229
  * `sync pull` would write to disk. Serializes via `serializeWorkflow`, parses
193
230
  * the resulting TOML (remote serialized TOML never carries `include`s, so a
194
- * plain `TOML.parse` is sufficient — no fragment path needed), then hashes
231
+ * plain `parseConfigToml` is sufficient — no fragment path needed), then hashes
195
232
  * through `hashWorkflowTomlForDiff` so it lines up with the local-file hash.
196
233
  */
197
234
  export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
198
235
  const serialized = serializeWorkflow(workflow, draft, configs || []);
199
- const parsed = TOML.parse(serialized);
236
+ // serializeWorkflow emits via smol-toml (issue #1450) and may contain a
237
+ // heterogeneous array, so re-parse through the TOML 1.0-compliant facade.
238
+ const parsed = parseConfigToml(serialized);
200
239
  return hashWorkflowTomlForDiff(parsed);
201
240
  }
241
+ // Boolean [auth] keys in app.toml that map 1:1 to app-settings fields.
242
+ const AUTH_BOOLEAN_KEYS = [
243
+ "googleOAuthEnabled",
244
+ "passkeyEnabled",
245
+ "magicLinkEnabled",
246
+ "appleSignInEnabled",
247
+ "otpEnabled",
248
+ ];
249
+ // Every recognized [auth] key. Single source of truth shared by pull
250
+ // serialization, push parsing, and the unrecognized-key warning so the three
251
+ // never drift apart. `appleAudiences` is a string array and `passkeys` is the
252
+ // passkey RP config object; the rest are booleans.
253
+ const RECOGNIZED_AUTH_KEYS = new Set([
254
+ ...AUTH_BOOLEAN_KEYS,
255
+ "appleAudiences",
256
+ "passkeys",
257
+ ]);
258
+ /**
259
+ * Build the [auth] block of app.toml from server settings (pull direction).
260
+ *
261
+ * Apple/OTP fields round-trip alongside the legacy google/passkey/magic-link
262
+ * keys. Two asymmetries are handled deliberately:
263
+ * - `appleSignInEnabled` comes back as `boolean | null`; smol-toml throws on
264
+ * null, so an unset value (null) is omitted rather than written.
265
+ * - `appleAudiences` is omitted when empty. An empty array and an omitted key
266
+ * both mean "no audiences" (the server normalizes [] -> null), so omitting
267
+ * an empty list on pull is not lossy.
268
+ */
269
+ export function serializeAuthBlock(settings) {
270
+ const auth = {
271
+ googleOAuthEnabled: settings.googleOAuthEnabled,
272
+ passkeyEnabled: settings.passkeyEnabled,
273
+ magicLinkEnabled: settings.magicLinkEnabled,
274
+ };
275
+ if (typeof settings.appleSignInEnabled === "boolean") {
276
+ auth.appleSignInEnabled = settings.appleSignInEnabled;
277
+ }
278
+ if (Array.isArray(settings.appleAudiences) &&
279
+ settings.appleAudiences.length > 0) {
280
+ auth.appleAudiences = settings.appleAudiences;
281
+ }
282
+ if (typeof settings.otpEnabled === "boolean") {
283
+ auth.otpEnabled = settings.otpEnabled;
284
+ }
285
+ if (settings.passkeyRpConfig) {
286
+ auth.passkeys = settings.passkeyRpConfig;
287
+ }
288
+ return auth;
289
+ }
290
+ /**
291
+ * Translate the [auth] block of app.toml into app-settings fields (push
292
+ * direction).
293
+ *
294
+ * Only keys actually present in the TOML are forwarded — an omitted key is left
295
+ * untouched so it never overwrites server state (e.g. flipping a previously-true
296
+ * flag to false). An explicit value, including `false`, is forwarded as-is.
297
+ * `appleAudiences = []` is forwarded too; the server normalizes [] -> null
298
+ * ("no audiences"). Keys outside RECOGNIZED_AUTH_KEYS produce a warning rather
299
+ * than a hard error, so a typo or a future key doesn't silently drop.
300
+ */
301
+ export function parseAppAuthSettings(auth) {
302
+ const settings = {};
303
+ const warnings = [];
304
+ for (const key of AUTH_BOOLEAN_KEYS) {
305
+ if (key in auth)
306
+ settings[key] = auth[key];
307
+ }
308
+ if ("appleAudiences" in auth) {
309
+ settings.appleAudiences = auth.appleAudiences;
310
+ }
311
+ if ("passkeys" in auth) {
312
+ settings.passkeyRpConfig = auth.passkeys;
313
+ }
314
+ for (const key of Object.keys(auth)) {
315
+ if (!RECOGNIZED_AUTH_KEYS.has(key)) {
316
+ warnings.push(`Unrecognized [auth] key "${key}" in app.toml — ignored. ` +
317
+ `Recognized keys: ${[...RECOGNIZED_AUTH_KEYS].join(", ")}.`);
318
+ }
319
+ }
320
+ return { settings, warnings };
321
+ }
202
322
  // TOML serialization helpers
203
323
  function serializeAppSettings(settings) {
204
324
  const data = {
@@ -208,15 +328,8 @@ function serializeAppSettings(settings) {
208
328
  waitlistEnabled: settings.waitlistEnabled,
209
329
  baseUrl: settings.baseUrl,
210
330
  },
211
- auth: {
212
- googleOAuthEnabled: settings.googleOAuthEnabled,
213
- passkeyEnabled: settings.passkeyEnabled,
214
- magicLinkEnabled: settings.magicLinkEnabled,
215
- },
331
+ auth: serializeAuthBlock(settings),
216
332
  };
217
- if (settings.passkeyRpConfig) {
218
- data.auth.passkeys = settings.passkeyRpConfig;
219
- }
220
333
  if (settings.corsMode === "custom") {
221
334
  data.cors = {
222
335
  mode: settings.corsMode,
@@ -226,7 +339,313 @@ function serializeAppSettings(settings) {
226
339
  maxAge: settings.corsMaxAge,
227
340
  };
228
341
  }
229
- return TOML.stringify(data);
342
+ return stringifyConfigToml(data);
343
+ }
344
+ // --- Config vars (issue #1423) ---------------------------------------------
345
+ // Per-environment non-secret scalars, round-tripped as a single flat
346
+ // `vars.toml` at the sync-dir root (like app.toml). They bind as
347
+ // `{{ vars.KEY }}` in workflow/integration config and `vars.*` in CEL rules.
348
+ /** Key format shared with the vars service (`^[A-Z][A-Z0-9_]{0,63}$`). */
349
+ const VARS_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
350
+ /** SHA-256 of a var's value — the per-key content hash tracked in sync state. */
351
+ function hashVarValue(value) {
352
+ return createHash("sha256").update(value).digest("hex");
353
+ }
354
+ /**
355
+ * Serialize the app config vars to a flat TOML table (keys sorted for a stable,
356
+ * diff-friendly file). Values are non-secret plaintext; a header comment says
357
+ * so loudly. An empty var set yields a comment-only file.
358
+ */
359
+ function serializeVars(vars) {
360
+ const data = {};
361
+ for (const v of [...vars].sort((a, b) => a.key.localeCompare(b.key))) {
362
+ data[v.key] = String(v.value ?? "");
363
+ }
364
+ const header = "# Per-environment non-secret config vars (issue #1423).\n" +
365
+ "# Bind as {{ vars.KEY }} in workflow/integration config and vars.* in CEL\n" +
366
+ "# rules. Values are checked into the repo and NOT secret — never put a\n" +
367
+ "# credential here; use `primitive secrets` for that.\n\n";
368
+ return header + stringifyConfigToml(data);
369
+ }
370
+ /**
371
+ * Decide whether `sync pull` should (over)write `vars.toml`, given the outcome
372
+ * of fetching the app's config vars.
373
+ *
374
+ * The distinction that matters (issue #1423 review): a SUCCESSFUL fetch that
375
+ * returns zero vars is safe to write — the comment-only file is the intended
376
+ * empty state. A FAILED fetch (transient 401/500/network) must NOT write,
377
+ * because `serializeVars([])` would clobber a good local `vars.toml` with a
378
+ * comment-only file while the pull otherwise reports success. On failure we
379
+ * keep the prior manifest entries so the untouched file and the sync state stay
380
+ * consistent.
381
+ *
382
+ * Pure and side-effect-free (the caller owns the actual `writeFileSync`) so the
383
+ * clobber guard is unit-testable without a live server.
384
+ */
385
+ export function planVarsPull(outcome, priorVars) {
386
+ const varEntities = {};
387
+ if (!outcome.ok) {
388
+ // Preserve the prior manifest entries for the (untouched) local file.
389
+ if (priorVars)
390
+ Object.assign(varEntities, priorVars);
391
+ return { write: false, content: null, varEntities };
392
+ }
393
+ for (const v of outcome.vars) {
394
+ varEntities[v.key] = {
395
+ // The vars API returns `updatedAt` (see toVarResponse); record it as the
396
+ // baseline server timestamp so `sync push` can detect a concurrent remote
397
+ // edit. Fall back to `modifiedAt`/now only for older/partial responses.
398
+ modifiedAt: v.updatedAt || v.modifiedAt || new Date().toISOString(),
399
+ contentHash: hashVarValue(String(v.value ?? "")),
400
+ };
401
+ }
402
+ return { write: true, content: serializeVars(outcome.vars), varEntities };
403
+ }
404
+ /**
405
+ * Maximum var value size, in bytes — mirrors the server's `MAX_VALUE_BYTES`
406
+ * (app-secrets-service.ts). Kept in sync manually because the CLI can't import
407
+ * the Worker service module.
408
+ */
409
+ const MAX_VAR_VALUE_BYTES = 2048;
410
+ /**
411
+ * Maximum number of vars per app — mirrors the server's `MAX_VARS_PER_APP`
412
+ * (app-config-vars-service.ts). Kept in sync manually because the CLI can't
413
+ * import the Worker service module.
414
+ */
415
+ const MAX_VARS_PER_APP = 100;
416
+ /**
417
+ * Validate a parsed `vars.toml` table against the same key/value constraints
418
+ * the server enforces (key format, string type, non-empty, size cap) plus the
419
+ * aggregate per-app var-count cap. Returns a list of human-readable errors
420
+ * (empty when valid).
421
+ *
422
+ * Run in `sync push`'s up-front preflight pass (issue #1423 review) so an
423
+ * invalid entry — or a file that would exceed the server's `MAX_VARS_PER_APP`
424
+ * cap — aborts BEFORE any mutation is applied. Validating only individual
425
+ * entries let a 101-entry file create/update many vars before the server
426
+ * rejected a later create at the cap, leaving a partial push (issue #1423
427
+ * review pass 3). Pure and side-effect-free so it's unit-testable without a
428
+ * live server.
429
+ */
430
+ export function validateVarsFile(parsedVars) {
431
+ const errors = [];
432
+ const keyCount = Object.keys(parsedVars).length;
433
+ if (keyCount > MAX_VARS_PER_APP) {
434
+ errors.push(`vars.toml declares ${keyCount} vars, exceeding the maximum of ${MAX_VARS_PER_APP} vars per app. Remove ${keyCount - MAX_VARS_PER_APP} entr${keyCount - MAX_VARS_PER_APP === 1 ? "y" : "ies"}.`);
435
+ }
436
+ for (const [key, rawValue] of Object.entries(parsedVars)) {
437
+ if (!VARS_KEY_RE.test(key)) {
438
+ errors.push(`Invalid var key "${key}" in vars.toml: must match ^[A-Z][A-Z0-9_]{0,63}$ (uppercase letters, digits, underscores; start with a letter; max 64 chars)`);
439
+ continue;
440
+ }
441
+ if (typeof rawValue !== "string") {
442
+ errors.push(`Var "${key}" in vars.toml must be a string value (got ${typeof rawValue}) — quote the value.`);
443
+ continue;
444
+ }
445
+ const byteLength = new TextEncoder().encode(rawValue).length;
446
+ if (byteLength === 0) {
447
+ errors.push(`Var "${key}" in vars.toml must not be empty.`);
448
+ }
449
+ else if (byteLength > MAX_VAR_VALUE_BYTES) {
450
+ errors.push(`Var "${key}" in vars.toml exceeds the maximum value size of ${MAX_VAR_VALUE_BYTES} bytes (got ${byteLength}).`);
451
+ }
452
+ }
453
+ return errors;
454
+ }
455
+ /**
456
+ * Plan the config-var writes a `sync push` should make, detecting concurrent
457
+ * remote edits before overwriting them (issue #1423 review).
458
+ *
459
+ * This is the client-side half of a two-layer concurrency guard. The vars
460
+ * PUT/DELETE endpoints now honor an `expectedModifiedAt` precondition (issue
461
+ * #1423 review r-2 P1) and raise a `ConflictError` when the server row changed
462
+ * since the caller's snapshot — the atomic, server-side check every other
463
+ * synced entity relies on. This function is the fast fail-before-mutate layer:
464
+ * it compares the recorded baseline against the current remote snapshot and, for
465
+ * a var whose remote value has drifted from what we last synced, reports a
466
+ * conflict (fed into the same `conflicts[]` accumulator the other entities use)
467
+ * before any write is attempted. A var tracked at the baseline but absent from
468
+ * the remote snapshot (deleted remotely while edited locally) is remote drift
469
+ * too, and also reported as a conflict rather than silently recreated.
470
+ * Conversely, a var removed locally that is already absent remotely is NOT
471
+ * planned for deletion — its desired (absent) state is already met, so a DELETE
472
+ * would 404 and fail the push. The same drift check guards deletions: a var
473
+ * removed locally whose remote value no longer matches the baseline was edited
474
+ * remotely since the last sync, and deleting it would silently discard that
475
+ * edit — reported as a conflict instead. `force` skips the conflict guard,
476
+ * matching `sync push --force` for every other entity.
477
+ *
478
+ * Pure and side-effect-free (the caller owns the API calls and sync-state
479
+ * updates) so the guard is unit-testable without a live server. Assumes
480
+ * `parsedVars` has already passed `validateVarsFile` (values are strings). The
481
+ * caller normally fails the push closed when the remote snapshot can't be
482
+ * fetched, but under `--force` it falls back to an empty snapshot and sets
483
+ * `snapshotUnavailable` — which keeps removed-key deletions in the plan (they'd
484
+ * otherwise be mistaken for already-deleted and dropped) and leans on the apply
485
+ * loop's idempotent 404 handling (issue #1423 review r-2 P2).
486
+ */
487
+ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
488
+ const remoteByKey = new Map(remoteVars.map((r) => [r.key, r]));
489
+ const upserts = [];
490
+ const conflicts = [];
491
+ const deletions = [];
492
+ let skippedCount = 0;
493
+ const localKeys = new Set();
494
+ for (const [key, value] of Object.entries(parsedVars)) {
495
+ localKeys.add(key);
496
+ const valueHash = hashVarValue(value);
497
+ const existing = baseline?.[key];
498
+ // Unchanged since last sync — skip (unless forced), mirroring the
499
+ // content-hash skip the other file-backed entities use.
500
+ if (!options.force && existing && existing.contentHash === valueHash) {
501
+ skippedCount++;
502
+ continue;
503
+ }
504
+ const action = existing ? "update" : "create";
505
+ // Concurrent-edit guard: if the remote value has drifted from the baseline
506
+ // we last synced (for an update) — or a var with this key was created
507
+ // remotely with a different value (for a create) — pushing would silently
508
+ // clobber that remote change. Report a conflict instead. `--force`
509
+ // overrides, exactly like the other entities.
510
+ if (!options.force) {
511
+ const remote = remoteByKey.get(key);
512
+ if (remote && typeof remote.value === "string") {
513
+ const remoteHash = hashVarValue(remote.value);
514
+ const remoteDrifted = existing?.contentHash
515
+ ? remoteHash !== existing.contentHash
516
+ : remoteHash !== valueHash;
517
+ if (remoteDrifted) {
518
+ conflicts.push({
519
+ key,
520
+ serverModifiedAt: remote.updatedAt || "unknown",
521
+ localModifiedAt: existing?.modifiedAt || "unknown",
522
+ });
523
+ continue;
524
+ }
525
+ }
526
+ else if (existing) {
527
+ // Tracked at the baseline but absent from the remote snapshot: the var
528
+ // was deleted remotely since our last sync while we edited it locally
529
+ // (we only reach here past the unchanged-skip above, so the local value
530
+ // differs from the baseline). Upserting would recreate the
531
+ // remotely-deleted var — that missing remote value is remote drift too,
532
+ // so report a conflict instead (issue #1423 review pass 3). `--force`
533
+ // overrides.
534
+ conflicts.push({
535
+ key,
536
+ serverModifiedAt: "deleted",
537
+ localModifiedAt: existing.modifiedAt || "unknown",
538
+ });
539
+ continue;
540
+ }
541
+ }
542
+ upserts.push({ action, key, value, valueHash });
543
+ }
544
+ // Prune vars tracked in the baseline but removed from the local file
545
+ // (hard-delete; nested-entity precedent). Skip keys already absent from the
546
+ // remote snapshot: a var independently deleted remotely and also removed
547
+ // locally is already in its desired (absent) state, and a server DELETE would
548
+ // 404 and fail the whole push (issue #1423 review pass 3). `--force` does not
549
+ // resurrect these deletes — the desired state is met either way.
550
+ for (const [key, entry] of Object.entries(baseline ?? {})) {
551
+ if (localKeys.has(key))
552
+ continue;
553
+ const remote = remoteByKey.get(key);
554
+ if (!remote) {
555
+ // Normally an absent remote entry means the var is already gone, so its
556
+ // desired (deleted) state is met and a DELETE would 404 — skip it. But
557
+ // when the remote snapshot could NOT be fetched (the `--force` fallback
558
+ // after a failed `listAppConfigVars`, which passes an empty snapshot),
559
+ // "absent from the snapshot" means "unknown", not "gone". Suppressing the
560
+ // delete would silently keep a var the user removed from `vars.toml` while
561
+ // the push still reports success (issue #1423 review r-2 P2). Enqueue the
562
+ // removal and rely on the apply loop's idempotent 404 handling to absorb a
563
+ // key that really was already absent.
564
+ if (options.snapshotUnavailable)
565
+ deletions.push(key);
566
+ continue;
567
+ }
568
+ // Concurrent-edit guard, mirroring the upsert path (issue #1423 review
569
+ // pass 4): deleting a var whose remote value drifted from the last-synced
570
+ // baseline would silently discard that remote edit. When drift cannot be
571
+ // verified (no baseline hash, or no remote value in the snapshot), fail
572
+ // closed and report the conflict. `--force` deletes unconditionally.
573
+ if (!options.force) {
574
+ const remoteDrifted = typeof remote.value !== "string" ||
575
+ !entry.contentHash ||
576
+ hashVarValue(remote.value) !== entry.contentHash;
577
+ if (remoteDrifted) {
578
+ conflicts.push({
579
+ key,
580
+ serverModifiedAt: remote.updatedAt || "unknown",
581
+ localModifiedAt: entry.modifiedAt || "unknown",
582
+ });
583
+ continue;
584
+ }
585
+ }
586
+ deletions.push(key);
587
+ }
588
+ return { upserts, deletions, conflicts, skippedCount };
589
+ }
590
+ /**
591
+ * Compute the number of config vars the server would hold after a push plan
592
+ * is applied (issue #1423 review pass 5). Validating only the local file's
593
+ * entry count misses remote-only vars: 95 local entries plus 10 vars that
594
+ * exist only on the server passes a local-count check, and the creates then
595
+ * exceed the server's cap mid-push, after earlier writes were already
596
+ * applied. Counted from the fetched remote snapshot: planned deletions remove
597
+ * keys that exist remotely (the plan already omits deletes for absent keys),
598
+ * and only upserts whose key is NOT already on the server add to the total.
599
+ * Conflicted keys are not mutated, so they contribute nothing beyond their
600
+ * current remote presence. Pure and side-effect-free so it's unit-testable
601
+ * without a live server.
602
+ */
603
+ export function countVarsAfterPush(remoteVars, plan) {
604
+ const remoteKeys = new Set(remoteVars.map((r) => r.key));
605
+ let count = remoteKeys.size;
606
+ for (const key of plan.deletions) {
607
+ if (remoteKeys.has(key))
608
+ count--;
609
+ }
610
+ for (const upsert of plan.upserts) {
611
+ if (!remoteKeys.has(upsert.key))
612
+ count++;
613
+ }
614
+ return count;
615
+ }
616
+ /**
617
+ * Compute the `sync diff` rows for config vars (issue #1423 review). Before
618
+ * this, `sync diff` ignored vars entirely, so an add/remove/value-drift between
619
+ * the local `vars.toml` and the server read as no difference. Value-aware: a
620
+ * var present on both sides whose value differs is reported as `modified`
621
+ * (framed like the other content-aware entities — `sync pull` would rewrite the
622
+ * local value). Pure and side-effect-free so it's unit-testable without a live
623
+ * server.
624
+ */
625
+ export function diffVars(localVars, remoteVars) {
626
+ const rows = [];
627
+ for (const [key, localValue] of localVars) {
628
+ if (!remoteVars.has(key)) {
629
+ rows.push({ type: "var", key, status: "local only" });
630
+ }
631
+ else if (remoteVars.get(key) !== localValue) {
632
+ rows.push({
633
+ type: "var",
634
+ key,
635
+ status: "modified",
636
+ hint: "run `sync pull` to rewrite vars.toml to match the server value",
637
+ });
638
+ }
639
+ else {
640
+ rows.push({ type: "var", key, status: "exists" });
641
+ }
642
+ }
643
+ for (const key of remoteVars.keys()) {
644
+ if (!localVars.has(key)) {
645
+ rows.push({ type: "var", key, status: "remote only" });
646
+ }
647
+ }
648
+ return rows;
230
649
  }
231
650
  function serializeIntegration(integration) {
232
651
  const data = {
@@ -239,9 +658,9 @@ function serializeIntegration(integration) {
239
658
  },
240
659
  requestConfig: integration.requestConfig || {},
241
660
  };
242
- return TOML.stringify(data);
661
+ return stringifyConfigToml(data);
243
662
  }
244
- function serializeWebhook(webhook) {
663
+ export function serializeWebhook(webhook) {
245
664
  const data = {
246
665
  webhook: {
247
666
  key: webhook.webhookKey,
@@ -256,6 +675,17 @@ function serializeWebhook(webhook) {
256
675
  secretGracePeriodMs: webhook.secretGracePeriodMs,
257
676
  },
258
677
  };
678
+ // Re-emit a `{{secrets.KEY}}` reference so a pull→push round-trip is lossless
679
+ // (#1235). The server only returns `signingSecret` when it is such a
680
+ // reference (a real secret is returned masked, never in plaintext); guard on
681
+ // the template shape here too so a masked or raw value can never be written
682
+ // to TOML.
683
+ if (typeof webhook.signingSecret === "string" &&
684
+ // Whitespace-tolerant (#1468) to match the server's shared resolver, so a
685
+ // hand-authored spaced reference `{{ secrets.KEY }}` round-trips pull→push.
686
+ /\{\{\s*secrets\.[A-Z][A-Z0-9_]{0,63}\s*\}\}/.test(webhook.signingSecret)) {
687
+ data.webhook.signingSecret = webhook.signingSecret;
688
+ }
259
689
  // Parse JSON fields if they're strings
260
690
  if (webhook.allowedIpCidrs) {
261
691
  try {
@@ -284,7 +714,7 @@ function serializeWebhook(webhook) {
284
714
  if (data.webhook[k] === undefined)
285
715
  delete data.webhook[k];
286
716
  });
287
- return TOML.stringify(data);
717
+ return stringifyConfigToml(data);
288
718
  }
289
719
  function serializeCronTrigger(trigger) {
290
720
  const data = {
@@ -320,7 +750,7 @@ function serializeCronTrigger(trigger) {
320
750
  if (data.cronTrigger[k] === undefined)
321
751
  delete data.cronTrigger[k];
322
752
  });
323
- return TOML.stringify(data);
753
+ return stringifyConfigToml(data);
324
754
  }
325
755
  function serializeBlobBucket(bucket) {
326
756
  const data = {
@@ -346,7 +776,32 @@ function serializeBlobBucket(bucket) {
346
776
  if (data.bucket[k] === undefined)
347
777
  delete data.bucket[k];
348
778
  });
349
- return TOML.stringify(data);
779
+ return stringifyConfigToml(data);
780
+ }
781
+ /**
782
+ * Build the PATCH payload for a blob-bucket update from local TOML.
783
+ *
784
+ * EXACT extraction of the inline builder shared by the existing-update and
785
+ * 409-adopt branches — do NOT "clean up" the truthiness checks. The server
786
+ * treats `preset` and `accessPolicy` as mutually exclusive, only clears
787
+ * `ruleSetId` when a preset/accessPolicy is also present, and nulls
788
+ * `description` only when the key is explicitly present with a falsy value.
789
+ * `bucketKey` and `ttlTier` are immutable and never sent. Both call sites
790
+ * must go through this helper so their field sets never drift.
791
+ */
792
+ function buildBlobBucketUpdatePayload(bucket) {
793
+ const updatePayload = {};
794
+ if (bucket.preset)
795
+ updatePayload.preset = bucket.preset;
796
+ else if (bucket.accessPolicy)
797
+ updatePayload.accessPolicy = bucket.accessPolicy;
798
+ if (bucket.ruleSetId)
799
+ updatePayload.ruleSetId = bucket.ruleSetId;
800
+ if (bucket.name)
801
+ updatePayload.name = bucket.name;
802
+ if (bucket.description !== undefined)
803
+ updatePayload.description = bucket.description || null;
804
+ return updatePayload;
350
805
  }
351
806
  function serializePrompt(prompt) {
352
807
  const data = {
@@ -369,9 +824,102 @@ function serializePrompt(prompt) {
369
824
  userPromptTemplate: config.userPromptTemplate,
370
825
  })),
371
826
  };
372
- return TOML.stringify(data);
827
+ return stringifyConfigToml(data);
828
+ }
829
+ /**
830
+ * Emit a workflow schema field (`inputSchema` / `outputSchema`) for the TOML
831
+ * `[workflow]` table.
832
+ *
833
+ * Issue #1446: pull used to `JSON.stringify` both schema fields into a single
834
+ * escaped-JSON line — unreadable and undiffable. We now emit the parsed JSON
835
+ * Schema as a native `[workflow.inputSchema]` / `[workflow.outputSchema]`
836
+ * table whenever it round-trips faithfully (`canEmitNative === null`), falling
837
+ * back to the JSON string only for the values TOML silently drops (`null` /
838
+ * `undefined` anywhere in the schema). The empty-object schema `{}` is a real,
839
+ * distinct value ("accepts anything") and emits as an empty table.
840
+ *
841
+ * Returns `undefined` for an absent schema (the key is then omitted). The
842
+ * sponsor-approved decision is to always move to native aggressively — no
843
+ * form preservation — so this does not consult the existing file's form.
844
+ */
845
+ function serializeWorkflowSchemaField(value, fieldName, workflowKey, log) {
846
+ if (value === undefined || value === null)
847
+ return undefined;
848
+ // Schemas arrive from the server GET as parsed objects; defensively accept a
849
+ // JSON string too (e.g. an inline-authored file re-serialized here).
850
+ let obj = value;
851
+ if (typeof value === "string") {
852
+ try {
853
+ obj = JSON.parse(value);
854
+ }
855
+ catch {
856
+ // Not valid JSON — keep the original string as the emitted value.
857
+ return value;
858
+ }
859
+ }
860
+ if (obj && typeof obj === "object") {
861
+ const reason = canEmitNative(obj);
862
+ if (reason) {
863
+ log(`Workflow '${workflowKey}': cannot emit \`${fieldName}\` as native TOML (${reason}); using JSON string.`);
864
+ return JSON.stringify(obj);
865
+ }
866
+ return obj;
867
+ }
868
+ // A non-object schema (unexpected) — preserve it as a JSON string.
869
+ return JSON.stringify(obj);
870
+ }
871
+ /**
872
+ * Rewrite a workflow TOML file's string-form `inputSchema` / `outputSchema` to
873
+ * native object form for `sync migrate-toml` (issue #1446).
874
+ *
875
+ * Purely local, no server fetch. Parses the raw source WITHOUT expanding
876
+ * fragment `include`s (so those directives survive the rewrite), swaps a
877
+ * JSON-string schema for its parsed object when it round-trips faithfully
878
+ * (`canEmitNative === null`), and re-serializes via `stringifyConfigToml`.
879
+ *
880
+ * Only rewrites when a field is actually converted: a file whose schemas are
881
+ * already native, absent, or un-representable (a `null` default) is returned
882
+ * with `changed: false` and its original bytes untouched — so migrate is
883
+ * idempotent and never reports a spurious migration or reformats a file it
884
+ * didn't need to touch.
885
+ */
886
+ export function rewriteWorkflowSchemasToNative(rawToml, workflowKey = "", log = () => { }) {
887
+ const parsed = parseConfigToml(rawToml);
888
+ if (!parsed || typeof parsed !== "object" || !parsed.workflow) {
889
+ return { content: rawToml, changed: false };
890
+ }
891
+ const workflow = parsed.workflow;
892
+ let converted = false;
893
+ for (const field of ["inputSchema", "outputSchema"]) {
894
+ const value = workflow[field];
895
+ // Only a JSON-string schema is a migration candidate; an object is already
896
+ // native and an absent key needs nothing.
897
+ if (typeof value !== "string")
898
+ continue;
899
+ let obj;
900
+ try {
901
+ obj = JSON.parse(value);
902
+ }
903
+ catch {
904
+ // Not JSON — leave the literal string in place.
905
+ continue;
906
+ }
907
+ if (!obj || typeof obj !== "object")
908
+ continue;
909
+ const reason = canEmitNative(obj);
910
+ if (reason) {
911
+ log(`Workflow '${workflowKey}': cannot migrate \`${field}\` to native TOML (${reason}); leaving as JSON string.`);
912
+ continue;
913
+ }
914
+ workflow[field] = obj;
915
+ converted = true;
916
+ }
917
+ if (!converted)
918
+ return { content: rawToml, changed: false };
919
+ return { content: stringifyConfigToml(parsed), changed: true };
373
920
  }
374
- function serializeWorkflow(workflow, draft, configs) {
921
+ export function serializeWorkflow(workflow, draft, configs, logger) {
922
+ const log = logger ?? (() => { });
375
923
  // Find the active config or use the first one
376
924
  const activeConfigId = workflow.activeConfigId;
377
925
  const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
@@ -391,8 +939,24 @@ function serializeWorkflow(workflow, draft, configs) {
391
939
  // #1081 — workflow principal mode. Emit only when set so a fresh
392
940
  // pull → push round-trips it without writing a noisy `runAs = ""`.
393
941
  runAs: workflow.runAs || undefined,
942
+ // #1233 — opt-in sensitive capability grants. The GET response always
943
+ // returns an array (empty when unset); emit only when non-empty so a
944
+ // fresh pull → push round-trips a real value without writing a noisy
945
+ // `capabilities = []`. This is the load-bearing fix: before this, pull
946
+ // dropped capabilities entirely and a pull → push cycle silently cleared
947
+ // them on the server.
948
+ capabilities: Array.isArray(workflow.capabilities) && workflow.capabilities.length > 0
949
+ ? workflow.capabilities
950
+ : undefined,
394
951
  perUserMaxRunning: workflow.perUserMaxRunning,
395
952
  perUserMaxQueued: workflow.perUserMaxQueued,
953
+ // #1177 — app-level queue limits + queue TTL. Emit only when set so a
954
+ // fresh pull → push round-trips them without writing noisy zero/empty
955
+ // keys. The shared payload builder forwards these on create AND update,
956
+ // so the pull serializer must surface them for the round-trip to close.
957
+ perAppMaxRunning: workflow.perAppMaxRunning ?? undefined,
958
+ perAppMaxQueued: workflow.perAppMaxQueued ?? undefined,
959
+ queueTtlSeconds: workflow.queueTtlSeconds ?? undefined,
396
960
  dequeueOrder: workflow.dequeueOrder,
397
961
  // Sync-callable flags (#807): always emit both so a fresh pull → push
398
962
  // cycle round-trips them. The GET response normalizes these to booleans
@@ -400,9 +964,11 @@ function serializeWorkflow(workflow, draft, configs) {
400
964
  // they're safe to coerce here.
401
965
  requiresClientApply: workflow.requiresClientApply !== false,
402
966
  syncCallable: workflow.syncCallable === true,
403
- // Schemas are at workflow level
404
- inputSchema: workflow.inputSchema ? JSON.stringify(workflow.inputSchema) : undefined,
405
- outputSchema: workflow.outputSchema ? JSON.stringify(workflow.outputSchema) : undefined,
967
+ // Schemas are at workflow level. Emit native `[workflow.inputSchema]` /
968
+ // `[workflow.outputSchema]` tables when representable, JSON string only
969
+ // as a data-loss fallback (issue #1446).
970
+ inputSchema: serializeWorkflowSchemaField(workflow.inputSchema, "inputSchema", workflow.workflowKey, log),
971
+ outputSchema: serializeWorkflowSchemaField(workflow.outputSchema, "outputSchema", workflow.workflowKey, log),
406
972
  },
407
973
  // Use active config steps, fall back to draft
408
974
  steps,
@@ -413,8 +979,12 @@ function serializeWorkflow(workflow, draft, configs) {
413
979
  status: config.status,
414
980
  isActive: config.configId === activeConfigId,
415
981
  })) || [],
982
+ // #1304 (P-C): the declared-access manifest — top-level `[metadata]` /
983
+ // `secrets`, merged the same way the collection/database type configs do so
984
+ // a workflow's `md`/`secrets` declarations round-trip through pull → push.
985
+ ...serializeDeclaredAccessManifest(workflow.metadataManifest),
416
986
  };
417
- return TOML.stringify(data);
987
+ return stringifyConfigToml(data);
418
988
  }
419
989
  // Database config serialization helpers
420
990
  //
@@ -423,15 +993,17 @@ function serializeWorkflow(workflow, draft, configs) {
423
993
  // rows over JSON-strings stuffed into a single field. The form is sticky
424
994
  // per file — if the existing file uses the legacy JSON-string form, we
425
995
  // preserve it so we don't generate surprise diffs. New files default to
426
- // native. Un-TOMLable shapes (null, mixed-type arrays) fall back to JSON
427
- // string per field with a log message.
996
+ // native. The round-trip serializes with `smol-toml` (TOML 1.0), so
997
+ // heterogeneous arrays emit natively (issue #1450); only shapes that TOML
998
+ // genuinely can't represent without data loss (null / undefined / an array
999
+ // containing null) fall back to a JSON string per field with a log message.
428
1000
  //
429
1001
  // Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
430
1002
  // blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
431
1003
  // object so the result round-trips as `[models.<Name>.fields.<field>]`.
432
1004
  function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
433
1005
  const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
434
- return TOML.stringify(data);
1006
+ return stringifyConfigToml(data);
435
1007
  }
436
1008
  function serializeEmailTemplate(template) {
437
1009
  // The API returns { emailType, hasOverride, override: { subject, htmlBody, textBody }, default: { ... } }
@@ -449,7 +1021,7 @@ function serializeEmailTemplate(template) {
449
1021
  if (override.textBody) {
450
1022
  data.template.textBody = override.textBody;
451
1023
  }
452
- return TOML.stringify(data);
1024
+ return stringifyConfigToml(data);
453
1025
  }
454
1026
  function serializeRuleSet(ruleSet) {
455
1027
  const data = {
@@ -466,9 +1038,9 @@ function serializeRuleSet(ruleSet) {
466
1038
  if (Object.keys(rules).length > 0) {
467
1039
  data.rules = rules;
468
1040
  }
469
- return TOML.stringify(data);
1041
+ return stringifyConfigToml(data);
470
1042
  }
471
- function serializeGroupTypeConfig(config, ruleSetIdToName) {
1043
+ export function serializeGroupTypeConfig(config, ruleSetIdToName) {
472
1044
  const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
473
1045
  const data = {
474
1046
  groupTypeConfig: {
@@ -476,8 +1048,12 @@ function serializeGroupTypeConfig(config, ruleSetIdToName) {
476
1048
  ruleSetName: ruleSetName,
477
1049
  autoAddCreator: config.autoAddCreator ?? true,
478
1050
  },
1051
+ // Declared-access manifest (issue #1350, extending #1304 P-C): merge the
1052
+ // top-level `[metadata]` + `secrets` fragments so a pull→push→pull cycle
1053
+ // round-trips, exactly like the collection/database/workflow type configs.
1054
+ ...serializeDeclaredAccessManifest(config.metadataManifest),
479
1055
  };
480
- return TOML.stringify(data);
1056
+ return stringifyConfigToml(data);
481
1057
  }
482
1058
  function serializeCollectionTypeConfig(config, ruleSetIdToName) {
483
1059
  const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
@@ -486,8 +1062,11 @@ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
486
1062
  collectionType: config.collectionType,
487
1063
  ruleSetName: ruleSetName,
488
1064
  },
1065
+ // Declared-access manifest (issue #1304, P-C): merge the top-level
1066
+ // `[metadata]` + `secrets` fragments so a pull→push→pull cycle round-trips.
1067
+ ...serializeDeclaredAccessManifest(config.metadataManifest),
489
1068
  };
490
- return TOML.stringify(data);
1069
+ return stringifyConfigToml(data);
491
1070
  }
492
1071
  export function parseDatabaseTypeToml(tomlData) {
493
1072
  const typeSection = tomlData.type || {};
@@ -530,6 +1109,17 @@ export function parseDatabaseTypeToml(tomlData) {
530
1109
  if (tomlData.triggers) {
531
1110
  typeConfig.triggers = tomlData.triggers;
532
1111
  }
1112
+ // Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
1113
+ // `secrets` blocks. Validated locally before push; the server re-validates
1114
+ // (graph/schema) at persist time.
1115
+ const manifest = parseDeclaredAccessManifestToml(tomlData);
1116
+ if (manifest) {
1117
+ const manifestError = validateDeclaredAccessManifest(manifest);
1118
+ if (manifestError) {
1119
+ throw new Error(`Invalid metadata manifest in database type "${typeSection.databaseType || ""}": ${manifestError}`);
1120
+ }
1121
+ typeConfig.metadataManifest = manifest;
1122
+ }
533
1123
  // Issue #666: extract the `[models.*]` subtree (if present) and re-serialize
534
1124
  // it to a TOML string for the server's `schema` field.
535
1125
  //
@@ -542,7 +1132,7 @@ export function parseDatabaseTypeToml(tomlData) {
542
1132
  let schemaSerialized = null;
543
1133
  if (tomlData.models && typeof tomlData.models === "object") {
544
1134
  try {
545
- schemaSerialized = TOML.stringify({ models: tomlData.models });
1135
+ schemaSerialized = stringifyConfigToml({ models: tomlData.models });
546
1136
  }
547
1137
  catch {
548
1138
  // Fall through — server-side parser will fail with a clear
@@ -584,6 +1174,17 @@ export function parseGroupTypeConfigToml(tomlData) {
584
1174
  else if (section.ruleSetId && section.ruleSetId !== "") {
585
1175
  result.ruleSetId = section.ruleSetId;
586
1176
  }
1177
+ // Declared-access manifest (issue #1350, extending #1304 P-C): the top-level
1178
+ // `[metadata]` + `secrets` blocks. Validated locally before push; the server
1179
+ // re-validates (graph/schema) at persist time.
1180
+ const manifest = parseDeclaredAccessManifestToml(tomlData);
1181
+ if (manifest) {
1182
+ const manifestError = validateDeclaredAccessManifest(manifest);
1183
+ if (manifestError) {
1184
+ throw new Error(`Invalid metadata manifest in group type config "${section.groupType || ""}": ${manifestError}`);
1185
+ }
1186
+ result.metadataManifest = manifest;
1187
+ }
587
1188
  return result;
588
1189
  }
589
1190
  export function parseCollectionTypeConfigToml(tomlData) {
@@ -598,6 +1199,17 @@ export function parseCollectionTypeConfigToml(tomlData) {
598
1199
  else if (section.ruleSetId && section.ruleSetId !== "") {
599
1200
  result.ruleSetId = section.ruleSetId;
600
1201
  }
1202
+ // Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
1203
+ // `secrets` blocks. Validated locally before push; the server re-validates
1204
+ // (graph/schema) at persist time.
1205
+ const manifest = parseDeclaredAccessManifestToml(tomlData);
1206
+ if (manifest) {
1207
+ const manifestError = validateDeclaredAccessManifest(manifest);
1208
+ if (manifestError) {
1209
+ throw new Error(`Invalid metadata manifest in collection type config "${section.collectionType || ""}": ${manifestError}`);
1210
+ }
1211
+ result.metadataManifest = manifest;
1212
+ }
601
1213
  return result;
602
1214
  }
603
1215
  // Parsing helpers
@@ -609,7 +1221,7 @@ export function parseCollectionTypeConfigToml(tomlData) {
609
1221
  // the original object untouched.
610
1222
  function parseTomlFile(filePath) {
611
1223
  const content = readFileSync(filePath, "utf-8");
612
- const parsed = TOML.parse(content);
1224
+ const parsed = parseConfigToml(content);
613
1225
  return expandWorkflowTomlData(parsed, filePath);
614
1226
  }
615
1227
  /**
@@ -629,17 +1241,6 @@ export async function fetchAll(listFn, pageSize = 100, maxPages = 100) {
629
1241
  } while (cursor);
630
1242
  return all;
631
1243
  }
632
- /**
633
- * Safely parse a JSON field that may be a string or already an object.
634
- * TOML can parse inline tables as objects, so we need to handle both.
635
- */
636
- export function safeJsonParse(value) {
637
- if (value === undefined || value === null)
638
- return undefined;
639
- if (typeof value === "string")
640
- return JSON.parse(value);
641
- return value; // Already an object (TOML parsed it as a table)
642
- }
643
1244
  /**
644
1245
  * Resolve a key-based rule set name reference to an ID.
645
1246
  * Throws if the name cannot be resolved and throwOnMissing is true.
@@ -681,7 +1282,14 @@ export function resolveSlugCollisions(slug, usedSlugs) {
681
1282
  return resolved;
682
1283
  }
683
1284
  function getTestsDir(configDir, blockType, blockKey) {
684
- const typeDir = blockType === "prompt" ? "prompts" : "workflows";
1285
+ // Scripts (blockType "script") live under `transforms/` on disk (their
1286
+ // bodies are `transforms/<name>.rhai`), so their test cases sit alongside at
1287
+ // `transforms/<name>.tests/`. Prompts and workflows keep their own dirs.
1288
+ const typeDir = blockType === "prompt"
1289
+ ? "prompts"
1290
+ : blockType === "script"
1291
+ ? "transforms"
1292
+ : "workflows";
685
1293
  return join(configDir, typeDir, `${blockKey}.tests`);
686
1294
  }
687
1295
  export function serializeTestCase(testCase, lookupMaps) {
@@ -705,7 +1313,7 @@ export function serializeTestCase(testCase, lookupMaps) {
705
1313
  expectedJsonSubset: testCase.expectedJsonSubset || "{}",
706
1314
  },
707
1315
  };
708
- return TOML.stringify(data);
1316
+ return stringifyConfigToml(data);
709
1317
  }
710
1318
  export function parseTestCaseToml(tomlData) {
711
1319
  const test = tomlData.test || {};
@@ -771,17 +1379,33 @@ export function parseTestCaseToml(tomlData) {
771
1379
  /**
772
1380
  * Pull server-side `Script` rows into `transforms/*.rhai` and record
773
1381
  * each in the returned sync-state map. Issue #892 slice 7 + codex
774
- * follow-up on PR #893. Exported so the unit test can drive it
775
- * against a stubbed `client.listScripts`.
1382
+ * follow-up on PR #893; body materialization fixed in issue #1196.
1383
+ * Exported so the unit test can drive it against stubbed
1384
+ * `client.listScripts` / `client.getScript`.
776
1385
  *
777
- * Idempotency: every call writes the body the server returned, so
778
- * re-running `sync pull` on an unchanged server overwrites with the
779
- * same bytes and produces the same `contentHash`. The result map
780
- * always reflects the current server state for the writes performed.
1386
+ * Two-call contract: `listScripts` returns the `Script` HEADER only
1387
+ * (no `body` see `serializeScript` in `src/admin-api.ts`). The Rhai
1388
+ * source lives on a versioned `ScriptConfig`. So for each listed
1389
+ * script we fetch the full record with `getScript`, which returns the
1390
+ * header plus serialized `configs` (each carrying its `body`), and
1391
+ * write the ACTIVE config's body. Reading the body off the list shape
1392
+ * (the old behavior) always produced 0-byte files.
1393
+ *
1394
+ * Idempotency: every call writes the active-config body the server
1395
+ * returned, so re-running `sync pull` on an unchanged server overwrites
1396
+ * with the same bytes and produces the same `contentHash`. The result
1397
+ * map always reflects the current server state for the writes performed.
1398
+ *
1399
+ * `count` is the number of files actually written, not the number of
1400
+ * scripts listed: scripts with no active config and scripts whose
1401
+ * config fetch fails are skipped (we never clobber a `.rhai` with an
1402
+ * empty file).
781
1403
  *
782
1404
  * Older-server graceful path: if `listScripts` rejects (e.g. older
783
1405
  * server without the route), the caller catches and treats it as
784
- * "no scripts to pull", leaving the directory empty.
1406
+ * "no scripts to pull", leaving the directory empty. A per-script
1407
+ * `getScript` failure is caught and that one script is skipped without
1408
+ * aborting the rest of the pull.
785
1409
  */
786
1410
  export async function pullScripts(client, appId, configDir, logger = () => { }) {
787
1411
  const transformsDir = join(configDir, "transforms");
@@ -795,22 +1419,49 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
795
1419
  catch {
796
1420
  return { scriptEntities, count: 0 };
797
1421
  }
1422
+ let written = 0;
798
1423
  for (const script of items) {
799
1424
  const name = script?.name;
800
1425
  if (!name)
801
1426
  continue;
1427
+ // The body lives on the active `ScriptConfig`, not the header. Skip
1428
+ // scripts with no active config rather than writing an empty file.
1429
+ if (!script.activeConfigId) {
1430
+ logger(` Skipped transforms/${name}.rhai (no active config)`);
1431
+ continue;
1432
+ }
1433
+ // Fetch the full script (header + serialized configs with bodies).
1434
+ // Tolerate a per-script failure: skip this one, keep the rest.
1435
+ let full;
1436
+ try {
1437
+ full = await client.getScript(appId, script.scriptId);
1438
+ }
1439
+ catch {
1440
+ logger(` Skipped transforms/${name}.rhai (could not fetch config)`);
1441
+ continue;
1442
+ }
1443
+ const configs = Array.isArray(full?.configs) ? full.configs : [];
1444
+ const activeConfigId = full?.activeConfigId ?? script.activeConfigId;
1445
+ const activeConfig = configs.find((c) => c.configId === activeConfigId) ||
1446
+ configs.find((c) => c.status === "active");
1447
+ if (!activeConfig || typeof activeConfig.body !== "string") {
1448
+ logger(` Skipped transforms/${name}.rhai (no active config body)`);
1449
+ continue;
1450
+ }
802
1451
  const filename = `${name}.rhai`;
803
1452
  const filePath = join(transformsDir, filename);
804
- const body = typeof script.body === "string" ? script.body : "";
805
- writeFileSync(filePath, body);
1453
+ writeFileSync(filePath, activeConfig.body);
806
1454
  scriptEntities[name] = {
807
1455
  id: script.scriptId,
808
1456
  modifiedAt: script.modifiedAt || new Date().toISOString(),
1457
+ // Recompute from the written file (not the server's contentHash) so
1458
+ // it matches what `sync push`'s `shouldPushFile` gate recomputes.
809
1459
  contentHash: computeFileHash(filePath),
810
1460
  };
1461
+ written += 1;
811
1462
  logger(` Wrote transforms/${filename}`);
812
1463
  }
813
- return { scriptEntities, count: items.length };
1464
+ return { scriptEntities, count: written };
814
1465
  }
815
1466
  async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
816
1467
  let testCases;
@@ -920,8 +1571,10 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
920
1571
  const configMapKey = `${blockKey}#${testPayload._configName}`;
921
1572
  const configMap = blockType === "prompt"
922
1573
  ? resolutionMaps.promptConfigNameToId
923
- : resolutionMaps.workflowConfigNameToId;
924
- const resolvedId = configMap.get(configMapKey);
1574
+ : blockType === "script"
1575
+ ? resolutionMaps.scriptConfigNameToId
1576
+ : resolutionMaps.workflowConfigNameToId;
1577
+ const resolvedId = configMap?.get(configMapKey);
925
1578
  if (resolvedId) {
926
1579
  testPayload.configId = resolvedId;
927
1580
  }
@@ -1123,6 +1776,7 @@ Directory Structure:
1123
1776
  rule-sets/*.toml # Access rule sets
1124
1777
  group-type-configs/*.toml # Group type configs
1125
1778
  collection-type-configs/*.toml # Collection type configs
1779
+ metadata-category-configs/*.toml # Resource-metadata category configs
1126
1780
  email-templates/*.toml # Email template overrides
1127
1781
  `);
1128
1782
  // Init
@@ -1153,6 +1807,7 @@ Directory Structure:
1153
1807
  ensureDir(join(configDir, "rule-sets"));
1154
1808
  ensureDir(join(configDir, "group-type-configs"));
1155
1809
  ensureDir(join(configDir, "collection-type-configs"));
1810
+ ensureDir(join(configDir, "metadata-category-configs"));
1156
1811
  ensureDir(join(configDir, "email-templates"));
1157
1812
  const state = {
1158
1813
  appId: resolvedAppId,
@@ -1224,11 +1879,12 @@ Directory Structure:
1224
1879
  return;
1225
1880
  }
1226
1881
  // Fetch database config resources
1227
- const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult,] = await Promise.all([
1882
+ const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult, metadataCategoryConfigsResult,] = await Promise.all([
1228
1883
  client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
1229
1884
  client.listRuleSets(resolvedAppId).catch(() => []),
1230
1885
  client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
1231
1886
  client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
1887
+ client.listMetadataCategoryConfigs(resolvedAppId).catch(() => []),
1232
1888
  ]);
1233
1889
  // Fetch operations + subscriptions for each database type. Issue #803:
1234
1890
  // subscriptions are pulled symmetrically with operations so a
@@ -1287,6 +1943,7 @@ Directory Structure:
1287
1943
  ensureDir(join(configDir, "rule-sets"));
1288
1944
  ensureDir(join(configDir, "group-type-configs"));
1289
1945
  ensureDir(join(configDir, "collection-type-configs"));
1946
+ ensureDir(join(configDir, "metadata-category-configs"));
1290
1947
  ensureDir(join(configDir, "email-templates"));
1291
1948
  // Write app settings
1292
1949
  if (settings) {
@@ -1294,6 +1951,38 @@ Directory Structure:
1294
1951
  writeFileSync(appTomlPath, serializeAppSettings(settings));
1295
1952
  info(" Wrote app.toml");
1296
1953
  }
1954
+ // Write config vars (issue #1423). A single flat vars.toml at the
1955
+ // sync-dir root (like app.toml), holding the per-environment non-secret
1956
+ // scalars. Written (comment-only when empty) so the managed file exists
1957
+ // and a later push knows the vars surface is under sync control.
1958
+ //
1959
+ // Distinguish "app genuinely has zero vars" (a successful empty
1960
+ // response — safe to write the comment-only file) from "fetch failed"
1961
+ // (a transient 401/500/network error). Previously a `.catch(() => [])`
1962
+ // conflated the two, so a transient error silently clobbered a good
1963
+ // local vars.toml with a comment-only file while reporting success. On a
1964
+ // fetch error we now leave the existing vars.toml untouched and preserve
1965
+ // the prior manifest entry so state stays consistent with the file.
1966
+ const varsTomlPathPull = join(configDir, "vars.toml");
1967
+ let varsOutcome;
1968
+ try {
1969
+ varsOutcome = { ok: true, vars: await client.listAppConfigVars(resolvedAppId) };
1970
+ }
1971
+ catch (varErr) {
1972
+ varsOutcome = { ok: false, error: varErr };
1973
+ }
1974
+ const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : loadSyncState(configDir)?.entities?.vars);
1975
+ const varEntities = varsPlan.varEntities;
1976
+ if (varsPlan.write && varsPlan.content !== null) {
1977
+ writeFileSync(varsTomlPathPull, varsPlan.content);
1978
+ const count = varsOutcome.vars.length;
1979
+ info(` Wrote vars.toml (${count} var${count === 1 ? "" : "s"})`);
1980
+ }
1981
+ else {
1982
+ const err = varsOutcome.error;
1983
+ error(` Skipped vars.toml — failed to fetch config vars: ${err?.message ?? err}. ` +
1984
+ "Left the existing vars.toml untouched.");
1985
+ }
1297
1986
  // Write integrations
1298
1987
  const integrationEntities = {};
1299
1988
  for (const integration of integrations) {
@@ -1382,7 +2071,7 @@ Directory Structure:
1382
2071
  for (const { workflow, draft, configs } of workflows) {
1383
2072
  const filename = `${workflow.workflowKey}.toml`;
1384
2073
  const filePath = join(configDir, "workflows", filename);
1385
- writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
2074
+ writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || [], (msg) => info(` ${msg}`)));
1386
2075
  // Hash the expanded (post-fragment-splice) form so subsequent pushes
1387
2076
  // can detect fragment-only edits. See `computeExpandedContentHash`.
1388
2077
  // Pulled workflows never carry `include`, so the expander is a
@@ -1425,6 +2114,23 @@ Directory Structure:
1425
2114
  }
1426
2115
  }
1427
2116
  }
2117
+ // Include script config names so a pinned script test case
2118
+ // round-trips its `configName` reference (the same way prompt/workflow
2119
+ // configs above do). One extra `configs` fetch per pulled script.
2120
+ for (const [name, entity] of Object.entries(scriptEntities)) {
2121
+ try {
2122
+ const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, entity.id);
2123
+ for (const config of scriptConfigs) {
2124
+ configIdToName.set(config.configId, config.configName);
2125
+ }
2126
+ }
2127
+ catch {
2128
+ // Non-fatal: a script test case pinned to a config just serializes
2129
+ // without a resolvable name (falls back to active on push). The
2130
+ // header/body still pulled fine — `name` retained for clarity.
2131
+ void name;
2132
+ }
2133
+ }
1428
2134
  const testCaseLookupMaps = { configIdToName, promptIdToKey };
1429
2135
  // Write email templates (use detailed emailTemplates, not summaries)
1430
2136
  const emailTemplateEntities = {};
@@ -1467,6 +2173,15 @@ Directory Structure:
1467
2173
  }
1468
2174
  totalTestCases += count;
1469
2175
  }
2176
+ // Pull test cases for scripts (transforms). Script blocks use
2177
+ // blockType "script"; their tests land under transforms/<name>.tests/.
2178
+ for (const [name, entity] of Object.entries(scriptEntities)) {
2179
+ const count = await pullTestCasesForBlock(client, resolvedAppId, "script", entity.id, name, configDir, testCaseEntities, testCaseLookupMaps);
2180
+ if (count > 0) {
2181
+ info(` Wrote ${count} test case(s) for script: ${name}`);
2182
+ }
2183
+ totalTestCases += count;
2184
+ }
1470
2185
  // Build ruleSetId → name map for database types and group type configs
1471
2186
  const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
1472
2187
  const ruleSetIdToName = new Map();
@@ -1562,6 +2277,23 @@ Directory Structure:
1562
2277
  };
1563
2278
  info(` Wrote collection-type-configs/${filename}`);
1564
2279
  }
2280
+ // Write metadata category configs (issue #1304, P-B). One file per
2281
+ // `{resourceType, category}`; the entity key is the `resourceType#category`
2282
+ // pair so push can match it back.
2283
+ const metadataCategoryConfigEntities = {};
2284
+ const metadataCategoryConfigs = Array.isArray(metadataCategoryConfigsResult)
2285
+ ? metadataCategoryConfigsResult
2286
+ : [];
2287
+ for (const config of metadataCategoryConfigs) {
2288
+ const filename = `${config.resourceType}.${config.category}.toml`;
2289
+ const filePath = join(configDir, "metadata-category-configs", filename);
2290
+ writeFileSync(filePath, serializeMetadataCategoryConfig(config));
2291
+ metadataCategoryConfigEntities[`${config.resourceType}#${config.category}`] = {
2292
+ modifiedAt: config.modifiedAt || new Date().toISOString(),
2293
+ contentHash: computeFileHash(filePath),
2294
+ };
2295
+ info(` Wrote metadata-category-configs/${filename}`);
2296
+ }
1565
2297
  // Save sync state
1566
2298
  const state = {
1567
2299
  appId: resolvedAppId,
@@ -1580,15 +2312,20 @@ Directory Structure:
1580
2312
  testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
1581
2313
  databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
1582
2314
  ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
2315
+ vars: Object.keys(varEntities).length > 0 ? varEntities : undefined,
1583
2316
  groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
1584
2317
  collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
1585
2318
  ? collectionTypeConfigEntities
1586
2319
  : undefined,
2320
+ metadataCategoryConfigs: Object.keys(metadataCategoryConfigEntities).length > 0
2321
+ ? metadataCategoryConfigEntities
2322
+ : undefined,
1587
2323
  },
1588
2324
  };
1589
2325
  saveSyncState(configDir, state);
1590
2326
  divider();
1591
2327
  success(`Pulled configuration to ${configDir}`);
2328
+ keyValue("Config Vars", Object.keys(varEntities).length);
1592
2329
  keyValue("Integrations", integrations.length);
1593
2330
  keyValue("Webhooks", webhooks.length);
1594
2331
  keyValue("Cron Triggers", cronTriggerItems.length);
@@ -1602,6 +2339,7 @@ Directory Structure:
1602
2339
  keyValue("Rule Sets", ruleSets.length);
1603
2340
  keyValue("Group Type Configs", groupTypeConfigs.length);
1604
2341
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
2342
+ keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
1605
2343
  }
1606
2344
  catch (err) {
1607
2345
  error(err.message);
@@ -1853,10 +2591,91 @@ Directory Structure:
1853
2591
  }
1854
2592
  }
1855
2593
  }
2594
+ // Validate config vars up-front (issue #1423 review). Parse vars.toml
2595
+ // and check the same key/value constraints the server enforces here, in
2596
+ // the preflight pass, so an invalid entry aborts BEFORE any mutation
2597
+ // (app settings or a var upsert) is applied. Previously validation ran
2598
+ // mid-push, after app settings could already have been updated.
2599
+ const preflightVarsTomlPath = join(configDir, "vars.toml");
2600
+ let preflightParsedVars = null;
2601
+ if (existsSync(preflightVarsTomlPath)) {
2602
+ try {
2603
+ const parsed = parseTomlFile(preflightVarsTomlPath);
2604
+ for (const e of validateVarsFile(parsed)) {
2605
+ preflightValidationErrors.push(` ${e}`);
2606
+ }
2607
+ preflightParsedVars = parsed;
2608
+ }
2609
+ catch (err) {
2610
+ preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
2611
+ }
2612
+ }
1856
2613
  if (preflightValidationErrors.length > 0) {
1857
2614
  throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
1858
2615
  preflightValidationErrors.join("\n"));
1859
2616
  }
2617
+ // Fetch the current remote config vars up-front — before ANY mutation
2618
+ // (issue #1423 review pass 3). `planVarsPush` needs this snapshot both
2619
+ // to detect concurrent remote edits and to decide which already-absent
2620
+ // deletes to skip. A failed fetch must FAIL CLOSED: proceeding with an
2621
+ // empty snapshot would silently disable the concurrency guard and let a
2622
+ // changed local value clobber a remote edit. Abort unless `--force`.
2623
+ // Fetching here (not mid-push) keeps the abort fail-before-mutate.
2624
+ let remoteVarsSnapshot = [];
2625
+ // Whether the snapshot above is authoritative. A failed fetch under
2626
+ // `--force` leaves it empty but UNKNOWN (not "the server has no vars") —
2627
+ // `planVarsPush` needs this so it doesn't mistake "absent from the empty
2628
+ // snapshot" for "already deleted remotely" and silently drop deletions
2629
+ // the user asked for (issue #1423 review r-2 P2).
2630
+ let varSnapshotUnavailable = false;
2631
+ if (existsSync(preflightVarsTomlPath)) {
2632
+ try {
2633
+ remoteVarsSnapshot = await client.listAppConfigVars(resolvedAppId);
2634
+ }
2635
+ catch (err) {
2636
+ if (!options.force) {
2637
+ throw new Error(`Aborting push: could not fetch remote config vars to check for ` +
2638
+ `concurrent edits (${err?.message || String(err)}) — no changes ` +
2639
+ `were applied. Re-run with --force to push without the ` +
2640
+ `concurrency guard.`);
2641
+ }
2642
+ // --force: proceed without a snapshot; the guard is intentionally
2643
+ // bypassed and every local var is upserted as authoritative. The
2644
+ // snapshot is unknown, so deletions of removed keys are still
2645
+ // enqueued (idempotent 404 handling absorbs already-absent keys).
2646
+ remoteVarsSnapshot = [];
2647
+ varSnapshotUnavailable = true;
2648
+ }
2649
+ }
2650
+ // Plan the var writes up-front and validate the FINAL server var
2651
+ // count against the cap BEFORE any mutation (issue #1423 review pass
2652
+ // 5). `validateVarsFile` above caps the local file's entry count, but
2653
+ // remote-only vars (on the server, not in vars.toml) still count
2654
+ // toward the server's limit: 95 local entries plus 10 remote-only
2655
+ // vars passes the local check, then a later create is rejected at the
2656
+ // cap after earlier writes were already applied. Computed from the
2657
+ // remote snapshot plus planned creates minus planned deletions. The
2658
+ // check applies even with `--force`: the cap is a server hard limit,
2659
+ // not a conflict guard, so a push that would exceed it cannot succeed
2660
+ // and forcing it would only leave a partial push. (Under `--force`
2661
+ // with a failed snapshot fetch the remote count is unknown; the check
2662
+ // then degrades to the local file count, like the concurrency guard.)
2663
+ let varPlan = null;
2664
+ if (preflightParsedVars) {
2665
+ varPlan = planVarsPush(preflightParsedVars, syncState?.entities?.vars, remoteVarsSnapshot, { force: options.force, snapshotUnavailable: varSnapshotUnavailable });
2666
+ const finalVarCount = countVarsAfterPush(remoteVarsSnapshot, varPlan);
2667
+ if (finalVarCount > MAX_VARS_PER_APP) {
2668
+ const localKeys = new Set(Object.keys(preflightParsedVars));
2669
+ const plannedDeletes = new Set(varPlan.deletions);
2670
+ const remoteOnlyCount = remoteVarsSnapshot.filter((r) => !localKeys.has(r.key) && !plannedDeletes.has(r.key)).length;
2671
+ throw new Error(`Aborting push: it would leave ${finalVarCount} config vars on the server, ` +
2672
+ `exceeding the maximum of ${MAX_VARS_PER_APP} vars per app ` +
2673
+ `(server has ${remoteVarsSnapshot.length} vars, ${remoteOnlyCount} of ` +
2674
+ `which are not in vars.toml) — no changes were applied. Run ` +
2675
+ `\`sync pull\` to bring the remote-only vars into vars.toml, or ` +
2676
+ `delete unneeded vars, then push again.`);
2677
+ }
2678
+ }
1860
2679
  // Process app settings
1861
2680
  const appTomlPath = join(configDir, "app.toml");
1862
2681
  if (existsSync(appTomlPath)) {
@@ -1877,11 +2696,10 @@ Directory Structure:
1877
2696
  }
1878
2697
  }
1879
2698
  if (tomlData.auth) {
1880
- settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
1881
- settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
1882
- settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
1883
- if (tomlData.auth.passkeys) {
1884
- settings.passkeyRpConfig = tomlData.auth.passkeys;
2699
+ const { settings: authSettings, warnings: authWarnings } = parseAppAuthSettings(tomlData.auth);
2700
+ Object.assign(settings, authSettings);
2701
+ for (const w of authWarnings) {
2702
+ warn(` ${w}`);
1885
2703
  }
1886
2704
  }
1887
2705
  if (tomlData.cors) {
@@ -1905,6 +2723,135 @@ Directory Structure:
1905
2723
  }
1906
2724
  }
1907
2725
  }
2726
+ // Process config vars (issue #1423). Read the flat vars.toml, upsert
2727
+ // each key (PUT by key is idempotent — one call covers create+update),
2728
+ // and hard-delete keys removed from the file (nested-entity precedent:
2729
+ // database operations/subscriptions). Pruning runs ONLY when the file
2730
+ // is present, so an absent vars.toml never mass-deletes server vars.
2731
+ //
2732
+ // Key/value validation now runs up-front in the preflight pass above,
2733
+ // so by the time we get here the file is known-valid (values are
2734
+ // strings). The remote-vars snapshot was also fetched up-front (fail
2735
+ // closed) so `planVarsPush` can detect a concurrent remote edit and
2736
+ // report it as a conflict instead of silently overwriting it (review
2737
+ // r-2 P1), and the plan itself was computed up-front so the final
2738
+ // server var count could be validated against the cap before any
2739
+ // mutation (review pass 5).
2740
+ if (varPlan) {
2741
+ skippedCount += varPlan.skippedCount;
2742
+ // Feed var conflicts into the shared accumulator — same convention as
2743
+ // every other entity, so the end-of-run report renders them as
2744
+ // `CONFLICT var: KEY` and the push exits non-zero.
2745
+ for (const c of varPlan.conflicts) {
2746
+ conflicts.push({
2747
+ type: "var",
2748
+ key: c.key,
2749
+ serverModifiedAt: c.serverModifiedAt,
2750
+ localModifiedAt: c.localModifiedAt,
2751
+ });
2752
+ }
2753
+ // Delete removed keys BEFORE upserting new ones (issue #1423 review
2754
+ // pass 3). At the server's 100-var cap, a replacement (remove one key,
2755
+ // add another) is a valid final state, but upserting the new key first
2756
+ // would transiently exceed the cap and the create would be rejected.
2757
+ // Freeing the removed slots first lets the replacement synchronize.
2758
+ // Each removal prints an "Unsetting var X" line so the destructive
2759
+ // action is visible.
2760
+ for (const key of varPlan.deletions) {
2761
+ changes.push({ type: "var", action: "delete", key });
2762
+ if (!options.dryRun) {
2763
+ // Server-side concurrency guard (issue #1423 review r-2 P1): send
2764
+ // the `updatedAt` we last synced so the server rejects the delete
2765
+ // (409 CONFLICT) if the var was edited remotely since — closing the
2766
+ // race between the client snapshot and this DELETE that the
2767
+ // client-side `planVarsPush` guard alone can't. `--force` omits it
2768
+ // and deletes unconditionally.
2769
+ const expectedModifiedAt = options.force
2770
+ ? undefined
2771
+ : syncState?.entities?.vars?.[key]?.modifiedAt;
2772
+ try {
2773
+ await client.deleteAppConfigVar(resolvedAppId, key, expectedModifiedAt);
2774
+ info(` Unsetting var ${key}`);
2775
+ if (syncState?.entities?.vars)
2776
+ delete syncState.entities.vars[key];
2777
+ }
2778
+ catch (err) {
2779
+ // A remote edit landed since our snapshot — report a conflict
2780
+ // instead of discarding it (feeds the shared accumulator →
2781
+ // `CONFLICT var: KEY`, non-zero exit).
2782
+ if (err instanceof ConflictError) {
2783
+ conflicts.push({
2784
+ type: "var",
2785
+ key,
2786
+ serverModifiedAt: err.serverModifiedAt,
2787
+ localModifiedAt: expectedModifiedAt || "unknown",
2788
+ });
2789
+ }
2790
+ else if (err instanceof ApiError && err.statusCode === 404) {
2791
+ // A 404 means the var is already absent — the desired
2792
+ // (deleted) state is met, so treat it as an idempotent success
2793
+ // rather than failing the whole push (issue #1423 review pass
2794
+ // 3). The plan omits deletes for keys absent from a fetched
2795
+ // snapshot; this also absorbs the forced no-snapshot fallback
2796
+ // (r-2 P2), where removed keys are enqueued unconditionally.
2797
+ info(` Var ${key} already absent (nothing to unset)`);
2798
+ if (syncState?.entities?.vars)
2799
+ delete syncState.entities.vars[key];
2800
+ }
2801
+ else {
2802
+ throw wrapEntityError(err, "delete", "var", key);
2803
+ }
2804
+ }
2805
+ }
2806
+ }
2807
+ for (const item of varPlan.upserts) {
2808
+ changes.push({ type: "var", action: item.action, key: item.key });
2809
+ if (!options.dryRun) {
2810
+ // Server-side concurrency guard (issue #1423 review r-2 P1): for an
2811
+ // update, send the `updatedAt` we last synced so the server rejects
2812
+ // the write (409 CONFLICT) if the var changed remotely since — this
2813
+ // closes the snapshot→PUT race the client-side guard can't. A
2814
+ // create has no baseline, so instead it sends a create-only
2815
+ // precondition (`expectNotExists`, review r-3 P1a): the server 409s
2816
+ // if the key was created remotely since our snapshot rather than
2817
+ // silently overwriting that concurrent create. `--force` overrides
2818
+ // both.
2819
+ const isCreate = item.action === "create";
2820
+ const expectedModifiedAt = options.force || isCreate
2821
+ ? undefined
2822
+ : syncState?.entities?.vars?.[item.key]?.modifiedAt;
2823
+ try {
2824
+ const result = await client.upsertAppConfigVar(resolvedAppId, item.key, { value: item.value }, expectedModifiedAt, { expectNotExists: !options.force && isCreate });
2825
+ if (syncState) {
2826
+ if (!syncState.entities.vars)
2827
+ syncState.entities.vars = {};
2828
+ syncState.entities.vars[item.key] = {
2829
+ // Record the server's returned timestamp (updatedAt) so the
2830
+ // next push's concurrent-edit guard has an accurate baseline.
2831
+ modifiedAt: result?.updatedAt || result?.modifiedAt || new Date().toISOString(),
2832
+ contentHash: item.valueHash,
2833
+ };
2834
+ }
2835
+ }
2836
+ catch (err) {
2837
+ // A remote edit landed since our snapshot — report a conflict
2838
+ // instead of clobbering it (feeds the shared accumulator →
2839
+ // `CONFLICT var: KEY`, non-zero exit).
2840
+ if (err instanceof ConflictError) {
2841
+ conflicts.push({
2842
+ type: "var",
2843
+ key: item.key,
2844
+ serverModifiedAt: err.serverModifiedAt,
2845
+ localModifiedAt: expectedModifiedAt || "unknown",
2846
+ });
2847
+ }
2848
+ else {
2849
+ throw wrapEntityError(err, item.action, "var", item.key);
2850
+ }
2851
+ }
2852
+ }
2853
+ }
2854
+ }
1908
2855
  // Process rule sets first (other entities may reference them by name)
1909
2856
  const ruleSetsDir = join(configDir, "rule-sets");
1910
2857
  if (existsSync(ruleSetsDir)) {
@@ -2099,6 +3046,14 @@ Directory Structure:
2099
3046
  deduplicationWindowMs: webhook.deduplicationWindowMs,
2100
3047
  secretGracePeriodMs: webhook.secretGracePeriodMs,
2101
3048
  };
3049
+ // Forward the declared signing secret so a `{{secrets.KEY}}`
3050
+ // reference reaches the server intact (#1235). Without this the
3051
+ // value declared in TOML is silently dropped and the server
3052
+ // rejects a verified webhook with "signingSecret is required".
3053
+ // Mirrors how integrations forward requestConfig with refs intact.
3054
+ if (webhook.signingSecret !== undefined) {
3055
+ payload.signingSecret = webhook.signingSecret;
3056
+ }
2102
3057
  // Handle JSON fields
2103
3058
  if (tomlData.allowedIps?.cidrs) {
2104
3059
  payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
@@ -2303,30 +3258,27 @@ Directory Structure:
2303
3258
  if (existingId) {
2304
3259
  // #1020 (D8): idempotently apply preset/ruleSet/name/description
2305
3260
  // changes via PATCH. bucketKey/ttlTier are immutable and not sent.
2306
- const updatePayload = {};
2307
- if (bucket.preset)
2308
- updatePayload.preset = bucket.preset;
2309
- else if (bucket.accessPolicy)
2310
- updatePayload.accessPolicy = bucket.accessPolicy;
2311
- if (bucket.ruleSetId)
2312
- updatePayload.ruleSetId = bucket.ruleSetId;
2313
- if (bucket.name)
2314
- updatePayload.name = bucket.name;
2315
- if (bucket.description !== undefined)
2316
- updatePayload.description = bucket.description || null;
3261
+ const updatePayload = buildBlobBucketUpdatePayload(bucket);
2317
3262
  changes.push({ type: "blob-bucket", action: "update", key });
2318
3263
  if (!options.dryRun) {
2319
3264
  try {
2320
3265
  await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
2321
3266
  info(` Updated blob bucket: ${key}`);
3267
+ // #1459: stamp the content hash ONLY after a successful
3268
+ // update, and only when this is not a dry run. A failed
3269
+ // update (or a dry run) must leave the file pending rather
3270
+ // than mark it in-sync and suppress it on the next push.
3271
+ if (syncState?.entities?.blobBuckets?.[key]) {
3272
+ syncState.entities.blobBuckets[key].contentHash =
3273
+ computeFileHash(filePath);
3274
+ }
2322
3275
  }
2323
3276
  catch (err) {
3277
+ // Keep the warn-and-continue contract (sponsor decision):
3278
+ // do not abort the push, just don't stamp the hash.
2324
3279
  warn(` Failed to update blob bucket ${key}: ${String(err?.message || err)}`);
2325
3280
  }
2326
3281
  }
2327
- if (syncState?.entities?.blobBuckets?.[key]) {
2328
- syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
2329
- }
2330
3282
  }
2331
3283
  else {
2332
3284
  const payload = {
@@ -2361,23 +3313,86 @@ Directory Structure:
2361
3313
  catch (err) {
2362
3314
  const msg = String(err?.message || err);
2363
3315
  if (msg.includes("already exists") || err.statusCode === 409) {
2364
- info(` Blob bucket already exists on server: ${key}`);
2365
- // Fetch the existing bucket to get its ID
3316
+ // #1459: adopt-by-key on 409. A blob bucket can already
3317
+ // exist on the server (an out-of-band create, or a prior
3318
+ // push that aborted before recording it in sync state). The
3319
+ // old handler stamped the LOCAL file's contentHash WITHOUT
3320
+ // applying the local config, so a divergent bucket was
3321
+ // silently marked in-sync and never reconciled. Mirror the
3322
+ // cron/workflow adopt paths: look up the bucket by key,
3323
+ // re-issue the local payload as an UPDATE, and stamp the
3324
+ // hash ONLY after the update succeeds.
3325
+ info(` Blob bucket already exists on server, adopting by key: ${key}`);
3326
+ let existing;
2366
3327
  try {
2367
- const existing = await client.getBlobBucket(resolvedAppId, key);
2368
- if (syncState && existing?.bucketId) {
2369
- if (!syncState.entities.blobBuckets) {
2370
- syncState.entities.blobBuckets = {};
2371
- }
2372
- syncState.entities.blobBuckets[key] = {
2373
- id: existing.bucketId,
2374
- modifiedAt: existing.modifiedAt || new Date().toISOString(),
2375
- contentHash: computeFileHash(filePath),
2376
- };
3328
+ existing = await client.getBlobBucket(resolvedAppId, key);
3329
+ }
3330
+ catch (lookupErr) {
3331
+ // A lookup failure after a 409 means we cannot adopt —
3332
+ // surface it instead of swallowing it (the old silent
3333
+ // `catch {}` left no sync-state entry, looping
3334
+ // create→409 on every push).
3335
+ throw wrapEntityError(new Error(`blob bucket "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "blob bucket", key);
3336
+ }
3337
+ if (!existing?.bucketId) {
3338
+ // 409 but no matching bucket found — surface the original
3339
+ // create error rather than silently proceeding.
3340
+ throw wrapEntityError(err, "create", "blob bucket", key);
3341
+ }
3342
+ // Verify the resolved bucket is actually the one we asked
3343
+ // for. `getBlobBucket` resolves the identifier by primary id
3344
+ // (appId, bucketId) BEFORE bucketKey, and a bucketKey may be
3345
+ // a ULID-shaped string (the key regex permits it). If the
3346
+ // local key collides with an unrelated bucket's bucketId,
3347
+ // the lookup returns that unrelated bucket — patching and
3348
+ // stamping it would overwrite the wrong resource. Require an
3349
+ // exact key match, matching the cron/workflow adopt paths'
3350
+ // exact-key equality (they list + filter by key for the same
3351
+ // reason). Surface the mismatch via wrapEntityError; do NOT
3352
+ // patch or stamp.
3353
+ if (existing.bucketKey !== key) {
3354
+ throw wrapEntityError(new Error(`blob bucket "${key}" already exists, but the server resolved a different bucket (bucketKey "${existing.bucketKey}", bucketId "${existing.bucketId}"); refusing to adopt. This happens when the local key collides with an unrelated bucket's id — rename the local bucket key so it does not match an existing bucket id.`), "create", "blob bucket", key);
3355
+ }
3356
+ // ttlTier is immutable and cannot be reconciled by an
3357
+ // UPDATE. A local TOML whose retention tier diverges from
3358
+ // the server must fail loudly (sponsor decision) rather
3359
+ // than be silently blessed while the cosmetic fields
3360
+ // converge.
3361
+ if (bucket.ttlTier &&
3362
+ existing.ttlTier &&
3363
+ bucket.ttlTier !== existing.ttlTier) {
3364
+ throw wrapEntityError(new Error(`blob bucket "${key}" ttlTier mismatch: local TOML has "${bucket.ttlTier}" but the server bucket has "${existing.ttlTier}". ttlTier is immutable; align the TOML with the server (or recreate the bucket) before pushing.`), "update", "blob bucket", key);
3365
+ }
3366
+ const updatePayload = buildBlobBucketUpdatePayload(bucket);
3367
+ let updated;
3368
+ if (Object.keys(updatePayload).length > 0) {
3369
+ // Surface an update failure via wrapEntityError (no
3370
+ // stamp) — matches the cron adopt path.
3371
+ try {
3372
+ updated = await client.updateBlobBucket(resolvedAppId, existing.bucketId, updatePayload);
3373
+ }
3374
+ catch (updateErr) {
3375
+ throw wrapEntityError(updateErr, "update", "blob bucket", key);
2377
3376
  }
3377
+ info(` Adopted + updated blob bucket: ${key}`);
3378
+ }
3379
+ else {
3380
+ // No mutable fields in the local TOML — the server
3381
+ // rejects an empty update (400). Adopt the existing
3382
+ // bucket without a PATCH.
3383
+ info(` Adopted blob bucket (no updatable fields): ${key}`);
2378
3384
  }
2379
- catch {
2380
- // Ignore fetch errors
3385
+ if (syncState) {
3386
+ if (!syncState.entities.blobBuckets) {
3387
+ syncState.entities.blobBuckets = {};
3388
+ }
3389
+ syncState.entities.blobBuckets[key] = {
3390
+ id: existing.bucketId,
3391
+ modifiedAt: updated?.modifiedAt ||
3392
+ existing.modifiedAt ||
3393
+ new Date().toISOString(),
3394
+ contentHash: computeFileHash(filePath),
3395
+ };
2381
3396
  }
2382
3397
  }
2383
3398
  else {
@@ -2731,6 +3746,19 @@ Directory Structure:
2731
3746
  const workflow = tomlData.workflow || {};
2732
3747
  const key = workflow.key || basename(file, ".toml");
2733
3748
  const steps = tomlData.steps || [];
3749
+ // #1304 (P-C): the declared-access manifest lives at top-level
3750
+ // `[metadata]` / `secrets` (same shape the database/collection type
3751
+ // configs use). Parse + surface-validate it, then carry it on the
3752
+ // `workflow` payload object so it flows through both the create call
3753
+ // and the shared `applyWorkflowBody` update. A null manifest (no
3754
+ // `[metadata]`) clears it on the server so pull → push round-trips a
3755
+ // drop; the deep graph check runs server-side at persist.
3756
+ const workflowManifest = parseDeclaredAccessManifestToml(tomlData);
3757
+ const workflowManifestError = validateDeclaredAccessManifest(workflowManifest);
3758
+ if (workflowManifestError) {
3759
+ throw new Error(`${filePath}: ${workflowManifestError}`);
3760
+ }
3761
+ workflow.metadataManifest = workflowManifest;
2734
3762
  const existingId = syncState?.entities?.workflows?.[key]?.id;
2735
3763
  const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
2736
3764
  // Skip if file hasn't changed since last sync. Use the expanded
@@ -2751,6 +3779,45 @@ Directory Structure:
2751
3779
  }
2752
3780
  continue;
2753
3781
  }
3782
+ // Shared update body for both the in-manifest update branch and the
3783
+ // adopt-by-key recovery branch (#1174). The body is large and
3784
+ // ordering-sensitive (metadata PATCH → config/draft steps →
3785
+ // deferred `syncCallable` PATCH); both call sites MUST run it
3786
+ // identically, so it lives here as a single closure rather than
3787
+ // being duplicated. `activeConfigId` is passed in explicitly: the
3788
+ // update branch reads it from sync state, while the adopt branch
3789
+ // seeds it from a fresh `getWorkflow` (sync state has none for an
3790
+ // out-of-manifest workflow).
3791
+ const applyWorkflowUpdate = async (workflowId, opts) => {
3792
+ const { expectedModifiedAt, activeConfigId } = opts;
3793
+ // The ordering-sensitive core (metadata PATCH → config/draft
3794
+ // steps → deferred `syncCallable` PATCH, #807) lives in the
3795
+ // shared `applyWorkflowBody` helper so `workflows update
3796
+ // --from-file` runs it identically (#1249, Fork 4). The
3797
+ // sync-specific bookkeeping (the progress log, sync-state
3798
+ // `modifiedAt`/hash, and config name→ID map) stays here.
3799
+ const { latestModifiedAt, updateSlotLabel, fullWorkflow } = await applyWorkflowBody(client, resolvedAppId, workflowId, workflow, steps, {
3800
+ expectedModifiedAt,
3801
+ activeConfigId,
3802
+ force: options.force,
3803
+ });
3804
+ info(` Updated workflow: ${key} (${updateSlotLabel})`);
3805
+ // Update sync state with new modifiedAt. Store the *expanded*
3806
+ // content hash so future fragment-only edits are detected. The
3807
+ // adopt branch seeds the entity record before calling this, so
3808
+ // the entry exists for both call sites.
3809
+ if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
3810
+ syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
3811
+ syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
3812
+ }
3813
+ // `applyWorkflowBody` already fetched the full workflow; reuse it
3814
+ // for the config name→ID mappings (the PATCH response omits configs).
3815
+ if (fullWorkflow?.configs) {
3816
+ for (const config of fullWorkflow.configs) {
3817
+ workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
3818
+ }
3819
+ }
3820
+ };
2754
3821
  if (existingId) {
2755
3822
  // Update existing workflow
2756
3823
  changes.push({ type: "workflow", action: "update", key });
@@ -2759,95 +3826,10 @@ Directory Structure:
2759
3826
  ? undefined
2760
3827
  : syncState?.entities?.workflows?.[key]?.modifiedAt;
2761
3828
  try {
2762
- // Update workflow metadata and schemas.
2763
- //
2764
- // Sync-callable ordering (#807, codex review): the metadata
2765
- // PATCH must NOT carry `syncCallable: true` here. The server
2766
- // re-validates `syncCallable: true` against the workflow's
2767
- // CURRENTLY-active server steps (`loadCurrentActiveSteps`),
2768
- // not the steps being pushed in the same sync. So when a
2769
- // single TOML edit both enables `syncCallable` and replaces
2770
- // sync-incompatible active steps with compatible ones, a
2771
- // combined metadata-first PATCH would be rejected against the
2772
- // stale steps. Defer `syncCallable` to a second PATCH issued
2773
- // AFTER the config/step update lands, so it validates against
2774
- // the new (compatible) active steps. `requiresClientApply`
2775
- // has no step-dependent validation, so it stays in the first
2776
- // PATCH unchanged.
2777
- const updated = await client.updateWorkflow(resolvedAppId, existingId, {
2778
- name: workflow.name,
2779
- description: workflow.description,
2780
- status: workflow.status,
2781
- accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
2782
- // #1081 — workflow principal mode. Absent key → undefined
2783
- // (server leaves it untouched); explicit value/empty is
2784
- // forwarded so it round-trips.
2785
- runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
2786
- perUserMaxRunning: workflow.perUserMaxRunning,
2787
- perUserMaxQueued: workflow.perUserMaxQueued,
2788
- dequeueOrder: workflow.dequeueOrder,
2789
- // Sync-callable flags (#807): pass through as-is. An absent
2790
- // TOML key is `undefined` (dropped by JSON.stringify, so the
2791
- // server's hasOwnProperty guard leaves the value untouched);
2792
- // an explicit `false` is preserved as a meaningful value.
2793
- requiresClientApply: workflow.requiresClientApply,
2794
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2795
- outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
2796
- }, expectedModifiedAt);
2797
- // Track the latest workflow `modifiedAt` for the sync-state
2798
- // write below. The first PATCH bumps it; the `syncCallable`
2799
- // PATCH (if any) bumps it again. The config/step update does
2800
- // NOT touch the workflow definition's `modifiedAt`.
2801
- let latestModifiedAt = updated?.workflow?.modifiedAt;
2802
- // Update active configuration steps (or draft for legacy).
2803
- // Issue #687: name the slot we touched so the dev-loop
2804
- // user can confirm before previewing.
2805
- let updateSlotLabel = "active config";
2806
- if (existingActiveConfigId) {
2807
- await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
2808
- steps,
2809
- });
2810
- }
2811
- else {
2812
- // Fallback to draft update for legacy workflows
2813
- await client.updateWorkflowDraft(resolvedAppId, existingId, {
2814
- steps,
2815
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2816
- });
2817
- updateSlotLabel = "draft (legacy)";
2818
- }
2819
- // Second PATCH: apply `syncCallable` now that the new steps
2820
- // are active (#807). Only sent when the TOML actually carries
2821
- // the key — an absent key stays `undefined` and we skip the
2822
- // call entirely, preserving the no-clobber discipline. Chain
2823
- // `expectedModifiedAt` off the first PATCH's returned value so
2824
- // optimistic concurrency stays intact (the step update above
2825
- // doesn't change the workflow definition's `modifiedAt`).
2826
- if (workflow.syncCallable !== undefined) {
2827
- const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, existingId, { syncCallable: workflow.syncCallable },
2828
- // Mirror the first PATCH's concurrency posture: `--force`
2829
- // skips the check (undefined), otherwise reuse the fresh
2830
- // `modifiedAt` from the first PATCH.
2831
- options.force ? undefined : latestModifiedAt);
2832
- if (syncCallableUpdated?.workflow?.modifiedAt) {
2833
- latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
2834
- }
2835
- }
2836
- info(` Updated workflow: ${key} (${updateSlotLabel})`);
2837
- // Update sync state with new modifiedAt. Store the *expanded*
2838
- // content hash so future fragment-only edits are detected.
2839
- if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
2840
- syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
2841
- syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
2842
- }
2843
- // Fetch full workflow to get config name→ID mappings
2844
- // (updateWorkflow response doesn't include configs)
2845
- const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
2846
- if (fullWorkflow?.configs) {
2847
- for (const config of fullWorkflow.configs) {
2848
- workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
2849
- }
2850
- }
3829
+ await applyWorkflowUpdate(existingId, {
3830
+ expectedModifiedAt,
3831
+ activeConfigId: existingActiveConfigId,
3832
+ });
2851
3833
  }
2852
3834
  catch (err) {
2853
3835
  if (err instanceof ConflictError) {
@@ -2869,25 +3851,22 @@ Directory Structure:
2869
3851
  changes.push({ type: "workflow", action: "create", key });
2870
3852
  if (!options.dryRun) {
2871
3853
  try {
3854
+ // #1177: build the `[workflow]`-derived fields through the
3855
+ // shared builder so `sync push` create can't drift from
3856
+ // `create --from-file` / the update path (this branch used to
3857
+ // drop `status`, `capabilities`, `perAppMax*`, `queueTtlSeconds`).
3858
+ // Structural fields (`workflowKey`, `steps`, `metadataManifest`)
3859
+ // and the manifest-key `name` fallback are attached here; the
3860
+ // `name` override follows the spread so it wins over the
3861
+ // builder's bare `name`.
2872
3862
  const created = await client.createWorkflow(resolvedAppId, {
2873
3863
  workflowKey: key,
2874
- name: workflow.name || key,
2875
- description: workflow.description,
2876
3864
  steps,
2877
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
2878
- outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
2879
- accessRule: workflow.accessRule || undefined,
2880
- // #1081 — workflow principal mode (absent → server default).
2881
- runAs: workflow.runAs || undefined,
2882
- perUserMaxRunning: workflow.perUserMaxRunning,
2883
- perUserMaxQueued: workflow.perUserMaxQueued,
2884
- dequeueOrder: workflow.dequeueOrder,
2885
- // Sync-callable flags (#807): pass through as-is. An absent
2886
- // TOML key is `undefined` (dropped by JSON.stringify), so the
2887
- // server applies its defaults (requiresClientApply=true,
2888
- // syncCallable=false); an explicit value is honored.
2889
- requiresClientApply: workflow.requiresClientApply,
2890
- syncCallable: workflow.syncCallable,
3865
+ ...buildWorkflowPayloadFromToml(workflow, { mode: "create" }),
3866
+ name: workflow.name || key,
3867
+ // #1304 (P-C): declared-access manifest (absent → undefined,
3868
+ // server default null).
3869
+ metadataManifest: workflow.metadataManifest ?? undefined,
2891
3870
  });
2892
3871
  info(` Created workflow: ${key}`);
2893
3872
  // Add new entity to sync state (including activeConfigId)
@@ -2936,7 +3915,116 @@ Directory Structure:
2936
3915
  }
2937
3916
  }
2938
3917
  catch (err) {
2939
- throw wrapEntityError(err, "create", "workflow", key);
3918
+ // Issue #1174: idempotent create adopt-by-key on conflict.
3919
+ // A workflow can exist on the server but be absent from local
3920
+ // sync state — e.g. created out-of-band via the admin API
3921
+ // (#971), or orphaned by a prior push that aborted before
3922
+ // recording it. On retry this CREATE path hits the
3923
+ // `workflowKeyPerApp` unique constraint and the server
3924
+ // returns "workflowKey already exists" (HTTP 400; cron uses
3925
+ // 409). Rather than abort the whole push, look up the
3926
+ // existing workflow by key (the list endpoint is app-scoped,
3927
+ // so every item is owned by this app), verify the SAME key,
3928
+ // adopt its id into sync state, and re-issue as an UPDATE so
3929
+ // the push converges. Mirrors the cron-trigger recovery at
3930
+ // ~sync.ts:2627. Unexpected errors still surface via
3931
+ // `wrapEntityError`, matching the db-type precedent.
3932
+ const msg = String(err?.message || err);
3933
+ const isConflict = err?.statusCode === 409 || msg.includes("already exists");
3934
+ if (!isConflict) {
3935
+ throw wrapEntityError(err, "create", "workflow", key);
3936
+ }
3937
+ info(` Workflow already exists on server, adopting by key: ${key}`);
3938
+ let adoptedId;
3939
+ try {
3940
+ // Use fetchAll — listWorkflows is paginated, so a single
3941
+ // page could miss the by-key match on a large app.
3942
+ const items = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
3943
+ // Verify same key + app ownership: the list endpoint only
3944
+ // returns workflows for `resolvedAppId`, so a workflowKey
3945
+ // match is also an ownership match. Never overwrite an
3946
+ // unrelated resource — require exact key equality.
3947
+ const existing = (items || []).find((w) => w?.workflowKey === key);
3948
+ if (existing?.workflowId) {
3949
+ adoptedId = existing.workflowId;
3950
+ }
3951
+ }
3952
+ catch (lookupErr) {
3953
+ throw wrapEntityError(new Error(`workflow "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "workflow", key);
3954
+ }
3955
+ if (!adoptedId) {
3956
+ // "already exists" but no matching key found on the server —
3957
+ // surface the original error rather than silently
3958
+ // overwriting an unrelated workflow.
3959
+ throw wrapEntityError(err, "create", "workflow", key);
3960
+ }
3961
+ // Out-of-manifest adopt: warn (info-level), matching the cron
3962
+ // adopt line, and seed the sync-state entity record so the
3963
+ // shared update body can write modifiedAt/contentHash into it.
3964
+ if (syncState) {
3965
+ if (!syncState.entities.workflows) {
3966
+ syncState.entities.workflows = {};
3967
+ }
3968
+ if (!syncState.entities.workflows[key]) {
3969
+ syncState.entities.workflows[key] = {
3970
+ id: adoptedId,
3971
+ };
3972
+ }
3973
+ else {
3974
+ syncState.entities.workflows[key].id = adoptedId;
3975
+ }
3976
+ }
3977
+ // Seed the real activeConfigId from the server before the
3978
+ // shared update body runs. Sync state has none for an
3979
+ // out-of-manifest workflow, so without this the shared body
3980
+ // would wrongly fall back to `updateWorkflowDraft`.
3981
+ let adoptedActiveConfigId;
3982
+ try {
3983
+ const fullWorkflow = await client.getWorkflow(resolvedAppId, adoptedId);
3984
+ adoptedActiveConfigId =
3985
+ fullWorkflow?.workflow?.activeConfigId;
3986
+ }
3987
+ catch (getErr) {
3988
+ throw wrapEntityError(new Error(`workflow "${key}" was adopted but could not be loaded (getWorkflow failed: ${String(getErr?.message || getErr)})`), "update", "workflow", key);
3989
+ }
3990
+ // Persist the fetched activeConfigId into the seeded
3991
+ // sync-state record so it round-trips identically to the
3992
+ // CREATE path (~sync.ts:3387). Without this the *next*
3993
+ // content-changing push reads `existingActiveConfigId ===
3994
+ // undefined` (sync.ts:3187) and wrongly falls back to the
3995
+ // legacy `updateWorkflowDraft` instead of
3996
+ // `updateWorkflowConfig` — a persisted-state divergence from
3997
+ // a normally-created workflow.
3998
+ if (syncState?.entities?.workflows?.[key] &&
3999
+ adoptedActiveConfigId) {
4000
+ syncState.entities.workflows[key].activeConfigId =
4001
+ adoptedActiveConfigId;
4002
+ }
4003
+ // Switch to UPDATE on the adopted workflow. First adopt has no
4004
+ // stored modifiedAt → omit expectedModifiedAt (force the
4005
+ // PATCH), matching the cron adopt + the --force path. An
4006
+ // unexpected ConflictError here is still adoptable resilience
4007
+ // scope, so collect it; any other error surfaces.
4008
+ try {
4009
+ await applyWorkflowUpdate(adoptedId, {
4010
+ expectedModifiedAt: undefined,
4011
+ activeConfigId: adoptedActiveConfigId,
4012
+ });
4013
+ info(` Adopted + updated workflow: ${key}`);
4014
+ }
4015
+ catch (updErr) {
4016
+ if (updErr instanceof ConflictError) {
4017
+ conflicts.push({
4018
+ type: "workflow",
4019
+ key,
4020
+ serverModifiedAt: updErr.serverModifiedAt,
4021
+ localModifiedAt: "unknown",
4022
+ });
4023
+ }
4024
+ else {
4025
+ throw wrapEntityError(updErr, "update", "workflow", key);
4026
+ }
4027
+ }
2940
4028
  }
2941
4029
  }
2942
4030
  }
@@ -3044,6 +4132,14 @@ Directory Structure:
3044
4132
  if ("timestamps" in typeConfig) {
3045
4133
  u.timestamps = typeConfig.timestamps || null;
3046
4134
  }
4135
+ // Issue #1304 (P-C): declared-access manifest. `parseDatabaseTypeToml`
4136
+ // only sets `metadataManifest` when the TOML declares a `[metadata]`
4137
+ // / `secrets` block, so this forwards a set/update; clearing a
4138
+ // manifest by removing the block is a follow-on (mirrors the
4139
+ // schema-deletion tracking).
4140
+ if ("metadataManifest" in typeConfig) {
4141
+ u.metadataManifest = typeConfig.metadataManifest || null;
4142
+ }
3047
4143
  const localHasSchema = typeof typeConfig.schema === "string" &&
3048
4144
  typeConfig.schema.trim().length > 0;
3049
4145
  if (localHasSchema) {
@@ -3413,7 +4509,8 @@ Directory Structure:
3413
4509
  "defaultAccess" in typeConfig ||
3414
4510
  "autoPopulatedFields" in typeConfig ||
3415
4511
  "timestamps" in typeConfig ||
3416
- "schema" in typeConfig;
4512
+ "schema" in typeConfig ||
4513
+ "metadataManifest" in typeConfig;
3417
4514
  if (wouldUpdate) {
3418
4515
  changes.push({ type: "database-type", action: "update", key: dbType });
3419
4516
  }
@@ -3446,6 +4543,9 @@ Directory Structure:
3446
4543
  // validating against the intended shape.
3447
4544
  if (typeConfig.schema)
3448
4545
  createData.schema = typeConfig.schema;
4546
+ // Issue #1304 (P-C): declared-access manifest.
4547
+ if (typeConfig.metadataManifest)
4548
+ createData.metadataManifest = typeConfig.metadataManifest;
3449
4549
  try {
3450
4550
  const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
3451
4551
  info(` Created database type: ${dbType}`);
@@ -3902,6 +5002,10 @@ Directory Structure:
3902
5002
  const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
3903
5003
  ruleSetId: configData.ruleSetId,
3904
5004
  autoAddCreator: configData.autoAddCreator,
5005
+ // Issue #1350: null clears the manifest server-side when
5006
+ // the TOML drops the `[metadata]` block (matches the
5007
+ // collection type config update semantics).
5008
+ metadataManifest: configData.metadataManifest ?? null,
3905
5009
  }, expectedModifiedAt);
3906
5010
  info(` Updated group type config: ${groupType}`);
3907
5011
  if (syncState?.entities?.groupTypeConfigs?.[groupType] && updated?.modifiedAt) {
@@ -3933,6 +5037,9 @@ Directory Structure:
3933
5037
  groupType,
3934
5038
  ruleSetId: configData.ruleSetId || undefined,
3935
5039
  autoAddCreator: configData.autoAddCreator,
5040
+ ...(configData.metadataManifest
5041
+ ? { metadataManifest: configData.metadataManifest }
5042
+ : {}),
3936
5043
  });
3937
5044
  info(` Created group type config: ${groupType}`);
3938
5045
  if (syncState) {
@@ -3979,6 +5086,7 @@ Directory Structure:
3979
5086
  try {
3980
5087
  const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
3981
5088
  ruleSetId: configData.ruleSetId ?? null,
5089
+ metadataManifest: configData.metadataManifest ?? null,
3982
5090
  }, expectedModifiedAt);
3983
5091
  info(` Updated collection type config: ${collectionType}`);
3984
5092
  if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
@@ -4009,6 +5117,9 @@ Directory Structure:
4009
5117
  const created = await client.createCollectionTypeConfig(resolvedAppId, {
4010
5118
  collectionType,
4011
5119
  ruleSetId: configData.ruleSetId || undefined,
5120
+ ...(configData.metadataManifest
5121
+ ? { metadataManifest: configData.metadataManifest }
5122
+ : {}),
4012
5123
  });
4013
5124
  info(` Created collection type config: ${collectionType}`);
4014
5125
  if (syncState) {
@@ -4028,6 +5139,69 @@ Directory Structure:
4028
5139
  }
4029
5140
  }
4030
5141
  }
5142
+ // Process metadata category configs (issue #1304, P-B). Idempotent
5143
+ // upsert via the path-addressed PUT route — no create/update split.
5144
+ const metadataCategoryConfigsDir = join(configDir, "metadata-category-configs");
5145
+ if (existsSync(metadataCategoryConfigsDir)) {
5146
+ const files = readdirSync(metadataCategoryConfigsDir).filter((f) => f.endsWith(".toml"));
5147
+ for (const file of files) {
5148
+ const filePath = join(metadataCategoryConfigsDir, file);
5149
+ const tomlData = parseTomlFile(filePath);
5150
+ const configData = parseMetadataCategoryToml(tomlData);
5151
+ // Fall back to the filename (`resourceType.category.toml`) if the
5152
+ // section omits the identity fields.
5153
+ if (!configData.resourceType || !configData.category) {
5154
+ const base = basename(file, ".toml");
5155
+ const dot = base.indexOf(".");
5156
+ if (dot > 0) {
5157
+ configData.resourceType = configData.resourceType || base.slice(0, dot);
5158
+ configData.category = configData.category || base.slice(dot + 1);
5159
+ }
5160
+ }
5161
+ const validationError = validateMetadataCategoryConfig(configData);
5162
+ if (validationError) {
5163
+ throw new Error(`Invalid metadata category config in ${file}: ${validationError}`);
5164
+ }
5165
+ const entityKey = `${configData.resourceType}#${configData.category}`;
5166
+ const existingEntry = syncState?.entities?.metadataCategoryConfigs?.[entityKey];
5167
+ // Skip if the file hasn't changed since last sync.
5168
+ if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
5169
+ skippedCount++;
5170
+ continue;
5171
+ }
5172
+ changes.push({
5173
+ type: "metadata-category-config",
5174
+ action: existingEntry ? "update" : "create",
5175
+ key: entityKey,
5176
+ });
5177
+ if (!options.dryRun) {
5178
+ try {
5179
+ const saved = await client.upsertMetadataCategoryConfig(resolvedAppId, configData.resourceType, configData.category, {
5180
+ schema: configData.schema,
5181
+ readRule: configData.readRule,
5182
+ writeRule: configData.writeRule,
5183
+ description: configData.description,
5184
+ // #1420 Phase 2: send the declared-access manifest so a
5185
+ // category's md.self/secrets access round-trips through sync.
5186
+ metadataManifest: configData.metadataManifest,
5187
+ });
5188
+ info(` Upserted metadata category config: ${entityKey}`);
5189
+ if (syncState) {
5190
+ if (!syncState.entities.metadataCategoryConfigs) {
5191
+ syncState.entities.metadataCategoryConfigs = {};
5192
+ }
5193
+ syncState.entities.metadataCategoryConfigs[entityKey] = {
5194
+ modifiedAt: saved?.modifiedAt || new Date().toISOString(),
5195
+ contentHash: computeFileHash(filePath),
5196
+ };
5197
+ }
5198
+ }
5199
+ catch (err) {
5200
+ throw wrapEntityError(err, existingEntry ? "update" : "create", "metadata category config", entityKey);
5201
+ }
5202
+ }
5203
+ }
5204
+ }
4031
5205
  // Process email templates
4032
5206
  const emailTemplatesDir = join(configDir, "email-templates");
4033
5207
  if (existsSync(emailTemplatesDir)) {
@@ -4064,11 +5238,13 @@ Directory Structure:
4064
5238
  }
4065
5239
  }
4066
5240
  }
4067
- // Push test cases for prompts and workflows
5241
+ // Push test cases for prompts, workflows, and scripts
5242
+ const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
4068
5243
  const pushMaps = {
4069
5244
  promptKeyToId,
4070
5245
  promptConfigNameToId,
4071
5246
  workflowConfigNameToId,
5247
+ scriptConfigNameToId,
4072
5248
  };
4073
5249
  const promptsDir2 = join(configDir, "prompts");
4074
5250
  if (existsSync(promptsDir2)) {
@@ -4092,6 +5268,33 @@ Directory Structure:
4092
5268
  }
4093
5269
  }
4094
5270
  }
5271
+ // Push test cases for scripts (transforms). Each script's tests live at
5272
+ // transforms/<name>.tests/; the scriptId was recorded in
5273
+ // entities.scripts[name].id when the script body was pushed above.
5274
+ const transformsDir2 = join(configDir, "transforms");
5275
+ if (existsSync(transformsDir2)) {
5276
+ for (const file of readdirSync(transformsDir2).filter((f) => f.endsWith(".rhai"))) {
5277
+ const scriptName = basename(file, ".rhai");
5278
+ const blockId = syncState?.entities?.scripts?.[scriptName]?.id;
5279
+ if (!blockId)
5280
+ continue;
5281
+ const scriptTestsDir = join(transformsDir2, `${scriptName}.tests`);
5282
+ if (!existsSync(scriptTestsDir))
5283
+ continue;
5284
+ // Populate scriptConfigNameToId for this script so a test case that
5285
+ // pins a config by name resolves to a live configId.
5286
+ try {
5287
+ const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, blockId);
5288
+ for (const config of scriptConfigs) {
5289
+ scriptConfigNameToId.set(`${scriptName}#${config.configName}`, config.configId);
5290
+ }
5291
+ }
5292
+ catch {
5293
+ // Non-fatal — unpinned (active-version) test cases still push.
5294
+ }
5295
+ skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "script", blockId, scriptName, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
5296
+ }
5297
+ }
4095
5298
  divider();
4096
5299
  // Issue #813: a gate failure surfaced by the validate-first pass (which
4097
5300
  // also runs in dry-run mode via the server's dry-run gate endpoints).
@@ -4180,13 +5383,9 @@ Directory Structure:
4180
5383
  // Don't mask the original error
4181
5384
  }
4182
5385
  }
4183
- error(err.message);
4184
- // Print structured server-side validation details (issue #684).
4185
- if (err instanceof ApiError && Array.isArray(err.details) && err.details.length > 0) {
4186
- for (const detail of err.details) {
4187
- console.error(` - ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
4188
- }
4189
- }
5386
+ // Print message + structured server-side validation details
5387
+ // (issue #684; now via the shared #1173 helper).
5388
+ printApiError(err);
4190
5389
  process.exit(1);
4191
5390
  }
4192
5391
  });
@@ -4309,6 +5508,55 @@ Directory Structure:
4309
5508
  localEmailTemplates.add(emailType);
4310
5509
  }
4311
5510
  }
5511
+ // Transforms (Rhai scripts) — issue #1196. Before this, `sync diff`
5512
+ // ignored scripts entirely, so a body that drifted between the local
5513
+ // `.rhai` and the server's active config silently read as Synced. We
5514
+ // list the server scripts (header only) and read the local file
5515
+ // contents so the comparison below can hash both sides.
5516
+ let scriptItemsDiff = [];
5517
+ try {
5518
+ const scriptsResult = await client.listScripts(resolvedAppId);
5519
+ scriptItemsDiff = scriptsResult.items || [];
5520
+ }
5521
+ catch {
5522
+ // Older server without the scripts route — treat as no scripts.
5523
+ }
5524
+ const remoteScripts = new Map(scriptItemsDiff
5525
+ .filter((s) => s?.name)
5526
+ .map((s) => [s.name, s]));
5527
+ const localScripts = new Map();
5528
+ const transformsDirPath = join(configDir, "transforms");
5529
+ if (existsSync(transformsDirPath)) {
5530
+ for (const file of readdirSync(transformsDirPath).filter((f) => f.endsWith(".rhai"))) {
5531
+ const name = basename(file, ".rhai");
5532
+ localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
5533
+ }
5534
+ }
5535
+ // Config vars — issue #1423. Before this, `sync diff` ignored vars
5536
+ // entirely, so an add/remove/value-drift between the local `vars.toml`
5537
+ // and the server silently read as no difference right before a push
5538
+ // would change or unset them. Fetch the remote vars (value included —
5539
+ // vars are non-secret) and read the local `vars.toml` so the comparison
5540
+ // below can report added/removed/modified vars.
5541
+ let remoteVarItemsDiff = [];
5542
+ try {
5543
+ remoteVarItemsDiff = await client.listAppConfigVars(resolvedAppId);
5544
+ }
5545
+ catch {
5546
+ // Older server without the vars route — treat as no remote vars.
5547
+ }
5548
+ const remoteVars = new Map(remoteVarItemsDiff
5549
+ .filter((v) => typeof v?.key === "string")
5550
+ .map((v) => [v.key, String(v.value ?? "")]));
5551
+ const localVars = new Map();
5552
+ const varsTomlPathDiff = join(configDir, "vars.toml");
5553
+ if (existsSync(varsTomlPathDiff)) {
5554
+ const parsed = parseTomlFile(varsTomlPathDiff);
5555
+ for (const [key, rawValue] of Object.entries(parsed)) {
5556
+ if (typeof rawValue === "string")
5557
+ localVars.set(key, rawValue);
5558
+ }
5559
+ }
4312
5560
  // Compare
4313
5561
  const differences = [];
4314
5562
  // Integrations
@@ -4464,6 +5712,59 @@ Directory Structure:
4464
5712
  differences.push({ type: "email-template", key, status: "remote only" });
4465
5713
  }
4466
5714
  }
5715
+ // Transforms (Rhai scripts) — issue #1196. Content-aware, mirroring
5716
+ // the workflow block: for scripts present on both sides we fetch the
5717
+ // server's active-config body (via `getScript`, the same call pull
5718
+ // uses) and compare it to the local `.rhai` contents. A difference is
5719
+ // reported as `modified`, framed as a preview of `sync pull`. A
5720
+ // per-script fetch failure degrades to key-only (Synced, not compared)
5721
+ // rather than aborting the whole diff.
5722
+ for (const [name, localBody] of localScripts) {
5723
+ const remote = remoteScripts.get(name);
5724
+ if (!remote) {
5725
+ differences.push({ type: "transform", key: name, status: "local only" });
5726
+ continue;
5727
+ }
5728
+ let status = "exists";
5729
+ let hint;
5730
+ try {
5731
+ if (!remote.activeConfigId) {
5732
+ // No active config server-side — nothing to compare against.
5733
+ status = "exists";
5734
+ hint = "content not compared (no active config)";
5735
+ }
5736
+ else {
5737
+ const full = await client.getScript(resolvedAppId, remote.scriptId);
5738
+ const configs = Array.isArray(full?.configs) ? full.configs : [];
5739
+ const activeConfigId = full?.activeConfigId ?? remote.activeConfigId;
5740
+ const activeConfig = configs.find((c) => c.configId === activeConfigId) ||
5741
+ configs.find((c) => c.status === "active");
5742
+ if (!activeConfig || typeof activeConfig.body !== "string") {
5743
+ status = "exists";
5744
+ hint = "content not compared (no active config body)";
5745
+ }
5746
+ else if (activeConfig.body !== localBody) {
5747
+ status = "modified";
5748
+ hint = "run `sync pull` to rewrite the local .rhai to match running state";
5749
+ }
5750
+ }
5751
+ }
5752
+ catch {
5753
+ // Per-script fetch failure must not abort the whole diff.
5754
+ status = "exists";
5755
+ hint = "content not compared (fetch failed)";
5756
+ }
5757
+ differences.push({ type: "transform", key: name, status, hint });
5758
+ }
5759
+ for (const name of remoteScripts.keys()) {
5760
+ if (!localScripts.has(name)) {
5761
+ differences.push({ type: "transform", key: name, status: "remote only" });
5762
+ }
5763
+ }
5764
+ // Config vars — issue #1423 (see `diffVars`).
5765
+ for (const row of diffVars(localVars, remoteVars)) {
5766
+ differences.push(row);
5767
+ }
4467
5768
  // Compare test cases for synced prompts and workflows
4468
5769
  const testCaseDiffs = [];
4469
5770
  // Helper to compare test cases for a block
@@ -4584,11 +5885,15 @@ Directory Structure:
4584
5885
  // Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
4585
5886
  // the legacy JSON-string form for `definition`/`params` to the native
4586
5887
  // nested-table form. Idempotent: ops already in native form are left as
4587
- // is; ops that are un-TOMLable (mixed-type arrays, null values) fall
4588
- // back to JSON-string per field with a log message.
5888
+ // is; the round-trip serializes with smol-toml (TOML 1.0, issue #1450), so
5889
+ // heterogeneous arrays now converge to native form too only shapes that
5890
+ // TOML can't represent without data loss (null / undefined / an array
5891
+ // containing null) fall back to JSON-string per field with a log message.
4589
5892
  sync
4590
5893
  .command("migrate-toml")
4591
- .description("Rewrite database-type TOML files to native [operations.definition] / [[operations.params]] form")
5894
+ .description("Rewrite database-type and workflow TOML files to native form " +
5895
+ "([operations.definition] / [[operations.params]] for database types; " +
5896
+ "[workflow.inputSchema] / [workflow.outputSchema] for workflows)")
4592
5897
  .argument("[app-id]", "App ID (uses current app if not specified)")
4593
5898
  .option("--app <app-id>", "App ID")
4594
5899
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
@@ -4607,83 +5912,122 @@ Directory Structure:
4607
5912
  process.exit(1);
4608
5913
  }
4609
5914
  const dbTypesDir = join(configDir, "database-types");
4610
- if (!existsSync(dbTypesDir)) {
4611
- info("No database-types/ directory found; nothing to migrate.");
5915
+ const workflowsDir = join(configDir, "workflows");
5916
+ const dbFiles = existsSync(dbTypesDir)
5917
+ ? readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"))
5918
+ : [];
5919
+ const workflowFiles = existsSync(workflowsDir)
5920
+ ? readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))
5921
+ : [];
5922
+ // Restructured early-return (issue #1446): don't bail just because
5923
+ // `database-types/` is absent — a workflow-only project still has
5924
+ // `inputSchema`/`outputSchema` to migrate.
5925
+ if (dbFiles.length === 0 && workflowFiles.length === 0) {
5926
+ info("No database-type or workflow TOML files found; nothing to migrate.");
4612
5927
  return;
4613
5928
  }
4614
- const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
4615
- if (files.length === 0) {
4616
- info("No database-type TOML files found; nothing to migrate.");
4617
- return;
4618
- }
4619
- // Hydrate ruleSetIdToName from local sync state so files that
4620
- // reference a rule set via the legacy `ruleSetId = "01..."` form
4621
- // round-trip with the correct `ruleSetName`. Without this, any user
4622
- // who runs migrate-toml against a file with a rule-set assignment
4623
- // would silently lose that reference (review feedback r3246633010).
4624
- //
4625
- // Files that use the modern key-based `ruleSetName = "..."` form
4626
- // don't need the map at all — `parseDatabaseTypeToml` stores the
4627
- // value in `typeConfig._ruleSetName` and the serializer now prefers
4628
- // that. The map is only load-bearing for the legacy ID-based form.
4629
- const ruleSetIdToName = new Map();
4630
- const migrateSyncState = loadSyncState(configDir);
4631
- if (migrateSyncState?.entities?.ruleSets) {
4632
- for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
4633
- if (entry && typeof entry === "object" && "id" in entry && entry.id) {
4634
- // fileKey is the sanitized rule-set name (see sync pull at
4635
- // sync.ts:1385). It's the same shape the server returned and
4636
- // matches what a TOML file's ruleSetName field would carry.
4637
- ruleSetIdToName.set(entry.id, fileKey);
4638
- }
4639
- }
4640
- }
4641
5929
  let migratedCount = 0;
4642
5930
  let unchangedCount = 0;
4643
- for (const file of files) {
4644
- const filePath = join(dbTypesDir, file);
4645
- const rawBefore = readFileSync(filePath, "utf-8");
4646
- const tomlData = parseTomlFile(filePath);
4647
- // `accessRule`/`access` conflicts surface as a clear error here too,
4648
- // rather than silently dropping the field during a migrate rewrite.
4649
- let typeConfig;
4650
- let operations;
4651
- let subscriptions;
4652
- try {
4653
- ({ typeConfig, operations, subscriptions } =
4654
- parseDatabaseTypeToml(tomlData));
5931
+ // ---- database-types/ pass ----
5932
+ if (dbFiles.length > 0) {
5933
+ // Hydrate ruleSetIdToName from local sync state so files that
5934
+ // reference a rule set via the legacy `ruleSetId = "01..."` form
5935
+ // round-trip with the correct `ruleSetName`. Without this, any user
5936
+ // who runs migrate-toml against a file with a rule-set assignment
5937
+ // would silently lose that reference (review feedback r3246633010).
5938
+ //
5939
+ // Files that use the modern key-based `ruleSetName = "..."` form
5940
+ // don't need the map at all — `parseDatabaseTypeToml` stores the
5941
+ // value in `typeConfig._ruleSetName` and the serializer now prefers
5942
+ // that. The map is only load-bearing for the legacy ID-based form.
5943
+ const ruleSetIdToName = new Map();
5944
+ const migrateSyncState = loadSyncState(configDir);
5945
+ if (migrateSyncState?.entities?.ruleSets) {
5946
+ for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
5947
+ if (entry && typeof entry === "object" && "id" in entry && entry.id) {
5948
+ // fileKey is the sanitized rule-set name (see sync pull at
5949
+ // sync.ts:1385). It's the same shape the server returned and
5950
+ // matches what a TOML file's ruleSetName field would carry.
5951
+ ruleSetIdToName.set(entry.id, fileKey);
5952
+ }
5953
+ }
4655
5954
  }
4656
- catch (err) {
4657
- if (err instanceof SubscriptionAccessKeyConflictError) {
4658
- error(` ${file}: ${err.message}`);
4659
- error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
4660
- process.exit(1);
5955
+ for (const file of dbFiles) {
5956
+ const filePath = join(dbTypesDir, file);
5957
+ const rawBefore = readFileSync(filePath, "utf-8");
5958
+ const tomlData = parseTomlFile(filePath);
5959
+ // `accessRule`/`access` conflicts surface as a clear error here too,
5960
+ // rather than silently dropping the field during a migrate rewrite.
5961
+ let typeConfig;
5962
+ let operations;
5963
+ let subscriptions;
5964
+ try {
5965
+ ({ typeConfig, operations, subscriptions } =
5966
+ parseDatabaseTypeToml(tomlData));
5967
+ }
5968
+ catch (err) {
5969
+ if (err instanceof SubscriptionAccessKeyConflictError) {
5970
+ error(` ${file}: ${err.message}`);
5971
+ error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
5972
+ process.exit(1);
5973
+ }
5974
+ throw err;
5975
+ }
5976
+ // Force-native: ignore existing hints so every TOMLable field
5977
+ // ends up in nested-table form. Subscriptions are preserved on the
5978
+ // rewrite (issue #803) so migrate-toml never drops them.
5979
+ const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
5980
+ defaultForm: "native",
5981
+ logger: (msg) => info(` ${msg}`),
5982
+ subscriptions,
5983
+ });
5984
+ if (rewritten === rawBefore) {
5985
+ unchangedCount++;
5986
+ continue;
4661
5987
  }
4662
- throw err;
5988
+ if (options.dryRun) {
5989
+ info(`Would migrate database-types/${file}`);
5990
+ }
5991
+ else {
5992
+ writeFileSync(filePath, rewritten);
5993
+ info(`Migrated database-types/${file}`);
5994
+ // Update content hash in sync state if we have an entry for it.
5995
+ const syncState = loadSyncState(configDir);
5996
+ const dbType = typeConfig.databaseType || basename(file, ".toml");
5997
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
5998
+ syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
5999
+ saveSyncState(configDir, syncState);
6000
+ }
6001
+ }
6002
+ migratedCount++;
4663
6003
  }
4664
- // Force-native: ignore existing hints so every TOMLable field
4665
- // ends up in nested-table form. Subscriptions are preserved on the
4666
- // rewrite (issue #803) so migrate-toml never drops them.
4667
- const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
4668
- defaultForm: "native",
4669
- logger: (msg) => info(` ${msg}`),
4670
- subscriptions,
4671
- });
4672
- if (rewritten === rawBefore) {
6004
+ }
6005
+ // ---- workflows/ pass (issue #1446) ----
6006
+ // Convert string-form `inputSchema`/`outputSchema` to native tables.
6007
+ // Purely local, mirroring the database-types pass. The stored workflow
6008
+ // hash uses `computeExpandedContentHash` (parse then hash), NOT
6009
+ // `computeFileHash`, matching the workflow convention on the pull/push
6010
+ // paths — otherwise a push after migrate would spuriously re-push.
6011
+ for (const file of workflowFiles) {
6012
+ const filePath = join(workflowsDir, file);
6013
+ const rawBefore = readFileSync(filePath, "utf-8");
6014
+ const workflowKey = basename(file, ".toml");
6015
+ const { content: rewritten, changed } = rewriteWorkflowSchemasToNative(rawBefore, workflowKey, (msg) => info(` ${msg}`));
6016
+ if (!changed) {
4673
6017
  unchangedCount++;
4674
6018
  continue;
4675
6019
  }
4676
6020
  if (options.dryRun) {
4677
- info(`Would migrate database-types/${file}`);
6021
+ info(`Would migrate workflows/${file}`);
4678
6022
  }
4679
6023
  else {
4680
6024
  writeFileSync(filePath, rewritten);
4681
- info(`Migrated database-types/${file}`);
4682
- // Update content hash in sync state if we have an entry for it.
6025
+ info(`Migrated workflows/${file}`);
6026
+ // Update the stored content hash so a subsequent push sees a no-op.
4683
6027
  const syncState = loadSyncState(configDir);
4684
- const dbType = typeConfig.databaseType || basename(file, ".toml");
4685
- if (syncState?.entities?.databaseTypes?.[dbType]) {
4686
- syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
6028
+ if (syncState?.entities?.workflows?.[workflowKey]) {
6029
+ syncState.entities.workflows[workflowKey].contentHash =
6030
+ computeExpandedContentHash(parseTomlFile(filePath));
4687
6031
  saveSyncState(configDir, syncState);
4688
6032
  }
4689
6033
  }
@@ -4699,6 +6043,63 @@ Directory Structure:
4699
6043
  success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
4700
6044
  }
4701
6045
  });
6046
+ // migrate-v2 — convert a legacy sync directory into the unified block
6047
+ // layout `blocks/<type>/<key>/block.toml` (issue #1183, Phase 3). Purely
6048
+ // local: it reads already-pulled legacy files and writes the v2 tree. The
6049
+ // legacy files are left in place (legacy stays the default until v2 is
6050
+ // proven), so re-running push/pull is unaffected.
6051
+ sync
6052
+ .command("migrate-v2")
6053
+ .description("Convert legacy per-type sync files (prompts/, integrations/, workflows/, transforms/) into the v2 blocks/<type>/<key>/block.toml layout")
6054
+ .argument("[app-id]", "App ID (uses current app if not specified)")
6055
+ .option("--app <app-id>", "App ID")
6056
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
6057
+ .option("--dry-run", "Show what would change without writing files")
6058
+ .option("--force", "Overwrite existing block.toml files instead of skipping them")
6059
+ .action(async (appId, options) => {
6060
+ // Resolve appId so we land in the per-env sync dir even when the user
6061
+ // didn't pass `--dir`. The app ID itself isn't used for any API calls —
6062
+ // migrate-v2 is a purely local rewrite (like migrate-toml).
6063
+ const resolvedAppId = resolveAppId(appId, options);
6064
+ const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
6065
+ if (isAutoResolvedSyncDir(options.dir)) {
6066
+ info(`Using per-environment sync directory: ${configDir}`);
6067
+ }
6068
+ if (!existsSync(configDir)) {
6069
+ error(`Config directory not found: ${configDir}`);
6070
+ process.exit(1);
6071
+ }
6072
+ const before = detectLayout(configDir);
6073
+ if (!before.legacy) {
6074
+ info("No legacy block files found (prompts/, integrations/, workflows/, transforms/); nothing to migrate.");
6075
+ return;
6076
+ }
6077
+ const result = migrateLegacyToV2(configDir, {
6078
+ dryRun: options.dryRun,
6079
+ force: options.force,
6080
+ });
6081
+ for (const b of result.blocks) {
6082
+ const suffix = b.testsCopied > 0 ? ` (+${b.testsCopied} test case(s))` : "";
6083
+ if (options.dryRun) {
6084
+ info(`Would write blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
6085
+ }
6086
+ else {
6087
+ info(`Wrote blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
6088
+ }
6089
+ }
6090
+ for (const s of result.skipped) {
6091
+ warn(`Skipped ${s.type}/${s.key}: ${s.reason}`);
6092
+ }
6093
+ divider();
6094
+ keyValue("Migrated", result.blocks.length);
6095
+ keyValue("Skipped", result.skipped.length);
6096
+ if (options.dryRun) {
6097
+ info("Dry-run only — no files were modified.");
6098
+ }
6099
+ else if (result.blocks.length > 0) {
6100
+ success(`Converted ${result.blocks.length} block(s) to the v2 layout. Legacy files were left in place.`);
6101
+ }
6102
+ });
4702
6103
  // Revert — restore a pre-pull snapshot (issue #578, Phase 1).
4703
6104
  sync
4704
6105
  .command("revert")