primitive-admin 1.0.53 → 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 (133) 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/databases.js +191 -74
  13. package/dist/src/commands/databases.js.map +1 -1
  14. package/dist/src/commands/group-type-configs.js +1 -9
  15. package/dist/src/commands/group-type-configs.js.map +1 -1
  16. package/dist/src/commands/groups.js +12 -6
  17. package/dist/src/commands/groups.js.map +1 -1
  18. package/dist/src/commands/guides.d.ts +161 -0
  19. package/dist/src/commands/guides.js +502 -84
  20. package/dist/src/commands/guides.js.map +1 -1
  21. package/dist/src/commands/integrations.js +2 -2
  22. package/dist/src/commands/integrations.js.map +1 -1
  23. package/dist/src/commands/metadata.d.ts +2 -0
  24. package/dist/src/commands/metadata.js +344 -0
  25. package/dist/src/commands/metadata.js.map +1 -0
  26. package/dist/src/commands/prompts.js +2 -2
  27. package/dist/src/commands/prompts.js.map +1 -1
  28. package/dist/src/commands/rule-sets.d.ts +1 -0
  29. package/dist/src/commands/rule-sets.js +25 -3
  30. package/dist/src/commands/rule-sets.js.map +1 -1
  31. package/dist/src/commands/scripts.d.ts +2 -0
  32. package/dist/src/commands/scripts.js +667 -0
  33. package/dist/src/commands/scripts.js.map +1 -0
  34. package/dist/src/commands/sync.d.ts +189 -9
  35. package/dist/src/commands/sync.js +1418 -256
  36. package/dist/src/commands/sync.js.map +1 -1
  37. package/dist/src/commands/vars.d.ts +8 -0
  38. package/dist/src/commands/vars.js +110 -0
  39. package/dist/src/commands/vars.js.map +1 -0
  40. package/dist/src/commands/webhooks.js +17 -3
  41. package/dist/src/commands/webhooks.js.map +1 -1
  42. package/dist/src/commands/workflows.d.ts +30 -0
  43. package/dist/src/commands/workflows.js +645 -59
  44. package/dist/src/commands/workflows.js.map +1 -1
  45. package/dist/src/lib/api-client.d.ts +120 -18
  46. package/dist/src/lib/api-client.js +125 -2
  47. package/dist/src/lib/api-client.js.map +1 -1
  48. package/dist/src/lib/batch.d.ts +26 -0
  49. package/dist/src/lib/batch.js +32 -0
  50. package/dist/src/lib/batch.js.map +1 -0
  51. package/dist/src/lib/block-layout.d.ts +160 -0
  52. package/dist/src/lib/block-layout.js +451 -0
  53. package/dist/src/lib/block-layout.js.map +1 -0
  54. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +61 -0
  55. package/dist/src/lib/codegen-shared/generatedFiles.js +127 -0
  56. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
  57. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +68 -0
  58. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +168 -0
  59. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
  60. package/dist/src/lib/config-toml.d.ts +10 -0
  61. package/dist/src/lib/config-toml.js +42 -0
  62. package/dist/src/lib/config-toml.js.map +1 -0
  63. package/dist/src/lib/config.d.ts +16 -0
  64. package/dist/src/lib/config.js +41 -0
  65. package/dist/src/lib/config.js.map +1 -1
  66. package/dist/src/lib/constants.d.ts +9 -0
  67. package/dist/src/lib/constants.js +9 -0
  68. package/dist/src/lib/constants.js.map +1 -1
  69. package/dist/src/lib/csv.d.ts +1 -2
  70. package/dist/src/lib/csv.js +3 -8
  71. package/dist/src/lib/csv.js.map +1 -1
  72. package/dist/src/lib/db-codegen/dbGenerator.d.ts +35 -7
  73. package/dist/src/lib/db-codegen/dbGenerator.js +384 -125
  74. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  75. package/dist/src/lib/db-codegen/dbNaming.d.ts +29 -0
  76. package/dist/src/lib/db-codegen/dbNaming.js +58 -0
  77. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  78. package/dist/src/lib/db-codegen/dbTemplates.d.ts +114 -5
  79. package/dist/src/lib/db-codegen/dbTemplates.js +163 -16
  80. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  81. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +7 -3
  82. package/dist/src/lib/db-codegen/dbTsTypes.js +23 -1
  83. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  84. package/dist/src/lib/generated-allowlist.js +43 -0
  85. package/dist/src/lib/generated-allowlist.js.map +1 -1
  86. package/dist/src/lib/init-config.js +2 -2
  87. package/dist/src/lib/init-config.js.map +1 -1
  88. package/dist/src/lib/output.d.ts +25 -5
  89. package/dist/src/lib/output.js +32 -4
  90. package/dist/src/lib/output.js.map +1 -1
  91. package/dist/src/lib/query-operators.d.ts +43 -0
  92. package/dist/src/lib/query-operators.js +80 -0
  93. package/dist/src/lib/query-operators.js.map +1 -0
  94. package/dist/src/lib/toml-database-config.d.ts +10 -19
  95. package/dist/src/lib/toml-database-config.js +28 -59
  96. package/dist/src/lib/toml-database-config.js.map +1 -1
  97. package/dist/src/lib/toml-metadata-config.d.ts +147 -0
  98. package/dist/src/lib/toml-metadata-config.js +463 -0
  99. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  100. package/dist/src/lib/toml-native-form.d.ts +46 -0
  101. package/dist/src/lib/toml-native-form.js +78 -0
  102. package/dist/src/lib/toml-native-form.js.map +1 -0
  103. package/dist/src/lib/toml-params-validator.d.ts +34 -0
  104. package/dist/src/lib/toml-params-validator.js +118 -3
  105. package/dist/src/lib/toml-params-validator.js.map +1 -1
  106. package/dist/src/lib/workflow-apply.d.ts +86 -0
  107. package/dist/src/lib/workflow-apply.js +127 -0
  108. package/dist/src/lib/workflow-apply.js.map +1 -0
  109. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
  110. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
  111. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
  112. package/dist/src/lib/workflow-codegen/generator.d.ts +74 -0
  113. package/dist/src/lib/workflow-codegen/generator.js +215 -0
  114. package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
  115. package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
  116. package/dist/src/lib/workflow-codegen/naming.js +81 -0
  117. package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
  118. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +64 -0
  119. package/dist/src/lib/workflow-codegen/schemaToTs.js +282 -0
  120. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
  121. package/dist/src/lib/workflow-fragments.js +3 -3
  122. package/dist/src/lib/workflow-fragments.js.map +1 -1
  123. package/dist/src/lib/workflow-payload.d.ts +68 -0
  124. package/dist/src/lib/workflow-payload.js +150 -0
  125. package/dist/src/lib/workflow-payload.js.map +1 -0
  126. package/dist/src/lib/workflow-toml-validator.d.ts +1 -1
  127. package/dist/src/lib/workflow-toml-validator.js +37 -1
  128. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  129. package/dist/src/types/index.d.ts +11 -0
  130. package/dist/src/validators.d.ts +4 -3
  131. package/dist/src/validators.js +6 -5
  132. package/dist/src/validators.js.map +1 -1
  133. package/package.json +5 -3
