primitive-admin 1.0.53 → 1.0.55

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 (173) hide show
  1. package/README.md +38 -10
  2. package/dist/bin/primitive.js +8 -0
  3. package/dist/bin/primitive.js.map +1 -1
  4. package/dist/src/commands/apps.js +54 -2
  5. package/dist/src/commands/apps.js.map +1 -1
  6. package/dist/src/commands/auth.js +16 -6
  7. package/dist/src/commands/auth.js.map +1 -1
  8. package/dist/src/commands/blob-buckets.js +84 -7
  9. package/dist/src/commands/blob-buckets.js.map +1 -1
  10. package/dist/src/commands/catalog.js +3 -3
  11. package/dist/src/commands/catalog.js.map +1 -1
  12. package/dist/src/commands/collections.js +12 -9
  13. package/dist/src/commands/collections.js.map +1 -1
  14. package/dist/src/commands/databases.js +220 -82
  15. package/dist/src/commands/databases.js.map +1 -1
  16. package/dist/src/commands/documents.js +77 -0
  17. package/dist/src/commands/documents.js.map +1 -1
  18. package/dist/src/commands/group-type-configs.js +1 -9
  19. package/dist/src/commands/group-type-configs.js.map +1 -1
  20. package/dist/src/commands/groups.js +12 -6
  21. package/dist/src/commands/groups.js.map +1 -1
  22. package/dist/src/commands/guides.d.ts +161 -0
  23. package/dist/src/commands/guides.js +502 -84
  24. package/dist/src/commands/guides.js.map +1 -1
  25. package/dist/src/commands/integrations.js +2 -2
  26. package/dist/src/commands/integrations.js.map +1 -1
  27. package/dist/src/commands/metadata.d.ts +2 -0
  28. package/dist/src/commands/metadata.js +344 -0
  29. package/dist/src/commands/metadata.js.map +1 -0
  30. package/dist/src/commands/prompts.js +2 -2
  31. package/dist/src/commands/prompts.js.map +1 -1
  32. package/dist/src/commands/rule-sets.d.ts +1 -0
  33. package/dist/src/commands/rule-sets.js +25 -3
  34. package/dist/src/commands/rule-sets.js.map +1 -1
  35. package/dist/src/commands/scripts.d.ts +2 -0
  36. package/dist/src/commands/scripts.js +667 -0
  37. package/dist/src/commands/scripts.js.map +1 -0
  38. package/dist/src/commands/settings.d.ts +15 -0
  39. package/dist/src/commands/settings.js +102 -0
  40. package/dist/src/commands/settings.js.map +1 -0
  41. package/dist/src/commands/sync-app-settings.d.ts +105 -0
  42. package/dist/src/commands/sync-app-settings.js +339 -0
  43. package/dist/src/commands/sync-app-settings.js.map +1 -0
  44. package/dist/src/commands/sync.d.ts +204 -9
  45. package/dist/src/commands/sync.js +1706 -371
  46. package/dist/src/commands/sync.js.map +1 -1
  47. package/dist/src/commands/vars.d.ts +8 -0
  48. package/dist/src/commands/vars.js +110 -0
  49. package/dist/src/commands/vars.js.map +1 -0
  50. package/dist/src/commands/webhooks.js +17 -3
  51. package/dist/src/commands/webhooks.js.map +1 -1
  52. package/dist/src/commands/workflows.d.ts +30 -0
  53. package/dist/src/commands/workflows.js +667 -59
  54. package/dist/src/commands/workflows.js.map +1 -1
  55. package/dist/src/lib/api-client.d.ts +122 -18
  56. package/dist/src/lib/api-client.js +131 -2
  57. package/dist/src/lib/api-client.js.map +1 -1
  58. package/dist/src/lib/app-settings-descriptor.d.ts +108 -0
  59. package/dist/src/lib/app-settings-descriptor.js +250 -0
  60. package/dist/src/lib/app-settings-descriptor.js.map +1 -0
  61. package/dist/src/lib/batch.d.ts +26 -0
  62. package/dist/src/lib/batch.js +32 -0
  63. package/dist/src/lib/batch.js.map +1 -0
  64. package/dist/src/lib/block-layout.d.ts +160 -0
  65. package/dist/src/lib/block-layout.js +451 -0
  66. package/dist/src/lib/block-layout.js.map +1 -0
  67. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +76 -0
  68. package/dist/src/lib/codegen-shared/generatedFiles.js +144 -0
  69. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
  70. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +68 -0
  71. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +168 -0
  72. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
  73. package/dist/src/lib/config-toml.d.ts +10 -0
  74. package/dist/src/lib/config-toml.js +42 -0
  75. package/dist/src/lib/config-toml.js.map +1 -0
  76. package/dist/src/lib/config.d.ts +16 -0
  77. package/dist/src/lib/config.js +41 -0
  78. package/dist/src/lib/config.js.map +1 -1
  79. package/dist/src/lib/constants.d.ts +9 -0
  80. package/dist/src/lib/constants.js +9 -0
  81. package/dist/src/lib/constants.js.map +1 -1
  82. package/dist/src/lib/csv.d.ts +1 -2
  83. package/dist/src/lib/csv.js +3 -8
  84. package/dist/src/lib/csv.js.map +1 -1
  85. package/dist/src/lib/db-codegen/dbGenerator.d.ts +21 -65
  86. package/dist/src/lib/db-codegen/dbGenerator.js +117 -321
  87. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  88. package/dist/src/lib/db-codegen/dbNaming.d.ts +37 -0
  89. package/dist/src/lib/db-codegen/dbNaming.js +70 -0
  90. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  91. package/dist/src/lib/db-codegen/dbTemplates.d.ts +165 -5
  92. package/dist/src/lib/db-codegen/dbTemplates.js +292 -16
  93. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  94. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +7 -3
  95. package/dist/src/lib/db-codegen/dbTsTypes.js +23 -1
  96. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  97. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
  98. package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
  99. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
  100. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +101 -0
  101. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +200 -0
  102. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
  103. package/dist/src/lib/generated-allowlist.js +43 -0
  104. package/dist/src/lib/generated-allowlist.js.map +1 -1
  105. package/dist/src/lib/init-config.js +2 -2
  106. package/dist/src/lib/init-config.js.map +1 -1
  107. package/dist/src/lib/output.d.ts +25 -5
  108. package/dist/src/lib/output.js +32 -4
  109. package/dist/src/lib/output.js.map +1 -1
  110. package/dist/src/lib/query-operators.d.ts +43 -0
  111. package/dist/src/lib/query-operators.js +80 -0
  112. package/dist/src/lib/query-operators.js.map +1 -0
  113. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +68 -0
  114. package/dist/src/lib/swift-codegen/dbGenerator.js +380 -0
  115. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
  116. package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
  117. package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
  118. package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
  119. package/dist/src/lib/swift-codegen/generator.d.ts +84 -0
  120. package/dist/src/lib/swift-codegen/generator.js +178 -0
  121. package/dist/src/lib/swift-codegen/generator.js.map +1 -0
  122. package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
  123. package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
  124. package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
  125. package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
  126. package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
  127. package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
  128. package/dist/src/lib/sync-resource-types.d.ts +225 -0
  129. package/dist/src/lib/sync-resource-types.js +394 -0
  130. package/dist/src/lib/sync-resource-types.js.map +1 -0
  131. package/dist/src/lib/toml-database-config.d.ts +10 -19
  132. package/dist/src/lib/toml-database-config.js +28 -59
  133. package/dist/src/lib/toml-database-config.js.map +1 -1
  134. package/dist/src/lib/toml-metadata-config.d.ts +147 -0
  135. package/dist/src/lib/toml-metadata-config.js +463 -0
  136. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  137. package/dist/src/lib/toml-native-form.d.ts +46 -0
  138. package/dist/src/lib/toml-native-form.js +78 -0
  139. package/dist/src/lib/toml-native-form.js.map +1 -0
  140. package/dist/src/lib/toml-params-validator.d.ts +34 -0
  141. package/dist/src/lib/toml-params-validator.js +118 -3
  142. package/dist/src/lib/toml-params-validator.js.map +1 -1
  143. package/dist/src/lib/workflow-apply.d.ts +86 -0
  144. package/dist/src/lib/workflow-apply.js +127 -0
  145. package/dist/src/lib/workflow-apply.js.map +1 -0
  146. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
  147. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
  148. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
  149. package/dist/src/lib/workflow-codegen/generator.d.ts +87 -0
  150. package/dist/src/lib/workflow-codegen/generator.js +290 -0
  151. package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
  152. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +85 -0
  153. package/dist/src/lib/workflow-codegen/invokerIR.js +71 -0
  154. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
  155. package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
  156. package/dist/src/lib/workflow-codegen/naming.js +81 -0
  157. package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
  158. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +64 -0
  159. package/dist/src/lib/workflow-codegen/schemaToTs.js +282 -0
  160. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
  161. package/dist/src/lib/workflow-fragments.js +3 -3
  162. package/dist/src/lib/workflow-fragments.js.map +1 -1
  163. package/dist/src/lib/workflow-payload.d.ts +68 -0
  164. package/dist/src/lib/workflow-payload.js +150 -0
  165. package/dist/src/lib/workflow-payload.js.map +1 -0
  166. package/dist/src/lib/workflow-toml-validator.d.ts +1 -1
  167. package/dist/src/lib/workflow-toml-validator.js +37 -1
  168. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  169. package/dist/src/types/index.d.ts +11 -0
  170. package/dist/src/validators.d.ts +4 -3
  171. package/dist/src/validators.js +6 -5
  172. package/dist/src/validators.js.map +1 -1
  173. package/package.json +7 -3
@@ -1,16 +1,24 @@
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 { serializeSection, parseTomlToAppSettings, } from "../lib/app-settings-descriptor.js";
17
+ import { serializeAppSettings, collectAppSettingsPushErrors, } from "./sync-app-settings.js";
18
+ import { applyWorkflowBody } from "../lib/workflow-apply.js";
19
+ import { buildWorkflowPayloadFromToml } from "../lib/workflow-payload.js";
20
+ import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
21
+ import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, SYNC_RESOURCE_TYPES, applyPrune, attemptFetch, classifyLocalOnly, hasUncommittedChanges, planPrune, preserveUnwrittenEntries, ruleSetFileKey, } from "../lib/sync-resource-types.js";
14
22
  import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
15
23
  import { confirmPrompt } from "../lib/confirm-prompt.js";
16
24
  import chalk from "chalk";
@@ -132,30 +140,43 @@ export function shouldPushExpandedFile(parsed, storedHash) {
132
140
  return computeExpandedContentHash(parsed) !== storedHash;
133
141
  }
134
142
  /**
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`.
143
+ * Model defaults for the workflow-level fields that `serializeWorkflow` emits
144
+ * with a concrete server default the *local* file may omit (`sync.ts` workflow
145
+ * serializer): `perUserMaxRunning`, `perUserMaxQueued`, `perAppMaxRunning`,
146
+ * `perAppMaxQueued`, `queueTtlSeconds`, `dequeueOrder`, `requiresClientApply`,
147
+ * `syncCallable`. These mirror `models.yaml` (`WorkflowDefinition`):
148
+ * `perUserMaxRunning=4`, `perUserMaxQueued=100`, `perAppMaxRunning=25`,
149
+ * `perAppMaxQueued=10000`, `queueTtlSeconds=43200`, `dequeueOrder="fifo"`,
150
+ * `requiresClientApply=true`, `syncCallable=false`.
151
+ *
152
+ * `perAppMax*` / `queueTtlSeconds` were added here for #1177: once the pull
153
+ * serializer began emitting them (they carry a non-null model default, so a GET
154
+ * always returns them), an unchanged workflow using the server defaults hashed
155
+ * unequal to a local TOML that omits them — a false `modified` diff that made
156
+ * `sync pull` rewrite the file just to inject defaults. Normalizing both sides
157
+ * to the model default here closes that gap the same way the per-user limits do.
141
158
  *
142
159
  * 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.
160
+ * carries defaults the serializer never writes. Only the serializer-emitted
161
+ * surface with a concrete default matters for a content comparison, because the
162
+ * remote side is hashed via `serializeWorkflow`. Emit-when-set fields whose GET
163
+ * value is null/empty when unset (`accessRule`, `runAs`, `capabilities`) are
164
+ * omitted on both sides and so need no normalization here.
147
165
  */
148
166
  const WORKFLOW_SERIALIZER_DEFAULTS = {
149
167
  perUserMaxRunning: 4,
150
168
  perUserMaxQueued: 100,
169
+ perAppMaxRunning: 25,
170
+ perAppMaxQueued: 10000,
171
+ queueTtlSeconds: 43200,
151
172
  dequeueOrder: "fifo",
152
173
  requiresClientApply: true,
153
174
  syncCallable: false,
154
175
  };
155
176
  /**
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.
177
+ * Normalize a parsed workflow TOML object so that any of the
178
+ * serializer-emitted default fields the *local* file omits are filled in with
179
+ * the model default. Used by `diff` so a hand-authored workflow that omits e.g.
159
180
  * `perUserMaxRunning` compares equal to a server that defaulted it to 4
160
181
  * (the #1175 false-positive guard) — while a server value that was
161
182
  * *explicitly set* to a non-default (e.g. 8) still shows as Modified.
@@ -173,6 +194,25 @@ export function normalizeWorkflowTomlDefaults(parsed) {
173
194
  workflow[field] = defaultValue;
174
195
  }
175
196
  }
197
+ // Schema-form canonicalization (issue #1446): a legacy file carries
198
+ // `inputSchema` / `outputSchema` as a JSON string, while the native form (and
199
+ // the remote serialization) carries a parsed object. Parse the string form to
200
+ // its object here so the two forms hash equal — `sync diff` stays
201
+ // form-independent and an unchanged schema never shows a false `modified`
202
+ // during the transition. This runs only on the diff-hash path
203
+ // (`hashWorkflowTomlForDiff`); the stored-state hash path is untouched, so
204
+ // there is no forced one-time re-push after upgrade.
205
+ for (const field of ["inputSchema", "outputSchema"]) {
206
+ if (typeof workflow[field] === "string") {
207
+ try {
208
+ workflow[field] = JSON.parse(workflow[field]);
209
+ }
210
+ catch {
211
+ // Not valid JSON — leave the raw string so a genuinely malformed value
212
+ // still compares by its literal content.
213
+ }
214
+ }
215
+ }
176
216
  return { ...parsed, workflow };
177
217
  }
178
218
  /**
@@ -191,42 +231,350 @@ export function hashWorkflowTomlForDiff(parsed) {
191
231
  * `getWorkflow` + active `getWorkflowConfig`), mirroring exactly what a fresh
192
232
  * `sync pull` would write to disk. Serializes via `serializeWorkflow`, parses
193
233
  * the resulting TOML (remote serialized TOML never carries `include`s, so a
194
- * plain `TOML.parse` is sufficient — no fragment path needed), then hashes
234
+ * plain `parseConfigToml` is sufficient — no fragment path needed), then hashes
195
235
  * through `hashWorkflowTomlForDiff` so it lines up with the local-file hash.
196
236
  */
197
237
  export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
198
238
  const serialized = serializeWorkflow(workflow, draft, configs || []);
199
- const parsed = TOML.parse(serialized);
239
+ // serializeWorkflow emits via smol-toml (issue #1450) and may contain a
240
+ // heterogeneous array, so re-parse through the TOML 1.0-compliant facade.
241
+ const parsed = parseConfigToml(serialized);
200
242
  return hashWorkflowTomlForDiff(parsed);
201
243
  }