@@ -1,16 +1,21 @@
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";
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";
14
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";
@@ -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);
373
870
  }
374
- function serializeWorkflow(workflow, draft, configs) {
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 };
920
+ }
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 || {};
@@ -963,8 +1571,10 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
963
1571
  const configMapKey = `${blockKey}#${testPayload._configName}`;
964
1572
  const configMap = blockType === "prompt"
965
1573
  ? resolutionMaps.promptConfigNameToId
966
- : resolutionMaps.workflowConfigNameToId;
967
- const resolvedId = configMap.get(configMapKey);
1574
+ : blockType === "script"
1575
+ ? resolutionMaps.scriptConfigNameToId
1576
+ : resolutionMaps.workflowConfigNameToId;
1577
+ const resolvedId = configMap?.get(configMapKey);
968
1578
  if (resolvedId) {
969
1579
  testPayload.configId = resolvedId;
970
1580
  }
@@ -1166,6 +1776,7 @@ Directory Structure:
1166
1776
  rule-sets/*.toml # Access rule sets
1167
1777
  group-type-configs/*.toml # Group type configs
1168
1778
  collection-type-configs/*.toml # Collection type configs
1779
+ metadata-category-configs/*.toml # Resource-metadata category configs
1169
1780
  email-templates/*.toml # Email template overrides
1170
1781
  `);
1171
1782
  // Init
@@ -1196,6 +1807,7 @@ Directory Structure:
1196
1807
  ensureDir(join(configDir, "rule-sets"));
1197
1808
  ensureDir(join(configDir, "group-type-configs"));
1198
1809
  ensureDir(join(configDir, "collection-type-configs"));
1810
+ ensureDir(join(configDir, "metadata-category-configs"));
1199
1811
  ensureDir(join(configDir, "email-templates"));
1200
1812
  const state = {
1201
1813
  appId: resolvedAppId,
@@ -1267,11 +1879,12 @@ Directory Structure:
1267
1879
  return;
1268
1880
  }
1269
1881
  // Fetch database config resources
1270
- const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult,] = await Promise.all([
1882
+ const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult, metadataCategoryConfigsResult,] = await Promise.all([
1271
1883
  client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
1272
1884
  client.listRuleSets(resolvedAppId).catch(() => []),
1273
1885
  client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
1274
1886
  client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
1887
+ client.listMetadataCategoryConfigs(resolvedAppId).catch(() => []),
1275
1888
  ]);
1276
1889
  // Fetch operations + subscriptions for each database type. Issue #803:
1277
1890
  // subscriptions are pulled symmetrically with operations so a
@@ -1330,6 +1943,7 @@ Directory Structure:
1330
1943
  ensureDir(join(configDir, "rule-sets"));
1331
1944
  ensureDir(join(configDir, "group-type-configs"));
1332
1945
  ensureDir(join(configDir, "collection-type-configs"));
1946
+ ensureDir(join(configDir, "metadata-category-configs"));
1333
1947
  ensureDir(join(configDir, "email-templates"));
1334
1948
  // Write app settings
1335
1949
  if (settings) {
@@ -1337,6 +1951,38 @@ Directory Structure:
1337
1951
  writeFileSync(appTomlPath, serializeAppSettings(settings));
1338
1952
  info(" Wrote app.toml");
1339
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
+ }
1340
1986
  // Write integrations
1341
1987
  const integrationEntities = {};
1342
1988
  for (const integration of integrations) {
@@ -1425,7 +2071,7 @@ Directory Structure:
1425
2071
  for (const { workflow, draft, configs } of workflows) {
1426
2072
  const filename = `${workflow.workflowKey}.toml`;
1427
2073
  const filePath = join(configDir, "workflows", filename);
1428
- writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
2074
+ writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || [], (msg) => info(` ${msg}`)));
1429
2075
  // Hash the expanded (post-fragment-splice) form so subsequent pushes
1430
2076
  // can detect fragment-only edits. See `computeExpandedContentHash`.
1431
2077
  // Pulled workflows never carry `include`, so the expander is a
@@ -1468,6 +2114,23 @@ Directory Structure:
1468
2114
  }
1469
2115
  }
1470
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
+ }
1471
2134
  const testCaseLookupMaps = { configIdToName, promptIdToKey };
1472
2135
  // Write email templates (use detailed emailTemplates, not summaries)
1473
2136
  const emailTemplateEntities = {};
@@ -1510,6 +2173,15 @@ Directory Structure:
1510
2173
  }
1511
2174
  totalTestCases += count;
1512
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
+ }
1513
2185
  // Build ruleSetId → name map for database types and group type configs
1514
2186
  const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
1515
2187
  const ruleSetIdToName = new Map();
@@ -1605,6 +2277,23 @@ Directory Structure:
1605
2277
  };
1606
2278
  info(` Wrote collection-type-configs/${filename}`);
1607
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
+ }
1608
2297
  // Save sync state
1609
2298
  const state = {
1610
2299
  appId: resolvedAppId,
@@ -1623,15 +2312,20 @@ Directory Structure:
1623
2312
  testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
1624
2313
  databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
1625
2314
  ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
2315
+ vars: Object.keys(varEntities).length > 0 ? varEntities : undefined,
1626
2316
  groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
1627
2317
  collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
1628
2318
  ? collectionTypeConfigEntities
1629
2319
  : undefined,
2320
+ metadataCategoryConfigs: Object.keys(metadataCategoryConfigEntities).length > 0
2321
+ ? metadataCategoryConfigEntities
2322
+ : undefined,
1630
2323
  },
1631
2324
  };
1632
2325
  saveSyncState(configDir, state);
1633
2326
  divider();
1634
2327
  success(`Pulled configuration to ${configDir}`);
2328
+ keyValue("Config Vars", Object.keys(varEntities).length);
1635
2329
  keyValue("Integrations", integrations.length);
1636
2330
  keyValue("Webhooks", webhooks.length);
1637
2331
  keyValue("Cron Triggers", cronTriggerItems.length);
@@ -1645,6 +2339,7 @@ Directory Structure:
1645
2339
  keyValue("Rule Sets", ruleSets.length);
1646
2340
  keyValue("Group Type Configs", groupTypeConfigs.length);
1647
2341
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
2342
+ keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
1648
2343
  }
1649
2344
  catch (err) {
1650
2345
  error(err.message);
@@ -1896,10 +2591,91 @@ Directory Structure:
1896
2591
  }
1897
2592
  }
1898
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
+ }
1899
2613
  if (preflightValidationErrors.length > 0) {
1900
2614
  throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
1901
2615
  preflightValidationErrors.join("\n"));
1902
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
+ }
1903
2679
  // Process app settings
1904
2680
  const appTomlPath = join(configDir, "app.toml");
1905
2681
  if (existsSync(appTomlPath)) {
@@ -1920,11 +2696,10 @@ Directory Structure:
1920
2696
  }
1921
2697
  }
1922
2698
  if (tomlData.auth) {
1923
- settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
1924
- settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
1925
- settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
1926
- if (tomlData.auth.passkeys) {
1927
- 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}`);
1928
2703
  }
1929
2704
  }
1930
2705
  if (tomlData.cors) {
@@ -1948,6 +2723,135 @@ Directory Structure:
1948
2723
  }
1949
2724
  }
1950
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
+ }
1951
2855
  // Process rule sets first (other entities may reference them by name)
1952
2856
  const ruleSetsDir = join(configDir, "rule-sets");
1953
2857
  if (existsSync(ruleSetsDir)) {
@@ -2142,6 +3046,14 @@ Directory Structure:
2142
3046
  deduplicationWindowMs: webhook.deduplicationWindowMs,
2143
3047
  secretGracePeriodMs: webhook.secretGracePeriodMs,
2144
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
+ }
2145
3057
  // Handle JSON fields
2146
3058
  if (tomlData.allowedIps?.cidrs) {
2147
3059
  payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
@@ -2346,30 +3258,27 @@ Directory Structure:
2346
3258
  if (existingId) {
2347
3259
  // #1020 (D8): idempotently apply preset/ruleSet/name/description
2348
3260
  // changes via PATCH. bucketKey/ttlTier are immutable and not sent.
2349
- const updatePayload = {};
2350
- if (bucket.preset)
2351
- updatePayload.preset = bucket.preset;
2352
- else if (bucket.accessPolicy)
2353
- updatePayload.accessPolicy = bucket.accessPolicy;
2354
- if (bucket.ruleSetId)
2355
- updatePayload.ruleSetId = bucket.ruleSetId;
2356
- if (bucket.name)
2357
- updatePayload.name = bucket.name;
2358
- if (bucket.description !== undefined)
2359
- updatePayload.description = bucket.description || null;
3261
+ const updatePayload = buildBlobBucketUpdatePayload(bucket);
2360
3262
  changes.push({ type: "blob-bucket", action: "update", key });
2361
3263
  if (!options.dryRun) {
2362
3264
  try {
2363
3265
  await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
2364
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
+ }
2365
3275
  }
2366
3276
  catch (err) {
3277
+ // Keep the warn-and-continue contract (sponsor decision):
3278
+ // do not abort the push, just don't stamp the hash.
2367
3279
  warn(` Failed to update blob bucket ${key}: ${String(err?.message || err)}`);
2368
3280
  }
2369
3281
  }
2370
- if (syncState?.entities?.blobBuckets?.[key]) {
2371
- syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
2372
- }
2373
3282
  }
2374
3283
  else {
2375
3284
  const payload = {
@@ -2404,23 +3313,86 @@ Directory Structure:
2404
3313
  catch (err) {
2405
3314
  const msg = String(err?.message || err);
2406
3315
  if (msg.includes("already exists") || err.statusCode === 409) {
2407
- info(` Blob bucket already exists on server: ${key}`);
2408
- // 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;
2409
3327
  try {
2410
- const existing = await client.getBlobBucket(resolvedAppId, key);
2411
- if (syncState && existing?.bucketId) {
2412
- if (!syncState.entities.blobBuckets) {
2413
- syncState.entities.blobBuckets = {};
2414
- }
2415
- syncState.entities.blobBuckets[key] = {
2416
- id: existing.bucketId,
2417
- modifiedAt: existing.modifiedAt || new Date().toISOString(),
2418
- contentHash: computeFileHash(filePath),
2419
- };
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);
2420
3373
  }
3374
+ catch (updateErr) {
3375
+ throw wrapEntityError(updateErr, "update", "blob bucket", key);
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}`);
2421
3384
  }
2422
- catch {
2423
- // 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
+ };
2424
3396
  }
2425
3397
  }
2426
3398
  else {
@@ -2774,6 +3746,19 @@ Directory Structure:
2774
3746
  const workflow = tomlData.workflow || {};
2775
3747
  const key = workflow.key || basename(file, ".toml");
2776
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;
2777
3762
  const existingId = syncState?.entities?.workflows?.[key]?.id;
2778
3763
  const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
2779
3764
  // Skip if file hasn't changed since last sync. Use the expanded
@@ -2805,80 +3790,17 @@ Directory Structure:
2805
3790
  // out-of-manifest workflow).
2806
3791
  const applyWorkflowUpdate = async (workflowId, opts) => {
2807
3792
  const { expectedModifiedAt, activeConfigId } = opts;
2808
- // Update workflow metadata and schemas.
2809
- //
2810
- // Sync-callable ordering (#807, codex review): the metadata
2811
- // PATCH must NOT carry `syncCallable: true` here. The server
2812
- // re-validates `syncCallable: true` against the workflow's
2813
- // CURRENTLY-active server steps (`loadCurrentActiveSteps`),
2814
- // not the steps being pushed in the same sync. So when a
2815
- // single TOML edit both enables `syncCallable` and replaces
2816
- // sync-incompatible active steps with compatible ones, a
2817
- // combined metadata-first PATCH would be rejected against the
2818
- // stale steps. Defer `syncCallable` to a second PATCH issued
2819
- // AFTER the config/step update lands, so it validates against
2820
- // the new (compatible) active steps. `requiresClientApply`
2821
- // has no step-dependent validation, so it stays in the first
2822
- // PATCH unchanged.
2823
- const updated = await client.updateWorkflow(resolvedAppId, workflowId, {
2824
- name: workflow.name,
2825
- description: workflow.description,
2826
- status: workflow.status,
2827
- accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
2828
- // #1081 — workflow principal mode. Absent key → undefined
2829
- // (server leaves it untouched); explicit value/empty is
2830
- // forwarded so it round-trips.
2831
- runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
2832
- perUserMaxRunning: workflow.perUserMaxRunning,
2833
- perUserMaxQueued: workflow.perUserMaxQueued,
2834
- dequeueOrder: workflow.dequeueOrder,
2835
- // Sync-callable flags (#807): pass through as-is. An absent
2836
- // TOML key is `undefined` (dropped by JSON.stringify, so the
2837
- // server's hasOwnProperty guard leaves the value untouched);
2838
- // an explicit `false` is preserved as a meaningful value.
2839
- requiresClientApply: workflow.requiresClientApply,
2840
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2841
- outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
2842
- }, expectedModifiedAt);
2843
- // Track the latest workflow `modifiedAt` for the sync-state
2844
- // write below. The first PATCH bumps it; the `syncCallable`
2845
- // PATCH (if any) bumps it again. The config/step update does
2846
- // NOT touch the workflow definition's `modifiedAt`.
2847
- let latestModifiedAt = updated?.workflow?.modifiedAt;
2848
- // Update active configuration steps (or draft for legacy).
2849
- // Issue #687: name the slot we touched so the dev-loop
2850
- // user can confirm before previewing.
2851
- let updateSlotLabel = "active config";
2852
- if (activeConfigId) {
2853
- await client.updateWorkflowConfig(resolvedAppId, workflowId, activeConfigId, {
2854
- steps,
2855
- });
2856
- }
2857
- else {
2858
- // Fallback to draft update for legacy workflows
2859
- await client.updateWorkflowDraft(resolvedAppId, workflowId, {
2860
- steps,
2861
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2862
- });
2863
- updateSlotLabel = "draft (legacy)";
2864
- }
2865
- // Second PATCH: apply `syncCallable` now that the new steps
2866
- // are active (#807). Only sent when the TOML actually carries
2867
- // the key — an absent key stays `undefined` and we skip the
2868
- // call entirely, preserving the no-clobber discipline. Chain
2869
- // `expectedModifiedAt` off the first PATCH's returned value so
2870
- // optimistic concurrency stays intact (the step update above
2871
- // doesn't change the workflow definition's `modifiedAt`).
2872
- if (workflow.syncCallable !== undefined) {
2873
- const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, workflowId, { syncCallable: workflow.syncCallable },
2874
- // Mirror the first PATCH's concurrency posture: `--force`
2875
- // skips the check (undefined), otherwise reuse the fresh
2876
- // `modifiedAt` from the first PATCH.
2877
- options.force ? undefined : latestModifiedAt);
2878
- if (syncCallableUpdated?.workflow?.modifiedAt) {
2879
- latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
2880
- }
2881
- }
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
+ });
2882
3804
  info(` Updated workflow: ${key} (${updateSlotLabel})`);
2883
3805
  // Update sync state with new modifiedAt. Store the *expanded*
2884
3806
  // content hash so future fragment-only edits are detected. The
@@ -2888,9 +3810,8 @@ Directory Structure:
2888
3810
  syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
2889
3811
  syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
2890
3812
  }
2891
- // Fetch full workflow to get config name→ID mappings
2892
- // (updateWorkflow response doesn't include configs)
2893
- const fullWorkflow = await client.getWorkflow(resolvedAppId, workflowId);
3813
+ // `applyWorkflowBody` already fetched the full workflow; reuse it
3814
+ // for the config name→ID mappings (the PATCH response omits configs).
2894
3815
  if (fullWorkflow?.configs) {
2895
3816
  for (const config of fullWorkflow.configs) {
2896
3817
  workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
@@ -2930,25 +3851,22 @@ Directory Structure:
2930
3851
  changes.push({ type: "workflow", action: "create", key });
2931
3852
  if (!options.dryRun) {
2932
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`.
2933
3862
  const created = await client.createWorkflow(resolvedAppId, {
2934
3863
  workflowKey: key,
2935
- name: workflow.name || key,
2936
- description: workflow.description,
2937
3864
  steps,
2938
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
2939
- outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
2940
- accessRule: workflow.accessRule || undefined,
2941
- // #1081 — workflow principal mode (absent → server default).
2942
- runAs: workflow.runAs || undefined,
2943
- perUserMaxRunning: workflow.perUserMaxRunning,
2944
- perUserMaxQueued: workflow.perUserMaxQueued,
2945
- dequeueOrder: workflow.dequeueOrder,
2946
- // Sync-callable flags (#807): pass through as-is. An absent
2947
- // TOML key is `undefined` (dropped by JSON.stringify), so the
2948
- // server applies its defaults (requiresClientApply=true,
2949
- // syncCallable=false); an explicit value is honored.
2950
- requiresClientApply: workflow.requiresClientApply,
2951
- 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,
2952
3870
  });
2953
3871
  info(` Created workflow: ${key}`);
2954
3872
  // Add new entity to sync state (including activeConfigId)
@@ -3214,6 +4132,14 @@ Directory Structure:
3214
4132
  if ("timestamps" in typeConfig) {
3215
4133
  u.timestamps = typeConfig.timestamps || null;
3216
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
+ }
3217
4143
  const localHasSchema = typeof typeConfig.schema === "string" &&
3218
4144
  typeConfig.schema.trim().length > 0;
3219
4145
  if (localHasSchema) {
@@ -3583,7 +4509,8 @@ Directory Structure:
3583
4509
  "defaultAccess" in typeConfig ||
3584
4510
  "autoPopulatedFields" in typeConfig ||
3585
4511
  "timestamps" in typeConfig ||
3586
- "schema" in typeConfig;
4512
+ "schema" in typeConfig ||
4513
+ "metadataManifest" in typeConfig;
3587
4514
  if (wouldUpdate) {
3588
4515
  changes.push({ type: "database-type", action: "update", key: dbType });
3589
4516
  }
@@ -3616,6 +4543,9 @@ Directory Structure:
3616
4543
  // validating against the intended shape.
3617
4544
  if (typeConfig.schema)
3618
4545
  createData.schema = typeConfig.schema;
4546
+ // Issue #1304 (P-C): declared-access manifest.
4547
+ if (typeConfig.metadataManifest)
4548
+ createData.metadataManifest = typeConfig.metadataManifest;
3619
4549
  try {
3620
4550
  const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
3621
4551
  info(` Created database type: ${dbType}`);
@@ -4072,6 +5002,10 @@ Directory Structure:
4072
5002
  const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
4073
5003
  ruleSetId: configData.ruleSetId,
4074
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,
4075
5009
  }, expectedModifiedAt);
4076
5010
  info(` Updated group type config: ${groupType}`);
4077
5011
  if (syncState?.entities?.groupTypeConfigs?.[groupType] && updated?.modifiedAt) {
@@ -4103,6 +5037,9 @@ Directory Structure:
4103
5037
  groupType,
4104
5038
  ruleSetId: configData.ruleSetId || undefined,
4105
5039
  autoAddCreator: configData.autoAddCreator,
5040
+ ...(configData.metadataManifest
5041
+ ? { metadataManifest: configData.metadataManifest }
5042
+ : {}),
4106
5043
  });
4107
5044
  info(` Created group type config: ${groupType}`);
4108
5045
  if (syncState) {
@@ -4149,6 +5086,7 @@ Directory Structure:
4149
5086
  try {
4150
5087
  const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
4151
5088
  ruleSetId: configData.ruleSetId ?? null,
5089
+ metadataManifest: configData.metadataManifest ?? null,
4152
5090
  }, expectedModifiedAt);
4153
5091
  info(` Updated collection type config: ${collectionType}`);
4154
5092
  if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
@@ -4179,6 +5117,9 @@ Directory Structure:
4179
5117
  const created = await client.createCollectionTypeConfig(resolvedAppId, {
4180
5118
  collectionType,
4181
5119
  ruleSetId: configData.ruleSetId || undefined,
5120
+ ...(configData.metadataManifest
5121
+ ? { metadataManifest: configData.metadataManifest }
5122
+ : {}),
4182
5123
  });
4183
5124
  info(` Created collection type config: ${collectionType}`);
4184
5125
  if (syncState) {
@@ -4198,6 +5139,69 @@ Directory Structure:
4198
5139
  }
4199
5140
  }
4200
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
+ }
4201
5205
  // Process email templates
4202
5206
  const emailTemplatesDir = join(configDir, "email-templates");
4203
5207
  if (existsSync(emailTemplatesDir)) {
@@ -4234,11 +5238,13 @@ Directory Structure:
4234
5238
  }
4235
5239
  }
4236
5240
  }
4237
- // Push test cases for prompts and workflows
5241
+ // Push test cases for prompts, workflows, and scripts
5242
+ const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
4238
5243
  const pushMaps = {
4239
5244
  promptKeyToId,
4240
5245
  promptConfigNameToId,
4241
5246
  workflowConfigNameToId,
5247
+ scriptConfigNameToId,
4242
5248
  };
4243
5249
  const promptsDir2 = join(configDir, "prompts");
4244
5250
  if (existsSync(promptsDir2)) {
@@ -4262,6 +5268,33 @@ Directory Structure:
4262
5268
  }
4263
5269
  }
4264
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
+ }
4265
5298
  divider();
4266
5299
  // Issue #813: a gate failure surfaced by the validate-first pass (which
4267
5300
  // also runs in dry-run mode via the server's dry-run gate endpoints).
@@ -4499,6 +5532,31 @@ Directory Structure:
4499
5532
  localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
4500
5533
  }
4501
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
+ }
4502
5560
  // Compare
4503
5561
  const differences = [];
4504
5562
  // Integrations
@@ -4703,6 +5761,10 @@ Directory Structure:
4703
5761
  differences.push({ type: "transform", key: name, status: "remote only" });
4704
5762
  }
4705
5763
  }
5764
+ // Config vars — issue #1423 (see `diffVars`).
5765
+ for (const row of diffVars(localVars, remoteVars)) {
5766
+ differences.push(row);
5767
+ }
4706
5768
  // Compare test cases for synced prompts and workflows
4707
5769
  const testCaseDiffs = [];
4708
5770
  // Helper to compare test cases for a block
@@ -4823,11 +5885,15 @@ Directory Structure:
4823
5885
  // Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
4824
5886
  // the legacy JSON-string form for `definition`/`params` to the native
4825
5887
  // nested-table form. Idempotent: ops already in native form are left as
4826
- // is; ops that are un-TOMLable (mixed-type arrays, null values) fall
4827
- // 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.
4828
5892
  sync
4829
5893
  .command("migrate-toml")
4830
- .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)")
4831
5897
  .argument("[app-id]", "App ID (uses current app if not specified)")
4832
5898
  .option("--app <app-id>", "App ID")
4833
5899
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
@@ -4846,83 +5912,122 @@ Directory Structure:
4846
5912
  process.exit(1);
4847
5913
  }
4848
5914
  const dbTypesDir = join(configDir, "database-types");
4849
- if (!existsSync(dbTypesDir)) {
4850
- 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.");
4851
5927
  return;
4852
5928
  }
4853
- const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
4854
- if (files.length === 0) {
4855
- info("No database-type TOML files found; nothing to migrate.");
4856
- return;
4857
- }
4858
- // Hydrate ruleSetIdToName from local sync state so files that
4859
- // reference a rule set via the legacy `ruleSetId = "01..."` form
4860
- // round-trip with the correct `ruleSetName`. Without this, any user
4861
- // who runs migrate-toml against a file with a rule-set assignment
4862
- // would silently lose that reference (review feedback r3246633010).
4863
- //
4864
- // Files that use the modern key-based `ruleSetName = "..."` form
4865
- // don't need the map at all — `parseDatabaseTypeToml` stores the
4866
- // value in `typeConfig._ruleSetName` and the serializer now prefers
4867
- // that. The map is only load-bearing for the legacy ID-based form.
4868
- const ruleSetIdToName = new Map();
4869
- const migrateSyncState = loadSyncState(configDir);
4870
- if (migrateSyncState?.entities?.ruleSets) {
4871
- for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
4872
- if (entry && typeof entry === "object" && "id" in entry && entry.id) {
4873
- // fileKey is the sanitized rule-set name (see sync pull at
4874
- // sync.ts:1385). It's the same shape the server returned and
4875
- // matches what a TOML file's ruleSetName field would carry.
4876
- ruleSetIdToName.set(entry.id, fileKey);
4877
- }
4878
- }
4879
- }
4880
5929
  let migratedCount = 0;
4881
5930
  let unchangedCount = 0;
4882
- for (const file of files) {
4883
- const filePath = join(dbTypesDir, file);
4884
- const rawBefore = readFileSync(filePath, "utf-8");
4885
- const tomlData = parseTomlFile(filePath);
4886
- // `accessRule`/`access` conflicts surface as a clear error here too,
4887
- // rather than silently dropping the field during a migrate rewrite.
4888
- let typeConfig;
4889
- let operations;
4890
- let subscriptions;
4891
- try {
4892
- ({ typeConfig, operations, subscriptions } =
4893
- 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
+ }
4894
5954
  }
4895
- catch (err) {
4896
- if (err instanceof SubscriptionAccessKeyConflictError) {
4897
- error(` ${file}: ${err.message}`);
4898
- error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
4899
- 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));
4900
5967
  }
4901
- throw err;
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;
5987
+ }
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++;
4902
6003
  }
4903
- // Force-native: ignore existing hints so every TOMLable field
4904
- // ends up in nested-table form. Subscriptions are preserved on the
4905
- // rewrite (issue #803) so migrate-toml never drops them.
4906
- const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
4907
- defaultForm: "native",
4908
- logger: (msg) => info(` ${msg}`),
4909
- subscriptions,
4910
- });
4911
- 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) {
4912
6017
  unchangedCount++;
4913
6018
  continue;
4914
6019
  }
4915
6020
  if (options.dryRun) {
4916
- info(`Would migrate database-types/${file}`);
6021
+ info(`Would migrate workflows/${file}`);
4917
6022
  }
4918
6023
  else {
4919
6024
  writeFileSync(filePath, rewritten);
4920
- info(`Migrated database-types/${file}`);
4921
- // 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.
4922
6027
  const syncState = loadSyncState(configDir);
4923
- const dbType = typeConfig.databaseType || basename(file, ".toml");
4924
- if (syncState?.entities?.databaseTypes?.[dbType]) {
4925
- syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
6028
+ if (syncState?.entities?.workflows?.[workflowKey]) {
6029
+ syncState.entities.workflows[workflowKey].contentHash =
6030
+ computeExpandedContentHash(parseTomlFile(filePath));
4926
6031
  saveSyncState(configDir, syncState);
4927
6032
  }
4928
6033
  }
@@ -4938,6 +6043,63 @@ Directory Structure:
4938
6043
  success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
4939
6044
  }
4940
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
+ });
4941
6103
  // Revert — restore a pre-pull snapshot (issue #578, Phase 1).
4942
6104
  sync
4943
6105
  .command("revert")