202
- // TOML serialization helpers
203
- function serializeAppSettings(settings) {
204
- const data = {
205
- app: {
206
- name: settings.name,
207
- mode: settings.mode,
208
- waitlistEnabled: settings.waitlistEnabled,
209
- baseUrl: settings.baseUrl,
210
- },
211
- auth: {
212
- googleOAuthEnabled: settings.googleOAuthEnabled,
213
- passkeyEnabled: settings.passkeyEnabled,
214
- magicLinkEnabled: settings.magicLinkEnabled,
215
- },
216
- };
217
- if (settings.passkeyRpConfig) {
218
- data.auth.passkeys = settings.passkeyRpConfig;
244
+ /**
245
+ * Build the [auth] block of app.toml from server settings (pull direction).
246
+ *
247
+ * Thin wrapper over the shared field descriptor
248
+ * (`cli/src/lib/app-settings-descriptor.ts`), which now owns the [auth] field
249
+ * set that `AUTH_BOOLEAN_KEYS`/`RECOGNIZED_AUTH_KEYS` used to hard-code. The
250
+ * descriptor drives all four sections in one place, so pull, push, and the
251
+ * unrecognized-key warning can never drift apart. `googleClientSecret` is a
252
+ * secret descriptor entry and is never written.
253
+ */
254
+ export function serializeAuthBlock(settings) {
255
+ return serializeSection(settings, "auth");
256
+ }
257
+ /**
258
+ * Translate the [auth] block of app.toml into app-settings fields (push
259
+ * direction). Kept as a focused helper over the shared descriptor; the full
260
+ * push path uses `parseTomlToAppSettings` for every section. Only keys present
261
+ * in the TOML are forwarded, so an omitted key never overwrites server state.
262
+ * Descriptor errors (a secret key, a type mismatch) are surfaced alongside the
263
+ * unrecognized-key warnings.
264
+ */
265
+ export function parseAppAuthSettings(auth) {
266
+ const { settings, warnings, errors } = parseTomlToAppSettings({ auth });
267
+ return { settings, warnings: [...warnings, ...errors] };
268
+ }
269
+ // `serializeAppSettings` (pull) and the app.toml push validation now live in
270
+ // `sync-app-settings.ts`, shared with the `primitive settings` command — see
271
+ // the imports above. Both directions stay descriptor-driven
272
+ // (`cli/src/lib/app-settings-descriptor.ts`).
273
+ // --- Config vars (issue #1423) ---------------------------------------------
274
+ // Per-environment non-secret scalars, round-tripped as a single flat
275
+ // `vars.toml` at the sync-dir root (like app.toml). They bind as
276
+ // `{{ vars.KEY }}` in workflow/integration config and `vars.*` in CEL rules.
277
+ /** Key format shared with the vars service (`^[A-Z][A-Z0-9_]{0,63}$`). */
278
+ const VARS_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
279
+ /** SHA-256 of a var's value — the per-key content hash tracked in sync state. */
280
+ function hashVarValue(value) {
281
+ return createHash("sha256").update(value).digest("hex");
282
+ }
283
+ /**
284
+ * Serialize the app config vars to a flat TOML table (keys sorted for a stable,
285
+ * diff-friendly file). Values are non-secret plaintext; a header comment says
286
+ * so loudly. An empty var set yields a comment-only file.
287
+ */
288
+ function serializeVars(vars) {
289
+ const data = {};
290
+ for (const v of [...vars].sort((a, b) => a.key.localeCompare(b.key))) {
291
+ data[v.key] = String(v.value ?? "");
219
292
  }
220
- if (settings.corsMode === "custom") {
221
- data.cors = {
222
- mode: settings.corsMode,
223
- allowedOrigins: settings.corsAllowedOrigins,
224
- allowCredentials: settings.corsAllowCredentials,
225
- allowedMethods: settings.corsAllowedMethods,
226
- maxAge: settings.corsMaxAge,
293
+ const header = "# Per-environment non-secret config vars (issue #1423).\n" +
294
+ "# Bind as {{ vars.KEY }} in workflow/integration config and vars.* in CEL\n" +
295
+ "# rules. Values are checked into the repo and NOT secret — never put a\n" +
296
+ "# credential here; use `primitive secrets` for that.\n\n";
297
+ return header + stringifyConfigToml(data);
298
+ }
299
+ /**
300
+ * Decide whether `sync pull` should (over)write `vars.toml`, given the outcome
301
+ * of fetching the app's config vars.
302
+ *
303
+ * The distinction that matters (issue #1423 review): a SUCCESSFUL fetch that
304
+ * returns zero vars is safe to write — the comment-only file is the intended
305
+ * empty state. A FAILED fetch (transient 401/500/network) must NOT write,
306
+ * because `serializeVars([])` would clobber a good local `vars.toml` with a
307
+ * comment-only file while the pull otherwise reports success. On failure we
308
+ * keep the prior manifest entries so the untouched file and the sync state stay
309
+ * consistent.
310
+ *
311
+ * Pure and side-effect-free (the caller owns the actual `writeFileSync`) so the
312
+ * clobber guard is unit-testable without a live server.
313
+ */
314
+ export function planVarsPull(outcome, priorVars) {
315
+ const varEntities = {};
316
+ if (!outcome.ok) {
317
+ // Preserve the prior manifest entries for the (untouched) local file.
318
+ if (priorVars)
319
+ Object.assign(varEntities, priorVars);
320
+ return { write: false, content: null, varEntities };
321
+ }
322
+ for (const v of outcome.vars) {
323
+ varEntities[v.key] = {
324
+ // The vars API returns `updatedAt` (see toVarResponse); record it as the
325
+ // baseline server timestamp so `sync push` can detect a concurrent remote
326
+ // edit. Fall back to `modifiedAt`/now only for older/partial responses.
327
+ modifiedAt: v.updatedAt || v.modifiedAt || new Date().toISOString(),
328
+ contentHash: hashVarValue(String(v.value ?? "")),
227
329
  };
228
330
  }
229
- return TOML.stringify(data);
331
+ return { write: true, content: serializeVars(outcome.vars), varEntities };
332
+ }
333
+ /**
334
+ * Maximum var value size, in bytes — mirrors the server's `MAX_VALUE_BYTES`
335
+ * (app-secrets-service.ts). Kept in sync manually because the CLI can't import
336
+ * the Worker service module.
337
+ */
338
+ const MAX_VAR_VALUE_BYTES = 2048;
339
+ /**
340
+ * Maximum number of vars per app — mirrors the server's `MAX_VARS_PER_APP`
341
+ * (app-config-vars-service.ts). Kept in sync manually because the CLI can't
342
+ * import the Worker service module.
343
+ */
344
+ const MAX_VARS_PER_APP = 100;
345
+ /**
346
+ * Validate a parsed `vars.toml` table against the same key/value constraints
347
+ * the server enforces (key format, string type, non-empty, size cap) plus the
348
+ * aggregate per-app var-count cap. Returns a list of human-readable errors
349
+ * (empty when valid).
350
+ *
351
+ * Run in `sync push`'s up-front preflight pass (issue #1423 review) so an
352
+ * invalid entry — or a file that would exceed the server's `MAX_VARS_PER_APP`
353
+ * cap — aborts BEFORE any mutation is applied. Validating only individual
354
+ * entries let a 101-entry file create/update many vars before the server
355
+ * rejected a later create at the cap, leaving a partial push (issue #1423
356
+ * review pass 3). Pure and side-effect-free so it's unit-testable without a
357
+ * live server.
358
+ */
359
+ export function validateVarsFile(parsedVars) {
360
+ const errors = [];
361
+ const keyCount = Object.keys(parsedVars).length;
362
+ if (keyCount > MAX_VARS_PER_APP) {
363
+ 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"}.`);
364
+ }
365
+ for (const [key, rawValue] of Object.entries(parsedVars)) {
366
+ if (!VARS_KEY_RE.test(key)) {
367
+ 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)`);
368
+ continue;
369
+ }
370
+ if (typeof rawValue !== "string") {
371
+ errors.push(`Var "${key}" in vars.toml must be a string value (got ${typeof rawValue}) — quote the value.`);
372
+ continue;
373
+ }
374
+ const byteLength = new TextEncoder().encode(rawValue).length;
375
+ if (byteLength === 0) {
376
+ errors.push(`Var "${key}" in vars.toml must not be empty.`);
377
+ }
378
+ else if (byteLength > MAX_VAR_VALUE_BYTES) {
379
+ errors.push(`Var "${key}" in vars.toml exceeds the maximum value size of ${MAX_VAR_VALUE_BYTES} bytes (got ${byteLength}).`);
380
+ }
381
+ }
382
+ return errors;
383
+ }
384
+ /**
385
+ * Plan the config-var writes a `sync push` should make, detecting concurrent
386
+ * remote edits before overwriting them (issue #1423 review).
387
+ *
388
+ * This is the client-side half of a two-layer concurrency guard. The vars
389
+ * PUT/DELETE endpoints now honor an `expectedModifiedAt` precondition (issue
390
+ * #1423 review r-2 P1) and raise a `ConflictError` when the server row changed
391
+ * since the caller's snapshot — the atomic, server-side check every other
392
+ * synced entity relies on. This function is the fast fail-before-mutate layer:
393
+ * it compares the recorded baseline against the current remote snapshot and, for
394
+ * a var whose remote value has drifted from what we last synced, reports a
395
+ * conflict (fed into the same `conflicts[]` accumulator the other entities use)
396
+ * before any write is attempted. A var tracked at the baseline but absent from
397
+ * the remote snapshot (deleted remotely while edited locally) is remote drift
398
+ * too, and also reported as a conflict rather than silently recreated.
399
+ * Conversely, a var removed locally that is already absent remotely is NOT
400
+ * planned for deletion — its desired (absent) state is already met, so a DELETE
401
+ * would 404 and fail the push. The same drift check guards deletions: a var
402
+ * removed locally whose remote value no longer matches the baseline was edited
403
+ * remotely since the last sync, and deleting it would silently discard that
404
+ * edit — reported as a conflict instead. `force` skips the conflict guard,
405
+ * matching `sync push --force` for every other entity.
406
+ *
407
+ * Pure and side-effect-free (the caller owns the API calls and sync-state
408
+ * updates) so the guard is unit-testable without a live server. Assumes
409
+ * `parsedVars` has already passed `validateVarsFile` (values are strings). The
410
+ * caller normally fails the push closed when the remote snapshot can't be
411
+ * fetched, but under `--force` it falls back to an empty snapshot and sets
412
+ * `snapshotUnavailable` — which keeps removed-key deletions in the plan (they'd
413
+ * otherwise be mistaken for already-deleted and dropped) and leans on the apply
414
+ * loop's idempotent 404 handling (issue #1423 review r-2 P2).
415
+ */
416
+ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
417
+ const remoteByKey = new Map(remoteVars.map((r) => [r.key, r]));
418
+ const upserts = [];
419
+ const conflicts = [];
420
+ const deletions = [];
421
+ let skippedCount = 0;
422
+ const localKeys = new Set();
423
+ for (const [key, value] of Object.entries(parsedVars)) {
424
+ localKeys.add(key);
425
+ const valueHash = hashVarValue(value);
426
+ const existing = baseline?.[key];
427
+ // Unchanged since last sync — skip (unless forced), mirroring the
428
+ // content-hash skip the other file-backed entities use.
429
+ if (!options.force && existing && existing.contentHash === valueHash) {
430
+ skippedCount++;
431
+ continue;
432
+ }
433
+ const action = existing ? "update" : "create";
434
+ // Concurrent-edit guard: if the remote value has drifted from the baseline
435
+ // we last synced (for an update) — or a var with this key was created
436
+ // remotely with a different value (for a create) — pushing would silently
437
+ // clobber that remote change. Report a conflict instead. `--force`
438
+ // overrides, exactly like the other entities.
439
+ if (!options.force) {
440
+ const remote = remoteByKey.get(key);
441
+ if (remote && typeof remote.value === "string") {
442
+ const remoteHash = hashVarValue(remote.value);
443
+ const remoteDrifted = existing?.contentHash
444
+ ? remoteHash !== existing.contentHash
445
+ : remoteHash !== valueHash;
446
+ if (remoteDrifted) {
447
+ conflicts.push({
448
+ key,
449
+ serverModifiedAt: remote.updatedAt || "unknown",
450
+ localModifiedAt: existing?.modifiedAt || "unknown",
451
+ });
452
+ continue;
453
+ }
454
+ }
455
+ else if (existing) {
456
+ // Tracked at the baseline but absent from the remote snapshot: the var
457
+ // was deleted remotely since our last sync while we edited it locally
458
+ // (we only reach here past the unchanged-skip above, so the local value
459
+ // differs from the baseline). Upserting would recreate the
460
+ // remotely-deleted var — that missing remote value is remote drift too,
461
+ // so report a conflict instead (issue #1423 review pass 3). `--force`
462
+ // overrides.
463
+ conflicts.push({
464
+ key,
465
+ serverModifiedAt: "deleted",
466
+ localModifiedAt: existing.modifiedAt || "unknown",
467
+ });
468
+ continue;
469
+ }
470
+ }
471
+ upserts.push({ action, key, value, valueHash });
472
+ }
473
+ // Prune vars tracked in the baseline but removed from the local file
474
+ // (hard-delete; nested-entity precedent). Skip keys already absent from the
475
+ // remote snapshot: a var independently deleted remotely and also removed
476
+ // locally is already in its desired (absent) state, and a server DELETE would
477
+ // 404 and fail the whole push (issue #1423 review pass 3). `--force` does not
478
+ // resurrect these deletes — the desired state is met either way.
479
+ for (const [key, entry] of Object.entries(baseline ?? {})) {
480
+ if (localKeys.has(key))
481
+ continue;
482
+ const remote = remoteByKey.get(key);
483
+ if (!remote) {
484
+ // Normally an absent remote entry means the var is already gone, so its
485
+ // desired (deleted) state is met and a DELETE would 404 — skip it. But
486
+ // when the remote snapshot could NOT be fetched (the `--force` fallback
487
+ // after a failed `listAppConfigVars`, which passes an empty snapshot),
488
+ // "absent from the snapshot" means "unknown", not "gone". Suppressing the
489
+ // delete would silently keep a var the user removed from `vars.toml` while
490
+ // the push still reports success (issue #1423 review r-2 P2). Enqueue the
491
+ // removal and rely on the apply loop's idempotent 404 handling to absorb a
492
+ // key that really was already absent.
493
+ if (options.snapshotUnavailable)
494
+ deletions.push(key);
495
+ continue;
496
+ }
497
+ // Concurrent-edit guard, mirroring the upsert path (issue #1423 review
498
+ // pass 4): deleting a var whose remote value drifted from the last-synced
499
+ // baseline would silently discard that remote edit. When drift cannot be
500
+ // verified (no baseline hash, or no remote value in the snapshot), fail
501
+ // closed and report the conflict. `--force` deletes unconditionally.
502
+ if (!options.force) {
503
+ const remoteDrifted = typeof remote.value !== "string" ||
504
+ !entry.contentHash ||
505
+ hashVarValue(remote.value) !== entry.contentHash;
506
+ if (remoteDrifted) {
507
+ conflicts.push({
508
+ key,
509
+ serverModifiedAt: remote.updatedAt || "unknown",
510
+ localModifiedAt: entry.modifiedAt || "unknown",
511
+ });
512
+ continue;
513
+ }
514
+ }
515
+ deletions.push(key);
516
+ }
517
+ return { upserts, deletions, conflicts, skippedCount };
518
+ }
519
+ /**
520
+ * Compute the number of config vars the server would hold after a push plan
521
+ * is applied (issue #1423 review pass 5). Validating only the local file's
522
+ * entry count misses remote-only vars: 95 local entries plus 10 vars that
523
+ * exist only on the server passes a local-count check, and the creates then
524
+ * exceed the server's cap mid-push, after earlier writes were already
525
+ * applied. Counted from the fetched remote snapshot: planned deletions remove
526
+ * keys that exist remotely (the plan already omits deletes for absent keys),
527
+ * and only upserts whose key is NOT already on the server add to the total.
528
+ * Conflicted keys are not mutated, so they contribute nothing beyond their
529
+ * current remote presence. Pure and side-effect-free so it's unit-testable
530
+ * without a live server.
531
+ */
532
+ export function countVarsAfterPush(remoteVars, plan) {
533
+ const remoteKeys = new Set(remoteVars.map((r) => r.key));
534
+ let count = remoteKeys.size;
535
+ for (const key of plan.deletions) {
536
+ if (remoteKeys.has(key))
537
+ count--;
538
+ }
539
+ for (const upsert of plan.upserts) {
540
+ if (!remoteKeys.has(upsert.key))
541
+ count++;
542
+ }
543
+ return count;
544
+ }
545
+ /**
546
+ * Compute the `sync diff` rows for config vars (issue #1423 review). Before
547
+ * this, `sync diff` ignored vars entirely, so an add/remove/value-drift between
548
+ * the local `vars.toml` and the server read as no difference. Value-aware: a
549
+ * var present on both sides whose value differs is reported as `modified`
550
+ * (framed like the other content-aware entities — `sync pull` would rewrite the
551
+ * local value). Pure and side-effect-free so it's unit-testable without a live
552
+ * server.
553
+ */
554
+ export function diffVars(localVars, remoteVars) {
555
+ const rows = [];
556
+ for (const [key, localValue] of localVars) {
557
+ if (!remoteVars.has(key)) {
558
+ rows.push({ type: "var", key, status: "local only" });
559
+ }
560
+ else if (remoteVars.get(key) !== localValue) {
561
+ rows.push({
562
+ type: "var",
563
+ key,
564
+ status: "modified",
565
+ hint: "run `sync pull` to rewrite vars.toml to match the server value",
566
+ });
567
+ }
568
+ else {
569
+ rows.push({ type: "var", key, status: "exists" });
570
+ }
571
+ }
572
+ for (const key of remoteVars.keys()) {
573
+ if (!localVars.has(key)) {
574
+ rows.push({ type: "var", key, status: "remote only" });
575
+ }
576
+ }
577
+ return rows;
230
578
  }
231
579
  function serializeIntegration(integration) {
232
580
  const data = {
@@ -239,9 +587,9 @@ function serializeIntegration(integration) {
239
587
  },
240
588
  requestConfig: integration.requestConfig || {},
241
589
  };
242
- return TOML.stringify(data);
590
+ return stringifyConfigToml(data);
243
591
  }
244
- function serializeWebhook(webhook) {
592
+ export function serializeWebhook(webhook) {
245
593
  const data = {
246
594
  webhook: {
247
595
  key: webhook.webhookKey,
@@ -256,6 +604,17 @@ function serializeWebhook(webhook) {
256
604
  secretGracePeriodMs: webhook.secretGracePeriodMs,
257
605
  },
258
606
  };
607
+ // Re-emit a `{{secrets.KEY}}` reference so a pull→push round-trip is lossless
608
+ // (#1235). The server only returns `signingSecret` when it is such a
609
+ // reference (a real secret is returned masked, never in plaintext); guard on
610
+ // the template shape here too so a masked or raw value can never be written
611
+ // to TOML.
612
+ if (typeof webhook.signingSecret === "string" &&
613
+ // Whitespace-tolerant (#1468) to match the server's shared resolver, so a
614
+ // hand-authored spaced reference `{{ secrets.KEY }}` round-trips pull→push.
615
+ /\{\{\s*secrets\.[A-Z][A-Z0-9_]{0,63}\s*\}\}/.test(webhook.signingSecret)) {
616
+ data.webhook.signingSecret = webhook.signingSecret;
617
+ }
259
618
  // Parse JSON fields if they're strings
260
619
  if (webhook.allowedIpCidrs) {
261
620
  try {
@@ -284,7 +643,7 @@ function serializeWebhook(webhook) {
284
643
  if (data.webhook[k] === undefined)
285
644
  delete data.webhook[k];
286
645
  });
287
- return TOML.stringify(data);
646
+ return stringifyConfigToml(data);
288
647
  }
289
648
  function serializeCronTrigger(trigger) {
290
649
  const data = {
@@ -320,7 +679,7 @@ function serializeCronTrigger(trigger) {
320
679
  if (data.cronTrigger[k] === undefined)
321
680
  delete data.cronTrigger[k];
322
681
  });
323
- return TOML.stringify(data);
682
+ return stringifyConfigToml(data);
324
683
  }
325
684
  function serializeBlobBucket(bucket) {
326
685
  const data = {
@@ -346,7 +705,32 @@ function serializeBlobBucket(bucket) {
346
705
  if (data.bucket[k] === undefined)
347
706
  delete data.bucket[k];
348
707
  });
349
- return TOML.stringify(data);
708
+ return stringifyConfigToml(data);
709
+ }
710
+ /**
711
+ * Build the PATCH payload for a blob-bucket update from local TOML.
712
+ *
713
+ * EXACT extraction of the inline builder shared by the existing-update and
714
+ * 409-adopt branches — do NOT "clean up" the truthiness checks. The server
715
+ * treats `preset` and `accessPolicy` as mutually exclusive, only clears
716
+ * `ruleSetId` when a preset/accessPolicy is also present, and nulls
717
+ * `description` only when the key is explicitly present with a falsy value.
718
+ * `bucketKey` and `ttlTier` are immutable and never sent. Both call sites
719
+ * must go through this helper so their field sets never drift.
720
+ */
721
+ function buildBlobBucketUpdatePayload(bucket) {
722
+ const updatePayload = {};
723
+ if (bucket.preset)
724
+ updatePayload.preset = bucket.preset;
725
+ else if (bucket.accessPolicy)
726
+ updatePayload.accessPolicy = bucket.accessPolicy;
727
+ if (bucket.ruleSetId)
728
+ updatePayload.ruleSetId = bucket.ruleSetId;
729
+ if (bucket.name)
730
+ updatePayload.name = bucket.name;
731
+ if (bucket.description !== undefined)
732
+ updatePayload.description = bucket.description || null;
733
+ return updatePayload;
350
734
  }
351
735
  function serializePrompt(prompt) {
352
736
  const data = {
@@ -369,9 +753,102 @@ function serializePrompt(prompt) {
369
753
  userPromptTemplate: config.userPromptTemplate,
370
754
  })),
371
755
  };
372
- return TOML.stringify(data);
756
+ return stringifyConfigToml(data);
373
757
  }
374
- function serializeWorkflow(workflow, draft, configs) {
758
+ /**
759
+ * Emit a workflow schema field (`inputSchema` / `outputSchema`) for the TOML
760
+ * `[workflow]` table.
761
+ *
762
+ * Issue #1446: pull used to `JSON.stringify` both schema fields into a single
763
+ * escaped-JSON line — unreadable and undiffable. We now emit the parsed JSON
764
+ * Schema as a native `[workflow.inputSchema]` / `[workflow.outputSchema]`
765
+ * table whenever it round-trips faithfully (`canEmitNative === null`), falling
766
+ * back to the JSON string only for the values TOML silently drops (`null` /
767
+ * `undefined` anywhere in the schema). The empty-object schema `{}` is a real,
768
+ * distinct value ("accepts anything") and emits as an empty table.
769
+ *
770
+ * Returns `undefined` for an absent schema (the key is then omitted). The
771
+ * sponsor-approved decision is to always move to native aggressively — no
772
+ * form preservation — so this does not consult the existing file's form.
773
+ */
774
+ function serializeWorkflowSchemaField(value, fieldName, workflowKey, log) {
775
+ if (value === undefined || value === null)
776
+ return undefined;
777
+ // Schemas arrive from the server GET as parsed objects; defensively accept a
778
+ // JSON string too (e.g. an inline-authored file re-serialized here).
779
+ let obj = value;
780
+ if (typeof value === "string") {
781
+ try {
782
+ obj = JSON.parse(value);
783
+ }
784
+ catch {
785
+ // Not valid JSON — keep the original string as the emitted value.
786
+ return value;
787
+ }
788
+ }
789
+ if (obj && typeof obj === "object") {
790
+ const reason = canEmitNative(obj);
791
+ if (reason) {
792
+ log(`Workflow '${workflowKey}': cannot emit \`${fieldName}\` as native TOML (${reason}); using JSON string.`);
793
+ return JSON.stringify(obj);
794
+ }
795
+ return obj;
796
+ }
797
+ // A non-object schema (unexpected) — preserve it as a JSON string.
798
+ return JSON.stringify(obj);
799
+ }
800
+ /**
801
+ * Rewrite a workflow TOML file's string-form `inputSchema` / `outputSchema` to
802
+ * native object form for `sync migrate-toml` (issue #1446).
803
+ *
804
+ * Purely local, no server fetch. Parses the raw source WITHOUT expanding
805
+ * fragment `include`s (so those directives survive the rewrite), swaps a
806
+ * JSON-string schema for its parsed object when it round-trips faithfully
807
+ * (`canEmitNative === null`), and re-serializes via `stringifyConfigToml`.
808
+ *
809
+ * Only rewrites when a field is actually converted: a file whose schemas are
810
+ * already native, absent, or un-representable (a `null` default) is returned
811
+ * with `changed: false` and its original bytes untouched — so migrate is
812
+ * idempotent and never reports a spurious migration or reformats a file it
813
+ * didn't need to touch.
814
+ */
815
+ export function rewriteWorkflowSchemasToNative(rawToml, workflowKey = "", log = () => { }) {
816
+ const parsed = parseConfigToml(rawToml);
817
+ if (!parsed || typeof parsed !== "object" || !parsed.workflow) {
818
+ return { content: rawToml, changed: false };
819
+ }
820
+ const workflow = parsed.workflow;
821
+ let converted = false;
822
+ for (const field of ["inputSchema", "outputSchema"]) {
823
+ const value = workflow[field];
824
+ // Only a JSON-string schema is a migration candidate; an object is already
825
+ // native and an absent key needs nothing.
826
+ if (typeof value !== "string")
827
+ continue;
828
+ let obj;
829
+ try {
830
+ obj = JSON.parse(value);
831
+ }
832
+ catch {
833
+ // Not JSON — leave the literal string in place.
834
+ continue;
835
+ }
836
+ if (!obj || typeof obj !== "object")
837
+ continue;
838
+ const reason = canEmitNative(obj);
839
+ if (reason) {
840
+ log(`Workflow '${workflowKey}': cannot migrate \`${field}\` to native TOML (${reason}); leaving as JSON string.`);
841
+ continue;
842
+ }
843
+ workflow[field] = obj;
844
+ converted = true;
845
+ }
846
+ if (!converted)
847
+ return { content: rawToml, changed: false };
848
+ return { content: stringifyConfigToml(parsed), changed: true };
849
+ }
850
+ export function serializeWorkflow(workflow, draft, configs, logger) {
851
+ const log = logger ?? (() => { });
375
852
  // Find the active config or use the first one
376
853
  const activeConfigId = workflow.activeConfigId;
377
854
  const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
@@ -391,8 +868,24 @@ function serializeWorkflow(workflow, draft, configs) {
391
868
  // #1081 — workflow principal mode. Emit only when set so a fresh
392
869
  // pull → push round-trips it without writing a noisy `runAs = ""`.
393
870
  runAs: workflow.runAs || undefined,
871
+ // #1233 — opt-in sensitive capability grants. The GET response always
872
+ // returns an array (empty when unset); emit only when non-empty so a
873
+ // fresh pull → push round-trips a real value without writing a noisy
874
+ // `capabilities = []`. This is the load-bearing fix: before this, pull
875
+ // dropped capabilities entirely and a pull → push cycle silently cleared
876
+ // them on the server.
877
+ capabilities: Array.isArray(workflow.capabilities) && workflow.capabilities.length > 0
878
+ ? workflow.capabilities
879
+ : undefined,
394
880
  perUserMaxRunning: workflow.perUserMaxRunning,
395
881
  perUserMaxQueued: workflow.perUserMaxQueued,
882
+ // #1177 — app-level queue limits + queue TTL. Emit only when set so a
883
+ // fresh pull → push round-trips them without writing noisy zero/empty
884
+ // keys. The shared payload builder forwards these on create AND update,
885
+ // so the pull serializer must surface them for the round-trip to close.
886
+ perAppMaxRunning: workflow.perAppMaxRunning ?? undefined,
887
+ perAppMaxQueued: workflow.perAppMaxQueued ?? undefined,
888
+ queueTtlSeconds: workflow.queueTtlSeconds ?? undefined,
396
889
  dequeueOrder: workflow.dequeueOrder,
397
890
  // Sync-callable flags (#807): always emit both so a fresh pull → push
398
891
  // cycle round-trips them. The GET response normalizes these to booleans
@@ -400,9 +893,11 @@ function serializeWorkflow(workflow, draft, configs) {
400
893
  // they're safe to coerce here.
401
894
  requiresClientApply: workflow.requiresClientApply !== false,
402
895
  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,
896
+ // Schemas are at workflow level. Emit native `[workflow.inputSchema]` /
897
+ // `[workflow.outputSchema]` tables when representable, JSON string only
898
+ // as a data-loss fallback (issue #1446).
899
+ inputSchema: serializeWorkflowSchemaField(workflow.inputSchema, "inputSchema", workflow.workflowKey, log),
900
+ outputSchema: serializeWorkflowSchemaField(workflow.outputSchema, "outputSchema", workflow.workflowKey, log),
406
901
  },
407
902
  // Use active config steps, fall back to draft
408
903
  steps,
@@ -413,8 +908,12 @@ function serializeWorkflow(workflow, draft, configs) {
413
908
  status: config.status,
414
909
  isActive: config.configId === activeConfigId,
415
910
  })) || [],
911
+ // #1304 (P-C): the declared-access manifest — top-level `[metadata]` /
912
+ // `secrets`, merged the same way the collection/database type configs do so
913
+ // a workflow's `md`/`secrets` declarations round-trip through pull → push.
914
+ ...serializeDeclaredAccessManifest(workflow.metadataManifest),
416
915
  };
417
- return TOML.stringify(data);
916
+ return stringifyConfigToml(data);
418
917
  }
419
918
  // Database config serialization helpers
420
919
  //
@@ -423,15 +922,17 @@ function serializeWorkflow(workflow, draft, configs) {
423
922
  // rows over JSON-strings stuffed into a single field. The form is sticky
424
923
  // per file — if the existing file uses the legacy JSON-string form, we
425
924
  // 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.
925
+ // native. The round-trip serializes with `smol-toml` (TOML 1.0), so
926
+ // heterogeneous arrays emit natively (issue #1450); only shapes that TOML
927
+ // genuinely can't represent without data loss (null / undefined / an array
928
+ // containing null) fall back to a JSON string per field with a log message.
428
929
  //
429
930
  // Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
430
931
  // blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
431
932
  // object so the result round-trips as `[models.<Name>.fields.<field>]`.
432
933
  function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
433
934
  const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
434
- return TOML.stringify(data);
935
+ return stringifyConfigToml(data);
435
936
  }
436
937
  function serializeEmailTemplate(template) {
437
938
  // The API returns { emailType, hasOverride, override: { subject, htmlBody, textBody }, default: { ... } }
@@ -449,7 +950,7 @@ function serializeEmailTemplate(template) {
449
950
  if (override.textBody) {
450
951
  data.template.textBody = override.textBody;
451
952
  }
452
- return TOML.stringify(data);
953
+ return stringifyConfigToml(data);
453
954
  }
454
955
  function serializeRuleSet(ruleSet) {
455
956
  const data = {
@@ -466,9 +967,9 @@ function serializeRuleSet(ruleSet) {
466
967
  if (Object.keys(rules).length > 0) {
467
968
  data.rules = rules;
468
969
  }
469
- return TOML.stringify(data);
970
+ return stringifyConfigToml(data);
470
971
  }
471
- function serializeGroupTypeConfig(config, ruleSetIdToName) {
972
+ export function serializeGroupTypeConfig(config, ruleSetIdToName) {
472
973
  const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
473
974
  const data = {
474
975
  groupTypeConfig: {
@@ -476,8 +977,12 @@ function serializeGroupTypeConfig(config, ruleSetIdToName) {
476
977
  ruleSetName: ruleSetName,
477
978
  autoAddCreator: config.autoAddCreator ?? true,
478
979
  },
980
+ // Declared-access manifest (issue #1350, extending #1304 P-C): merge the
981
+ // top-level `[metadata]` + `secrets` fragments so a pull→push→pull cycle
982
+ // round-trips, exactly like the collection/database/workflow type configs.
983
+ ...serializeDeclaredAccessManifest(config.metadataManifest),
479
984
  };
480
- return TOML.stringify(data);
985
+ return stringifyConfigToml(data);
481
986
  }
482
987
  function serializeCollectionTypeConfig(config, ruleSetIdToName) {
483
988
  const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
@@ -486,8 +991,11 @@ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
486
991
  collectionType: config.collectionType,
487
992
  ruleSetName: ruleSetName,
488
993
  },
994
+ // Declared-access manifest (issue #1304, P-C): merge the top-level
995
+ // `[metadata]` + `secrets` fragments so a pull→push→pull cycle round-trips.
996
+ ...serializeDeclaredAccessManifest(config.metadataManifest),
489
997
  };
490
- return TOML.stringify(data);
998
+ return stringifyConfigToml(data);
491
999
  }
492
1000
  export function parseDatabaseTypeToml(tomlData) {
493
1001
  const typeSection = tomlData.type || {};
@@ -530,6 +1038,17 @@ export function parseDatabaseTypeToml(tomlData) {
530
1038
  if (tomlData.triggers) {
531
1039
  typeConfig.triggers = tomlData.triggers;
532
1040
  }
1041
+ // Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
1042
+ // `secrets` blocks. Validated locally before push; the server re-validates
1043
+ // (graph/schema) at persist time.
1044
+ const manifest = parseDeclaredAccessManifestToml(tomlData);
1045
+ if (manifest) {
1046
+ const manifestError = validateDeclaredAccessManifest(manifest);
1047
+ if (manifestError) {
1048
+ throw new Error(`Invalid metadata manifest in database type "${typeSection.databaseType || ""}": ${manifestError}`);
1049
+ }
1050
+ typeConfig.metadataManifest = manifest;
1051
+ }
533
1052
  // Issue #666: extract the `[models.*]` subtree (if present) and re-serialize
534
1053
  // it to a TOML string for the server's `schema` field.
535
1054
  //
@@ -542,7 +1061,7 @@ export function parseDatabaseTypeToml(tomlData) {
542
1061
  let schemaSerialized = null;
543
1062
  if (tomlData.models && typeof tomlData.models === "object") {
544
1063
  try {
545
- schemaSerialized = TOML.stringify({ models: tomlData.models });
1064
+ schemaSerialized = stringifyConfigToml({ models: tomlData.models });
546
1065
  }
547
1066
  catch {
548
1067
  // Fall through — server-side parser will fail with a clear
@@ -584,6 +1103,17 @@ export function parseGroupTypeConfigToml(tomlData) {
584
1103
  else if (section.ruleSetId && section.ruleSetId !== "") {
585
1104
  result.ruleSetId = section.ruleSetId;
586
1105
  }
1106
+ // Declared-access manifest (issue #1350, extending #1304 P-C): the top-level
1107
+ // `[metadata]` + `secrets` blocks. Validated locally before push; the server
1108
+ // re-validates (graph/schema) at persist time.
1109
+ const manifest = parseDeclaredAccessManifestToml(tomlData);
1110
+ if (manifest) {
1111
+ const manifestError = validateDeclaredAccessManifest(manifest);
1112
+ if (manifestError) {
1113
+ throw new Error(`Invalid metadata manifest in group type config "${section.groupType || ""}": ${manifestError}`);
1114
+ }
1115
+ result.metadataManifest = manifest;
1116
+ }
587
1117
  return result;
588
1118
  }
589
1119
  export function parseCollectionTypeConfigToml(tomlData) {
@@ -598,6 +1128,17 @@ export function parseCollectionTypeConfigToml(tomlData) {
598
1128
  else if (section.ruleSetId && section.ruleSetId !== "") {
599
1129
  result.ruleSetId = section.ruleSetId;
600
1130
  }
1131
+ // Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
1132
+ // `secrets` blocks. Validated locally before push; the server re-validates
1133
+ // (graph/schema) at persist time.
1134
+ const manifest = parseDeclaredAccessManifestToml(tomlData);
1135
+ if (manifest) {
1136
+ const manifestError = validateDeclaredAccessManifest(manifest);
1137
+ if (manifestError) {
1138
+ throw new Error(`Invalid metadata manifest in collection type config "${section.collectionType || ""}": ${manifestError}`);
1139
+ }
1140
+ result.metadataManifest = manifest;
1141
+ }
601
1142
  return result;
602
1143
  }
603
1144
  // Parsing helpers
@@ -609,7 +1150,7 @@ export function parseCollectionTypeConfigToml(tomlData) {
609
1150
  // the original object untouched.
610
1151
  function parseTomlFile(filePath) {
611
1152
  const content = readFileSync(filePath, "utf-8");
612
- const parsed = TOML.parse(content);
1153
+ const parsed = parseConfigToml(content);
613
1154
  return expandWorkflowTomlData(parsed, filePath);
614
1155
  }
615
1156
  /**
@@ -629,17 +1170,6 @@ export async function fetchAll(listFn, pageSize = 100, maxPages = 100) {
629
1170
  } while (cursor);
630
1171
  return all;
631
1172
  }
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
1173
  /**
644
1174
  * Resolve a key-based rule set name reference to an ID.
645
1175
  * Throws if the name cannot be resolved and throwOnMissing is true.
@@ -681,7 +1211,14 @@ export function resolveSlugCollisions(slug, usedSlugs) {
681
1211
  return resolved;
682
1212
  }
683
1213
  function getTestsDir(configDir, blockType, blockKey) {
684
- const typeDir = blockType === "prompt" ? "prompts" : "workflows";
1214
+ // Scripts (blockType "script") live under `transforms/` on disk (their
1215
+ // bodies are `transforms/<name>.rhai`), so their test cases sit alongside at
1216
+ // `transforms/<name>.tests/`. Prompts and workflows keep their own dirs.
1217
+ const typeDir = blockType === "prompt"
1218
+ ? "prompts"
1219
+ : blockType === "script"
1220
+ ? "transforms"
1221
+ : "workflows";
685
1222
  return join(configDir, typeDir, `${blockKey}.tests`);
686
1223
  }
687
1224
  export function serializeTestCase(testCase, lookupMaps) {
@@ -705,7 +1242,7 @@ export function serializeTestCase(testCase, lookupMaps) {
705
1242
  expectedJsonSubset: testCase.expectedJsonSubset || "{}",
706
1243
  },
707
1244
  };
708
- return TOML.stringify(data);
1245
+ return stringifyConfigToml(data);
709
1246
  }
710
1247
  export function parseTestCaseToml(tomlData) {
711
1248
  const test = tomlData.test || {};
@@ -809,8 +1346,15 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
809
1346
  items = result.items || [];
810
1347
  }
811
1348
  catch {
812
- return { scriptEntities, count: 0 };
1349
+ return {
1350
+ scriptEntities,
1351
+ count: 0,
1352
+ presence: { ok: false },
1353
+ listOk: false,
1354
+ serverKeys: [],
1355
+ };
813
1356
  }
1357
+ let detailsOk = true;
814
1358
  let written = 0;
815
1359
  for (const script of items) {
816
1360
  const name = script?.name;
@@ -829,6 +1373,7 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
829
1373
  full = await client.getScript(appId, script.scriptId);
830
1374
  }
831
1375
  catch {
1376
+ detailsOk = false;
832
1377
  logger(` Skipped transforms/${name}.rhai (could not fetch config)`);
833
1378
  continue;
834
1379
  }
@@ -853,7 +1398,11 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
853
1398
  written += 1;
854
1399
  logger(` Wrote transforms/${filename}`);
855
1400
  }
856
- return { scriptEntities, count: written };
1401
+ const serverKeys = items.map((s) => s?.name).filter(Boolean);
1402
+ const presence = detailsOk
1403
+ ? { ok: true, serverKeys }
1404
+ : { ok: false };
1405
+ return { scriptEntities, count: written, presence, listOk: true, serverKeys };
857
1406
  }
858
1407
  async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
859
1408
  let testCases;
@@ -963,8 +1512,10 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
963
1512
  const configMapKey = `${blockKey}#${testPayload._configName}`;
964
1513
  const configMap = blockType === "prompt"
965
1514
  ? resolutionMaps.promptConfigNameToId
966
- : resolutionMaps.workflowConfigNameToId;
967
- const resolvedId = configMap.get(configMapKey);
1515
+ : blockType === "script"
1516
+ ? resolutionMaps.scriptConfigNameToId
1517
+ : resolutionMaps.workflowConfigNameToId;
1518
+ const resolvedId = configMap?.get(configMapKey);
968
1519
  if (resolvedId) {
969
1520
  testPayload.configId = resolvedId;
970
1521
  }
@@ -1166,6 +1717,7 @@ Directory Structure:
1166
1717
  rule-sets/*.toml # Access rule sets
1167
1718
  group-type-configs/*.toml # Group type configs
1168
1719
  collection-type-configs/*.toml # Collection type configs
1720
+ metadata-category-configs/*.toml # Resource-metadata category configs
1169
1721
  email-templates/*.toml # Email template overrides
1170
1722
  `);
1171
1723
  // Init
@@ -1196,6 +1748,7 @@ Directory Structure:
1196
1748
  ensureDir(join(configDir, "rule-sets"));
1197
1749
  ensureDir(join(configDir, "group-type-configs"));
1198
1750
  ensureDir(join(configDir, "collection-type-configs"));
1751
+ ensureDir(join(configDir, "metadata-category-configs"));
1199
1752
  ensureDir(join(configDir, "email-templates"));
1200
1753
  const state = {
1201
1754
  appId: resolvedAppId,
@@ -1215,6 +1768,7 @@ Directory Structure:
1215
1768
  .option("--app <app-id>", "App ID")
1216
1769
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
1217
1770
  .option("--json", "Output as JSON instead of writing files")
1771
+ .option("--no-prune", "Keep local files for entities that are absent from the sync export (the next push will re-create them)")
1218
1772
  .action(async (appId, options) => {
1219
1773
  const resolvedAppId = resolveAppId(appId, options);
1220
1774
  const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
@@ -1227,14 +1781,24 @@ Directory Structure:
1227
1781
  const client = new ApiClient();
1228
1782
  info(`Pulling configuration for app ${resolvedAppId}...`);
1229
1783
  try {
1230
- // Fetch all data
1231
- const [settings, integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
1784
+ // Fetch all data. Issue #1659: prune decides whether a local file's
1785
+ // entity still exists server-side, so every fetch that establishes
1786
+ // presence must report success or failure explicitly — an error that
1787
+ // reads as an empty list would prune every local file of that type.
1788
+ // `integrations`/`prompts`/`workflows` throw straight out of the pull
1789
+ // on failure (no `.catch`), so reaching the prune pass at all proves
1790
+ // their listings succeeded. The types that swallow errors below carry
1791
+ // an explicit outcome instead.
1792
+ const [settings, integrationItems, promptItems, workflowItems, emailTemplatesOutcome] = await Promise.all([
1232
1793
  client.getAppSettings(resolvedAppId).catch(() => null),
1233
1794
  fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
1234
1795
  fetchAll((p) => client.listPrompts(resolvedAppId, p)),
1235
1796
  fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
1236
- client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
1797
+ attemptFetch(() => client.listEmailTemplates(resolvedAppId)),
1237
1798
  ]);
1799
+ const emailTemplatesResult = emailTemplatesOutcome.ok
1800
+ ? emailTemplatesOutcome.value
1801
+ : { templates: [] };
1238
1802
  // Fetch details for each entity
1239
1803
  const integrations = await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
1240
1804
  const prompts = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
@@ -1259,29 +1823,71 @@ Directory Structure:
1259
1823
  }
1260
1824
  return workflowData;
1261
1825
  }));
1262
- // Fetch full details for email template overrides
1826
+ // Fetch full details for email template overrides. Only overrides get a
1827
+ // local file, so they are the type's server key set. A detail fetch that
1828
+ // fails leaves the override unwritten — presence for that key is no
1829
+ // longer established, so the type is skipped for pruning (#1659).
1263
1830
  const emailTemplateOverrides = emailTemplatesResult.templates.filter((t) => t.hasOverride);
1264
- const emailTemplates = await Promise.all(emailTemplateOverrides.map((t) => client.getEmailTemplate(resolvedAppId, t.emailType).catch(() => null))).then((results) => results.filter(Boolean));
1831
+ const emailTemplateOutcomes = await Promise.all(emailTemplateOverrides.map((t) => attemptFetch(() => client.getEmailTemplate(resolvedAppId, t.emailType))));
1832
+ const emailTemplates = emailTemplateOutcomes
1833
+ .map((o) => (o.ok ? o.value : null))
1834
+ .filter(Boolean);
1835
+ const emailTemplateDetailsOk = emailTemplateOutcomes.every((o) => o.ok && o.value);
1836
+ const emailTemplatePresence = emailTemplatesOutcome.ok && emailTemplateDetailsOk
1837
+ ? {
1838
+ ok: true,
1839
+ serverKeys: emailTemplateOverrides.map((t) => t.emailType),
1840
+ }
1841
+ : { ok: false };
1265
1842
  if (options.json) {
1266
1843
  json({ settings, integrations, prompts, workflows, emailTemplates });
1267
1844
  return;
1268
1845
  }
1269
1846
  // Fetch database config resources
1270
- const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult,] = await Promise.all([
1271
- client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
1272
- client.listRuleSets(resolvedAppId).catch(() => []),
1273
- client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
1274
- client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
1847
+ // These five listings previously used `.catch(() => [])`, which made a
1848
+ // transient 401/500/network error indistinguishable from "the app has
1849
+ // none of these". Pull still tolerates the failure the same way (it
1850
+ // writes nothing for that type and carries on), but it now records the
1851
+ // outcome so the prune pass can skip a type whose presence is unknown
1852
+ // rather than delete every local file of it (#1659).
1853
+ const [databaseTypeConfigsOutcome, ruleSetsOutcome, groupTypeConfigsOutcome, collectionTypeConfigsOutcome, metadataCategoryConfigsOutcome,] = await Promise.all([
1854
+ attemptFetch(() => client.listDatabaseTypeConfigs(resolvedAppId)),
1855
+ attemptFetch(() => client.listRuleSets(resolvedAppId)),
1856
+ attemptFetch(() => client.listGroupTypeConfigs(resolvedAppId)),
1857
+ attemptFetch(() => client.listCollectionTypeConfigs(resolvedAppId)),
1858
+ attemptFetch(() => client.listMetadataCategoryConfigs(resolvedAppId)),
1275
1859
  ]);
1860
+ const databaseTypeConfigsResult = databaseTypeConfigsOutcome.ok
1861
+ ? databaseTypeConfigsOutcome.value
1862
+ : [];
1863
+ const ruleSetsResult = ruleSetsOutcome.ok ? ruleSetsOutcome.value : [];
1864
+ const groupTypeConfigsResult = groupTypeConfigsOutcome.ok
1865
+ ? groupTypeConfigsOutcome.value
1866
+ : [];
1867
+ const collectionTypeConfigsResult = collectionTypeConfigsOutcome.ok
1868
+ ? collectionTypeConfigsOutcome.value
1869
+ : [];
1870
+ const metadataCategoryConfigsResult = metadataCategoryConfigsOutcome.ok
1871
+ ? metadataCategoryConfigsOutcome.value
1872
+ : [];
1276
1873
  // Fetch operations + subscriptions for each database type. Issue #803:
1277
1874
  // subscriptions are pulled symmetrically with operations so a
1278
1875
  // pull → push cycle round-trips `[[subscriptions]]` blocks without a
1279
1876
  // spurious diff. The list endpoint already excludes archived rows.
1877
+ //
1878
+ // A failed operation/subscription fetch also disqualifies the type from
1879
+ // pruning (#1659): the file we write for it is then incomplete, so the
1880
+ // pull's picture of that type is not one to reconcile against.
1881
+ let databaseTypeDetailsOk = true;
1280
1882
  const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
1281
- const [ops, subs] = await Promise.all([
1282
- client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []),
1283
- client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType).catch(() => []),
1883
+ const [opsOutcome, subsOutcome] = await Promise.all([
1884
+ attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)),
1885
+ attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType)),
1284
1886
  ]);
1887
+ if (!opsOutcome.ok || !subsOutcome.ok)
1888
+ databaseTypeDetailsOk = false;
1889
+ const ops = opsOutcome.ok ? opsOutcome.value : [];
1890
+ const subs = subsOutcome.ok ? subsOutcome.value : [];
1285
1891
  return {
1286
1892
  typeConfig,
1287
1893
  operations: Array.isArray(ops) ? ops : [],
@@ -1330,13 +1936,52 @@ Directory Structure:
1330
1936
  ensureDir(join(configDir, "rule-sets"));
1331
1937
  ensureDir(join(configDir, "group-type-configs"));
1332
1938
  ensureDir(join(configDir, "collection-type-configs"));
1939
+ ensureDir(join(configDir, "metadata-category-configs"));
1333
1940
  ensureDir(join(configDir, "email-templates"));
1941
+ // The prior sync state is the record of which local files a previous
1942
+ // pull wrote — the discriminator the prune pass keys off (#1659). Read
1943
+ // it now: `saveSyncState` at the end of the pull overwrites it, and the
1944
+ // files themselves are about to be rewritten.
1945
+ const priorState = loadSyncState(configDir);
1946
+ const priorEntities = priorState?.entities ?? {};
1334
1947
  // Write app settings
1335
1948
  if (settings) {
1336
1949
  const appTomlPath = join(configDir, "app.toml");
1337
1950
  writeFileSync(appTomlPath, serializeAppSettings(settings));
1338
1951
  info(" Wrote app.toml");
1339
1952
  }
1953
+ // Write config vars (issue #1423). A single flat vars.toml at the
1954
+ // sync-dir root (like app.toml), holding the per-environment non-secret
1955
+ // scalars. Written (comment-only when empty) so the managed file exists
1956
+ // and a later push knows the vars surface is under sync control.
1957
+ //
1958
+ // Distinguish "app genuinely has zero vars" (a successful empty
1959
+ // response — safe to write the comment-only file) from "fetch failed"
1960
+ // (a transient 401/500/network error). Previously a `.catch(() => [])`
1961
+ // conflated the two, so a transient error silently clobbered a good
1962
+ // local vars.toml with a comment-only file while reporting success. On a
1963
+ // fetch error we now leave the existing vars.toml untouched and preserve
1964
+ // the prior manifest entry so state stays consistent with the file.
1965
+ const varsTomlPathPull = join(configDir, "vars.toml");
1966
+ let varsOutcome;
1967
+ try {
1968
+ varsOutcome = { ok: true, vars: await client.listAppConfigVars(resolvedAppId) };
1969
+ }
1970
+ catch (varErr) {
1971
+ varsOutcome = { ok: false, error: varErr };
1972
+ }
1973
+ const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : priorEntities.vars);
1974
+ const varEntities = varsPlan.varEntities;
1975
+ if (varsPlan.write && varsPlan.content !== null) {
1976
+ writeFileSync(varsTomlPathPull, varsPlan.content);
1977
+ const count = varsOutcome.vars.length;
1978
+ info(` Wrote vars.toml (${count} var${count === 1 ? "" : "s"})`);
1979
+ }
1980
+ else {
1981
+ const err = varsOutcome.error;
1982
+ error(` Skipped vars.toml — failed to fetch config vars: ${err?.message ?? err}. ` +
1983
+ "Left the existing vars.toml untouched.");
1984
+ }
1340
1985
  // Write integrations
1341
1986
  const integrationEntities = {};
1342
1987
  for (const integration of integrations) {
@@ -1367,15 +2012,13 @@ Directory Structure:
1367
2012
  };
1368
2013
  }
1369
2014
  info(` Pulled ${webhooks.length} webhook(s)`);
1370
- // Pull cron triggers
1371
- let cronTriggerItems = [];
1372
- try {
1373
- const cronResult = await client.listCronTriggers(resolvedAppId);
1374
- cronTriggerItems = cronResult.items || [];
1375
- }
1376
- catch {
1377
- // Cron triggers may not be available on older servers
1378
- }
2015
+ // Pull cron triggers. An older server without the route (or any other
2016
+ // failure) leaves the type unlisted — tolerated as before, but recorded
2017
+ // so prune skips it rather than reading it as "no cron triggers".
2018
+ const cronTriggersOutcome = await attemptFetch(() => client.listCronTriggers(resolvedAppId));
2019
+ const cronTriggerItems = cronTriggersOutcome.ok
2020
+ ? cronTriggersOutcome.value.items || []
2021
+ : [];
1379
2022
  const cronTriggersDir = join(configDir, "cron-triggers");
1380
2023
  mkdirSync(cronTriggersDir, { recursive: true });
1381
2024
  const cronTriggerEntities = {};
@@ -1391,8 +2034,10 @@ Directory Structure:
1391
2034
  }
1392
2035
  info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
1393
2036
  // Pull blob buckets
1394
- const blobBucketsResult = await client.listBlobBuckets(resolvedAppId).catch(() => ({ items: [] }));
1395
- const blobBucketItems = blobBucketsResult.items || [];
2037
+ const blobBucketsOutcome = await attemptFetch(() => client.listBlobBuckets(resolvedAppId));
2038
+ const blobBucketItems = blobBucketsOutcome.ok
2039
+ ? blobBucketsOutcome.value.items || []
2040
+ : [];
1396
2041
  const blobBucketsDir = join(configDir, "blob-buckets");
1397
2042
  mkdirSync(blobBucketsDir, { recursive: true });
1398
2043
  const blobBucketEntities = {};
@@ -1425,7 +2070,7 @@ Directory Structure:
1425
2070
  for (const { workflow, draft, configs } of workflows) {
1426
2071
  const filename = `${workflow.workflowKey}.toml`;
1427
2072
  const filePath = join(configDir, "workflows", filename);
1428
- writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
2073
+ writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || [], (msg) => info(` ${msg}`)));
1429
2074
  // Hash the expanded (post-fragment-splice) form so subsequent pushes
1430
2075
  // can detect fragment-only edits. See `computeExpandedContentHash`.
1431
2076
  // Pulled workflows never carry `include`, so the expander is a
@@ -1445,7 +2090,7 @@ Directory Structure:
1445
2090
  // `transforms/<name>.rhai`, and record `{id, modifiedAt,
1446
2091
  // contentHash}` under `entities.scripts[name]` so a subsequent
1447
2092
  // `sync push` round-trips without false diffs.
1448
- const { scriptEntities, count: pulledScriptsCount } = await pullScripts(client, resolvedAppId, configDir, info);
2093
+ const { scriptEntities, count: pulledScriptsCount, presence: scriptPresence, listOk: scriptListOk, serverKeys: scriptServerKeys, } = await pullScripts(client, resolvedAppId, configDir, info);
1449
2094
  if (pulledScriptsCount > 0) {
1450
2095
  info(` Pulled ${pulledScriptsCount} transform(s)`);
1451
2096
  }
@@ -1468,6 +2113,23 @@ Directory Structure:
1468
2113
  }
1469
2114
  }
1470
2115
  }
2116
+ // Include script config names so a pinned script test case
2117
+ // round-trips its `configName` reference (the same way prompt/workflow
2118
+ // configs above do). One extra `configs` fetch per pulled script.
2119
+ for (const [name, entity] of Object.entries(scriptEntities)) {
2120
+ try {
2121
+ const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, entity.id);
2122
+ for (const config of scriptConfigs) {
2123
+ configIdToName.set(config.configId, config.configName);
2124
+ }
2125
+ }
2126
+ catch {
2127
+ // Non-fatal: a script test case pinned to a config just serializes
2128
+ // without a resolvable name (falls back to active on push). The
2129
+ // header/body still pulled fine — `name` retained for clarity.
2130
+ void name;
2131
+ }
2132
+ }
1471
2133
  const testCaseLookupMaps = { configIdToName, promptIdToKey };
1472
2134
  // Write email templates (use detailed emailTemplates, not summaries)
1473
2135
  const emailTemplateEntities = {};
@@ -1480,19 +2142,10 @@ Directory Structure:
1480
2142
  };
1481
2143
  info(` Wrote email-templates/${filename}`);
1482
2144
  }
1483
- // Clean up local email template files for remotely-deleted overrides
1484
- const emailTemplatesDir = join(configDir, "email-templates");
1485
- if (existsSync(emailTemplatesDir)) {
1486
- const pulledTypes = new Set(emailTemplates.map((t) => t.emailType));
1487
- const localFiles = readdirSync(emailTemplatesDir).filter((f) => f.endsWith(".toml"));
1488
- for (const file of localFiles) {
1489
- const localType = basename(file, ".toml");
1490
- if (!pulledTypes.has(localType)) {
1491
- unlinkSync(join(emailTemplatesDir, file));
1492
- info(` Removed email-templates/${file} (override deleted on server)`);
1493
- }
1494
- }
1495
- }
2145
+ // Email-template files for removed overrides used to be cleaned up here
2146
+ // by a hand-coded loop — the only type that pruned at all (#1659). It is
2147
+ // now folded into the general prune pass below, under the same rules and
2148
+ // the same `--no-prune` flag as every other type.
1496
2149
  // Pull test cases for prompts and workflows
1497
2150
  const testCaseEntities = {};
1498
2151
  let totalTestCases = 0;
@@ -1510,6 +2163,15 @@ Directory Structure:
1510
2163
  }
1511
2164
  totalTestCases += count;
1512
2165
  }
2166
+ // Pull test cases for scripts (transforms). Script blocks use
2167
+ // blockType "script"; their tests land under transforms/<name>.tests/.
2168
+ for (const [name, entity] of Object.entries(scriptEntities)) {
2169
+ const count = await pullTestCasesForBlock(client, resolvedAppId, "script", entity.id, name, configDir, testCaseEntities, testCaseLookupMaps);
2170
+ if (count > 0) {
2171
+ info(` Wrote ${count} test case(s) for script: ${name}`);
2172
+ }
2173
+ totalTestCases += count;
2174
+ }
1513
2175
  // Build ruleSetId → name map for database types and group type configs
1514
2176
  const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
1515
2177
  const ruleSetIdToName = new Map();
@@ -1566,7 +2228,7 @@ Directory Structure:
1566
2228
  // Write rule sets
1567
2229
  const ruleSetEntities = {};
1568
2230
  for (const ruleSet of ruleSets) {
1569
- const fileKey = (ruleSet.name || ruleSet.ruleSetId).replace(/[/\\:*?"<>|]/g, "_");
2231
+ const fileKey = ruleSetFileKey(ruleSet);
1570
2232
  const filename = `${fileKey}.toml`;
1571
2233
  const filePath = join(configDir, "rule-sets", filename);
1572
2234
  writeFileSync(filePath, serializeRuleSet(ruleSet));
@@ -1605,6 +2267,199 @@ Directory Structure:
1605
2267
  };
1606
2268
  info(` Wrote collection-type-configs/${filename}`);
1607
2269
  }
2270
+ // Write metadata category configs (issue #1304, P-B). One file per
2271
+ // `{resourceType, category}`; the entity key is the `resourceType#category`
2272
+ // pair so push can match it back.
2273
+ const metadataCategoryConfigEntities = {};
2274
+ const metadataCategoryConfigs = Array.isArray(metadataCategoryConfigsResult)
2275
+ ? metadataCategoryConfigsResult
2276
+ : [];
2277
+ for (const config of metadataCategoryConfigs) {
2278
+ const filename = `${config.resourceType}.${config.category}.toml`;
2279
+ const filePath = join(configDir, "metadata-category-configs", filename);
2280
+ writeFileSync(filePath, serializeMetadataCategoryConfig(config));
2281
+ metadataCategoryConfigEntities[`${config.resourceType}#${config.category}`] = {
2282
+ modifiedAt: config.modifiedAt || new Date().toISOString(),
2283
+ contentHash: computeFileHash(filePath),
2284
+ };
2285
+ info(` Wrote metadata-category-configs/${filename}`);
2286
+ }
2287
+ // ── Prune reconciliation (issue #1659) ───────────────────────────────
2288
+ //
2289
+ // Every write above is done, so we now know which entities the server
2290
+ // still exports. A local file is removed iff its key was MANAGED (a
2291
+ // prior pull wrote it, so it is in `priorEntities[<slot>]`) and is
2292
+ // ABSENT from this pull's server keys. A file that was never in prior
2293
+ // state was hand-authored — it stays, and `sync diff` reports it as new.
2294
+ //
2295
+ // Presence comes from the server LISTING, never from "what we just
2296
+ // wrote": several types legitimately skip writing a present entity (a
2297
+ // script with no active config), and pruning those would delete a live
2298
+ // entity's file. A type whose presence could not be established is
2299
+ // skipped whole, and its prior state slot is preserved so the managed
2300
+ // discriminator survives to the next pull.
2301
+ //
2302
+ // This runs after the pre-pull snapshot, so `sync revert` restores
2303
+ // anything pruned in error.
2304
+ const presenceBySlot = {
2305
+ integrations: { ok: true, serverKeys: integrationItems.map((i) => i.integrationKey) },
2306
+ webhooks: { ok: true, serverKeys: webhookItems.map((w) => w.webhookKey) },
2307
+ prompts: { ok: true, serverKeys: promptItems.map((p) => p.promptKey) },
2308
+ workflows: { ok: true, serverKeys: workflowItems.map((w) => w.workflowKey) },
2309
+ cronTriggers: cronTriggersOutcome.ok
2310
+ ? { ok: true, serverKeys: cronTriggerItems.map((t) => t.triggerKey) }
2311
+ : { ok: false },
2312
+ blobBuckets: blobBucketsOutcome.ok
2313
+ ? { ok: true, serverKeys: blobBucketItems.map((b) => b.bucketKey) }
2314
+ : { ok: false },
2315
+ scripts: scriptPresence,
2316
+ emailTemplates: emailTemplatePresence,
2317
+ databaseTypes: databaseTypeConfigsOutcome.ok && databaseTypeDetailsOk
2318
+ ? { ok: true, serverKeys: databaseTypesWithOps.map((d) => d.typeConfig.databaseType) }
2319
+ : { ok: false },
2320
+ ruleSets: ruleSetsOutcome.ok
2321
+ ? { ok: true, serverKeys: ruleSets.map((rs) => ruleSetFileKey(rs)) }
2322
+ : { ok: false },
2323
+ groupTypeConfigs: groupTypeConfigsOutcome.ok
2324
+ ? { ok: true, serverKeys: groupTypeConfigs.map((c) => c.groupType) }
2325
+ : { ok: false },
2326
+ collectionTypeConfigs: collectionTypeConfigsOutcome.ok
2327
+ ? { ok: true, serverKeys: collectionTypeConfigs.map((c) => c.collectionType) }
2328
+ : { ok: false },
2329
+ metadataCategoryConfigs: metadataCategoryConfigsOutcome.ok
2330
+ ? {
2331
+ ok: true,
2332
+ serverKeys: metadataCategoryConfigs.map((c) => `${c.resourceType}#${c.category}`),
2333
+ }
2334
+ : { ok: false },
2335
+ };
2336
+ let prunedCount = 0;
2337
+ // Keys whose file this prune deliberately left in place. They are absent
2338
+ // server-side, so nothing wrote them into this pull's entity map — see
2339
+ // the restore pass below `keepPrior`.
2340
+ const keptKeysBySlot = {};
2341
+ for (const resourceType of SYNC_RESOURCE_TYPES) {
2342
+ const presence = presenceBySlot[resourceType.stateSlot] ?? { ok: false };
2343
+ const plan = planPrune({
2344
+ resourceType,
2345
+ priorKeys: Object.keys(priorEntities[resourceType.stateSlot] ?? {}),
2346
+ presence,
2347
+ enabled: options.prune !== false,
2348
+ });
2349
+ if (plan.skipped) {
2350
+ // `--no-prune` is the operator's explicit choice — no warning for it.
2351
+ // A failed fetch is worth saying out loud: stale files survive this
2352
+ // pull, and the next push would re-create their entities.
2353
+ if (plan.skipReason === "fetch-failed" && Object.keys(priorEntities[resourceType.stateSlot] ?? {}).length > 0) {
2354
+ warn(` Skipped pruning ${resourceType.dir} — could not establish which entities the server has; ` +
2355
+ "local files left untouched");
2356
+ }
2357
+ // Only worth saying when a file is actually being left behind:
2358
+ // this listing is incomplete on every pull, so warning
2359
+ // unconditionally would just train operators to ignore it.
2360
+ if (plan.skipReason === "listing-incomplete" && plan.unprunedAbsentKeys.length > 0) {
2361
+ warn(` Skipped pruning ${plan.unprunedAbsentKeys.length} ${resourceType.dir} file(s) — ` +
2362
+ `the server's ${resourceType.dir} listing is not paginated, so an entity missing from it ` +
2363
+ "may still be live (#1668). Delete the file yourself if the entity is really gone: " +
2364
+ plan.unprunedAbsentKeys.join(", "));
2365
+ }
2366
+ // The files stayed, so their keys stay managed — same rule as the
2367
+ // kept-file restore below.
2368
+ if (plan.unprunedAbsentKeys.length > 0) {
2369
+ keptKeysBySlot[resourceType.stateSlot] = [...plan.unprunedAbsentKeys];
2370
+ }
2371
+ continue;
2372
+ }
2373
+ for (const key of plan.unsafeKeys) {
2374
+ warn(` Skipped pruning ${resourceType.dir} entry "${key}" — its sync-state key does not map to a safe filename`);
2375
+ }
2376
+ const result = await applyPrune({
2377
+ configDir,
2378
+ resourceType,
2379
+ keys: plan.keys,
2380
+ testCaseEntities,
2381
+ logger: info,
2382
+ });
2383
+ prunedCount += result.prunedKeys.length;
2384
+ const kept = [...result.keptWithLocalEdits, ...plan.unsafeKeys];
2385
+ if (kept.length > 0) {
2386
+ keptKeysBySlot[resourceType.stateSlot] = kept;
2387
+ }
2388
+ }
2389
+ // Preserve prior state for types whose listing failed: nothing was
2390
+ // written for them, so the untouched local files must keep their
2391
+ // managed marker (the same rule `planVarsPull` applies to vars.toml).
2392
+ // For a type that listed fine but had a per-entity fetch failure, keep
2393
+ // the prior entry for that one key — see `preserveUnwrittenEntries`.
2394
+ const keepPrior = (ok, built, slot) => ok ? built : (priorEntities[slot] ?? built);
2395
+ preserveUnwrittenEntries(scriptEntities, priorEntities.scripts, scriptServerKeys);
2396
+ preserveUnwrittenEntries(emailTemplateEntities, priorEntities.emailTemplates, emailTemplatesOutcome.ok
2397
+ ? emailTemplateOverrides.map((t) => t.emailType)
2398
+ : []);
2399
+ const finalCronTriggerEntities = keepPrior(cronTriggersOutcome.ok, cronTriggerEntities, "cronTriggers");
2400
+ const finalBlobBucketEntities = keepPrior(blobBucketsOutcome.ok, blobBucketEntities, "blobBuckets");
2401
+ const finalScriptEntities = keepPrior(scriptListOk, scriptEntities, "scripts");
2402
+ const finalEmailTemplateEntities = keepPrior(emailTemplatesOutcome.ok, emailTemplateEntities, "emailTemplates");
2403
+ const finalDatabaseTypeEntities = keepPrior(databaseTypeConfigsOutcome.ok, databaseTypeEntities, "databaseTypes");
2404
+ const finalRuleSetEntities = keepPrior(ruleSetsOutcome.ok, ruleSetEntities, "ruleSets");
2405
+ const finalGroupTypeConfigEntities = keepPrior(groupTypeConfigsOutcome.ok, groupTypeConfigEntities, "groupTypeConfigs");
2406
+ const finalCollectionTypeConfigEntities = keepPrior(collectionTypeConfigsOutcome.ok, collectionTypeConfigEntities, "collectionTypeConfigs");
2407
+ const finalMetadataCategoryConfigEntities = keepPrior(metadataCategoryConfigsOutcome.ok, metadataCategoryConfigEntities, "metadataCategoryConfigs");
2408
+ // Keep the managed marker on every file prune deliberately left in
2409
+ // place: uncommitted local edits, an unsafe filename, `--no-prune`, or
2410
+ // a listing too incomplete to trust.
2411
+ //
2412
+ // Those keys are absent from the server, so this pull wrote no entry for
2413
+ // them: `preserveUnwrittenEntries` only rescues keys the server LISTED,
2414
+ // and `keepPrior` only fires when a whole listing failed. Without this
2415
+ // pass the key drops out of `entities[<slot>]` here, and on the next pull
2416
+ // the file is indistinguishable from a hand-authored one — never pruned,
2417
+ // never warned about, and reported by `sync diff` as "local only" rather
2418
+ // than absent-from-export. The skip-with-warning safety hatch would work
2419
+ // once and then quietly stop, re-creating the entity on the next push.
2420
+ // Carrying the prior entry forward keeps the file managed until it is
2421
+ // actually gone.
2422
+ const entitiesBySlot = {
2423
+ integrations: integrationEntities,
2424
+ webhooks: webhookEntities,
2425
+ cronTriggers: finalCronTriggerEntities,
2426
+ blobBuckets: finalBlobBucketEntities,
2427
+ prompts: promptEntities,
2428
+ workflows: workflowEntities,
2429
+ scripts: finalScriptEntities,
2430
+ emailTemplates: finalEmailTemplateEntities,
2431
+ databaseTypes: finalDatabaseTypeEntities,
2432
+ ruleSets: finalRuleSetEntities,
2433
+ groupTypeConfigs: finalGroupTypeConfigEntities,
2434
+ collectionTypeConfigs: finalCollectionTypeConfigEntities,
2435
+ metadataCategoryConfigs: finalMetadataCategoryConfigEntities,
2436
+ };
2437
+ for (const [slot, keys] of Object.entries(keptKeysBySlot)) {
2438
+ const built = entitiesBySlot[slot];
2439
+ const prior = priorEntities[slot];
2440
+ if (!built || !prior)
2441
+ continue;
2442
+ for (const key of keys) {
2443
+ if (!(key in built) && key in prior) {
2444
+ built[key] = prior[key];
2445
+ }
2446
+ }
2447
+ // The block's sidecar test cases were not written either (the block is
2448
+ // gone from the export), and its `<key>.tests/` files are still on disk
2449
+ // because the block's own file was kept — so their records carry
2450
+ // forward on the same rule.
2451
+ const resourceType = SYNC_RESOURCE_TYPES.find((rt) => rt.stateSlot === slot);
2452
+ const blockType = resourceType?.testBlockType;
2453
+ if (!blockType || !priorEntities.testCases)
2454
+ continue;
2455
+ for (const [tcKey, tcValue] of Object.entries(priorEntities.testCases)) {
2456
+ if (tcValue.blockType === blockType &&
2457
+ keys.includes(tcValue.blockKey) &&
2458
+ !(tcKey in testCaseEntities)) {
2459
+ testCaseEntities[tcKey] = tcValue;
2460
+ }
2461
+ }
2462
+ }
1608
2463
  // Save sync state
1609
2464
  const state = {
1610
2465
  appId: resolvedAppId,
@@ -1614,24 +2469,29 @@ Directory Structure:
1614
2469
  app: settings ? { modifiedAt: new Date().toISOString(), contentHash: computeFileHash(join(configDir, "app.toml")) } : undefined,
1615
2470
  integrations: integrationEntities,
1616
2471
  webhooks: webhookEntities,
1617
- cronTriggers: Object.keys(cronTriggerEntities).length > 0 ? cronTriggerEntities : undefined,
1618
- blobBuckets: Object.keys(blobBucketEntities).length > 0 ? blobBucketEntities : undefined,
2472
+ cronTriggers: Object.keys(finalCronTriggerEntities).length > 0 ? finalCronTriggerEntities : undefined,
2473
+ blobBuckets: Object.keys(finalBlobBucketEntities).length > 0 ? finalBlobBucketEntities : undefined,
1619
2474
  prompts: promptEntities,
1620
2475
  workflows: workflowEntities,
1621
- scripts: Object.keys(scriptEntities).length > 0 ? scriptEntities : undefined,
1622
- emailTemplates: Object.keys(emailTemplateEntities).length > 0 ? emailTemplateEntities : undefined,
2476
+ scripts: Object.keys(finalScriptEntities).length > 0 ? finalScriptEntities : undefined,
2477
+ emailTemplates: Object.keys(finalEmailTemplateEntities).length > 0 ? finalEmailTemplateEntities : undefined,
1623
2478
  testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
1624
- databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
1625
- ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
1626
- groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
1627
- collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
1628
- ? collectionTypeConfigEntities
2479
+ databaseTypes: Object.keys(finalDatabaseTypeEntities).length > 0 ? finalDatabaseTypeEntities : undefined,
2480
+ ruleSets: Object.keys(finalRuleSetEntities).length > 0 ? finalRuleSetEntities : undefined,
2481
+ vars: Object.keys(varEntities).length > 0 ? varEntities : undefined,
2482
+ groupTypeConfigs: Object.keys(finalGroupTypeConfigEntities).length > 0 ? finalGroupTypeConfigEntities : undefined,
2483
+ collectionTypeConfigs: Object.keys(finalCollectionTypeConfigEntities).length > 0
2484
+ ? finalCollectionTypeConfigEntities
2485
+ : undefined,
2486
+ metadataCategoryConfigs: Object.keys(finalMetadataCategoryConfigEntities).length > 0
2487
+ ? finalMetadataCategoryConfigEntities
1629
2488
  : undefined,
1630
2489
  },
1631
2490
  };
1632
2491
  saveSyncState(configDir, state);
1633
2492
  divider();
1634
2493
  success(`Pulled configuration to ${configDir}`);
2494
+ keyValue("Config Vars", Object.keys(varEntities).length);
1635
2495
  keyValue("Integrations", integrations.length);
1636
2496
  keyValue("Webhooks", webhooks.length);
1637
2497
  keyValue("Cron Triggers", cronTriggerItems.length);
@@ -1645,6 +2505,8 @@ Directory Structure:
1645
2505
  keyValue("Rule Sets", ruleSets.length);
1646
2506
  keyValue("Group Type Configs", groupTypeConfigs.length);
1647
2507
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
2508
+ keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
2509
+ keyValue("Pruned", prunedCount);
1648
2510
  }
1649
2511
  catch (err) {
1650
2512
  error(err.message);
@@ -1896,10 +2758,110 @@ Directory Structure:
1896
2758
  }
1897
2759
  }
1898
2760
  }
2761
+ // Validate app.toml settings up-front (issue #1033). A hand-added
2762
+ // `googleClientSecret` key or a mistyped value is a hard error that must
2763
+ // abort BEFORE any mutation — same fail-before-mutate contract as the
2764
+ // vars preflight below. The apply block re-parses for the payload.
2765
+ const preflightAppTomlPath = join(configDir, "app.toml");
2766
+ if (existsSync(preflightAppTomlPath)) {
2767
+ try {
2768
+ const parsedApp = parseTomlFile(preflightAppTomlPath);
2769
+ const appErrors = collectAppSettingsPushErrors(parsedApp, {
2770
+ isCrossAppPush,
2771
+ });
2772
+ for (const e of appErrors) {
2773
+ preflightValidationErrors.push(` app.toml: ${e}`);
2774
+ }
2775
+ }
2776
+ catch (err) {
2777
+ preflightValidationErrors.push(` app.toml: ${err?.message || String(err)}`);
2778
+ }
2779
+ }
2780
+ // Validate config vars up-front (issue #1423 review). Parse vars.toml
2781
+ // and check the same key/value constraints the server enforces here, in
2782
+ // the preflight pass, so an invalid entry aborts BEFORE any mutation
2783
+ // (app settings or a var upsert) is applied. Previously validation ran
2784
+ // mid-push, after app settings could already have been updated.
2785
+ const preflightVarsTomlPath = join(configDir, "vars.toml");
2786
+ let preflightParsedVars = null;
2787
+ if (existsSync(preflightVarsTomlPath)) {
2788
+ try {
2789
+ const parsed = parseTomlFile(preflightVarsTomlPath);
2790
+ for (const e of validateVarsFile(parsed)) {
2791
+ preflightValidationErrors.push(` ${e}`);
2792
+ }
2793
+ preflightParsedVars = parsed;
2794
+ }
2795
+ catch (err) {
2796
+ preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
2797
+ }
2798
+ }
1899
2799
  if (preflightValidationErrors.length > 0) {
1900
2800
  throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
1901
2801
  preflightValidationErrors.join("\n"));
1902
2802
  }
2803
+ // Fetch the current remote config vars up-front — before ANY mutation
2804
+ // (issue #1423 review pass 3). `planVarsPush` needs this snapshot both
2805
+ // to detect concurrent remote edits and to decide which already-absent
2806
+ // deletes to skip. A failed fetch must FAIL CLOSED: proceeding with an
2807
+ // empty snapshot would silently disable the concurrency guard and let a
2808
+ // changed local value clobber a remote edit. Abort unless `--force`.
2809
+ // Fetching here (not mid-push) keeps the abort fail-before-mutate.
2810
+ let remoteVarsSnapshot = [];
2811
+ // Whether the snapshot above is authoritative. A failed fetch under
2812
+ // `--force` leaves it empty but UNKNOWN (not "the server has no vars") —
2813
+ // `planVarsPush` needs this so it doesn't mistake "absent from the empty
2814
+ // snapshot" for "already deleted remotely" and silently drop deletions
2815
+ // the user asked for (issue #1423 review r-2 P2).
2816
+ let varSnapshotUnavailable = false;
2817
+ if (existsSync(preflightVarsTomlPath)) {
2818
+ try {
2819
+ remoteVarsSnapshot = await client.listAppConfigVars(resolvedAppId);
2820
+ }
2821
+ catch (err) {
2822
+ if (!options.force) {
2823
+ throw new Error(`Aborting push: could not fetch remote config vars to check for ` +
2824
+ `concurrent edits (${err?.message || String(err)}) — no changes ` +
2825
+ `were applied. Re-run with --force to push without the ` +
2826
+ `concurrency guard.`);
2827
+ }
2828
+ // --force: proceed without a snapshot; the guard is intentionally
2829
+ // bypassed and every local var is upserted as authoritative. The
2830
+ // snapshot is unknown, so deletions of removed keys are still
2831
+ // enqueued (idempotent 404 handling absorbs already-absent keys).
2832
+ remoteVarsSnapshot = [];
2833
+ varSnapshotUnavailable = true;
2834
+ }
2835
+ }
2836
+ // Plan the var writes up-front and validate the FINAL server var
2837
+ // count against the cap BEFORE any mutation (issue #1423 review pass
2838
+ // 5). `validateVarsFile` above caps the local file's entry count, but
2839
+ // remote-only vars (on the server, not in vars.toml) still count
2840
+ // toward the server's limit: 95 local entries plus 10 remote-only
2841
+ // vars passes the local check, then a later create is rejected at the
2842
+ // cap after earlier writes were already applied. Computed from the
2843
+ // remote snapshot plus planned creates minus planned deletions. The
2844
+ // check applies even with `--force`: the cap is a server hard limit,
2845
+ // not a conflict guard, so a push that would exceed it cannot succeed
2846
+ // and forcing it would only leave a partial push. (Under `--force`
2847
+ // with a failed snapshot fetch the remote count is unknown; the check
2848
+ // then degrades to the local file count, like the concurrency guard.)
2849
+ let varPlan = null;
2850
+ if (preflightParsedVars) {
2851
+ varPlan = planVarsPush(preflightParsedVars, syncState?.entities?.vars, remoteVarsSnapshot, { force: options.force, snapshotUnavailable: varSnapshotUnavailable });
2852
+ const finalVarCount = countVarsAfterPush(remoteVarsSnapshot, varPlan);
2853
+ if (finalVarCount > MAX_VARS_PER_APP) {
2854
+ const localKeys = new Set(Object.keys(preflightParsedVars));
2855
+ const plannedDeletes = new Set(varPlan.deletions);
2856
+ const remoteOnlyCount = remoteVarsSnapshot.filter((r) => !localKeys.has(r.key) && !plannedDeletes.has(r.key)).length;
2857
+ throw new Error(`Aborting push: it would leave ${finalVarCount} config vars on the server, ` +
2858
+ `exceeding the maximum of ${MAX_VARS_PER_APP} vars per app ` +
2859
+ `(server has ${remoteVarsSnapshot.length} vars, ${remoteOnlyCount} of ` +
2860
+ `which are not in vars.toml) — no changes were applied. Run ` +
2861
+ `\`sync pull\` to bring the remote-only vars into vars.toml, or ` +
2862
+ `delete unneeded vars, then push again.`);
2863
+ }
2864
+ }
1903
2865
  // Process app settings
1904
2866
  const appTomlPath = join(configDir, "app.toml");
1905
2867
  if (existsSync(appTomlPath)) {
@@ -1911,28 +2873,13 @@ Directory Structure:
1911
2873
  }
1912
2874
  else {
1913
2875
  const tomlData = parseTomlFile(appTomlPath);
1914
- const settings = {};
1915
- if (tomlData.app) {
1916
- Object.assign(settings, tomlData.app);
1917
- // Don't overwrite the target app's name during cross-app push
1918
- if (isCrossAppPush) {
1919
- delete settings.name;
1920
- }
1921
- }
1922
- 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;
1928
- }
1929
- }
1930
- if (tomlData.cors) {
1931
- settings.corsMode = tomlData.cors.mode;
1932
- settings.corsAllowedOrigins = tomlData.cors.allowedOrigins;
1933
- settings.corsAllowCredentials = tomlData.cors.allowCredentials;
1934
- settings.corsAllowedMethods = tomlData.cors.allowedMethods;
1935
- settings.corsMaxAge = tomlData.cors.maxAge;
2876
+ // Descriptor-driven parse of every section ([app]/[auth]/[cors]/
2877
+ // [invitations]) — issue #1033. `name` is stripped on cross-app
2878
+ // push. Errors (secret key / type mismatch) already aborted the
2879
+ // push in the preflight pass above, so only warnings remain here.
2880
+ const { settings, warnings: appWarnings } = parseTomlToAppSettings(tomlData, { isCrossAppPush });
2881
+ for (const w of appWarnings) {
2882
+ warn(` ${w}`);
1936
2883
  }
1937
2884
  changes.push({ type: "app", action: "update", key: "settings" });
1938
2885
  if (!options.dryRun) {
@@ -1948,6 +2895,135 @@ Directory Structure:
1948
2895
  }
1949
2896
  }
1950
2897
  }
2898
+ // Process config vars (issue #1423). Read the flat vars.toml, upsert
2899
+ // each key (PUT by key is idempotent — one call covers create+update),
2900
+ // and hard-delete keys removed from the file (nested-entity precedent:
2901
+ // database operations/subscriptions). Pruning runs ONLY when the file
2902
+ // is present, so an absent vars.toml never mass-deletes server vars.
2903
+ //
2904
+ // Key/value validation now runs up-front in the preflight pass above,
2905
+ // so by the time we get here the file is known-valid (values are
2906
+ // strings). The remote-vars snapshot was also fetched up-front (fail
2907
+ // closed) so `planVarsPush` can detect a concurrent remote edit and
2908
+ // report it as a conflict instead of silently overwriting it (review
2909
+ // r-2 P1), and the plan itself was computed up-front so the final
2910
+ // server var count could be validated against the cap before any
2911
+ // mutation (review pass 5).
2912
+ if (varPlan) {
2913
+ skippedCount += varPlan.skippedCount;
2914
+ // Feed var conflicts into the shared accumulator — same convention as
2915
+ // every other entity, so the end-of-run report renders them as
2916
+ // `CONFLICT var: KEY` and the push exits non-zero.
2917
+ for (const c of varPlan.conflicts) {
2918
+ conflicts.push({
2919
+ type: "var",
2920
+ key: c.key,
2921
+ serverModifiedAt: c.serverModifiedAt,
2922
+ localModifiedAt: c.localModifiedAt,
2923
+ });
2924
+ }
2925
+ // Delete removed keys BEFORE upserting new ones (issue #1423 review
2926
+ // pass 3). At the server's 100-var cap, a replacement (remove one key,
2927
+ // add another) is a valid final state, but upserting the new key first
2928
+ // would transiently exceed the cap and the create would be rejected.
2929
+ // Freeing the removed slots first lets the replacement synchronize.
2930
+ // Each removal prints an "Unsetting var X" line so the destructive
2931
+ // action is visible.
2932
+ for (const key of varPlan.deletions) {
2933
+ changes.push({ type: "var", action: "delete", key });
2934
+ if (!options.dryRun) {
2935
+ // Server-side concurrency guard (issue #1423 review r-2 P1): send
2936
+ // the `updatedAt` we last synced so the server rejects the delete
2937
+ // (409 CONFLICT) if the var was edited remotely since — closing the
2938
+ // race between the client snapshot and this DELETE that the
2939
+ // client-side `planVarsPush` guard alone can't. `--force` omits it
2940
+ // and deletes unconditionally.
2941
+ const expectedModifiedAt = options.force
2942
+ ? undefined
2943
+ : syncState?.entities?.vars?.[key]?.modifiedAt;
2944
+ try {
2945
+ await client.deleteAppConfigVar(resolvedAppId, key, expectedModifiedAt);
2946
+ info(` Unsetting var ${key}`);
2947
+ if (syncState?.entities?.vars)
2948
+ delete syncState.entities.vars[key];
2949
+ }
2950
+ catch (err) {
2951
+ // A remote edit landed since our snapshot — report a conflict
2952
+ // instead of discarding it (feeds the shared accumulator →
2953
+ // `CONFLICT var: KEY`, non-zero exit).
2954
+ if (err instanceof ConflictError) {
2955
+ conflicts.push({
2956
+ type: "var",
2957
+ key,
2958
+ serverModifiedAt: err.serverModifiedAt,
2959
+ localModifiedAt: expectedModifiedAt || "unknown",
2960
+ });
2961
+ }
2962
+ else if (err instanceof ApiError && err.statusCode === 404) {
2963
+ // A 404 means the var is already absent — the desired
2964
+ // (deleted) state is met, so treat it as an idempotent success
2965
+ // rather than failing the whole push (issue #1423 review pass
2966
+ // 3). The plan omits deletes for keys absent from a fetched
2967
+ // snapshot; this also absorbs the forced no-snapshot fallback
2968
+ // (r-2 P2), where removed keys are enqueued unconditionally.
2969
+ info(` Var ${key} already absent (nothing to unset)`);
2970
+ if (syncState?.entities?.vars)
2971
+ delete syncState.entities.vars[key];
2972
+ }
2973
+ else {
2974
+ throw wrapEntityError(err, "delete", "var", key);
2975
+ }
2976
+ }
2977
+ }
2978
+ }
2979
+ for (const item of varPlan.upserts) {
2980
+ changes.push({ type: "var", action: item.action, key: item.key });
2981
+ if (!options.dryRun) {
2982
+ // Server-side concurrency guard (issue #1423 review r-2 P1): for an
2983
+ // update, send the `updatedAt` we last synced so the server rejects
2984
+ // the write (409 CONFLICT) if the var changed remotely since — this
2985
+ // closes the snapshot→PUT race the client-side guard can't. A
2986
+ // create has no baseline, so instead it sends a create-only
2987
+ // precondition (`expectNotExists`, review r-3 P1a): the server 409s
2988
+ // if the key was created remotely since our snapshot rather than
2989
+ // silently overwriting that concurrent create. `--force` overrides
2990
+ // both.
2991
+ const isCreate = item.action === "create";
2992
+ const expectedModifiedAt = options.force || isCreate
2993
+ ? undefined
2994
+ : syncState?.entities?.vars?.[item.key]?.modifiedAt;
2995
+ try {
2996
+ const result = await client.upsertAppConfigVar(resolvedAppId, item.key, { value: item.value }, expectedModifiedAt, { expectNotExists: !options.force && isCreate });
2997
+ if (syncState) {
2998
+ if (!syncState.entities.vars)
2999
+ syncState.entities.vars = {};
3000
+ syncState.entities.vars[item.key] = {
3001
+ // Record the server's returned timestamp (updatedAt) so the
3002
+ // next push's concurrent-edit guard has an accurate baseline.
3003
+ modifiedAt: result?.updatedAt || result?.modifiedAt || new Date().toISOString(),
3004
+ contentHash: item.valueHash,
3005
+ };
3006
+ }
3007
+ }
3008
+ catch (err) {
3009
+ // A remote edit landed since our snapshot — report a conflict
3010
+ // instead of clobbering it (feeds the shared accumulator →
3011
+ // `CONFLICT var: KEY`, non-zero exit).
3012
+ if (err instanceof ConflictError) {
3013
+ conflicts.push({
3014
+ type: "var",
3015
+ key: item.key,
3016
+ serverModifiedAt: err.serverModifiedAt,
3017
+ localModifiedAt: expectedModifiedAt || "unknown",
3018
+ });
3019
+ }
3020
+ else {
3021
+ throw wrapEntityError(err, item.action, "var", item.key);
3022
+ }
3023
+ }
3024
+ }
3025
+ }
3026
+ }
1951
3027
  // Process rule sets first (other entities may reference them by name)
1952
3028
  const ruleSetsDir = join(configDir, "rule-sets");
1953
3029
  if (existsSync(ruleSetsDir)) {
@@ -2142,6 +3218,14 @@ Directory Structure:
2142
3218
  deduplicationWindowMs: webhook.deduplicationWindowMs,
2143
3219
  secretGracePeriodMs: webhook.secretGracePeriodMs,
2144
3220
  };
3221
+ // Forward the declared signing secret so a `{{secrets.KEY}}`
3222
+ // reference reaches the server intact (#1235). Without this the
3223
+ // value declared in TOML is silently dropped and the server
3224
+ // rejects a verified webhook with "signingSecret is required".
3225
+ // Mirrors how integrations forward requestConfig with refs intact.
3226
+ if (webhook.signingSecret !== undefined) {
3227
+ payload.signingSecret = webhook.signingSecret;
3228
+ }
2145
3229
  // Handle JSON fields
2146
3230
  if (tomlData.allowedIps?.cidrs) {
2147
3231
  payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
@@ -2346,30 +3430,27 @@ Directory Structure:
2346
3430
  if (existingId) {
2347
3431
  // #1020 (D8): idempotently apply preset/ruleSet/name/description
2348
3432
  // 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;
3433
+ const updatePayload = buildBlobBucketUpdatePayload(bucket);
2360
3434
  changes.push({ type: "blob-bucket", action: "update", key });
2361
3435
  if (!options.dryRun) {
2362
3436
  try {
2363
3437
  await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
2364
3438
  info(` Updated blob bucket: ${key}`);
3439
+ // #1459: stamp the content hash ONLY after a successful
3440
+ // update, and only when this is not a dry run. A failed
3441
+ // update (or a dry run) must leave the file pending rather
3442
+ // than mark it in-sync and suppress it on the next push.
3443
+ if (syncState?.entities?.blobBuckets?.[key]) {
3444
+ syncState.entities.blobBuckets[key].contentHash =
3445
+ computeFileHash(filePath);
3446
+ }
2365
3447
  }
2366
3448
  catch (err) {
3449
+ // Keep the warn-and-continue contract (sponsor decision):
3450
+ // do not abort the push, just don't stamp the hash.
2367
3451
  warn(` Failed to update blob bucket ${key}: ${String(err?.message || err)}`);
2368
3452
  }
2369
3453
  }
2370
- if (syncState?.entities?.blobBuckets?.[key]) {
2371
- syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
2372
- }
2373
3454
  }
2374
3455
  else {
2375
3456
  const payload = {
@@ -2404,23 +3485,86 @@ Directory Structure:
2404
3485
  catch (err) {
2405
3486
  const msg = String(err?.message || err);
2406
3487
  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
3488
+ // #1459: adopt-by-key on 409. A blob bucket can already
3489
+ // exist on the server (an out-of-band create, or a prior
3490
+ // push that aborted before recording it in sync state). The
3491
+ // old handler stamped the LOCAL file's contentHash WITHOUT
3492
+ // applying the local config, so a divergent bucket was
3493
+ // silently marked in-sync and never reconciled. Mirror the
3494
+ // cron/workflow adopt paths: look up the bucket by key,
3495
+ // re-issue the local payload as an UPDATE, and stamp the
3496
+ // hash ONLY after the update succeeds.
3497
+ info(` Blob bucket already exists on server, adopting by key: ${key}`);
3498
+ let existing;
2409
3499
  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
- };
3500
+ existing = await client.getBlobBucket(resolvedAppId, key);
3501
+ }
3502
+ catch (lookupErr) {
3503
+ // A lookup failure after a 409 means we cannot adopt —
3504
+ // surface it instead of swallowing it (the old silent
3505
+ // `catch {}` left no sync-state entry, looping
3506
+ // create→409 on every push).
3507
+ throw wrapEntityError(new Error(`blob bucket "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "blob bucket", key);
3508
+ }
3509
+ if (!existing?.bucketId) {
3510
+ // 409 but no matching bucket found — surface the original
3511
+ // create error rather than silently proceeding.
3512
+ throw wrapEntityError(err, "create", "blob bucket", key);
3513
+ }
3514
+ // Verify the resolved bucket is actually the one we asked
3515
+ // for. `getBlobBucket` resolves the identifier by primary id
3516
+ // (appId, bucketId) BEFORE bucketKey, and a bucketKey may be
3517
+ // a ULID-shaped string (the key regex permits it). If the
3518
+ // local key collides with an unrelated bucket's bucketId,
3519
+ // the lookup returns that unrelated bucket — patching and
3520
+ // stamping it would overwrite the wrong resource. Require an
3521
+ // exact key match, matching the cron/workflow adopt paths'
3522
+ // exact-key equality (they list + filter by key for the same
3523
+ // reason). Surface the mismatch via wrapEntityError; do NOT
3524
+ // patch or stamp.
3525
+ if (existing.bucketKey !== key) {
3526
+ 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);
3527
+ }
3528
+ // ttlTier is immutable and cannot be reconciled by an
3529
+ // UPDATE. A local TOML whose retention tier diverges from
3530
+ // the server must fail loudly (sponsor decision) rather
3531
+ // than be silently blessed while the cosmetic fields
3532
+ // converge.
3533
+ if (bucket.ttlTier &&
3534
+ existing.ttlTier &&
3535
+ bucket.ttlTier !== existing.ttlTier) {
3536
+ 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);
3537
+ }
3538
+ const updatePayload = buildBlobBucketUpdatePayload(bucket);
3539
+ let updated;
3540
+ if (Object.keys(updatePayload).length > 0) {
3541
+ // Surface an update failure via wrapEntityError (no
3542
+ // stamp) — matches the cron adopt path.
3543
+ try {
3544
+ updated = await client.updateBlobBucket(resolvedAppId, existing.bucketId, updatePayload);
3545
+ }
3546
+ catch (updateErr) {
3547
+ throw wrapEntityError(updateErr, "update", "blob bucket", key);
2420
3548
  }
3549
+ info(` Adopted + updated blob bucket: ${key}`);
2421
3550
  }
2422
- catch {
2423
- // Ignore fetch errors
3551
+ else {
3552
+ // No mutable fields in the local TOML — the server
3553
+ // rejects an empty update (400). Adopt the existing
3554
+ // bucket without a PATCH.
3555
+ info(` Adopted blob bucket (no updatable fields): ${key}`);
3556
+ }
3557
+ if (syncState) {
3558
+ if (!syncState.entities.blobBuckets) {
3559
+ syncState.entities.blobBuckets = {};
3560
+ }
3561
+ syncState.entities.blobBuckets[key] = {
3562
+ id: existing.bucketId,
3563
+ modifiedAt: updated?.modifiedAt ||
3564
+ existing.modifiedAt ||
3565
+ new Date().toISOString(),
3566
+ contentHash: computeFileHash(filePath),
3567
+ };
2424
3568
  }
2425
3569
  }
2426
3570
  else {
@@ -2774,6 +3918,19 @@ Directory Structure:
2774
3918
  const workflow = tomlData.workflow || {};
2775
3919
  const key = workflow.key || basename(file, ".toml");
2776
3920
  const steps = tomlData.steps || [];
3921
+ // #1304 (P-C): the declared-access manifest lives at top-level
3922
+ // `[metadata]` / `secrets` (same shape the database/collection type
3923
+ // configs use). Parse + surface-validate it, then carry it on the
3924
+ // `workflow` payload object so it flows through both the create call
3925
+ // and the shared `applyWorkflowBody` update. A null manifest (no
3926
+ // `[metadata]`) clears it on the server so pull → push round-trips a
3927
+ // drop; the deep graph check runs server-side at persist.
3928
+ const workflowManifest = parseDeclaredAccessManifestToml(tomlData);
3929
+ const workflowManifestError = validateDeclaredAccessManifest(workflowManifest);
3930
+ if (workflowManifestError) {
3931
+ throw new Error(`${filePath}: ${workflowManifestError}`);
3932
+ }
3933
+ workflow.metadataManifest = workflowManifest;
2777
3934
  const existingId = syncState?.entities?.workflows?.[key]?.id;
2778
3935
  const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
2779
3936
  // Skip if file hasn't changed since last sync. Use the expanded
@@ -2805,80 +3962,17 @@ Directory Structure:
2805
3962
  // out-of-manifest workflow).
2806
3963
  const applyWorkflowUpdate = async (workflowId, opts) => {
2807
3964
  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
- }
3965
+ // The ordering-sensitive core (metadata PATCH → config/draft
3966
+ // steps → deferred `syncCallable` PATCH, #807) lives in the
3967
+ // shared `applyWorkflowBody` helper so `workflows update
3968
+ // --from-file` runs it identically (#1249, Fork 4). The
3969
+ // sync-specific bookkeeping (the progress log, sync-state
3970
+ // `modifiedAt`/hash, and config name→ID map) stays here.
3971
+ const { latestModifiedAt, updateSlotLabel, fullWorkflow } = await applyWorkflowBody(client, resolvedAppId, workflowId, workflow, steps, {
3972
+ expectedModifiedAt,
3973
+ activeConfigId,
3974
+ force: options.force,
3975
+ });
2882
3976
  info(` Updated workflow: ${key} (${updateSlotLabel})`);
2883
3977
  // Update sync state with new modifiedAt. Store the *expanded*
2884
3978
  // content hash so future fragment-only edits are detected. The
@@ -2888,9 +3982,8 @@ Directory Structure:
2888
3982
  syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
2889
3983
  syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
2890
3984
  }
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);
3985
+ // `applyWorkflowBody` already fetched the full workflow; reuse it
3986
+ // for the config name→ID mappings (the PATCH response omits configs).
2894
3987
  if (fullWorkflow?.configs) {
2895
3988
  for (const config of fullWorkflow.configs) {
2896
3989
  workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
@@ -2930,25 +4023,22 @@ Directory Structure:
2930
4023
  changes.push({ type: "workflow", action: "create", key });
2931
4024
  if (!options.dryRun) {
2932
4025
  try {
4026
+ // #1177: build the `[workflow]`-derived fields through the
4027
+ // shared builder so `sync push` create can't drift from
4028
+ // `create --from-file` / the update path (this branch used to
4029
+ // drop `status`, `capabilities`, `perAppMax*`, `queueTtlSeconds`).
4030
+ // Structural fields (`workflowKey`, `steps`, `metadataManifest`)
4031
+ // and the manifest-key `name` fallback are attached here; the
4032
+ // `name` override follows the spread so it wins over the
4033
+ // builder's bare `name`.
2933
4034
  const created = await client.createWorkflow(resolvedAppId, {
2934
4035
  workflowKey: key,
2935
- name: workflow.name || key,
2936
- description: workflow.description,
2937
4036
  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,
4037
+ ...buildWorkflowPayloadFromToml(workflow, { mode: "create" }),
4038
+ name: workflow.name || key,
4039
+ // #1304 (P-C): declared-access manifest (absent → undefined,
4040
+ // server default null).
4041
+ metadataManifest: workflow.metadataManifest ?? undefined,
2952
4042
  });
2953
4043
  info(` Created workflow: ${key}`);
2954
4044
  // Add new entity to sync state (including activeConfigId)
@@ -3214,6 +4304,14 @@ Directory Structure:
3214
4304
  if ("timestamps" in typeConfig) {
3215
4305
  u.timestamps = typeConfig.timestamps || null;
3216
4306
  }
4307
+ // Issue #1304 (P-C): declared-access manifest. `parseDatabaseTypeToml`
4308
+ // only sets `metadataManifest` when the TOML declares a `[metadata]`
4309
+ // / `secrets` block, so this forwards a set/update; clearing a
4310
+ // manifest by removing the block is a follow-on (mirrors the
4311
+ // schema-deletion tracking).
4312
+ if ("metadataManifest" in typeConfig) {
4313
+ u.metadataManifest = typeConfig.metadataManifest || null;
4314
+ }
3217
4315
  const localHasSchema = typeof typeConfig.schema === "string" &&
3218
4316
  typeConfig.schema.trim().length > 0;
3219
4317
  if (localHasSchema) {
@@ -3583,7 +4681,8 @@ Directory Structure:
3583
4681
  "defaultAccess" in typeConfig ||
3584
4682
  "autoPopulatedFields" in typeConfig ||
3585
4683
  "timestamps" in typeConfig ||
3586
- "schema" in typeConfig;
4684
+ "schema" in typeConfig ||
4685
+ "metadataManifest" in typeConfig;
3587
4686
  if (wouldUpdate) {
3588
4687
  changes.push({ type: "database-type", action: "update", key: dbType });
3589
4688
  }
@@ -3616,6 +4715,9 @@ Directory Structure:
3616
4715
  // validating against the intended shape.
3617
4716
  if (typeConfig.schema)
3618
4717
  createData.schema = typeConfig.schema;
4718
+ // Issue #1304 (P-C): declared-access manifest.
4719
+ if (typeConfig.metadataManifest)
4720
+ createData.metadataManifest = typeConfig.metadataManifest;
3619
4721
  try {
3620
4722
  const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
3621
4723
  info(` Created database type: ${dbType}`);
@@ -4072,6 +5174,10 @@ Directory Structure:
4072
5174
  const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
4073
5175
  ruleSetId: configData.ruleSetId,
4074
5176
  autoAddCreator: configData.autoAddCreator,
5177
+ // Issue #1350: null clears the manifest server-side when
5178
+ // the TOML drops the `[metadata]` block (matches the
5179
+ // collection type config update semantics).
5180
+ metadataManifest: configData.metadataManifest ?? null,
4075
5181
  }, expectedModifiedAt);
4076
5182
  info(` Updated group type config: ${groupType}`);
4077
5183
  if (syncState?.entities?.groupTypeConfigs?.[groupType] && updated?.modifiedAt) {
@@ -4103,6 +5209,9 @@ Directory Structure:
4103
5209
  groupType,
4104
5210
  ruleSetId: configData.ruleSetId || undefined,
4105
5211
  autoAddCreator: configData.autoAddCreator,
5212
+ ...(configData.metadataManifest
5213
+ ? { metadataManifest: configData.metadataManifest }
5214
+ : {}),
4106
5215
  });
4107
5216
  info(` Created group type config: ${groupType}`);
4108
5217
  if (syncState) {
@@ -4149,6 +5258,7 @@ Directory Structure:
4149
5258
  try {
4150
5259
  const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
4151
5260
  ruleSetId: configData.ruleSetId ?? null,
5261
+ metadataManifest: configData.metadataManifest ?? null,
4152
5262
  }, expectedModifiedAt);
4153
5263
  info(` Updated collection type config: ${collectionType}`);
4154
5264
  if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
@@ -4179,6 +5289,9 @@ Directory Structure:
4179
5289
  const created = await client.createCollectionTypeConfig(resolvedAppId, {
4180
5290
  collectionType,
4181
5291
  ruleSetId: configData.ruleSetId || undefined,
5292
+ ...(configData.metadataManifest
5293
+ ? { metadataManifest: configData.metadataManifest }
5294
+ : {}),
4182
5295
  });
4183
5296
  info(` Created collection type config: ${collectionType}`);
4184
5297
  if (syncState) {
@@ -4198,6 +5311,69 @@ Directory Structure:
4198
5311
  }
4199
5312
  }
4200
5313
  }
5314
+ // Process metadata category configs (issue #1304, P-B). Idempotent
5315
+ // upsert via the path-addressed PUT route — no create/update split.
5316
+ const metadataCategoryConfigsDir = join(configDir, "metadata-category-configs");
5317
+ if (existsSync(metadataCategoryConfigsDir)) {
5318
+ const files = readdirSync(metadataCategoryConfigsDir).filter((f) => f.endsWith(".toml"));
5319
+ for (const file of files) {
5320
+ const filePath = join(metadataCategoryConfigsDir, file);
5321
+ const tomlData = parseTomlFile(filePath);
5322
+ const configData = parseMetadataCategoryToml(tomlData);
5323
+ // Fall back to the filename (`resourceType.category.toml`) if the
5324
+ // section omits the identity fields.
5325
+ if (!configData.resourceType || !configData.category) {
5326
+ const base = basename(file, ".toml");
5327
+ const dot = base.indexOf(".");
5328
+ if (dot > 0) {
5329
+ configData.resourceType = configData.resourceType || base.slice(0, dot);
5330
+ configData.category = configData.category || base.slice(dot + 1);
5331
+ }
5332
+ }
5333
+ const validationError = validateMetadataCategoryConfig(configData);
5334
+ if (validationError) {
5335
+ throw new Error(`Invalid metadata category config in ${file}: ${validationError}`);
5336
+ }
5337
+ const entityKey = `${configData.resourceType}#${configData.category}`;
5338
+ const existingEntry = syncState?.entities?.metadataCategoryConfigs?.[entityKey];
5339
+ // Skip if the file hasn't changed since last sync.
5340
+ if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
5341
+ skippedCount++;
5342
+ continue;
5343
+ }
5344
+ changes.push({
5345
+ type: "metadata-category-config",
5346
+ action: existingEntry ? "update" : "create",
5347
+ key: entityKey,
5348
+ });
5349
+ if (!options.dryRun) {
5350
+ try {
5351
+ const saved = await client.upsertMetadataCategoryConfig(resolvedAppId, configData.resourceType, configData.category, {
5352
+ schema: configData.schema,
5353
+ readRule: configData.readRule,
5354
+ writeRule: configData.writeRule,
5355
+ description: configData.description,
5356
+ // #1420 Phase 2: send the declared-access manifest so a
5357
+ // category's md.self/secrets access round-trips through sync.
5358
+ metadataManifest: configData.metadataManifest,
5359
+ });
5360
+ info(` Upserted metadata category config: ${entityKey}`);
5361
+ if (syncState) {
5362
+ if (!syncState.entities.metadataCategoryConfigs) {
5363
+ syncState.entities.metadataCategoryConfigs = {};
5364
+ }
5365
+ syncState.entities.metadataCategoryConfigs[entityKey] = {
5366
+ modifiedAt: saved?.modifiedAt || new Date().toISOString(),
5367
+ contentHash: computeFileHash(filePath),
5368
+ };
5369
+ }
5370
+ }
5371
+ catch (err) {
5372
+ throw wrapEntityError(err, existingEntry ? "update" : "create", "metadata category config", entityKey);
5373
+ }
5374
+ }
5375
+ }
5376
+ }
4201
5377
  // Process email templates
4202
5378
  const emailTemplatesDir = join(configDir, "email-templates");
4203
5379
  if (existsSync(emailTemplatesDir)) {
@@ -4234,11 +5410,13 @@ Directory Structure:
4234
5410
  }
4235
5411
  }
4236
5412
  }
4237
- // Push test cases for prompts and workflows
5413
+ // Push test cases for prompts, workflows, and scripts
5414
+ const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
4238
5415
  const pushMaps = {
4239
5416
  promptKeyToId,
4240
5417
  promptConfigNameToId,
4241
5418
  workflowConfigNameToId,
5419
+ scriptConfigNameToId,
4242
5420
  };
4243
5421
  const promptsDir2 = join(configDir, "prompts");
4244
5422
  if (existsSync(promptsDir2)) {
@@ -4262,6 +5440,33 @@ Directory Structure:
4262
5440
  }
4263
5441
  }
4264
5442
  }
5443
+ // Push test cases for scripts (transforms). Each script's tests live at
5444
+ // transforms/<name>.tests/; the scriptId was recorded in
5445
+ // entities.scripts[name].id when the script body was pushed above.
5446
+ const transformsDir2 = join(configDir, "transforms");
5447
+ if (existsSync(transformsDir2)) {
5448
+ for (const file of readdirSync(transformsDir2).filter((f) => f.endsWith(".rhai"))) {
5449
+ const scriptName = basename(file, ".rhai");
5450
+ const blockId = syncState?.entities?.scripts?.[scriptName]?.id;
5451
+ if (!blockId)
5452
+ continue;
5453
+ const scriptTestsDir = join(transformsDir2, `${scriptName}.tests`);
5454
+ if (!existsSync(scriptTestsDir))
5455
+ continue;
5456
+ // Populate scriptConfigNameToId for this script so a test case that
5457
+ // pins a config by name resolves to a live configId.
5458
+ try {
5459
+ const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, blockId);
5460
+ for (const config of scriptConfigs) {
5461
+ scriptConfigNameToId.set(`${scriptName}#${config.configName}`, config.configId);
5462
+ }
5463
+ }
5464
+ catch {
5465
+ // Non-fatal — unpinned (active-version) test cases still push.
5466
+ }
5467
+ skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "script", blockId, scriptName, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
5468
+ }
5469
+ }
4265
5470
  divider();
4266
5471
  // Issue #813: a gate failure surfaced by the validate-first pass (which
4267
5472
  // also runs in dry-run mode via the server's dry-run gate endpoints).
@@ -4499,12 +5704,45 @@ Directory Structure:
4499
5704
  localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
4500
5705
  }
4501
5706
  }
5707
+ // Config vars — issue #1423. Before this, `sync diff` ignored vars
5708
+ // entirely, so an add/remove/value-drift between the local `vars.toml`
5709
+ // and the server silently read as no difference right before a push
5710
+ // would change or unset them. Fetch the remote vars (value included —
5711
+ // vars are non-secret) and read the local `vars.toml` so the comparison
5712
+ // below can report added/removed/modified vars.
5713
+ let remoteVarItemsDiff = [];
5714
+ try {
5715
+ remoteVarItemsDiff = await client.listAppConfigVars(resolvedAppId);
5716
+ }
5717
+ catch {
5718
+ // Older server without the vars route — treat as no remote vars.
5719
+ }
5720
+ const remoteVars = new Map(remoteVarItemsDiff
5721
+ .filter((v) => typeof v?.key === "string")
5722
+ .map((v) => [v.key, String(v.value ?? "")]));
5723
+ const localVars = new Map();
5724
+ const varsTomlPathDiff = join(configDir, "vars.toml");
5725
+ if (existsSync(varsTomlPathDiff)) {
5726
+ const parsed = parseTomlFile(varsTomlPathDiff);
5727
+ for (const [key, rawValue] of Object.entries(parsed)) {
5728
+ if (typeof rawValue === "string")
5729
+ localVars.set(key, rawValue);
5730
+ }
5731
+ }
4502
5732
  // Compare
4503
5733
  const differences = [];
5734
+ // Issue #1659: a local file with no server entity means one of two very
5735
+ // different things, and the operator needs to tell them apart. If a
5736
+ // prior pull managed the key, the entity was deleted server-side and
5737
+ // this stale file will RE-create it on the next push.
5738
+ // If it was never pulled, the operator authored it and push creating it
5739
+ // is the point. Prior sync state is the discriminator, exactly as it is
5740
+ // for the pull-side prune.
5741
+ const localOnlyStatus = (slot, key) => classifyLocalOnly(syncState?.entities, slot, key);
4504
5742
  // Integrations
4505
5743
  for (const key of localIntegrations) {
4506
5744
  if (!remoteIntegrations.has(key)) {
4507
- differences.push({ type: "integration", key, status: "local only" });
5745
+ differences.push({ type: "integration", key, status: localOnlyStatus("integrations", key) });
4508
5746
  }
4509
5747
  else {
4510
5748
  differences.push({ type: "integration", key, status: "exists" });
@@ -4518,7 +5756,7 @@ Directory Structure:
4518
5756
  // Webhooks
4519
5757
  for (const key of localWebhooks) {
4520
5758
  if (!remoteWebhooks.has(key)) {
4521
- differences.push({ type: "webhook", key, status: "local only" });
5759
+ differences.push({ type: "webhook", key, status: localOnlyStatus("webhooks", key) });
4522
5760
  }
4523
5761
  else {
4524
5762
  differences.push({ type: "webhook", key, status: "exists" });
@@ -4532,7 +5770,7 @@ Directory Structure:
4532
5770
  // Cron Triggers
4533
5771
  for (const key of localCronTriggers) {
4534
5772
  if (!remoteCronTriggers.has(key)) {
4535
- differences.push({ type: "cron-trigger", key, status: "local only" });
5773
+ differences.push({ type: "cron-trigger", key, status: localOnlyStatus("cronTriggers", key) });
4536
5774
  }
4537
5775
  else {
4538
5776
  differences.push({ type: "cron-trigger", key, status: "exists" });
@@ -4546,7 +5784,7 @@ Directory Structure:
4546
5784
  // Blob Buckets
4547
5785
  for (const key of localBlobBuckets) {
4548
5786
  if (!remoteBlobBuckets.has(key)) {
4549
- differences.push({ type: "blob-bucket", key, status: "local only" });
5787
+ differences.push({ type: "blob-bucket", key, status: localOnlyStatus("blobBuckets", key) });
4550
5788
  }
4551
5789
  else {
4552
5790
  differences.push({ type: "blob-bucket", key, status: "exists" });
@@ -4560,7 +5798,7 @@ Directory Structure:
4560
5798
  // Prompts
4561
5799
  for (const key of localPrompts) {
4562
5800
  if (!remotePrompts.has(key)) {
4563
- differences.push({ type: "prompt", key, status: "local only" });
5801
+ differences.push({ type: "prompt", key, status: localOnlyStatus("prompts", key) });
4564
5802
  }
4565
5803
  else {
4566
5804
  differences.push({ type: "prompt", key, status: "exists" });
@@ -4592,7 +5830,7 @@ Directory Structure:
4592
5830
  const remoteWorkflowIds = new Map(workflowItems.map((w) => [w.workflowKey, w.workflowId]));
4593
5831
  for (const key of localWorkflows) {
4594
5832
  if (!remoteWorkflows.has(key)) {
4595
- differences.push({ type: "workflow", key, status: "local only" });
5833
+ differences.push({ type: "workflow", key, status: localOnlyStatus("workflows", key) });
4596
5834
  continue;
4597
5835
  }
4598
5836
  // Present on both sides → compare content.
@@ -4643,7 +5881,7 @@ Directory Structure:
4643
5881
  // Email Templates
4644
5882
  for (const key of localEmailTemplates) {
4645
5883
  if (!remoteEmailTemplates.has(key)) {
4646
- differences.push({ type: "email-template", key, status: "local only" });
5884
+ differences.push({ type: "email-template", key, status: localOnlyStatus("emailTemplates", key) });
4647
5885
  }
4648
5886
  else {
4649
5887
  differences.push({ type: "email-template", key, status: "exists" });
@@ -4664,7 +5902,7 @@ Directory Structure:
4664
5902
  for (const [name, localBody] of localScripts) {
4665
5903
  const remote = remoteScripts.get(name);
4666
5904
  if (!remote) {
4667
- differences.push({ type: "transform", key: name, status: "local only" });
5905
+ differences.push({ type: "transform", key: name, status: localOnlyStatus("scripts", name) });
4668
5906
  continue;
4669
5907
  }
4670
5908
  let status = "exists";
@@ -4703,6 +5941,10 @@ Directory Structure:
4703
5941
  differences.push({ type: "transform", key: name, status: "remote only" });
4704
5942
  }
4705
5943
  }
5944
+ // Config vars — issue #1423 (see `diffVars`).
5945
+ for (const row of diffVars(localVars, remoteVars)) {
5946
+ differences.push(row);
5947
+ }
4706
5948
  // Compare test cases for synced prompts and workflows
4707
5949
  const testCaseDiffs = [];
4708
5950
  // Helper to compare test cases for a block
@@ -4727,7 +5969,7 @@ Directory Structure:
4727
5969
  }
4728
5970
  for (const slug of localTestSlugs) {
4729
5971
  if (!remoteTestNames.has(slug)) {
4730
- testCaseDiffs.push({ blockType, blockKey, slug, status: "local only" });
5972
+ testCaseDiffs.push({ blockType, blockKey, slug, status: LOCAL_ONLY_NEW });
4731
5973
  }
4732
5974
  else {
4733
5975
  testCaseDiffs.push({ blockType, blockKey, slug, status: "exists" });
@@ -4751,17 +5993,29 @@ Directory Structure:
4751
5993
  }
4752
5994
  }
4753
5995
  divider();
4754
- const localOnly = differences.filter((d) => d.status === "local only");
5996
+ const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
5997
+ const localOnlyAbsent = differences.filter((d) => d.status === LOCAL_ONLY_ABSENT);
4755
5998
  const remoteOnly = differences.filter((d) => d.status === "remote only");
4756
5999
  const modified = differences.filter((d) => d.status === "modified");
4757
6000
  const existing = differences.filter((d) => d.status === "exists");
4758
6001
  if (localOnly.length > 0) {
4759
- info("Local only (will be created on push):");
6002
+ info("Local only — new (will be created on push):");
4760
6003
  for (const d of localOnly) {
4761
6004
  console.log(` ${chalk.green("+")} ${d.type}: ${d.key}`);
4762
6005
  }
4763
6006
  console.log();
4764
6007
  }
6008
+ // #1659: these files were written by a prior pull, so their entity was
6009
+ // deleted server-side afterwards. `sync pull` prunes them by
6010
+ // default; they only survive under `--no-prune` or a skipped prune, and
6011
+ // while they sit here the next push re-creates what was deleted.
6012
+ if (localOnlyAbsent.length > 0) {
6013
+ warn(`Local only — ${ABSENT_FROM_EXPORT}; push will RE-create these unless you remove them:`);
6014
+ for (const d of localOnlyAbsent) {
6015
+ console.log(` ${chalk.yellow("!")} ${d.type}: ${d.key}`);
6016
+ }
6017
+ console.log();
6018
+ }
4765
6019
  if (remoteOnly.length > 0) {
4766
6020
  warn("Remote only (not in local config):");
4767
6021
  for (const d of remoteOnly) {
@@ -4788,7 +6042,7 @@ Directory Structure:
4788
6042
  }
4789
6043
  }
4790
6044
  // Show test case differences
4791
- const tcLocalOnly = testCaseDiffs.filter((d) => d.status === "local only");
6045
+ const tcLocalOnly = testCaseDiffs.filter((d) => d.status === LOCAL_ONLY_NEW);
4792
6046
  const tcRemoteOnly = testCaseDiffs.filter((d) => d.status === "remote only");
4793
6047
  const tcSynced = testCaseDiffs.filter((d) => d.status === "exists");
4794
6048
  if (tcLocalOnly.length > 0 || tcRemoteOnly.length > 0) {
@@ -4805,7 +6059,8 @@ Directory Structure:
4805
6059
  }
4806
6060
  }
4807
6061
  divider();
4808
- keyValue("Local only", localOnly.length);
6062
+ keyValue("Local only (new)", localOnly.length);
6063
+ keyValue("Local only (absent from export)", localOnlyAbsent.length);
4809
6064
  keyValue("Remote only", remoteOnly.length);
4810
6065
  keyValue("Modified", modified.length);
4811
6066
  keyValue("Synced", existing.length);
@@ -4823,11 +6078,15 @@ Directory Structure:
4823
6078
  // Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
4824
6079
  // the legacy JSON-string form for `definition`/`params` to the native
4825
6080
  // 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.
6081
+ // is; the round-trip serializes with smol-toml (TOML 1.0, issue #1450), so
6082
+ // heterogeneous arrays now converge to native form too only shapes that
6083
+ // TOML can't represent without data loss (null / undefined / an array
6084
+ // containing null) fall back to JSON-string per field with a log message.
4828
6085
  sync
4829
6086
  .command("migrate-toml")
4830
- .description("Rewrite database-type TOML files to native [operations.definition] / [[operations.params]] form")
6087
+ .description("Rewrite database-type and workflow TOML files to native form " +
6088
+ "([operations.definition] / [[operations.params]] for database types; " +
6089
+ "[workflow.inputSchema] / [workflow.outputSchema] for workflows)")
4831
6090
  .argument("[app-id]", "App ID (uses current app if not specified)")
4832
6091
  .option("--app <app-id>", "App ID")
4833
6092
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
@@ -4846,83 +6105,122 @@ Directory Structure:
4846
6105
  process.exit(1);
4847
6106
  }
4848
6107
  const dbTypesDir = join(configDir, "database-types");
4849
- if (!existsSync(dbTypesDir)) {
4850
- info("No database-types/ directory found; nothing to migrate.");
4851
- return;
4852
- }
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.");
6108
+ const workflowsDir = join(configDir, "workflows");
6109
+ const dbFiles = existsSync(dbTypesDir)
6110
+ ? readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"))
6111
+ : [];
6112
+ const workflowFiles = existsSync(workflowsDir)
6113
+ ? readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))
6114
+ : [];
6115
+ // Restructured early-return (issue #1446): don't bail just because
6116
+ // `database-types/` is absent — a workflow-only project still has
6117
+ // `inputSchema`/`outputSchema` to migrate.
6118
+ if (dbFiles.length === 0 && workflowFiles.length === 0) {
6119
+ info("No database-type or workflow TOML files found; nothing to migrate.");
4856
6120
  return;
4857
6121
  }
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
6122
  let migratedCount = 0;
4881
6123
  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));
6124
+ // ---- database-types/ pass ----
6125
+ if (dbFiles.length > 0) {
6126
+ // Hydrate ruleSetIdToName from local sync state so files that
6127
+ // reference a rule set via the legacy `ruleSetId = "01..."` form
6128
+ // round-trip with the correct `ruleSetName`. Without this, any user
6129
+ // who runs migrate-toml against a file with a rule-set assignment
6130
+ // would silently lose that reference (review feedback r3246633010).
6131
+ //
6132
+ // Files that use the modern key-based `ruleSetName = "..."` form
6133
+ // don't need the map at all — `parseDatabaseTypeToml` stores the
6134
+ // value in `typeConfig._ruleSetName` and the serializer now prefers
6135
+ // that. The map is only load-bearing for the legacy ID-based form.
6136
+ const ruleSetIdToName = new Map();
6137
+ const migrateSyncState = loadSyncState(configDir);
6138
+ if (migrateSyncState?.entities?.ruleSets) {
6139
+ for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
6140
+ if (entry && typeof entry === "object" && "id" in entry && entry.id) {
6141
+ // fileKey is the sanitized rule-set name (see sync pull at
6142
+ // sync.ts:1385). It's the same shape the server returned and
6143
+ // matches what a TOML file's ruleSetName field would carry.
6144
+ ruleSetIdToName.set(entry.id, fileKey);
6145
+ }
6146
+ }
4894
6147
  }
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);
6148
+ for (const file of dbFiles) {
6149
+ const filePath = join(dbTypesDir, file);
6150
+ const rawBefore = readFileSync(filePath, "utf-8");
6151
+ const tomlData = parseTomlFile(filePath);
6152
+ // `accessRule`/`access` conflicts surface as a clear error here too,
6153
+ // rather than silently dropping the field during a migrate rewrite.
6154
+ let typeConfig;
6155
+ let operations;
6156
+ let subscriptions;
6157
+ try {
6158
+ ({ typeConfig, operations, subscriptions } =
6159
+ parseDatabaseTypeToml(tomlData));
6160
+ }
6161
+ catch (err) {
6162
+ if (err instanceof SubscriptionAccessKeyConflictError) {
6163
+ error(` ${file}: ${err.message}`);
6164
+ error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
6165
+ process.exit(1);
6166
+ }
6167
+ throw err;
6168
+ }
6169
+ // Force-native: ignore existing hints so every TOMLable field
6170
+ // ends up in nested-table form. Subscriptions are preserved on the
6171
+ // rewrite (issue #803) so migrate-toml never drops them.
6172
+ const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
6173
+ defaultForm: "native",
6174
+ logger: (msg) => info(` ${msg}`),
6175
+ subscriptions,
6176
+ });
6177
+ if (rewritten === rawBefore) {
6178
+ unchangedCount++;
6179
+ continue;
6180
+ }
6181
+ if (options.dryRun) {
6182
+ info(`Would migrate database-types/${file}`);
4900
6183
  }
4901
- throw err;
6184
+ else {
6185
+ writeFileSync(filePath, rewritten);
6186
+ info(`Migrated database-types/${file}`);
6187
+ // Update content hash in sync state if we have an entry for it.
6188
+ const syncState = loadSyncState(configDir);
6189
+ const dbType = typeConfig.databaseType || basename(file, ".toml");
6190
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
6191
+ syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
6192
+ saveSyncState(configDir, syncState);
6193
+ }
6194
+ }
6195
+ migratedCount++;
4902
6196
  }
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) {
6197
+ }
6198
+ // ---- workflows/ pass (issue #1446) ----
6199
+ // Convert string-form `inputSchema`/`outputSchema` to native tables.
6200
+ // Purely local, mirroring the database-types pass. The stored workflow
6201
+ // hash uses `computeExpandedContentHash` (parse then hash), NOT
6202
+ // `computeFileHash`, matching the workflow convention on the pull/push
6203
+ // paths — otherwise a push after migrate would spuriously re-push.
6204
+ for (const file of workflowFiles) {
6205
+ const filePath = join(workflowsDir, file);
6206
+ const rawBefore = readFileSync(filePath, "utf-8");
6207
+ const workflowKey = basename(file, ".toml");
6208
+ const { content: rewritten, changed } = rewriteWorkflowSchemasToNative(rawBefore, workflowKey, (msg) => info(` ${msg}`));
6209
+ if (!changed) {
4912
6210
  unchangedCount++;
4913
6211
  continue;
4914
6212
  }
4915
6213
  if (options.dryRun) {
4916
- info(`Would migrate database-types/${file}`);
6214
+ info(`Would migrate workflows/${file}`);
4917
6215
  }
4918
6216
  else {
4919
6217
  writeFileSync(filePath, rewritten);
4920
- info(`Migrated database-types/${file}`);
4921
- // Update content hash in sync state if we have an entry for it.
6218
+ info(`Migrated workflows/${file}`);
6219
+ // Update the stored content hash so a subsequent push sees a no-op.
4922
6220
  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);
6221
+ if (syncState?.entities?.workflows?.[workflowKey]) {
6222
+ syncState.entities.workflows[workflowKey].contentHash =
6223
+ computeExpandedContentHash(parseTomlFile(filePath));
4926
6224
  saveSyncState(configDir, syncState);
4927
6225
  }
4928
6226
  }
@@ -4938,6 +6236,63 @@ Directory Structure:
4938
6236
  success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
4939
6237
  }
4940
6238
  });
6239
+ // migrate-v2 — convert a legacy sync directory into the unified block
6240
+ // layout `blocks/<type>/<key>/block.toml` (issue #1183, Phase 3). Purely
6241
+ // local: it reads already-pulled legacy files and writes the v2 tree. The
6242
+ // legacy files are left in place (legacy stays the default until v2 is
6243
+ // proven), so re-running push/pull is unaffected.
6244
+ sync
6245
+ .command("migrate-v2")
6246
+ .description("Convert legacy per-type sync files (prompts/, integrations/, workflows/, transforms/) into the v2 blocks/<type>/<key>/block.toml layout")
6247
+ .argument("[app-id]", "App ID (uses current app if not specified)")
6248
+ .option("--app <app-id>", "App ID")
6249
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
6250
+ .option("--dry-run", "Show what would change without writing files")
6251
+ .option("--force", "Overwrite existing block.toml files instead of skipping them")
6252
+ .action(async (appId, options) => {
6253
+ // Resolve appId so we land in the per-env sync dir even when the user
6254
+ // didn't pass `--dir`. The app ID itself isn't used for any API calls —
6255
+ // migrate-v2 is a purely local rewrite (like migrate-toml).
6256
+ const resolvedAppId = resolveAppId(appId, options);
6257
+ const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
6258
+ if (isAutoResolvedSyncDir(options.dir)) {
6259
+ info(`Using per-environment sync directory: ${configDir}`);
6260
+ }
6261
+ if (!existsSync(configDir)) {
6262
+ error(`Config directory not found: ${configDir}`);
6263
+ process.exit(1);
6264
+ }
6265
+ const before = detectLayout(configDir);
6266
+ if (!before.legacy) {
6267
+ info("No legacy block files found (prompts/, integrations/, workflows/, transforms/); nothing to migrate.");
6268
+ return;
6269
+ }
6270
+ const result = migrateLegacyToV2(configDir, {
6271
+ dryRun: options.dryRun,
6272
+ force: options.force,
6273
+ });
6274
+ for (const b of result.blocks) {
6275
+ const suffix = b.testsCopied > 0 ? ` (+${b.testsCopied} test case(s))` : "";
6276
+ if (options.dryRun) {
6277
+ info(`Would write blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
6278
+ }
6279
+ else {
6280
+ info(`Wrote blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
6281
+ }
6282
+ }
6283
+ for (const s of result.skipped) {
6284
+ warn(`Skipped ${s.type}/${s.key}: ${s.reason}`);
6285
+ }
6286
+ divider();
6287
+ keyValue("Migrated", result.blocks.length);
6288
+ keyValue("Skipped", result.skipped.length);
6289
+ if (options.dryRun) {
6290
+ info("Dry-run only — no files were modified.");
6291
+ }
6292
+ else if (result.blocks.length > 0) {
6293
+ success(`Converted ${result.blocks.length} block(s) to the v2 layout. Legacy files were left in place.`);
6294
+ }
6295
+ });
4941
6296
  // Revert — restore a pre-pull snapshot (issue #578, Phase 1).
4942
6297
  sync
4943
6298
  .command("revert")
@@ -5032,24 +6387,4 @@ Directory Structure:
5032
6387
  info("Run 'primitive sync diff' to inspect the restored state versus the server.");
5033
6388
  });
5034
6389
  }
5035
- /**
5036
- * Best-effort check for uncommitted git changes under `dir`. Used only to warn
5037
- * before a revert overwrites local edits — never fatal. Returns false if git
5038
- * isn't available, the dir isn't in a repo, or anything goes wrong.
5039
- */
5040
- async function hasUncommittedChanges(dir) {
5041
- if (!existsSync(dir))
5042
- return false;
5043
- try {
5044
- const { execSync } = await import("child_process");
5045
- const out = execSync(`git status --porcelain -- "${dir}"`, {
5046
- stdio: ["ignore", "pipe", "ignore"],
5047
- encoding: "utf-8",
5048
- });
5049
- return out.trim().length > 0;
5050
- }
5051
- catch {
5052
- return false;
5053
- }
5054
- }
5055
6390
  //# sourceMappingURL=sync.js.map