primitive-admin 1.1.0-alpha.3 → 1.1.0-alpha.31

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 (99) hide show
  1. package/README.md +168 -21
  2. package/assets/skill/skills/primitive-platform/SKILL.md +226 -0
  3. package/dist/bin/primitive.js +198 -15
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/admins.js +107 -0
  6. package/dist/src/commands/admins.js.map +1 -1
  7. package/dist/src/commands/analytics.js +464 -55
  8. package/dist/src/commands/analytics.js.map +1 -1
  9. package/dist/src/commands/apps.js +27 -13
  10. package/dist/src/commands/apps.js.map +1 -1
  11. package/dist/src/commands/auth.js +165 -5
  12. package/dist/src/commands/auth.js.map +1 -1
  13. package/dist/src/commands/blob-buckets.js +354 -0
  14. package/dist/src/commands/blob-buckets.js.map +1 -0
  15. package/dist/src/commands/catalog.js +17 -17
  16. package/dist/src/commands/catalog.js.map +1 -1
  17. package/dist/src/commands/collection-type-configs.js +178 -0
  18. package/dist/src/commands/collection-type-configs.js.map +1 -0
  19. package/dist/src/commands/collections.js +526 -0
  20. package/dist/src/commands/collections.js.map +1 -0
  21. package/dist/src/commands/comparisons.js +6 -6
  22. package/dist/src/commands/comparisons.js.map +1 -1
  23. package/dist/src/commands/cron-triggers.js +364 -0
  24. package/dist/src/commands/cron-triggers.js.map +1 -0
  25. package/dist/src/commands/database-types.js +462 -0
  26. package/dist/src/commands/database-types.js.map +1 -0
  27. package/dist/src/commands/databases.js +1067 -58
  28. package/dist/src/commands/databases.js.map +1 -1
  29. package/dist/src/commands/documents.js +510 -2
  30. package/dist/src/commands/documents.js.map +1 -1
  31. package/dist/src/commands/email-templates.js +281 -0
  32. package/dist/src/commands/email-templates.js.map +1 -0
  33. package/dist/src/commands/env.js +260 -0
  34. package/dist/src/commands/env.js.map +1 -0
  35. package/dist/src/commands/group-type-configs.js +189 -0
  36. package/dist/src/commands/group-type-configs.js.map +1 -0
  37. package/dist/src/commands/groups.js +28 -25
  38. package/dist/src/commands/groups.js.map +1 -1
  39. package/dist/src/commands/init.js +719 -188
  40. package/dist/src/commands/init.js.map +1 -1
  41. package/dist/src/commands/integrations.js +504 -33
  42. package/dist/src/commands/integrations.js.map +1 -1
  43. package/dist/src/commands/prompts.js +17 -16
  44. package/dist/src/commands/prompts.js.map +1 -1
  45. package/dist/src/commands/rule-sets.js +366 -0
  46. package/dist/src/commands/rule-sets.js.map +1 -0
  47. package/dist/src/commands/secrets.js +108 -0
  48. package/dist/src/commands/secrets.js.map +1 -0
  49. package/dist/src/commands/skill.js +29 -0
  50. package/dist/src/commands/skill.js.map +1 -0
  51. package/dist/src/commands/sync.js +2363 -182
  52. package/dist/src/commands/sync.js.map +1 -1
  53. package/dist/src/commands/tokens.js +9 -9
  54. package/dist/src/commands/tokens.js.map +1 -1
  55. package/dist/src/commands/users.js +426 -4
  56. package/dist/src/commands/users.js.map +1 -1
  57. package/dist/src/commands/webhooks.js +386 -0
  58. package/dist/src/commands/webhooks.js.map +1 -0
  59. package/dist/src/commands/workflows.js +614 -64
  60. package/dist/src/commands/workflows.js.map +1 -1
  61. package/dist/src/lib/api-client.js +942 -83
  62. package/dist/src/lib/api-client.js.map +1 -1
  63. package/dist/src/lib/config.js +51 -53
  64. package/dist/src/lib/config.js.map +1 -1
  65. package/dist/src/lib/constants.js +3 -0
  66. package/dist/src/lib/constants.js.map +1 -0
  67. package/dist/src/lib/credentials-store.js +307 -0
  68. package/dist/src/lib/credentials-store.js.map +1 -0
  69. package/dist/src/lib/env-resolver.js +121 -0
  70. package/dist/src/lib/env-resolver.js.map +1 -0
  71. package/dist/src/lib/init-config.js +87 -0
  72. package/dist/src/lib/init-config.js.map +1 -0
  73. package/dist/src/lib/migration-nag.js +163 -0
  74. package/dist/src/lib/migration-nag.js.map +1 -0
  75. package/dist/src/lib/output.js +58 -6
  76. package/dist/src/lib/output.js.map +1 -1
  77. package/dist/src/lib/paginate.js +42 -0
  78. package/dist/src/lib/paginate.js.map +1 -0
  79. package/dist/src/lib/project-config.js +209 -0
  80. package/dist/src/lib/project-config.js.map +1 -0
  81. package/dist/src/lib/refresh-admin-credentials.js +103 -0
  82. package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
  83. package/dist/src/lib/skill-installer.js +135 -0
  84. package/dist/src/lib/skill-installer.js.map +1 -0
  85. package/dist/src/lib/sync-paths.js +102 -0
  86. package/dist/src/lib/sync-paths.js.map +1 -0
  87. package/dist/src/lib/template.js +279 -18
  88. package/dist/src/lib/template.js.map +1 -1
  89. package/dist/src/lib/toml-database-config.js +384 -0
  90. package/dist/src/lib/toml-database-config.js.map +1 -0
  91. package/dist/src/lib/toml-params-validator.js +183 -0
  92. package/dist/src/lib/toml-params-validator.js.map +1 -0
  93. package/dist/src/lib/version-check.js +172 -0
  94. package/dist/src/lib/version-check.js.map +1 -0
  95. package/dist/src/lib/workflow-fragments.js +121 -0
  96. package/dist/src/lib/workflow-fragments.js.map +1 -0
  97. package/dist/src/lib/workflow-toml-validator.js +328 -0
  98. package/dist/src/lib/workflow-toml-validator.js.map +1 -0
  99. package/package.json +7 -4
@@ -1,9 +1,15 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
2
2
  import { join, basename } from "path";
3
+ import { createHash } from "crypto";
3
4
  import * as TOML from "@iarna/toml";
4
5
  import { lookup as mimeLookup } from "mime-types";
5
- import { ApiClient, ConflictError } from "../lib/api-client.js";
6
+ import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
7
+ import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, } from "../lib/toml-database-config.js";
8
+ import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
6
9
  import { getServerUrl, resolveAppId } from "../lib/config.js";
10
+ import { resolveSyncDir, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
11
+ import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
12
+ import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
7
13
  import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
8
14
  import chalk from "chalk";
9
15
  function ensureDir(dirPath) {
@@ -27,6 +33,89 @@ function saveSyncState(configDir, state) {
27
33
  const syncFile = join(configDir, ".primitive-sync.json");
28
34
  writeFileSync(syncFile, JSON.stringify(state, null, 2));
29
35
  }
36
+ /**
37
+ * Wrap a server-side error so the printed message identifies which entity
38
+ * was in flight. Used by every entity create/update/delete call site in the
39
+ * push loops (issue #684).
40
+ *
41
+ * Format: `Failed to ${action} ${kind} ${key}: ${serverMessage}`
42
+ *
43
+ * - `ConflictError` is rethrown unchanged so the existing
44
+ * `ConflictError → conflicts[]` accumulator in each loop still fires.
45
+ * - Other errors are rewrapped as a new `ApiError` carrying the wrapped
46
+ * message + the original `details[]` and `statusCode`.
47
+ */
48
+ export function wrapEntityError(err, action, kind, key) {
49
+ if (err instanceof ConflictError) {
50
+ return err;
51
+ }
52
+ const original = err instanceof Error ? err.message : String(err?.message ?? err);
53
+ const wrappedMessage = `Failed to ${action} ${kind} ${key}: ${original}`;
54
+ if (err instanceof ApiError) {
55
+ return new ApiError(wrappedMessage, err.statusCode, err.code, err.details);
56
+ }
57
+ // Non-ApiError (e.g. network failure, JSON parse error) — preserve cause.
58
+ const wrapped = new Error(wrappedMessage);
59
+ return wrapped;
60
+ }
61
+ export function computeFileHash(filePath) {
62
+ const content = readFileSync(filePath);
63
+ return createHash("sha256").update(content).digest("hex");
64
+ }
65
+ export function shouldPushFile(filePath, storedHash) {
66
+ if (!storedHash)
67
+ return true;
68
+ return computeFileHash(filePath) !== storedHash;
69
+ }
70
+ /**
71
+ * Canonical-JSON serializer for hash computation.
72
+ *
73
+ * Sorts object keys at every level so two semantically-equivalent JSON
74
+ * blobs produce the same string. Arrays preserve order (step order
75
+ * matters in workflows). Used by `computeExpandedContentHash`.
76
+ */
77
+ function canonicalJsonStringify(value) {
78
+ if (value === null || typeof value !== "object") {
79
+ return JSON.stringify(value);
80
+ }
81
+ if (Array.isArray(value)) {
82
+ return "[" + value.map((v) => canonicalJsonStringify(v)).join(",") + "]";
83
+ }
84
+ const keys = Object.keys(value).sort();
85
+ return ("{" +
86
+ keys
87
+ .map((k) => JSON.stringify(k) + ":" + canonicalJsonStringify(value[k]))
88
+ .join(",") +
89
+ "}");
90
+ }
91
+ /**
92
+ * Hash the *expanded* (post-fragment-splice) content of a workflow TOML.
93
+ *
94
+ * The raw-file hash (`computeFileHash`) misses fragment-only edits: if a
95
+ * workflow uses `include = ["x"]` and only `workflow-fragments/x.toml`
96
+ * changes, the workflow file is byte-identical and shouldPushFile would
97
+ * skip the push, leaving the server with stale expanded steps.
98
+ *
99
+ * This helper hashes the parsed-and-expanded data instead, so fragment
100
+ * edits change the hash and force a re-push. Pass the already-parsed
101
+ * `tomlData` (from `parseTomlFile`) — that function runs the expander,
102
+ * so `tomlData` is the canonical expanded shape the server will see.
103
+ */
104
+ export function computeExpandedContentHash(parsed) {
105
+ const canonical = canonicalJsonStringify(parsed);
106
+ return createHash("sha256").update(canonical).digest("hex");
107
+ }
108
+ /**
109
+ * Variant of `shouldPushFile` for workflow files: compares the stored hash
110
+ * against the hash of the *expanded* content rather than the raw file
111
+ * bytes. Required so that edits to included fragments invalidate the
112
+ * push-skip cache for any workflow that references them.
113
+ */
114
+ export function shouldPushExpandedFile(parsed, storedHash) {
115
+ if (!storedHash)
116
+ return true;
117
+ return computeExpandedContentHash(parsed) !== storedHash;
118
+ }
30
119
  // TOML serialization helpers
31
120
  function serializeAppSettings(settings) {
32
121
  const data = {
@@ -69,6 +158,107 @@ function serializeIntegration(integration) {
69
158
  };
70
159
  return TOML.stringify(data);
71
160
  }
161
+ function serializeWebhook(webhook) {
162
+ const data = {
163
+ webhook: {
164
+ key: webhook.webhookKey,
165
+ displayName: webhook.displayName,
166
+ description: webhook.description || undefined,
167
+ status: webhook.status,
168
+ workflowKey: webhook.workflowKey,
169
+ verificationScheme: webhook.verificationScheme,
170
+ toleranceSeconds: webhook.toleranceSeconds,
171
+ deduplicationEnabled: webhook.deduplicationEnabled,
172
+ deduplicationWindowMs: webhook.deduplicationWindowMs,
173
+ secretGracePeriodMs: webhook.secretGracePeriodMs,
174
+ },
175
+ };
176
+ // Parse JSON fields if they're strings
177
+ if (webhook.allowedIpCidrs) {
178
+ try {
179
+ data.allowedIps = { cidrs: JSON.parse(webhook.allowedIpCidrs) };
180
+ }
181
+ catch {
182
+ data.allowedIps = { cidrs: webhook.allowedIpCidrs };
183
+ }
184
+ }
185
+ if (webhook.inputMapping) {
186
+ try {
187
+ data.inputMapping = JSON.parse(webhook.inputMapping);
188
+ }
189
+ catch {
190
+ data.inputMapping = { raw: webhook.inputMapping };
191
+ }
192
+ }
193
+ if (webhook.metadata) {
194
+ try {
195
+ data.metadata = JSON.parse(webhook.metadata);
196
+ }
197
+ catch { }
198
+ }
199
+ // Remove undefined values
200
+ Object.keys(data.webhook).forEach(k => {
201
+ if (data.webhook[k] === undefined)
202
+ delete data.webhook[k];
203
+ });
204
+ return TOML.stringify(data);
205
+ }
206
+ function serializeCronTrigger(trigger) {
207
+ const data = {
208
+ cronTrigger: {
209
+ key: trigger.triggerKey,
210
+ displayName: trigger.displayName,
211
+ description: trigger.description || undefined,
212
+ cron: trigger.cron,
213
+ timezone: trigger.timezone || "UTC",
214
+ workflowKey: trigger.workflowKey,
215
+ overlapPolicy: trigger.overlapPolicy || "skip",
216
+ state: trigger.state,
217
+ },
218
+ };
219
+ if (trigger.rootInput) {
220
+ try {
221
+ data.rootInput = JSON.parse(trigger.rootInput);
222
+ }
223
+ catch {
224
+ data.rootInput = { raw: trigger.rootInput };
225
+ }
226
+ }
227
+ if (trigger.inputMapping) {
228
+ try {
229
+ data.inputMapping = JSON.parse(trigger.inputMapping);
230
+ }
231
+ catch {
232
+ data.inputMapping = { raw: trigger.inputMapping };
233
+ }
234
+ }
235
+ // Remove undefined values
236
+ Object.keys(data.cronTrigger).forEach(k => {
237
+ if (data.cronTrigger[k] === undefined)
238
+ delete data.cronTrigger[k];
239
+ });
240
+ return TOML.stringify(data);
241
+ }
242
+ function serializeBlobBucket(bucket) {
243
+ const data = {
244
+ bucket: {
245
+ key: bucket.bucketKey,
246
+ name: bucket.name,
247
+ description: bucket.description || undefined,
248
+ ttlTier: bucket.ttlTier,
249
+ accessPolicy: bucket.accessPolicy,
250
+ },
251
+ };
252
+ if (bucket.ruleSetId) {
253
+ data.bucket.ruleSetId = bucket.ruleSetId;
254
+ }
255
+ // Remove undefined values
256
+ Object.keys(data.bucket).forEach(k => {
257
+ if (data.bucket[k] === undefined)
258
+ delete data.bucket[k];
259
+ });
260
+ return TOML.stringify(data);
261
+ }
72
262
  function serializePrompt(prompt) {
73
263
  const data = {
74
264
  prompt: {
@@ -83,7 +273,7 @@ function serializePrompt(prompt) {
83
273
  description: config.description,
84
274
  provider: config.provider,
85
275
  model: config.model,
86
- temperature: config.temperature?.toString(),
276
+ temperature: config.temperature != null ? Number(config.temperature) : undefined,
87
277
  maxTokens: config.maxTokens,
88
278
  outputFormat: config.outputFormat,
89
279
  systemPrompt: config.systemPrompt,
@@ -96,6 +286,7 @@ function serializeWorkflow(workflow, draft, configs) {
96
286
  // Find the active config or use the first one
97
287
  const activeConfigId = workflow.activeConfigId;
98
288
  const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
289
+ const activeConfigName = activeConfig?.configName || "";
99
290
  // Determine which steps to use (prefer config steps if non-empty, else draft steps)
100
291
  const configSteps = activeConfig?.steps;
101
292
  const draftSteps = draft?.steps;
@@ -106,7 +297,8 @@ function serializeWorkflow(workflow, draft, configs) {
106
297
  name: workflow.name,
107
298
  description: workflow.description,
108
299
  status: workflow.status,
109
- activeConfigId: activeConfigId,
300
+ activeConfigName: activeConfigName,
301
+ accessRule: workflow.accessRule || undefined,
110
302
  perUserMaxRunning: workflow.perUserMaxRunning,
111
303
  perUserMaxQueued: workflow.perUserMaxQueued,
112
304
  dequeueOrder: workflow.dequeueOrder,
@@ -118,7 +310,6 @@ function serializeWorkflow(workflow, draft, configs) {
118
310
  steps,
119
311
  // Include all configurations
120
312
  configs: configs?.map((config) => ({
121
- id: config.configId,
122
313
  name: config.configName,
123
314
  description: config.description,
124
315
  status: config.status,
@@ -127,10 +318,235 @@ function serializeWorkflow(workflow, draft, configs) {
127
318
  };
128
319
  return TOML.stringify(data);
129
320
  }
321
+ // Database config serialization helpers
322
+ //
323
+ // Native-TOML form (issue #752): when emitting a database-type config, we
324
+ // prefer nested `[operations.definition]` tables and `[[operations.params]]`
325
+ // rows over JSON-strings stuffed into a single field. The form is sticky
326
+ // per file — if the existing file uses the legacy JSON-string form, we
327
+ // preserve it so we don't generate surprise diffs. New files default to
328
+ // native. Un-TOMLable shapes (null, mixed-type arrays) fall back to JSON
329
+ // string per field with a log message.
330
+ //
331
+ // Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
332
+ // blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
333
+ // object so the result round-trips as `[models.<Name>.fields.<field>]`.
334
+ function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
335
+ const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
336
+ return TOML.stringify(data);
337
+ }
338
+ function serializeEmailTemplate(template) {
339
+ // The API returns { emailType, hasOverride, override: { subject, htmlBody, textBody }, default: { ... } }
340
+ // We only serialize overrides (custom templates), using the override fields.
341
+ const override = template.override || {};
342
+ const data = {
343
+ template: {
344
+ emailType: template.emailType,
345
+ subject: override.subject || "",
346
+ },
347
+ };
348
+ if (override.htmlBody) {
349
+ data.template.htmlBody = override.htmlBody;
350
+ }
351
+ if (override.textBody) {
352
+ data.template.textBody = override.textBody;
353
+ }
354
+ return TOML.stringify(data);
355
+ }
356
+ function serializeRuleSet(ruleSet) {
357
+ const data = {
358
+ ruleSet: {
359
+ name: ruleSet.name,
360
+ resourceType: ruleSet.resourceType,
361
+ },
362
+ };
363
+ if (ruleSet.description) {
364
+ data.ruleSet.description = ruleSet.description;
365
+ }
366
+ // Rules are structured as { category: { operation: "expression" } }
367
+ const rules = typeof ruleSet.rules === "string" ? JSON.parse(ruleSet.rules) : (ruleSet.rules || {});
368
+ if (Object.keys(rules).length > 0) {
369
+ data.rules = rules;
370
+ }
371
+ return TOML.stringify(data);
372
+ }
373
+ function serializeGroupTypeConfig(config, ruleSetIdToName) {
374
+ const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
375
+ const data = {
376
+ groupTypeConfig: {
377
+ groupType: config.groupType,
378
+ ruleSetName: ruleSetName,
379
+ autoAddCreator: config.autoAddCreator ?? true,
380
+ },
381
+ };
382
+ return TOML.stringify(data);
383
+ }
384
+ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
385
+ const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
386
+ const data = {
387
+ collectionTypeConfig: {
388
+ collectionType: config.collectionType,
389
+ ruleSetName: ruleSetName,
390
+ },
391
+ };
392
+ return TOML.stringify(data);
393
+ }
394
+ export function parseDatabaseTypeToml(tomlData) {
395
+ const typeSection = tomlData.type || {};
396
+ const typeConfig = {
397
+ databaseType: typeSection.databaseType,
398
+ };
399
+ // Support both new key-based reference and legacy ID-based reference
400
+ if (typeSection.ruleSetName && typeSection.ruleSetName !== "") {
401
+ typeConfig._ruleSetName = typeSection.ruleSetName;
402
+ }
403
+ else if (typeSection.ruleSetId && typeSection.ruleSetId !== "") {
404
+ typeConfig.ruleSetId = typeSection.ruleSetId;
405
+ }
406
+ // Accept both `celContextAccess` (new user-facing key) and `metadataAccess`
407
+ // (legacy). The newer key wins when both appear.
408
+ if (typeSection.celContextAccess) {
409
+ typeConfig.metadataAccess = typeSection.celContextAccess;
410
+ }
411
+ else if (typeSection.metadataAccess) {
412
+ typeConfig.metadataAccess = typeSection.metadataAccess;
413
+ }
414
+ if (typeSection.defaultAccess !== undefined) {
415
+ typeConfig.defaultAccess = typeSection.defaultAccess;
416
+ }
417
+ // autoPopulatedFields (issue #750) — pass through unchanged. Both the
418
+ // shorthand (`ownerId = "user.userId"`) and the verbose object form
419
+ // (`updatedBy = { value = "user.userId", on = ["create","update"] }`) are
420
+ // accepted; the server validates and normalizes at write time.
421
+ if (typeSection.autoPopulatedFields !== undefined) {
422
+ typeConfig.autoPopulatedFields = typeSection.autoPopulatedFields;
423
+ }
424
+ if (tomlData.triggers) {
425
+ typeConfig.triggers = tomlData.triggers;
426
+ }
427
+ // Issue #666: extract the `[models.*]` subtree (if present) and re-serialize
428
+ // it to a TOML string for the server's `schema` field.
429
+ //
430
+ // We always set `schema` — either to the serialized TOML string or to
431
+ // `null` — so the sync push diff can detect "models removed from local
432
+ // file" and forward `schema: null` to the server. Without this, removing
433
+ // `[models.*]` blocks would silently no-op and the next pull would
434
+ // resurrect the stale server schema, making schema deletion via `sync push`
435
+ // impossible (codex review on PR #766, [P2]).
436
+ let schemaSerialized = null;
437
+ if (tomlData.models && typeof tomlData.models === "object") {
438
+ try {
439
+ schemaSerialized = TOML.stringify({ models: tomlData.models });
440
+ }
441
+ catch {
442
+ // Fall through — server-side parser will fail with a clear
443
+ // TOML_PARSE_ERROR if we somehow produced an invalid round-trip.
444
+ }
445
+ }
446
+ typeConfig.schema = schemaSerialized;
447
+ // `normalizeOperationFromToml` accepts both forms:
448
+ // - legacy: `definition = '{...}'` (JSON string), `params = '{...}'`
449
+ // - native: `[operations.definition]` table, `[[operations.params]]` rows
450
+ // and returns the JS shape the server expects.
451
+ const operations = (tomlData.operations || []).map((op) => normalizeOperationFromToml(op));
452
+ return { typeConfig, operations };
453
+ }
454
+ function parseRuleSetToml(tomlData) {
455
+ const ruleSetSection = tomlData.ruleSet || {};
456
+ return {
457
+ name: ruleSetSection.name,
458
+ resourceType: ruleSetSection.resourceType,
459
+ description: ruleSetSection.description || null,
460
+ rules: tomlData.rules || {},
461
+ };
462
+ }
463
+ export function parseGroupTypeConfigToml(tomlData) {
464
+ const section = tomlData.groupTypeConfig || {};
465
+ const result = {
466
+ groupType: section.groupType,
467
+ autoAddCreator: section.autoAddCreator ?? true,
468
+ };
469
+ // Support both new key-based reference and legacy ID-based reference
470
+ if (section.ruleSetName && section.ruleSetName !== "") {
471
+ result._ruleSetName = section.ruleSetName;
472
+ }
473
+ else if (section.ruleSetId && section.ruleSetId !== "") {
474
+ result.ruleSetId = section.ruleSetId;
475
+ }
476
+ return result;
477
+ }
478
+ export function parseCollectionTypeConfigToml(tomlData) {
479
+ const section = tomlData.collectionTypeConfig || {};
480
+ const result = {
481
+ collectionType: section.collectionType,
482
+ };
483
+ // Support both new key-based reference and legacy ID-based reference
484
+ if (section.ruleSetName && section.ruleSetName !== "") {
485
+ result._ruleSetName = section.ruleSetName;
486
+ }
487
+ else if (section.ruleSetId && section.ruleSetId !== "") {
488
+ result.ruleSetId = section.ruleSetId;
489
+ }
490
+ return result;
491
+ }
130
492
  // Parsing helpers
493
+ //
494
+ // All 20+ TOML parse sites in this file route through `parseTomlFile()`.
495
+ // Workflow fragment expansion is wired in here so every push path (workflows,
496
+ // prompts, tests, etc.) gets it for free. For non-workflow TOMLs the
497
+ // expander is a no-op: if the parsed result has no `include` key, it returns
498
+ // the original object untouched.
131
499
  function parseTomlFile(filePath) {
132
500
  const content = readFileSync(filePath, "utf-8");
133
- return TOML.parse(content);
501
+ const parsed = TOML.parse(content);
502
+ return expandWorkflowTomlData(parsed, filePath);
503
+ }
504
+ /**
505
+ * Paginate through a list endpoint, collecting all items.
506
+ */
507
+ export async function fetchAll(listFn, pageSize = 100, maxPages = 100) {
508
+ const all = [];
509
+ let cursor;
510
+ let page = 0;
511
+ do {
512
+ if (++page > maxPages) {
513
+ throw new Error(`fetchAll: too many pages (>${maxPages}), aborting to prevent infinite loop`);
514
+ }
515
+ const result = await listFn({ limit: pageSize, cursor: cursor });
516
+ all.push(...result.items);
517
+ cursor = result.nextCursor || undefined;
518
+ } while (cursor);
519
+ return all;
520
+ }
521
+ /**
522
+ * Safely parse a JSON field that may be a string or already an object.
523
+ * TOML can parse inline tables as objects, so we need to handle both.
524
+ */
525
+ export function safeJsonParse(value) {
526
+ if (value === undefined || value === null)
527
+ return undefined;
528
+ if (typeof value === "string")
529
+ return JSON.parse(value);
530
+ return value; // Already an object (TOML parsed it as a table)
531
+ }
532
+ /**
533
+ * Resolve a key-based rule set name reference to an ID.
534
+ * Throws if the name cannot be resolved and throwOnMissing is true.
535
+ */
536
+ export function resolveRuleSetReference(entityConfig, ruleSetNameToId, entityLabel, options = {}) {
537
+ if (!entityConfig._ruleSetName)
538
+ return;
539
+ const resolvedRuleSetId = ruleSetNameToId.get(entityConfig._ruleSetName);
540
+ if (resolvedRuleSetId) {
541
+ entityConfig.ruleSetId = resolvedRuleSetId;
542
+ }
543
+ else {
544
+ const msg = `Rule set "${entityConfig._ruleSetName}" not found for ${entityLabel}. The entity would be created without a rule set.`;
545
+ if (options.throwOnMissing !== false) {
546
+ throw new Error(msg);
547
+ }
548
+ }
549
+ delete entityConfig._ruleSetName;
134
550
  }
135
551
  // Test case helpers (exported for unit testing)
136
552
  export function slugifyTestCaseName(name) {
@@ -157,15 +573,22 @@ function getTestsDir(configDir, blockType, blockKey) {
157
573
  const typeDir = blockType === "prompt" ? "prompts" : "workflows";
158
574
  return join(configDir, typeDir, `${blockKey}.tests`);
159
575
  }
160
- export function serializeTestCase(testCase) {
576
+ export function serializeTestCase(testCase, lookupMaps) {
577
+ // Resolve ID references to key-based references
578
+ const configName = testCase.configId && lookupMaps?.configIdToName
579
+ ? (lookupMaps.configIdToName.get(testCase.configId) || "") : "";
580
+ const evaluatorPromptKey = testCase.evaluatorPromptId && lookupMaps?.promptIdToKey
581
+ ? (lookupMaps.promptIdToKey.get(testCase.evaluatorPromptId) || "") : "";
582
+ const evaluatorConfigName = testCase.evaluatorConfigId && lookupMaps?.configIdToName
583
+ ? (lookupMaps.configIdToName.get(testCase.evaluatorConfigId) || "") : "";
161
584
  const data = {
162
585
  test: {
163
586
  name: testCase.name || "",
164
587
  description: testCase.description || "",
165
588
  inputVariables: testCase.inputVariables || "{}",
166
- configId: testCase.configId || "",
167
- evaluatorPromptId: testCase.evaluatorPromptId || "",
168
- evaluatorConfigId: testCase.evaluatorConfigId || "",
589
+ configName: configName,
590
+ evaluatorPromptKey: evaluatorPromptKey,
591
+ evaluatorConfigName: evaluatorConfigName,
169
592
  expectedOutputPattern: testCase.expectedOutputPattern || "",
170
593
  expectedOutputContains: testCase.expectedOutputContains || "[]",
171
594
  expectedJsonSubset: testCase.expectedJsonSubset || "{}",
@@ -192,6 +615,18 @@ export function parseTestCaseToml(tomlData) {
192
615
  if (test.description && test.description !== "") {
193
616
  payload.description = test.description;
194
617
  }
618
+ // Support both new key-based references and legacy ID-based references
619
+ // Key-based references (portable across apps)
620
+ if (test.configName && test.configName !== "") {
621
+ payload._configName = test.configName;
622
+ }
623
+ if (test.evaluatorPromptKey && test.evaluatorPromptKey !== "") {
624
+ payload._evaluatorPromptKey = test.evaluatorPromptKey;
625
+ }
626
+ if (test.evaluatorConfigName && test.evaluatorConfigName !== "") {
627
+ payload._evaluatorConfigName = test.evaluatorConfigName;
628
+ }
629
+ // Legacy ID-based references (backward compat)
195
630
  if (test.configId && test.configId !== "") {
196
631
  payload.configId = test.configId;
197
632
  }
@@ -222,7 +657,7 @@ export function parseTestCaseToml(tomlData) {
222
657
  }
223
658
  return payload;
224
659
  }
225
- async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities) {
660
+ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
226
661
  let testCases;
227
662
  try {
228
663
  const result = await client.listTestCases(appId, blockType, blockId);
@@ -243,7 +678,7 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
243
678
  pulledSlugs.add(slug);
244
679
  // Write test case TOML
245
680
  const tomlPath = join(testsDir, `${slug}.toml`);
246
- writeFileSync(tomlPath, serializeTestCase(tc));
681
+ writeFileSync(tomlPath, serializeTestCase(tc, lookupMaps));
247
682
  // Handle attachments
248
683
  let attachmentFilenames = [];
249
684
  try {
@@ -279,6 +714,7 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
279
714
  slug,
280
715
  modifiedAt: tc.modifiedAt || tc.createdAt || new Date().toISOString(),
281
716
  attachmentFilenames: attachmentFilenames.length > 0 ? attachmentFilenames : undefined,
717
+ contentHash: computeFileHash(tomlPath),
282
718
  };
283
719
  }
284
720
  // Clean up local files for remotely-deleted test cases
@@ -308,21 +744,75 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
308
744
  }
309
745
  return testCases.length;
310
746
  }
311
- async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, syncState, dryRun, changes) {
747
+ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, syncState, dryRun, changes, resolutionMaps, options) {
748
+ let skipped = 0;
312
749
  const testsDir = getTestsDir(configDir, blockType, blockKey);
313
750
  if (!existsSync(testsDir))
314
- return;
751
+ return 0;
315
752
  const files = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
316
753
  if (files.length === 0)
317
- return;
754
+ return 0;
318
755
  const TEN_MB = 10 * 1024 * 1024;
319
756
  for (const file of files) {
320
757
  const slug = basename(file, ".toml");
321
758
  const filePath = join(testsDir, file);
322
759
  const tomlData = parseTomlFile(filePath);
323
760
  const testPayload = parseTestCaseToml(tomlData);
761
+ // Resolve key-based references to IDs
762
+ if (resolutionMaps) {
763
+ // Resolve configName → configId
764
+ if (testPayload._configName && !testPayload.configId) {
765
+ const configMapKey = `${blockKey}#${testPayload._configName}`;
766
+ const configMap = blockType === "prompt"
767
+ ? resolutionMaps.promptConfigNameToId
768
+ : resolutionMaps.workflowConfigNameToId;
769
+ const resolvedId = configMap.get(configMapKey);
770
+ if (resolvedId) {
771
+ testPayload.configId = resolvedId;
772
+ }
773
+ }
774
+ delete testPayload._configName;
775
+ // Resolve evaluatorPromptKey → evaluatorPromptId
776
+ if (testPayload._evaluatorPromptKey && !testPayload.evaluatorPromptId) {
777
+ const resolvedId = resolutionMaps.promptKeyToId.get(testPayload._evaluatorPromptKey);
778
+ if (resolvedId) {
779
+ testPayload.evaluatorPromptId = resolvedId;
780
+ }
781
+ }
782
+ const evaluatorPromptKey = testPayload._evaluatorPromptKey;
783
+ delete testPayload._evaluatorPromptKey;
784
+ // Resolve evaluatorConfigName → evaluatorConfigId
785
+ if (testPayload._evaluatorConfigName && !testPayload.evaluatorConfigId && evaluatorPromptKey) {
786
+ const configMapKey = `${evaluatorPromptKey}#${testPayload._evaluatorConfigName}`;
787
+ const resolvedId = resolutionMaps.promptConfigNameToId.get(configMapKey);
788
+ if (resolvedId) {
789
+ testPayload.evaluatorConfigId = resolvedId;
790
+ }
791
+ }
792
+ delete testPayload._evaluatorConfigName;
793
+ }
324
794
  const stateKey = `${blockType}/${blockKey}/${slug}`;
325
795
  const existingEntry = syncState?.entities?.testCases?.[stateKey];
796
+ // Skip if file hasn't changed since last sync (also check for attachment changes)
797
+ if (!options?.force && existingEntry?.id && !shouldPushFile(filePath, existingEntry.contentHash)) {
798
+ const attachDir = join(testsDir, slug);
799
+ const localAttachFiles = existsSync(attachDir)
800
+ ? readdirSync(attachDir).filter((f) => { try {
801
+ return statSync(join(attachDir, f)).isFile();
802
+ }
803
+ catch {
804
+ return false;
805
+ } })
806
+ : [];
807
+ const prevAttachFiles = existingEntry.attachmentFilenames || [];
808
+ const attachmentsChanged = localAttachFiles.length !== prevAttachFiles.length ||
809
+ localAttachFiles.some((f) => !prevAttachFiles.includes(f)) ||
810
+ prevAttachFiles.some((f) => !localAttachFiles.includes(f));
811
+ if (!attachmentsChanged) {
812
+ skipped++;
813
+ continue;
814
+ }
815
+ }
326
816
  if (existingEntry?.id) {
327
817
  // Update existing test case
328
818
  changes.push({ type: "test-case", action: "update", key: `${blockKey}/${slug}` });
@@ -330,6 +820,9 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
330
820
  try {
331
821
  await client.updateTestCase(appId, blockType, blockId, existingEntry.id, testPayload);
332
822
  info(` Updated test case: ${blockKey}/${slug}`);
823
+ if (syncState?.entities?.testCases?.[stateKey]) {
824
+ syncState.entities.testCases[stateKey].contentHash = computeFileHash(filePath);
825
+ }
333
826
  }
334
827
  catch (err) {
335
828
  warn(` Failed to update test case ${blockKey}/${slug}: ${err.message}`);
@@ -360,6 +853,7 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
360
853
  blockKey,
361
854
  slug,
362
855
  modifiedAt: created.modifiedAt || created.createdAt || new Date().toISOString(),
856
+ contentHash: computeFileHash(filePath),
363
857
  };
364
858
  }
365
859
  }
@@ -438,6 +932,7 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
438
932
  }
439
933
  }
440
934
  }
935
+ return skipped;
441
936
  }
442
937
  function formatFileSize(bytes) {
443
938
  if (bytes < 1024)
@@ -459,13 +954,21 @@ Examples:
459
954
 
460
955
  Directory Structure:
461
956
  config/
462
- .primitive-sync.json # Sync state (auto-managed)
463
- app.toml # App settings
464
- integrations/*.toml # Integration configs
465
- prompts/*.toml # Prompt configs
466
- prompts/{key}.tests/*.toml # Prompt test cases
467
- workflows/*.toml # Workflow definitions
468
- workflows/{key}.tests/*.toml # Workflow test cases
957
+ .primitive-sync.json # Sync state (auto-managed)
958
+ app.toml # App settings
959
+ integrations/*.toml # Integration configs
960
+ webhooks/*.toml # Webhook configs
961
+ cron-triggers/*.toml # Cron trigger configs
962
+ blob-buckets/*.toml # Blob bucket configs
963
+ prompts/*.toml # Prompt configs
964
+ prompts/{key}.tests/*.toml # Prompt test cases
965
+ workflows/*.toml # Workflow definitions
966
+ workflows/{key}.tests/*.toml # Workflow test cases
967
+ database-types/*.toml # Database type configs + operations
968
+ rule-sets/*.toml # Access rule sets
969
+ group-type-configs/*.toml # Group type configs
970
+ collection-type-configs/*.toml # Collection type configs
971
+ email-templates/*.toml # Email template overrides
469
972
  `);
470
973
  // Init
471
974
  sync
@@ -473,14 +976,28 @@ Directory Structure:
473
976
  .description("Initialize a sync directory structure")
474
977
  .argument("[app-id]", "App ID (uses current app if not specified)")
475
978
  .option("--app <app-id>", "App ID")
476
- .option("--dir <path>", "Config directory", "./config")
979
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
477
980
  .action(async (appId, options) => {
478
981
  const resolvedAppId = resolveAppId(appId, options);
479
- const configDir = options.dir;
982
+ const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
983
+ if (isAutoResolvedSyncDir(options.dir)) {
984
+ info(`Using per-environment sync directory: ${configDir}`);
985
+ const legacyHint = checkLegacySyncMigration(configDir);
986
+ if (legacyHint)
987
+ warn(legacyHint);
988
+ }
480
989
  ensureDir(configDir);
481
990
  ensureDir(join(configDir, "integrations"));
991
+ ensureDir(join(configDir, "webhooks"));
992
+ ensureDir(join(configDir, "cron-triggers"));
993
+ ensureDir(join(configDir, "blob-buckets"));
482
994
  ensureDir(join(configDir, "prompts"));
483
995
  ensureDir(join(configDir, "workflows"));
996
+ ensureDir(join(configDir, "database-types"));
997
+ ensureDir(join(configDir, "rule-sets"));
998
+ ensureDir(join(configDir, "group-type-configs"));
999
+ ensureDir(join(configDir, "collection-type-configs"));
1000
+ ensureDir(join(configDir, "email-templates"));
484
1001
  const state = {
485
1002
  appId: resolvedAppId,
486
1003
  serverUrl: getServerUrl(),
@@ -497,25 +1014,32 @@ Directory Structure:
497
1014
  .description("Download configuration from server to TOML files")
498
1015
  .argument("[app-id]", "App ID (uses current app if not specified)")
499
1016
  .option("--app <app-id>", "App ID")
500
- .option("--dir <path>", "Config directory", "./config")
1017
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
501
1018
  .option("--json", "Output as JSON instead of writing files")
502
1019
  .action(async (appId, options) => {
503
1020
  const resolvedAppId = resolveAppId(appId, options);
504
- const configDir = options.dir;
1021
+ const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
1022
+ if (isAutoResolvedSyncDir(options.dir) && !options.json) {
1023
+ info(`Using per-environment sync directory: ${configDir}`);
1024
+ const legacyHint = checkLegacySyncMigration(configDir);
1025
+ if (legacyHint)
1026
+ warn(legacyHint);
1027
+ }
505
1028
  const client = new ApiClient();
506
1029
  info(`Pulling configuration for app ${resolvedAppId}...`);
507
1030
  try {
508
1031
  // Fetch all data
509
- const [settings, integrationsResult, promptsResult, workflowsResult] = await Promise.all([
1032
+ const [settings, integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
510
1033
  client.getAppSettings(resolvedAppId).catch(() => null),
511
- client.listIntegrations(resolvedAppId, { limit: 100 }),
512
- client.listPrompts(resolvedAppId, { limit: 100 }),
513
- client.listWorkflows(resolvedAppId, { limit: 100 }),
1034
+ fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
1035
+ fetchAll((p) => client.listPrompts(resolvedAppId, p)),
1036
+ fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
1037
+ client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
514
1038
  ]);
515
1039
  // Fetch details for each entity
516
- const integrations = await Promise.all(integrationsResult.items.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
517
- const prompts = await Promise.all(promptsResult.items.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
518
- const workflows = await Promise.all(workflowsResult.items.map(async (w) => {
1040
+ const integrations = await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
1041
+ const prompts = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
1042
+ const workflows = await Promise.all(workflowItems.map(async (w) => {
519
1043
  const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
520
1044
  // Fetch active config with steps if available
521
1045
  const activeConfigId = workflowData.workflow?.activeConfigId;
@@ -536,39 +1060,124 @@ Directory Structure:
536
1060
  }
537
1061
  return workflowData;
538
1062
  }));
1063
+ // Fetch full details for email template overrides
1064
+ const emailTemplateOverrides = emailTemplatesResult.templates.filter((t) => t.hasOverride);
1065
+ const emailTemplates = await Promise.all(emailTemplateOverrides.map((t) => client.getEmailTemplate(resolvedAppId, t.emailType).catch(() => null))).then((results) => results.filter(Boolean));
539
1066
  if (options.json) {
540
- json({ settings, integrations, prompts, workflows });
1067
+ json({ settings, integrations, prompts, workflows, emailTemplates });
541
1068
  return;
542
1069
  }
1070
+ // Fetch database config resources
1071
+ const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult,] = await Promise.all([
1072
+ client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
1073
+ client.listRuleSets(resolvedAppId).catch(() => []),
1074
+ client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
1075
+ client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
1076
+ ]);
1077
+ // Fetch operations for each database type
1078
+ const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
1079
+ const ops = await client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []);
1080
+ return { typeConfig, operations: Array.isArray(ops) ? ops : [] };
1081
+ }));
543
1082
  // Ensure directories exist
544
1083
  ensureDir(configDir);
545
1084
  ensureDir(join(configDir, "integrations"));
1085
+ ensureDir(join(configDir, "webhooks"));
1086
+ ensureDir(join(configDir, "cron-triggers"));
1087
+ ensureDir(join(configDir, "blob-buckets"));
546
1088
  ensureDir(join(configDir, "prompts"));
547
1089
  ensureDir(join(configDir, "workflows"));
1090
+ ensureDir(join(configDir, "database-types"));
1091
+ ensureDir(join(configDir, "rule-sets"));
1092
+ ensureDir(join(configDir, "group-type-configs"));
1093
+ ensureDir(join(configDir, "collection-type-configs"));
1094
+ ensureDir(join(configDir, "email-templates"));
548
1095
  // Write app settings
549
1096
  if (settings) {
550
- writeFileSync(join(configDir, "app.toml"), serializeAppSettings(settings));
1097
+ const appTomlPath = join(configDir, "app.toml");
1098
+ writeFileSync(appTomlPath, serializeAppSettings(settings));
551
1099
  info(" Wrote app.toml");
552
1100
  }
553
1101
  // Write integrations
554
1102
  const integrationEntities = {};
555
1103
  for (const integration of integrations) {
556
1104
  const filename = `${integration.integrationKey}.toml`;
557
- writeFileSync(join(configDir, "integrations", filename), serializeIntegration(integration));
1105
+ const filePath = join(configDir, "integrations", filename);
1106
+ writeFileSync(filePath, serializeIntegration(integration));
558
1107
  integrationEntities[integration.integrationKey] = {
559
1108
  id: integration.integrationId,
560
1109
  modifiedAt: integration.modifiedAt || new Date().toISOString(),
1110
+ contentHash: computeFileHash(filePath),
561
1111
  };
562
1112
  info(` Wrote integrations/${filename}`);
563
1113
  }
1114
+ // Pull webhooks
1115
+ const webhookItems = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
1116
+ const webhooks = await Promise.all(webhookItems.map((w) => client.getWebhook(resolvedAppId, w.webhookId)));
1117
+ const webhooksDir = join(configDir, "webhooks");
1118
+ mkdirSync(webhooksDir, { recursive: true });
1119
+ const webhookEntities = {};
1120
+ for (const webhook of webhooks) {
1121
+ const filename = `${webhook.webhookKey}.toml`;
1122
+ const filePath = join(webhooksDir, filename);
1123
+ writeFileSync(filePath, serializeWebhook(webhook));
1124
+ webhookEntities[webhook.webhookKey] = {
1125
+ id: webhook.webhookId,
1126
+ modifiedAt: webhook.modifiedAt || new Date().toISOString(),
1127
+ contentHash: computeFileHash(filePath),
1128
+ };
1129
+ }
1130
+ info(` Pulled ${webhooks.length} webhook(s)`);
1131
+ // Pull cron triggers
1132
+ let cronTriggerItems = [];
1133
+ try {
1134
+ const cronResult = await client.listCronTriggers(resolvedAppId);
1135
+ cronTriggerItems = cronResult.items || [];
1136
+ }
1137
+ catch {
1138
+ // Cron triggers may not be available on older servers
1139
+ }
1140
+ const cronTriggersDir = join(configDir, "cron-triggers");
1141
+ mkdirSync(cronTriggersDir, { recursive: true });
1142
+ const cronTriggerEntities = {};
1143
+ for (const trigger of cronTriggerItems) {
1144
+ const filename = `${trigger.triggerKey}.toml`;
1145
+ const filePath = join(cronTriggersDir, filename);
1146
+ writeFileSync(filePath, serializeCronTrigger(trigger));
1147
+ cronTriggerEntities[trigger.triggerKey] = {
1148
+ id: trigger.triggerId,
1149
+ modifiedAt: trigger.modifiedAt || new Date().toISOString(),
1150
+ contentHash: computeFileHash(filePath),
1151
+ };
1152
+ }
1153
+ info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
1154
+ // Pull blob buckets
1155
+ const blobBucketsResult = await client.listBlobBuckets(resolvedAppId).catch(() => ({ items: [] }));
1156
+ const blobBucketItems = blobBucketsResult.items || [];
1157
+ const blobBucketsDir = join(configDir, "blob-buckets");
1158
+ mkdirSync(blobBucketsDir, { recursive: true });
1159
+ const blobBucketEntities = {};
1160
+ for (const bucket of blobBucketItems) {
1161
+ const filename = `${bucket.bucketKey}.toml`;
1162
+ const filePath = join(blobBucketsDir, filename);
1163
+ writeFileSync(filePath, serializeBlobBucket(bucket));
1164
+ blobBucketEntities[bucket.bucketKey] = {
1165
+ id: bucket.bucketId,
1166
+ modifiedAt: bucket.modifiedAt || new Date().toISOString(),
1167
+ contentHash: computeFileHash(filePath),
1168
+ };
1169
+ }
1170
+ info(` Pulled ${blobBucketItems.length} blob bucket(s)`);
564
1171
  // Write prompts
565
1172
  const promptEntities = {};
566
1173
  for (const prompt of prompts) {
567
1174
  const filename = `${prompt.promptKey}.toml`;
568
- writeFileSync(join(configDir, "prompts", filename), serializePrompt(prompt));
1175
+ const filePath = join(configDir, "prompts", filename);
1176
+ writeFileSync(filePath, serializePrompt(prompt));
569
1177
  promptEntities[prompt.promptKey] = {
570
1178
  id: prompt.promptId,
571
1179
  modifiedAt: prompt.modifiedAt || new Date().toISOString(),
1180
+ contentHash: computeFileHash(filePath),
572
1181
  };
573
1182
  info(` Wrote prompts/${filename}`);
574
1183
  }
@@ -576,51 +1185,200 @@ Directory Structure:
576
1185
  const workflowEntities = {};
577
1186
  for (const { workflow, draft, configs } of workflows) {
578
1187
  const filename = `${workflow.workflowKey}.toml`;
579
- writeFileSync(join(configDir, "workflows", filename), serializeWorkflow(workflow, draft, configs || []));
1188
+ const filePath = join(configDir, "workflows", filename);
1189
+ writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
1190
+ // Hash the expanded (post-fragment-splice) form so subsequent pushes
1191
+ // can detect fragment-only edits. See `computeExpandedContentHash`.
1192
+ // Pulled workflows never carry `include`, so the expander is a
1193
+ // no-op here — but we use the same canonical-JSON hash function so
1194
+ // the push-side comparison stays consistent.
580
1195
  workflowEntities[workflow.workflowKey] = {
581
1196
  id: workflow.workflowId,
582
1197
  modifiedAt: workflow.modifiedAt || new Date().toISOString(),
583
1198
  activeConfigId: workflow.activeConfigId,
1199
+ contentHash: computeExpandedContentHash(parseTomlFile(filePath)),
584
1200
  };
585
1201
  info(` Wrote workflows/${filename}`);
586
1202
  }
1203
+ // Build lookup maps for test case serialization
1204
+ const configIdToName = new Map();
1205
+ const promptIdToKey = new Map();
1206
+ for (const prompt of prompts) {
1207
+ promptIdToKey.set(prompt.promptId, prompt.promptKey);
1208
+ if (prompt.configs) {
1209
+ for (const config of prompt.configs) {
1210
+ configIdToName.set(config.configId, config.configName);
1211
+ }
1212
+ }
1213
+ }
1214
+ // Also include workflow config names
1215
+ for (const { configs } of workflows) {
1216
+ if (configs) {
1217
+ for (const config of configs) {
1218
+ configIdToName.set(config.configId, config.configName);
1219
+ }
1220
+ }
1221
+ }
1222
+ const testCaseLookupMaps = { configIdToName, promptIdToKey };
1223
+ // Write email templates (use detailed emailTemplates, not summaries)
1224
+ const emailTemplateEntities = {};
1225
+ for (const template of emailTemplates) {
1226
+ const filename = `${template.emailType}.toml`;
1227
+ writeFileSync(join(configDir, "email-templates", filename), serializeEmailTemplate(template));
1228
+ emailTemplateEntities[template.emailType] = {
1229
+ id: template.emailType,
1230
+ modifiedAt: template.override?.modifiedAt || new Date().toISOString(),
1231
+ };
1232
+ info(` Wrote email-templates/${filename}`);
1233
+ }
1234
+ // Clean up local email template files for remotely-deleted overrides
1235
+ const emailTemplatesDir = join(configDir, "email-templates");
1236
+ if (existsSync(emailTemplatesDir)) {
1237
+ const pulledTypes = new Set(emailTemplates.map((t) => t.emailType));
1238
+ const localFiles = readdirSync(emailTemplatesDir).filter((f) => f.endsWith(".toml"));
1239
+ for (const file of localFiles) {
1240
+ const localType = basename(file, ".toml");
1241
+ if (!pulledTypes.has(localType)) {
1242
+ unlinkSync(join(emailTemplatesDir, file));
1243
+ info(` Removed email-templates/${file} (override deleted on server)`);
1244
+ }
1245
+ }
1246
+ }
587
1247
  // Pull test cases for prompts and workflows
588
1248
  const testCaseEntities = {};
589
1249
  let totalTestCases = 0;
590
1250
  for (const prompt of prompts) {
591
- const count = await pullTestCasesForBlock(client, resolvedAppId, "prompt", prompt.promptId, prompt.promptKey, configDir, testCaseEntities);
1251
+ const count = await pullTestCasesForBlock(client, resolvedAppId, "prompt", prompt.promptId, prompt.promptKey, configDir, testCaseEntities, testCaseLookupMaps);
592
1252
  if (count > 0) {
593
1253
  info(` Wrote ${count} test case(s) for prompt: ${prompt.promptKey}`);
594
1254
  }
595
1255
  totalTestCases += count;
596
1256
  }
597
1257
  for (const { workflow } of workflows) {
598
- const count = await pullTestCasesForBlock(client, resolvedAppId, "workflow", workflow.workflowId, workflow.workflowKey, configDir, testCaseEntities);
1258
+ const count = await pullTestCasesForBlock(client, resolvedAppId, "workflow", workflow.workflowId, workflow.workflowKey, configDir, testCaseEntities, testCaseLookupMaps);
599
1259
  if (count > 0) {
600
1260
  info(` Wrote ${count} test case(s) for workflow: ${workflow.workflowKey}`);
601
1261
  }
602
1262
  totalTestCases += count;
603
1263
  }
1264
+ // Build ruleSetId → name map for database types and group type configs
1265
+ const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
1266
+ const ruleSetIdToName = new Map();
1267
+ for (const ruleSet of ruleSets) {
1268
+ ruleSetIdToName.set(ruleSet.ruleSetId, ruleSet.name);
1269
+ }
1270
+ // Write database types
1271
+ const databaseTypeEntities = {};
1272
+ for (const { typeConfig, operations } of databaseTypesWithOps) {
1273
+ const filename = `${typeConfig.databaseType}.toml`;
1274
+ const filePath = join(configDir, "database-types", filename);
1275
+ // Preserve the existing file's per-op form (issue #752): if the
1276
+ // file already exists, read it raw and detect which operations
1277
+ // currently use JSON-string vs native nested-table form.
1278
+ const hints = existsSync(filePath)
1279
+ ? detectExistingOperationForms(readFileSync(filePath, "utf-8"))
1280
+ : undefined;
1281
+ writeFileSync(filePath, serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
1282
+ hints,
1283
+ defaultForm: "native",
1284
+ logger: (msg) => info(` ${msg}`),
1285
+ }));
1286
+ const opsEntities = {};
1287
+ for (const op of operations) {
1288
+ opsEntities[op.name] = {
1289
+ modifiedAt: op.modifiedAt || new Date().toISOString(),
1290
+ };
1291
+ }
1292
+ databaseTypeEntities[typeConfig.databaseType] = {
1293
+ databaseType: typeConfig.databaseType,
1294
+ modifiedAt: typeConfig.modifiedAt || new Date().toISOString(),
1295
+ operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
1296
+ contentHash: computeFileHash(filePath),
1297
+ hasSchema: typeof typeConfig.schema === "string" &&
1298
+ typeConfig.schema.trim().length > 0,
1299
+ };
1300
+ info(` Wrote database-types/${filename}`);
1301
+ }
1302
+ // Write rule sets
1303
+ const ruleSetEntities = {};
1304
+ for (const ruleSet of ruleSets) {
1305
+ const fileKey = (ruleSet.name || ruleSet.ruleSetId).replace(/[/\\:*?"<>|]/g, "_");
1306
+ const filename = `${fileKey}.toml`;
1307
+ const filePath = join(configDir, "rule-sets", filename);
1308
+ writeFileSync(filePath, serializeRuleSet(ruleSet));
1309
+ ruleSetEntities[fileKey] = {
1310
+ id: ruleSet.ruleSetId,
1311
+ modifiedAt: ruleSet.modifiedAt || new Date().toISOString(),
1312
+ contentHash: computeFileHash(filePath),
1313
+ };
1314
+ info(` Wrote rule-sets/${filename}`);
1315
+ }
1316
+ // Write group type configs
1317
+ const groupTypeConfigEntities = {};
1318
+ const groupTypeConfigs = Array.isArray(groupTypeConfigsResult) ? groupTypeConfigsResult : [];
1319
+ for (const config of groupTypeConfigs) {
1320
+ const filename = `${config.groupType}.toml`;
1321
+ const filePath = join(configDir, "group-type-configs", filename);
1322
+ writeFileSync(filePath, serializeGroupTypeConfig(config, ruleSetIdToName));
1323
+ groupTypeConfigEntities[config.groupType] = {
1324
+ modifiedAt: config.modifiedAt || new Date().toISOString(),
1325
+ contentHash: computeFileHash(filePath),
1326
+ };
1327
+ info(` Wrote group-type-configs/${filename}`);
1328
+ }
1329
+ // Write collection type configs
1330
+ const collectionTypeConfigEntities = {};
1331
+ const collectionTypeConfigs = Array.isArray(collectionTypeConfigsResult)
1332
+ ? collectionTypeConfigsResult
1333
+ : [];
1334
+ for (const config of collectionTypeConfigs) {
1335
+ const filename = `${config.collectionType}.toml`;
1336
+ const filePath = join(configDir, "collection-type-configs", filename);
1337
+ writeFileSync(filePath, serializeCollectionTypeConfig(config, ruleSetIdToName));
1338
+ collectionTypeConfigEntities[config.collectionType] = {
1339
+ modifiedAt: config.modifiedAt || new Date().toISOString(),
1340
+ contentHash: computeFileHash(filePath),
1341
+ };
1342
+ info(` Wrote collection-type-configs/${filename}`);
1343
+ }
604
1344
  // Save sync state
605
1345
  const state = {
606
1346
  appId: resolvedAppId,
607
1347
  serverUrl: getServerUrl(),
608
1348
  lastSyncedAt: new Date().toISOString(),
609
1349
  entities: {
610
- app: settings ? { modifiedAt: new Date().toISOString() } : undefined,
1350
+ app: settings ? { modifiedAt: new Date().toISOString(), contentHash: computeFileHash(join(configDir, "app.toml")) } : undefined,
611
1351
  integrations: integrationEntities,
1352
+ webhooks: webhookEntities,
1353
+ cronTriggers: Object.keys(cronTriggerEntities).length > 0 ? cronTriggerEntities : undefined,
1354
+ blobBuckets: Object.keys(blobBucketEntities).length > 0 ? blobBucketEntities : undefined,
612
1355
  prompts: promptEntities,
613
1356
  workflows: workflowEntities,
1357
+ emailTemplates: Object.keys(emailTemplateEntities).length > 0 ? emailTemplateEntities : undefined,
614
1358
  testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
1359
+ databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
1360
+ ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
1361
+ groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
1362
+ collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
1363
+ ? collectionTypeConfigEntities
1364
+ : undefined,
615
1365
  },
616
1366
  };
617
1367
  saveSyncState(configDir, state);
618
1368
  divider();
619
1369
  success(`Pulled configuration to ${configDir}`);
620
1370
  keyValue("Integrations", integrations.length);
1371
+ keyValue("Webhooks", webhooks.length);
1372
+ keyValue("Cron Triggers", cronTriggerItems.length);
1373
+ keyValue("Blob Buckets", blobBucketItems.length);
621
1374
  keyValue("Prompts", prompts.length);
622
1375
  keyValue("Workflows", workflows.length);
1376
+ keyValue("Email Templates", emailTemplates.length);
623
1377
  keyValue("Test Cases", totalTestCases);
1378
+ keyValue("Database Types", databaseTypesWithOps.length);
1379
+ keyValue("Rule Sets", ruleSets.length);
1380
+ keyValue("Group Type Configs", groupTypeConfigs.length);
1381
+ keyValue("Collection Type Configs", collectionTypeConfigs.length);
624
1382
  }
625
1383
  catch (err) {
626
1384
  error(err.message);
@@ -633,49 +1391,194 @@ Directory Structure:
633
1391
  .description("Upload local TOML configuration to server")
634
1392
  .argument("[app-id]", "App ID (uses current app if not specified)")
635
1393
  .option("--app <app-id>", "App ID")
636
- .option("--dir <path>", "Config directory", "./config")
1394
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
637
1395
  .option("--dry-run", "Show what would be changed without applying")
638
1396
  .option("--force", "Overwrite remote even if modified since last pull")
1397
+ .option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
639
1398
  .action(async (appId, options) => {
640
1399
  const resolvedAppId = resolveAppId(appId, options);
641
- const configDir = options.dir;
1400
+ const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
1401
+ if (isAutoResolvedSyncDir(options.dir)) {
1402
+ info(`Using per-environment sync directory: ${configDir}`);
1403
+ const legacyHint = checkLegacySyncMigration(configDir);
1404
+ if (legacyHint)
1405
+ warn(legacyHint);
1406
+ }
642
1407
  const client = new ApiClient();
643
1408
  if (!existsSync(configDir)) {
644
1409
  error(`Config directory not found: ${configDir}`);
645
1410
  process.exit(1);
646
1411
  }
647
- const syncState = loadSyncState(configDir);
1412
+ let syncState = loadSyncState(configDir);
1413
+ // Detect cross-app push: if pushing to a different app, clear entity IDs
1414
+ // so everything goes through the create path
1415
+ const isCrossAppPush = syncState && syncState.appId !== resolvedAppId;
1416
+ if (isCrossAppPush) {
1417
+ info(`Cross-app push detected: ${syncState.appId} → ${resolvedAppId}`);
1418
+ info("All entities will be created fresh in the target app.");
1419
+ syncState = {
1420
+ appId: resolvedAppId,
1421
+ serverUrl: syncState.serverUrl,
1422
+ lastSyncedAt: new Date().toISOString(),
1423
+ entities: {},
1424
+ };
1425
+ }
648
1426
  info(`Pushing configuration for app ${resolvedAppId}...`);
649
1427
  try {
650
1428
  const changes = [];
1429
+ let skippedCount = 0;
651
1430
  const conflicts = [];
1431
+ // Issue #666: per-class buckets for schema-feature errors. Each entry
1432
+ // groups failures by the code so the end-of-run report can print
1433
+ // them once with the friendly hint.
1434
+ const schemaErrors = {
1435
+ schemaRequired: [],
1436
+ opRefs: [],
1437
+ schemaBreaks: [],
1438
+ uncheckableOps: [],
1439
+ tomlParse: [],
1440
+ opsExist: [],
1441
+ };
1442
+ // Track name→ID mappings for resolving cross-references during push
1443
+ const ruleSetNameToId = new Map();
1444
+ const promptKeyToId = new Map();
1445
+ const promptConfigNameToId = new Map(); // key: "promptKey#configName"
1446
+ const workflowConfigNameToId = new Map(); // key: "workflowKey#configName"
652
1447
  // Process app settings
653
1448
  const appTomlPath = join(configDir, "app.toml");
654
1449
  if (existsSync(appTomlPath)) {
655
- const tomlData = parseTomlFile(appTomlPath);
656
- const settings = {};
657
- if (tomlData.app) {
658
- Object.assign(settings, tomlData.app);
1450
+ const appChanged = options.force || !syncState?.entities?.app?.contentHash ||
1451
+ shouldPushFile(appTomlPath, syncState.entities.app.contentHash);
1452
+ if (!appChanged) {
1453
+ skippedCount++;
1454
+ info(" Skipped app settings (unchanged)");
659
1455
  }
660
- if (tomlData.auth) {
661
- settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
662
- settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
663
- settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
664
- if (tomlData.auth.passkeys) {
665
- settings.passkeyRpConfig = tomlData.auth.passkeys;
1456
+ else {
1457
+ const tomlData = parseTomlFile(appTomlPath);
1458
+ const settings = {};
1459
+ if (tomlData.app) {
1460
+ Object.assign(settings, tomlData.app);
1461
+ // Don't overwrite the target app's name during cross-app push
1462
+ if (isCrossAppPush) {
1463
+ delete settings.name;
1464
+ }
1465
+ }
1466
+ if (tomlData.auth) {
1467
+ settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
1468
+ settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
1469
+ settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
1470
+ if (tomlData.auth.passkeys) {
1471
+ settings.passkeyRpConfig = tomlData.auth.passkeys;
1472
+ }
1473
+ }
1474
+ if (tomlData.cors) {
1475
+ settings.corsMode = tomlData.cors.mode;
1476
+ settings.corsAllowedOrigins = tomlData.cors.allowedOrigins;
1477
+ settings.corsAllowCredentials = tomlData.cors.allowCredentials;
1478
+ settings.corsAllowedMethods = tomlData.cors.allowedMethods;
1479
+ settings.corsMaxAge = tomlData.cors.maxAge;
1480
+ }
1481
+ changes.push({ type: "app", action: "update", key: "settings" });
1482
+ if (!options.dryRun) {
1483
+ await client.updateAppSettings(resolvedAppId, settings);
1484
+ info(" Updated app settings");
1485
+ // Store content hash after successful push
1486
+ if (syncState) {
1487
+ if (!syncState.entities.app) {
1488
+ syncState.entities.app = { modifiedAt: new Date().toISOString() };
1489
+ }
1490
+ syncState.entities.app.contentHash = computeFileHash(appTomlPath);
1491
+ }
666
1492
  }
667
1493
  }
668
- if (tomlData.cors) {
669
- settings.corsMode = tomlData.cors.mode;
670
- settings.corsAllowedOrigins = tomlData.cors.allowedOrigins;
671
- settings.corsAllowCredentials = tomlData.cors.allowCredentials;
672
- settings.corsAllowedMethods = tomlData.cors.allowedMethods;
673
- settings.corsMaxAge = tomlData.cors.maxAge;
674
- }
675
- changes.push({ type: "app", action: "update", key: "settings" });
676
- if (!options.dryRun) {
677
- await client.updateAppSettings(resolvedAppId, settings);
678
- info(" Updated app settings");
1494
+ }
1495
+ // Process rule sets first (other entities may reference them by name)
1496
+ const ruleSetsDir = join(configDir, "rule-sets");
1497
+ if (existsSync(ruleSetsDir)) {
1498
+ const files = readdirSync(ruleSetsDir).filter((f) => f.endsWith(".toml"));
1499
+ for (const file of files) {
1500
+ const filePath = join(ruleSetsDir, file);
1501
+ const tomlData = parseTomlFile(filePath);
1502
+ const ruleSetData = parseRuleSetToml(tomlData);
1503
+ const fileKey = basename(file, ".toml");
1504
+ const existingId = syncState?.entities?.ruleSets?.[fileKey]?.id;
1505
+ // Skip if file hasn't changed since last sync
1506
+ if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.ruleSets?.[fileKey]?.contentHash)) {
1507
+ skippedCount++;
1508
+ ruleSetNameToId.set(ruleSetData.name, existingId);
1509
+ continue;
1510
+ }
1511
+ if (existingId) {
1512
+ // Update existing rule set
1513
+ changes.push({ type: "rule-set", action: "update", key: fileKey });
1514
+ if (!options.dryRun) {
1515
+ const expectedModifiedAt = options.force
1516
+ ? undefined
1517
+ : syncState?.entities?.ruleSets?.[fileKey]?.modifiedAt;
1518
+ try {
1519
+ const updated = await client.updateRuleSet(resolvedAppId, existingId, {
1520
+ name: ruleSetData.name,
1521
+ description: ruleSetData.description,
1522
+ rules: ruleSetData.rules,
1523
+ }, expectedModifiedAt);
1524
+ info(` Updated rule set: ${fileKey}`);
1525
+ if (syncState?.entities?.ruleSets?.[fileKey] && updated?.modifiedAt) {
1526
+ syncState.entities.ruleSets[fileKey].modifiedAt = updated.modifiedAt;
1527
+ syncState.entities.ruleSets[fileKey].contentHash = computeFileHash(filePath);
1528
+ }
1529
+ }
1530
+ catch (err) {
1531
+ if (err instanceof ConflictError) {
1532
+ conflicts.push({
1533
+ type: "rule-set",
1534
+ key: fileKey,
1535
+ serverModifiedAt: err.serverModifiedAt,
1536
+ localModifiedAt: expectedModifiedAt || "unknown",
1537
+ });
1538
+ }
1539
+ else {
1540
+ throw wrapEntityError(err, "update", "rule set", fileKey);
1541
+ }
1542
+ }
1543
+ }
1544
+ // Track name→ID for cross-reference resolution
1545
+ ruleSetNameToId.set(ruleSetData.name, existingId);
1546
+ }
1547
+ else {
1548
+ // Create new rule set
1549
+ changes.push({ type: "rule-set", action: "create", key: fileKey });
1550
+ if (!options.dryRun) {
1551
+ let created;
1552
+ try {
1553
+ created = await client.createRuleSet(resolvedAppId, {
1554
+ name: ruleSetData.name,
1555
+ resourceType: ruleSetData.resourceType,
1556
+ rules: ruleSetData.rules,
1557
+ description: ruleSetData.description,
1558
+ });
1559
+ }
1560
+ catch (err) {
1561
+ throw wrapEntityError(err, "create", "rule set", fileKey);
1562
+ }
1563
+ info(` Created rule set: ${fileKey}`);
1564
+ if (syncState && created?.ruleSetId) {
1565
+ if (!syncState.entities.ruleSets) {
1566
+ syncState.entities.ruleSets = {};
1567
+ }
1568
+ syncState.entities.ruleSets[fileKey] = {
1569
+ id: created.ruleSetId,
1570
+ modifiedAt: created.modifiedAt || new Date().toISOString(),
1571
+ contentHash: computeFileHash(filePath),
1572
+ };
1573
+ }
1574
+ // Track name→ID for cross-reference resolution
1575
+ ruleSetNameToId.set(ruleSetData.name, created.ruleSetId);
1576
+ }
1577
+ else {
1578
+ // In dry-run mode, use a placeholder so dependent entities can resolve
1579
+ ruleSetNameToId.set(ruleSetData.name, `dry-run-${ruleSetData.name}`);
1580
+ }
1581
+ }
679
1582
  }
680
1583
  }
681
1584
  // Process integrations
@@ -688,6 +1591,11 @@ Directory Structure:
688
1591
  const integration = tomlData.integration || {};
689
1592
  const key = integration.key || basename(file, ".toml");
690
1593
  const existingId = syncState?.entities?.integrations?.[key]?.id;
1594
+ // Skip if file hasn't changed since last sync
1595
+ if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.integrations?.[key]?.contentHash)) {
1596
+ skippedCount++;
1597
+ continue;
1598
+ }
691
1599
  const payload = {
692
1600
  integrationKey: key,
693
1601
  displayName: integration.displayName || key,
@@ -708,6 +1616,7 @@ Directory Structure:
708
1616
  // Update sync state with new modifiedAt
709
1617
  if (syncState?.entities?.integrations?.[key] && updated?.modifiedAt) {
710
1618
  syncState.entities.integrations[key].modifiedAt = updated.modifiedAt;
1619
+ syncState.entities.integrations[key].contentHash = computeFileHash(filePath);
711
1620
  }
712
1621
  }
713
1622
  catch (err) {
@@ -720,7 +1629,7 @@ Directory Structure:
720
1629
  });
721
1630
  }
722
1631
  else {
723
- throw err;
1632
+ throw wrapEntityError(err, "update", "integration", key);
724
1633
  }
725
1634
  }
726
1635
  }
@@ -728,64 +1637,377 @@ Directory Structure:
728
1637
  else {
729
1638
  changes.push({ type: "integration", action: "create", key });
730
1639
  if (!options.dryRun) {
731
- const created = await client.createIntegration(resolvedAppId, payload);
732
- info(` Created integration: ${key}`);
733
- // Add new entity to sync state
734
- if (syncState && created?.integrationId && created?.modifiedAt) {
735
- if (!syncState.entities.integrations) {
736
- syncState.entities.integrations = {};
737
- }
738
- syncState.entities.integrations[key] = {
739
- id: created.integrationId,
740
- modifiedAt: created.modifiedAt,
741
- };
1640
+ try {
1641
+ const created = await client.createIntegration(resolvedAppId, payload);
1642
+ info(` Created integration: ${key}`);
1643
+ // Add new entity to sync state
1644
+ if (syncState && created?.integrationId && created?.modifiedAt) {
1645
+ if (!syncState.entities.integrations) {
1646
+ syncState.entities.integrations = {};
1647
+ }
1648
+ syncState.entities.integrations[key] = {
1649
+ id: created.integrationId,
1650
+ modifiedAt: created.modifiedAt,
1651
+ contentHash: computeFileHash(filePath),
1652
+ };
1653
+ }
1654
+ }
1655
+ catch (err) {
1656
+ throw wrapEntityError(err, "create", "integration", key);
742
1657
  }
743
1658
  }
744
1659
  }
745
1660
  }
746
1661
  }
747
- // Process prompts
748
- const promptsDir = join(configDir, "prompts");
749
- if (existsSync(promptsDir)) {
750
- const files = readdirSync(promptsDir).filter((f) => f.endsWith(".toml"));
1662
+ // Process webhooks
1663
+ const webhooksDir = join(configDir, "webhooks");
1664
+ if (existsSync(webhooksDir)) {
1665
+ const files = readdirSync(webhooksDir).filter((f) => f.endsWith(".toml"));
751
1666
  for (const file of files) {
752
- const filePath = join(promptsDir, file);
1667
+ const filePath = join(webhooksDir, file);
753
1668
  const tomlData = parseTomlFile(filePath);
754
- const prompt = tomlData.prompt || {};
755
- const key = prompt.key || basename(file, ".toml");
756
- const configs = tomlData.configs || [];
757
- const existingId = syncState?.entities?.prompts?.[key]?.id;
1669
+ const webhook = tomlData.webhook || {};
1670
+ const key = webhook.key || basename(file, ".toml");
1671
+ const existingId = syncState?.entities?.webhooks?.[key]?.id;
1672
+ if (!options.force && existingId &&
1673
+ !shouldPushFile(filePath, syncState?.entities?.webhooks?.[key]?.contentHash)) {
1674
+ skippedCount++;
1675
+ continue;
1676
+ }
1677
+ const payload = {
1678
+ webhookKey: key,
1679
+ displayName: webhook.displayName || key,
1680
+ description: webhook.description,
1681
+ workflowKey: webhook.workflowKey,
1682
+ verificationScheme: webhook.verificationScheme,
1683
+ status: webhook.status,
1684
+ toleranceSeconds: webhook.toleranceSeconds,
1685
+ deduplicationEnabled: webhook.deduplicationEnabled,
1686
+ deduplicationWindowMs: webhook.deduplicationWindowMs,
1687
+ secretGracePeriodMs: webhook.secretGracePeriodMs,
1688
+ };
1689
+ // Handle JSON fields
1690
+ if (tomlData.allowedIps?.cidrs) {
1691
+ payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
1692
+ }
1693
+ if (tomlData.inputMapping) {
1694
+ payload.inputMapping = JSON.stringify(tomlData.inputMapping);
1695
+ }
1696
+ if (tomlData.metadata) {
1697
+ payload.metadata = JSON.stringify(tomlData.metadata);
1698
+ }
758
1699
  if (existingId) {
759
- // Update existing prompt
760
- changes.push({ type: "prompt", action: "update", key });
1700
+ changes.push({ type: "webhook", action: "update", key });
761
1701
  if (!options.dryRun) {
762
1702
  const expectedModifiedAt = options.force
763
1703
  ? undefined
764
- : syncState?.entities?.prompts?.[key]?.modifiedAt;
1704
+ : syncState?.entities?.webhooks?.[key]?.modifiedAt;
765
1705
  try {
766
- const updated = await client.updatePrompt(resolvedAppId, existingId, {
767
- displayName: prompt.displayName,
768
- description: prompt.description,
769
- inputSchema: prompt.inputSchema,
770
- status: prompt.status,
771
- }, expectedModifiedAt);
772
- info(` Updated prompt: ${key}`);
773
- // Update sync state with new modifiedAt
774
- if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
775
- syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
1706
+ const updated = await client.updateWebhook(resolvedAppId, existingId, payload, expectedModifiedAt);
1707
+ info(` Updated webhook: ${key}`);
1708
+ if (syncState?.entities?.webhooks?.[key] && updated?.modifiedAt) {
1709
+ syncState.entities.webhooks[key].modifiedAt = updated.modifiedAt;
1710
+ syncState.entities.webhooks[key].contentHash = computeFileHash(filePath);
776
1711
  }
777
1712
  }
778
1713
  catch (err) {
779
1714
  if (err instanceof ConflictError) {
780
1715
  conflicts.push({
781
- type: "prompt",
1716
+ type: "webhook",
782
1717
  key,
783
1718
  serverModifiedAt: err.serverModifiedAt,
784
1719
  localModifiedAt: expectedModifiedAt || "unknown",
785
1720
  });
786
1721
  }
787
1722
  else {
788
- throw err;
1723
+ throw wrapEntityError(err, "update", "webhook", key);
1724
+ }
1725
+ }
1726
+ }
1727
+ }
1728
+ else {
1729
+ changes.push({ type: "webhook", action: "create", key });
1730
+ if (!options.dryRun) {
1731
+ try {
1732
+ const created = await client.createWebhook(resolvedAppId, payload);
1733
+ info(` Created webhook: ${key}`);
1734
+ if (syncState && created?.webhookId && created?.modifiedAt) {
1735
+ if (!syncState.entities.webhooks) {
1736
+ syncState.entities.webhooks = {};
1737
+ }
1738
+ syncState.entities.webhooks[key] = {
1739
+ id: created.webhookId,
1740
+ modifiedAt: created.modifiedAt,
1741
+ contentHash: computeFileHash(filePath),
1742
+ };
1743
+ }
1744
+ }
1745
+ catch (err) {
1746
+ throw wrapEntityError(err, "create", "webhook", key);
1747
+ }
1748
+ }
1749
+ }
1750
+ }
1751
+ }
1752
+ // Process cron triggers
1753
+ const cronTriggersDir = join(configDir, "cron-triggers");
1754
+ if (existsSync(cronTriggersDir)) {
1755
+ const files = readdirSync(cronTriggersDir).filter((f) => f.endsWith(".toml"));
1756
+ for (const file of files) {
1757
+ const filePath = join(cronTriggersDir, file);
1758
+ const tomlData = parseTomlFile(filePath);
1759
+ const cronTrigger = tomlData.cronTrigger || {};
1760
+ const key = cronTrigger.key || basename(file, ".toml");
1761
+ const existingId = syncState?.entities?.cronTriggers?.[key]?.id;
1762
+ if (!options.force && existingId &&
1763
+ !shouldPushFile(filePath, syncState?.entities?.cronTriggers?.[key]?.contentHash)) {
1764
+ skippedCount++;
1765
+ continue;
1766
+ }
1767
+ const payload = {
1768
+ triggerKey: key,
1769
+ displayName: cronTrigger.displayName || key,
1770
+ description: cronTrigger.description,
1771
+ cron: cronTrigger.cron,
1772
+ timezone: cronTrigger.timezone,
1773
+ workflowKey: cronTrigger.workflowKey,
1774
+ overlapPolicy: cronTrigger.overlapPolicy,
1775
+ state: cronTrigger.state,
1776
+ };
1777
+ // Handle JSON fields
1778
+ if (tomlData.rootInput) {
1779
+ payload.rootInput = JSON.stringify(tomlData.rootInput);
1780
+ }
1781
+ if (tomlData.inputMapping) {
1782
+ payload.inputMapping = JSON.stringify(tomlData.inputMapping);
1783
+ }
1784
+ if (existingId) {
1785
+ changes.push({ type: "cron-trigger", action: "update", key });
1786
+ if (!options.dryRun) {
1787
+ try {
1788
+ const updated = await client.updateCronTrigger(resolvedAppId, existingId, payload);
1789
+ info(` Updated cron trigger: ${key}`);
1790
+ if (syncState?.entities?.cronTriggers?.[key] && updated?.modifiedAt) {
1791
+ syncState.entities.cronTriggers[key].modifiedAt = updated.modifiedAt;
1792
+ syncState.entities.cronTriggers[key].contentHash = computeFileHash(filePath);
1793
+ }
1794
+ }
1795
+ catch (err) {
1796
+ throw wrapEntityError(err, "update", "cron trigger", key);
1797
+ }
1798
+ }
1799
+ }
1800
+ else {
1801
+ changes.push({ type: "cron-trigger", action: "create", key });
1802
+ if (!options.dryRun) {
1803
+ try {
1804
+ const created = await client.createCronTrigger(resolvedAppId, payload);
1805
+ info(` Created cron trigger: ${key}`);
1806
+ if (syncState && created?.triggerId && created?.modifiedAt) {
1807
+ if (!syncState.entities.cronTriggers) {
1808
+ syncState.entities.cronTriggers = {};
1809
+ }
1810
+ syncState.entities.cronTriggers[key] = {
1811
+ id: created.triggerId,
1812
+ modifiedAt: created.modifiedAt,
1813
+ contentHash: computeFileHash(filePath),
1814
+ };
1815
+ }
1816
+ }
1817
+ catch (err) {
1818
+ throw wrapEntityError(err, "create", "cron trigger", key);
1819
+ }
1820
+ }
1821
+ }
1822
+ }
1823
+ }
1824
+ // Process blob buckets
1825
+ const blobBucketsPushDir = join(configDir, "blob-buckets");
1826
+ if (existsSync(blobBucketsPushDir)) {
1827
+ const files = readdirSync(blobBucketsPushDir).filter((f) => f.endsWith(".toml"));
1828
+ for (const file of files) {
1829
+ const filePath = join(blobBucketsPushDir, file);
1830
+ const tomlData = parseTomlFile(filePath);
1831
+ const bucket = tomlData.bucket || {};
1832
+ const key = bucket.key || basename(file, ".toml");
1833
+ const existingId = syncState?.entities?.blobBuckets?.[key]?.id;
1834
+ if (!options.force && existingId &&
1835
+ !shouldPushFile(filePath, syncState?.entities?.blobBuckets?.[key]?.contentHash)) {
1836
+ skippedCount++;
1837
+ continue;
1838
+ }
1839
+ if (existingId) {
1840
+ // Blob buckets don't have an update API - skip if already exists
1841
+ info(` Blob bucket already exists, skipping: ${key}`);
1842
+ if (syncState?.entities?.blobBuckets?.[key]) {
1843
+ syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
1844
+ }
1845
+ }
1846
+ else {
1847
+ const payload = {
1848
+ bucketKey: key,
1849
+ name: bucket.name || key,
1850
+ ttlTier: bucket.ttlTier,
1851
+ accessPolicy: bucket.accessPolicy,
1852
+ };
1853
+ if (bucket.description)
1854
+ payload.description = bucket.description;
1855
+ if (bucket.ruleSetId)
1856
+ payload.ruleSetId = bucket.ruleSetId;
1857
+ changes.push({ type: "blob-bucket", action: "create", key });
1858
+ if (!options.dryRun) {
1859
+ try {
1860
+ const created = await client.createBlobBucket(resolvedAppId, payload);
1861
+ info(` Created blob bucket: ${key}`);
1862
+ if (syncState) {
1863
+ if (!syncState.entities.blobBuckets) {
1864
+ syncState.entities.blobBuckets = {};
1865
+ }
1866
+ syncState.entities.blobBuckets[key] = {
1867
+ id: created.bucketId,
1868
+ modifiedAt: created.modifiedAt || new Date().toISOString(),
1869
+ contentHash: computeFileHash(filePath),
1870
+ };
1871
+ }
1872
+ }
1873
+ catch (err) {
1874
+ const msg = String(err?.message || err);
1875
+ if (msg.includes("already exists") || err.statusCode === 409) {
1876
+ info(` Blob bucket already exists on server: ${key}`);
1877
+ // Fetch the existing bucket to get its ID
1878
+ try {
1879
+ const existing = await client.getBlobBucket(resolvedAppId, key);
1880
+ if (syncState && existing?.bucketId) {
1881
+ if (!syncState.entities.blobBuckets) {
1882
+ syncState.entities.blobBuckets = {};
1883
+ }
1884
+ syncState.entities.blobBuckets[key] = {
1885
+ id: existing.bucketId,
1886
+ modifiedAt: existing.modifiedAt || new Date().toISOString(),
1887
+ contentHash: computeFileHash(filePath),
1888
+ };
1889
+ }
1890
+ }
1891
+ catch {
1892
+ // Ignore fetch errors
1893
+ }
1894
+ }
1895
+ else {
1896
+ throw wrapEntityError(err, "create", "blob bucket", key);
1897
+ }
1898
+ }
1899
+ }
1900
+ }
1901
+ }
1902
+ }
1903
+ // Process prompts
1904
+ const promptsDir = join(configDir, "prompts");
1905
+ if (existsSync(promptsDir)) {
1906
+ const files = readdirSync(promptsDir).filter((f) => f.endsWith(".toml"));
1907
+ for (const file of files) {
1908
+ const filePath = join(promptsDir, file);
1909
+ const tomlData = parseTomlFile(filePath);
1910
+ const prompt = tomlData.prompt || {};
1911
+ const key = prompt.key || basename(file, ".toml");
1912
+ const configs = tomlData.configs || [];
1913
+ const existingId = syncState?.entities?.prompts?.[key]?.id;
1914
+ // Skip if file hasn't changed since last sync
1915
+ if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.prompts?.[key]?.contentHash)) {
1916
+ skippedCount++;
1917
+ promptKeyToId.set(key, existingId);
1918
+ // Only fetch config name→ID mappings if test cases exist for this prompt
1919
+ const promptTestsDir = getTestsDir(configDir, "prompt", key);
1920
+ if (!options.dryRun && existsSync(promptTestsDir)) {
1921
+ const fullPrompt = await client.getPrompt(resolvedAppId, existingId);
1922
+ if (fullPrompt?.configs) {
1923
+ for (const config of fullPrompt.configs) {
1924
+ promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
1925
+ }
1926
+ }
1927
+ }
1928
+ continue;
1929
+ }
1930
+ if (existingId) {
1931
+ // Update existing prompt
1932
+ changes.push({ type: "prompt", action: "update", key });
1933
+ promptKeyToId.set(key, existingId);
1934
+ if (!options.dryRun) {
1935
+ const expectedModifiedAt = options.force
1936
+ ? undefined
1937
+ : syncState?.entities?.prompts?.[key]?.modifiedAt;
1938
+ try {
1939
+ const updated = await client.updatePrompt(resolvedAppId, existingId, {
1940
+ displayName: prompt.displayName,
1941
+ description: prompt.description,
1942
+ inputSchema: prompt.inputSchema,
1943
+ status: prompt.status,
1944
+ }, expectedModifiedAt);
1945
+ info(` Updated prompt: ${key}`);
1946
+ // Update sync state with new modifiedAt
1947
+ if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
1948
+ syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
1949
+ syncState.entities.prompts[key].contentHash = computeFileHash(filePath);
1950
+ }
1951
+ // Fetch full prompt to get config name→ID mappings
1952
+ // (updatePrompt response doesn't include configs)
1953
+ const fullPrompt = await client.getPrompt(resolvedAppId, existingId);
1954
+ if (fullPrompt?.configs) {
1955
+ for (const config of fullPrompt.configs) {
1956
+ promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
1957
+ }
1958
+ }
1959
+ // Update existing prompt configs with TOML values
1960
+ if (fullPrompt?.configs && configs.length > 0) {
1961
+ for (const tomlConfig of configs) {
1962
+ const configName = tomlConfig.name;
1963
+ if (!configName)
1964
+ continue;
1965
+ const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
1966
+ if (serverConfig) {
1967
+ // Update existing config
1968
+ await client.updatePromptConfig(resolvedAppId, existingId, serverConfig.configId, {
1969
+ description: tomlConfig.description,
1970
+ provider: tomlConfig.provider,
1971
+ model: tomlConfig.model,
1972
+ systemPrompt: tomlConfig.systemPrompt,
1973
+ userPromptTemplate: tomlConfig.userPromptTemplate,
1974
+ temperature: tomlConfig.temperature,
1975
+ maxTokens: tomlConfig.maxTokens,
1976
+ outputFormat: tomlConfig.outputFormat,
1977
+ });
1978
+ }
1979
+ else {
1980
+ // Create new config that doesn't exist on server yet
1981
+ const newConfig = await client.createPromptConfig(resolvedAppId, existingId, {
1982
+ configName,
1983
+ description: tomlConfig.description,
1984
+ provider: tomlConfig.provider || "openrouter",
1985
+ model: tomlConfig.model || "google/gemini-2.0-flash-001",
1986
+ systemPrompt: tomlConfig.systemPrompt,
1987
+ userPromptTemplate: tomlConfig.userPromptTemplate,
1988
+ temperature: tomlConfig.temperature,
1989
+ maxTokens: tomlConfig.maxTokens,
1990
+ outputFormat: tomlConfig.outputFormat,
1991
+ });
1992
+ if (newConfig?.configId) {
1993
+ promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
1994
+ }
1995
+ }
1996
+ }
1997
+ info(` Synced ${configs.length} config(s) for prompt: ${key}`);
1998
+ }
1999
+ }
2000
+ catch (err) {
2001
+ if (err instanceof ConflictError) {
2002
+ conflicts.push({
2003
+ type: "prompt",
2004
+ key,
2005
+ serverModifiedAt: err.serverModifiedAt,
2006
+ localModifiedAt: expectedModifiedAt || "unknown",
2007
+ });
2008
+ }
2009
+ else {
2010
+ throw wrapEntityError(err, "update", "prompt", key);
789
2011
  }
790
2012
  }
791
2013
  }
@@ -795,29 +2017,70 @@ Directory Structure:
795
2017
  const firstConfig = configs[0] || {};
796
2018
  changes.push({ type: "prompt", action: "create", key });
797
2019
  if (!options.dryRun) {
798
- const created = await client.createPrompt(resolvedAppId, {
799
- promptKey: key,
800
- displayName: prompt.displayName || key,
801
- description: prompt.description,
802
- provider: firstConfig.provider || "openrouter",
803
- model: firstConfig.model || "google/gemini-2.0-flash-001",
804
- systemPrompt: firstConfig.systemPrompt,
805
- userPromptTemplate: firstConfig.userPromptTemplate || "{{ input }}",
806
- temperature: firstConfig.temperature,
807
- maxTokens: firstConfig.maxTokens,
808
- outputFormat: firstConfig.outputFormat,
809
- inputSchema: prompt.inputSchema,
810
- });
811
- info(` Created prompt: ${key}`);
812
- // Add new entity to sync state
813
- if (syncState && created?.promptId && created?.modifiedAt) {
814
- if (!syncState.entities.prompts) {
815
- syncState.entities.prompts = {};
816
- }
817
- syncState.entities.prompts[key] = {
818
- id: created.promptId,
819
- modifiedAt: created.modifiedAt,
820
- };
2020
+ try {
2021
+ const created = await client.createPrompt(resolvedAppId, {
2022
+ promptKey: key,
2023
+ displayName: prompt.displayName || key,
2024
+ description: prompt.description,
2025
+ provider: firstConfig.provider || "openrouter",
2026
+ model: firstConfig.model || "google/gemini-2.0-flash-001",
2027
+ systemPrompt: firstConfig.systemPrompt,
2028
+ userPromptTemplate: firstConfig.userPromptTemplate || "{{ input }}",
2029
+ temperature: firstConfig.temperature,
2030
+ maxTokens: firstConfig.maxTokens,
2031
+ outputFormat: firstConfig.outputFormat,
2032
+ inputSchema: prompt.inputSchema,
2033
+ });
2034
+ info(` Created prompt: ${key}`);
2035
+ // Add new entity to sync state
2036
+ if (syncState && created?.promptId && created?.modifiedAt) {
2037
+ if (!syncState.entities.prompts) {
2038
+ syncState.entities.prompts = {};
2039
+ }
2040
+ syncState.entities.prompts[key] = {
2041
+ id: created.promptId,
2042
+ modifiedAt: created.modifiedAt,
2043
+ contentHash: computeFileHash(filePath),
2044
+ };
2045
+ }
2046
+ // Track prompt key→ID and config name→ID
2047
+ if (created?.promptId) {
2048
+ promptKeyToId.set(key, created.promptId);
2049
+ }
2050
+ if (created?.configs) {
2051
+ for (const config of created.configs) {
2052
+ promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
2053
+ }
2054
+ }
2055
+ // Create additional configs (configs[1..n]) that weren't included in the initial create
2056
+ if (created?.promptId && configs.length > 1) {
2057
+ for (let i = 1; i < configs.length; i++) {
2058
+ const extraConfig = configs[i];
2059
+ const extraCreated = await client.createPromptConfig(resolvedAppId, created.promptId, {
2060
+ configName: extraConfig.name || `config-${i + 1}`,
2061
+ description: extraConfig.description,
2062
+ provider: extraConfig.provider || firstConfig.provider || "openrouter",
2063
+ model: extraConfig.model || firstConfig.model || "google/gemini-2.0-flash-001",
2064
+ systemPrompt: extraConfig.systemPrompt,
2065
+ userPromptTemplate: extraConfig.userPromptTemplate,
2066
+ temperature: extraConfig.temperature,
2067
+ maxTokens: extraConfig.maxTokens,
2068
+ outputFormat: extraConfig.outputFormat,
2069
+ });
2070
+ if (extraCreated?.configId) {
2071
+ const configName = extraConfig.name || `config-${i + 1}`;
2072
+ promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
2073
+ }
2074
+ // Activate this config if it was the active one
2075
+ if (extraConfig.isActive && extraCreated?.configId) {
2076
+ await client.activatePromptConfig(resolvedAppId, created.promptId, extraCreated.configId);
2077
+ }
2078
+ }
2079
+ info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
2080
+ }
2081
+ }
2082
+ catch (err) {
2083
+ throw wrapEntityError(err, "create", "prompt", key);
821
2084
  }
822
2085
  }
823
2086
  }
@@ -830,11 +2093,40 @@ Directory Structure:
830
2093
  for (const file of files) {
831
2094
  const filePath = join(workflowsDir, file);
832
2095
  const tomlData = parseTomlFile(filePath);
2096
+ // Issue #685: reject misnested headers (e.g.
2097
+ // [steps.<id>.request]) before pushing. The runtime silently
2098
+ // ignores fields outside the allowlist, so this is the only
2099
+ // place to catch the footgun. Validation happens BEFORE the
2100
+ // skip-if-unchanged check so a previously-pushed-but-broken
2101
+ // file gets a clear diagnostic on every push attempt.
2102
+ const tomlErrors = validateWorkflowToml(tomlData);
2103
+ if (tomlErrors.length > 0) {
2104
+ error(formatWorkflowTomlErrors(filePath, tomlErrors));
2105
+ process.exit(1);
2106
+ }
833
2107
  const workflow = tomlData.workflow || {};
834
2108
  const key = workflow.key || basename(file, ".toml");
835
2109
  const steps = tomlData.steps || [];
836
2110
  const existingId = syncState?.entities?.workflows?.[key]?.id;
837
2111
  const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
2112
+ // Skip if file hasn't changed since last sync. Use the expanded
2113
+ // content hash (post fragment splice) so that edits to included
2114
+ // `workflow-fragments/*.toml` files invalidate the push-skip cache
2115
+ // for any workflow that references them. See `computeExpandedContentHash`.
2116
+ if (!options.force && existingId && !shouldPushExpandedFile(tomlData, syncState?.entities?.workflows?.[key]?.contentHash)) {
2117
+ skippedCount++;
2118
+ // Only fetch config name→ID mappings if test cases exist for this workflow
2119
+ const workflowTestsDir = getTestsDir(configDir, "workflow", key);
2120
+ if (!options.dryRun && existsSync(workflowTestsDir)) {
2121
+ const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
2122
+ if (fullWorkflow?.configs) {
2123
+ for (const config of fullWorkflow.configs) {
2124
+ workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
2125
+ }
2126
+ }
2127
+ }
2128
+ continue;
2129
+ }
838
2130
  if (existingId) {
839
2131
  // Update existing workflow
840
2132
  changes.push({ type: "workflow", action: "update", key });
@@ -848,13 +2140,17 @@ Directory Structure:
848
2140
  name: workflow.name,
849
2141
  description: workflow.description,
850
2142
  status: workflow.status,
2143
+ accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
851
2144
  perUserMaxRunning: workflow.perUserMaxRunning,
852
2145
  perUserMaxQueued: workflow.perUserMaxQueued,
853
2146
  dequeueOrder: workflow.dequeueOrder,
854
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : null,
855
- outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : null,
2147
+ inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2148
+ outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
856
2149
  }, expectedModifiedAt);
857
- // Update active configuration steps (or draft for legacy)
2150
+ // Update active configuration steps (or draft for legacy).
2151
+ // Issue #687: name the slot we touched so the dev-loop
2152
+ // user can confirm before previewing.
2153
+ let updateSlotLabel = "active config";
858
2154
  if (existingActiveConfigId) {
859
2155
  await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
860
2156
  steps,
@@ -864,13 +2160,24 @@ Directory Structure:
864
2160
  // Fallback to draft update for legacy workflows
865
2161
  await client.updateWorkflowDraft(resolvedAppId, existingId, {
866
2162
  steps,
867
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : null,
2163
+ inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
868
2164
  });
2165
+ updateSlotLabel = "draft (legacy)";
869
2166
  }
870
- info(` Updated workflow: ${key}`);
871
- // Update sync state with new modifiedAt
2167
+ info(` Updated workflow: ${key} (${updateSlotLabel})`);
2168
+ // Update sync state with new modifiedAt. Store the *expanded*
2169
+ // content hash so future fragment-only edits are detected.
872
2170
  if (syncState?.entities?.workflows?.[key] && updated?.workflow?.modifiedAt) {
873
2171
  syncState.entities.workflows[key].modifiedAt = updated.workflow.modifiedAt;
2172
+ syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
2173
+ }
2174
+ // Fetch full workflow to get config name→ID mappings
2175
+ // (updateWorkflow response doesn't include configs)
2176
+ const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
2177
+ if (fullWorkflow?.configs) {
2178
+ for (const config of fullWorkflow.configs) {
2179
+ workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
2180
+ }
874
2181
  }
875
2182
  }
876
2183
  catch (err) {
@@ -883,7 +2190,7 @@ Directory Structure:
883
2190
  });
884
2191
  }
885
2192
  else {
886
- throw err;
2193
+ throw wrapEntityError(err, "update", "workflow", key);
887
2194
  }
888
2195
  }
889
2196
  }
@@ -892,34 +2199,589 @@ Directory Structure:
892
2199
  // Create new workflow (automatically creates default config)
893
2200
  changes.push({ type: "workflow", action: "create", key });
894
2201
  if (!options.dryRun) {
895
- const created = await client.createWorkflow(resolvedAppId, {
896
- workflowKey: key,
897
- name: workflow.name || key,
898
- description: workflow.description,
899
- steps,
900
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
901
- outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
902
- perUserMaxRunning: workflow.perUserMaxRunning,
903
- perUserMaxQueued: workflow.perUserMaxQueued,
904
- dequeueOrder: workflow.dequeueOrder,
905
- });
906
- info(` Created workflow: ${key}`);
907
- // Add new entity to sync state (including activeConfigId)
908
- if (syncState && created?.workflow?.workflowId && created?.workflow?.modifiedAt) {
909
- if (!syncState.entities.workflows) {
910
- syncState.entities.workflows = {};
911
- }
912
- syncState.entities.workflows[key] = {
913
- id: created.workflow.workflowId,
914
- modifiedAt: created.workflow.modifiedAt,
915
- activeConfigId: created.workflow.activeConfigId,
2202
+ try {
2203
+ const created = await client.createWorkflow(resolvedAppId, {
2204
+ workflowKey: key,
2205
+ name: workflow.name || key,
2206
+ description: workflow.description,
2207
+ steps,
2208
+ inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
2209
+ outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
2210
+ accessRule: workflow.accessRule || undefined,
2211
+ perUserMaxRunning: workflow.perUserMaxRunning,
2212
+ perUserMaxQueued: workflow.perUserMaxQueued,
2213
+ dequeueOrder: workflow.dequeueOrder,
2214
+ });
2215
+ info(` Created workflow: ${key}`);
2216
+ // Add new entity to sync state (including activeConfigId)
2217
+ if (syncState && created?.workflow?.workflowId && created?.workflow?.modifiedAt) {
2218
+ if (!syncState.entities.workflows) {
2219
+ syncState.entities.workflows = {};
2220
+ }
2221
+ syncState.entities.workflows[key] = {
2222
+ id: created.workflow.workflowId,
2223
+ modifiedAt: created.workflow.modifiedAt,
2224
+ activeConfigId: created.workflow.activeConfigId,
2225
+ contentHash: computeExpandedContentHash(tomlData),
2226
+ };
2227
+ }
2228
+ // Track config name→ID mappings
2229
+ if (created?.configs) {
2230
+ for (const config of created.configs) {
2231
+ workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
2232
+ }
2233
+ }
2234
+ const workflowId = created?.workflow?.workflowId;
2235
+ const tomlConfigs = tomlData.configs || [];
2236
+ // Create additional workflow configs (configs[1..n]) beyond the default
2237
+ if (workflowId && tomlConfigs.length > 1) {
2238
+ for (let i = 1; i < tomlConfigs.length; i++) {
2239
+ const extraConfig = tomlConfigs[i];
2240
+ const extraCreated = await client.createWorkflowConfig(resolvedAppId, workflowId, {
2241
+ configName: extraConfig.name || `config-${i + 1}`,
2242
+ description: extraConfig.description,
2243
+ steps,
2244
+ });
2245
+ if (extraCreated?.configId) {
2246
+ const configName = extraConfig.name || `config-${i + 1}`;
2247
+ workflowConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
2248
+ }
2249
+ }
2250
+ info(` Created ${tomlConfigs.length - 1} additional config(s) for workflow: ${key}`);
2251
+ }
2252
+ // Activate the correct config based on activeConfigName from TOML
2253
+ if (workflowId && workflow.activeConfigName) {
2254
+ const activeConfigId = workflowConfigNameToId.get(`${key}#${workflow.activeConfigName}`);
2255
+ if (activeConfigId && activeConfigId !== created?.workflow?.activeConfigId) {
2256
+ await client.activateWorkflowConfig(resolvedAppId, workflowId, activeConfigId);
2257
+ info(` Activated config "${workflow.activeConfigName}" for workflow: ${key}`);
2258
+ }
2259
+ }
2260
+ }
2261
+ catch (err) {
2262
+ throw wrapEntityError(err, "create", "workflow", key);
2263
+ }
2264
+ }
2265
+ }
2266
+ }
2267
+ }
2268
+ // Process database types
2269
+ const dbTypesDir = join(configDir, "database-types");
2270
+ if (existsSync(dbTypesDir)) {
2271
+ const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
2272
+ for (const file of files) {
2273
+ const filePath = join(dbTypesDir, file);
2274
+ const rawToml = readFileSync(filePath, "utf-8");
2275
+ const tomlData = parseTomlFile(filePath);
2276
+ const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
2277
+ const dbType = typeConfig.databaseType || basename(file, ".toml");
2278
+ // Resolve ruleSetName → ruleSetId if using key-based reference
2279
+ resolveRuleSetReference(typeConfig, ruleSetNameToId, `database type ${dbType}`);
2280
+ // $params validator (issue #752): every `$params.X` reference
2281
+ // inside `definition` must correspond to a declared param in
2282
+ // `[[operations.params]]`. Catches typos like `$params.proectId`
2283
+ // at push time with the TOML file:line of the offending op block.
2284
+ const validation = validateOperations({
2285
+ filePath,
2286
+ rawToml,
2287
+ operations,
2288
+ });
2289
+ for (const w of validation.warnings) {
2290
+ warn(` ${formatIssue(w)}`);
2291
+ }
2292
+ if (validation.errors.length > 0) {
2293
+ for (const e of validation.errors) {
2294
+ error(` ${formatIssue(e)}`);
2295
+ }
2296
+ error(`Aborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
2297
+ process.exit(1);
2298
+ }
2299
+ const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
2300
+ // Skip if file hasn't changed since last sync
2301
+ if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
2302
+ skippedCount++;
2303
+ continue;
2304
+ }
2305
+ if (existingEntry) {
2306
+ // Update existing type config — only if there are type-level fields to update.
2307
+ // Operations are handled separately below, so skipping the PATCH here when
2308
+ // updateData is empty avoids sending an empty body (which the server rejects
2309
+ // with HTTP 400).
2310
+ if (!options.dryRun) {
2311
+ const expectedModifiedAt = options.force
2312
+ ? undefined
2313
+ : existingEntry.modifiedAt;
2314
+ try {
2315
+ const updateData = {};
2316
+ if ("ruleSetId" in typeConfig)
2317
+ updateData.ruleSetId = typeConfig.ruleSetId || null;
2318
+ if ("triggers" in typeConfig)
2319
+ updateData.triggers = typeConfig.triggers || null;
2320
+ if ("metadataAccess" in typeConfig)
2321
+ updateData.metadataAccess = typeConfig.metadataAccess || null;
2322
+ if ("defaultAccess" in typeConfig)
2323
+ updateData.defaultAccess = typeConfig.defaultAccess || null;
2324
+ if ("autoPopulatedFields" in typeConfig) {
2325
+ updateData.autoPopulatedFields =
2326
+ typeConfig.autoPopulatedFields || null;
2327
+ }
2328
+ // Issue #666: forward `schema` when the local TOML declares
2329
+ // one (a set/update), OR when the server had one at last
2330
+ // sync and the local file no longer does (a deletion —
2331
+ // `schema: null` clears it server-side; codex review gap on
2332
+ // PR #766). When the type never had a schema and still
2333
+ // doesn't, omit it so an operations-only edit doesn't
2334
+ // register as an empty type-level update (issue #369).
2335
+ const localHasSchema = typeof typeConfig.schema === "string" &&
2336
+ typeConfig.schema.trim().length > 0;
2337
+ if (localHasSchema) {
2338
+ updateData.schema = typeConfig.schema;
2339
+ }
2340
+ else if (existingEntry.hasSchema) {
2341
+ updateData.schema = null;
2342
+ }
2343
+ if (Object.keys(updateData).length > 0) {
2344
+ changes.push({ type: "database-type", action: "update", key: dbType });
2345
+ const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
2346
+ dryRun: !!options.dryRun,
2347
+ acceptWarnings: !!options.acceptWarnings,
2348
+ });
2349
+ info(` Updated database type: ${dbType}`);
2350
+ if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
2351
+ syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
2352
+ syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
2353
+ syncState.entities.databaseTypes[dbType].hasSchema =
2354
+ "schema" in updateData
2355
+ ? updateData.schema !== null
2356
+ : syncState.entities.databaseTypes[dbType].hasSchema;
2357
+ }
2358
+ }
2359
+ }
2360
+ catch (err) {
2361
+ if (err instanceof ConflictError) {
2362
+ conflicts.push({
2363
+ type: "database-type",
2364
+ key: dbType,
2365
+ serverModifiedAt: err.serverModifiedAt,
2366
+ localModifiedAt: expectedModifiedAt || "unknown",
2367
+ });
2368
+ }
2369
+ else if (err instanceof SchemaBreaksOpsError) {
2370
+ schemaErrors.schemaBreaks.push({
2371
+ type: "database-type",
2372
+ key: dbType,
2373
+ operations: err.operations,
2374
+ message: err.message,
2375
+ });
2376
+ }
2377
+ else if (err instanceof SchemaHasUncheckableOpsError) {
2378
+ schemaErrors.uncheckableOps.push({
2379
+ type: "database-type",
2380
+ key: dbType,
2381
+ operations: err.operations,
2382
+ message: err.message,
2383
+ });
2384
+ }
2385
+ else if (err instanceof TomlParseError) {
2386
+ schemaErrors.tomlParse.push({
2387
+ type: "database-type",
2388
+ key: dbType,
2389
+ line: err.line,
2390
+ column: err.column,
2391
+ message: err.message,
2392
+ });
2393
+ }
2394
+ else if (err instanceof OpsExistError) {
2395
+ schemaErrors.opsExist.push({
2396
+ type: "database-type",
2397
+ key: dbType,
2398
+ opCount: err.opCount,
2399
+ message: err.message,
2400
+ });
2401
+ }
2402
+ else {
2403
+ throw wrapEntityError(err, "update", "database type", dbType);
2404
+ }
2405
+ }
2406
+ }
2407
+ else {
2408
+ // In dry-run mode, still report the change iff we would actually PATCH.
2409
+ const wouldUpdate = "ruleSetId" in typeConfig ||
2410
+ "triggers" in typeConfig ||
2411
+ "metadataAccess" in typeConfig ||
2412
+ "defaultAccess" in typeConfig ||
2413
+ "autoPopulatedFields" in typeConfig ||
2414
+ "schema" in typeConfig;
2415
+ if (wouldUpdate) {
2416
+ changes.push({ type: "database-type", action: "update", key: dbType });
2417
+ }
2418
+ }
2419
+ }
2420
+ else {
2421
+ // Create new type config
2422
+ changes.push({ type: "database-type", action: "create", key: dbType });
2423
+ if (!options.dryRun) {
2424
+ const createData = {
2425
+ databaseType: dbType,
2426
+ };
2427
+ if (typeConfig.ruleSetId)
2428
+ createData.ruleSetId = typeConfig.ruleSetId;
2429
+ if (typeConfig.triggers)
2430
+ createData.triggers = typeConfig.triggers;
2431
+ if (typeConfig.metadataAccess)
2432
+ createData.metadataAccess = typeConfig.metadataAccess;
2433
+ if (typeConfig.defaultAccess)
2434
+ createData.defaultAccess = typeConfig.defaultAccess;
2435
+ if (typeConfig.autoPopulatedFields)
2436
+ createData.autoPopulatedFields = typeConfig.autoPopulatedFields;
2437
+ // Issue #666 addendum A2: forward `schema` on POST so a
2438
+ // single sync push can land both the type + its schema in
2439
+ // one round-trip (instead of POST → PATCH). The op-edit gate
2440
+ // itself no longer requires a schema on a fresh type, but
2441
+ // landing the schema up front keeps subsequent op-pushes
2442
+ // validating against the intended shape.
2443
+ if (typeConfig.schema)
2444
+ createData.schema = typeConfig.schema;
2445
+ try {
2446
+ const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
2447
+ info(` Created database type: ${dbType}`);
2448
+ if (syncState) {
2449
+ if (!syncState.entities.databaseTypes) {
2450
+ syncState.entities.databaseTypes = {};
2451
+ }
2452
+ syncState.entities.databaseTypes[dbType] = {
2453
+ databaseType: dbType,
2454
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2455
+ contentHash: computeFileHash(filePath),
2456
+ hasSchema: !!createData.schema,
2457
+ };
2458
+ }
2459
+ }
2460
+ catch (err) {
2461
+ throw wrapEntityError(err, "create", "database type", dbType);
2462
+ }
2463
+ }
2464
+ }
2465
+ // Process operations for this type
2466
+ const existingOps = existingEntry?.operations || {};
2467
+ const tomlOpNames = new Set(operations.map((op) => op.name));
2468
+ for (const op of operations) {
2469
+ const existingOp = existingOps[op.name];
2470
+ if (existingOp) {
2471
+ // Update existing operation
2472
+ changes.push({ type: "operation", action: "update", key: `${dbType}/${op.name}` });
2473
+ if (!options.dryRun) {
2474
+ const expectedOpModifiedAt = options.force
2475
+ ? undefined
2476
+ : existingOp.modifiedAt;
2477
+ try {
2478
+ const updated = await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
2479
+ // Include `type` so the server can detect/apply
2480
+ // type-in-place changes (issue #692). When the type
2481
+ // matches the stored value, this is a no-op.
2482
+ type: op.type,
2483
+ modelName: op.modelName,
2484
+ access: op.access,
2485
+ definition: op.definition,
2486
+ params: op.params,
2487
+ }, expectedOpModifiedAt);
2488
+ info(` Updated operation: ${dbType}/${op.name}`);
2489
+ if (syncState?.entities?.databaseTypes?.[dbType]?.operations?.[op.name] && updated?.modifiedAt) {
2490
+ syncState.entities.databaseTypes[dbType].operations[op.name].modifiedAt = updated.modifiedAt;
2491
+ }
2492
+ }
2493
+ catch (err) {
2494
+ if (err instanceof ConflictError) {
2495
+ conflicts.push({
2496
+ type: "operation",
2497
+ key: `${dbType}/${op.name}`,
2498
+ serverModifiedAt: err.serverModifiedAt,
2499
+ localModifiedAt: expectedOpModifiedAt || "unknown",
2500
+ });
2501
+ }
2502
+ else if (err instanceof SchemaRequiredError) {
2503
+ schemaErrors.schemaRequired.push({
2504
+ type: "operation",
2505
+ key: `${dbType}/${op.name}`,
2506
+ message: err.message,
2507
+ });
2508
+ }
2509
+ else if (err instanceof OperationRefError) {
2510
+ schemaErrors.opRefs.push({
2511
+ type: "operation",
2512
+ key: `${dbType}/${op.name}`,
2513
+ refs: err.refs,
2514
+ message: err.message,
2515
+ });
2516
+ }
2517
+ else {
2518
+ throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
2519
+ }
2520
+ }
2521
+ }
2522
+ }
2523
+ else {
2524
+ // Create new operation
2525
+ changes.push({ type: "operation", action: "create", key: `${dbType}/${op.name}` });
2526
+ if (!options.dryRun) {
2527
+ try {
2528
+ const created = await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
2529
+ name: op.name,
2530
+ type: op.type,
2531
+ modelName: op.modelName,
2532
+ access: op.access,
2533
+ definition: op.definition,
2534
+ params: op.params,
2535
+ });
2536
+ info(` Created operation: ${dbType}/${op.name}`);
2537
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
2538
+ if (!syncState.entities.databaseTypes[dbType].operations) {
2539
+ syncState.entities.databaseTypes[dbType].operations = {};
2540
+ }
2541
+ syncState.entities.databaseTypes[dbType].operations[op.name] = {
2542
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2543
+ };
2544
+ }
2545
+ }
2546
+ catch (err) {
2547
+ if (err instanceof SchemaRequiredError) {
2548
+ schemaErrors.schemaRequired.push({
2549
+ type: "operation",
2550
+ key: `${dbType}/${op.name}`,
2551
+ message: err.message,
2552
+ });
2553
+ }
2554
+ else if (err instanceof OperationRefError) {
2555
+ schemaErrors.opRefs.push({
2556
+ type: "operation",
2557
+ key: `${dbType}/${op.name}`,
2558
+ refs: err.refs,
2559
+ message: err.message,
2560
+ });
2561
+ }
2562
+ else {
2563
+ throw wrapEntityError(err, "create", "operation", `${dbType}/${op.name}`);
2564
+ }
2565
+ }
2566
+ }
2567
+ }
2568
+ }
2569
+ // Delete operations that were removed from TOML
2570
+ for (const existingOpName of Object.keys(existingOps)) {
2571
+ if (!tomlOpNames.has(existingOpName)) {
2572
+ changes.push({ type: "operation", action: "delete", key: `${dbType}/${existingOpName}` });
2573
+ if (!options.dryRun) {
2574
+ try {
2575
+ await client.deleteDatabaseTypeOperation(resolvedAppId, dbType, existingOpName);
2576
+ info(` Deleted operation: ${dbType}/${existingOpName}`);
2577
+ if (syncState?.entities?.databaseTypes?.[dbType]?.operations) {
2578
+ delete syncState.entities.databaseTypes[dbType].operations[existingOpName];
2579
+ }
2580
+ }
2581
+ catch (err) {
2582
+ throw wrapEntityError(err, "delete", "operation", `${dbType}/${existingOpName}`);
2583
+ }
2584
+ }
2585
+ }
2586
+ }
2587
+ }
2588
+ }
2589
+ // Process group type configs
2590
+ const groupTypeConfigsDir = join(configDir, "group-type-configs");
2591
+ if (existsSync(groupTypeConfigsDir)) {
2592
+ const files = readdirSync(groupTypeConfigsDir).filter((f) => f.endsWith(".toml"));
2593
+ for (const file of files) {
2594
+ const filePath = join(groupTypeConfigsDir, file);
2595
+ const tomlData = parseTomlFile(filePath);
2596
+ const configData = parseGroupTypeConfigToml(tomlData);
2597
+ const groupType = configData.groupType || basename(file, ".toml");
2598
+ // Resolve ruleSetName → ruleSetId if using key-based reference
2599
+ resolveRuleSetReference(configData, ruleSetNameToId, `group type ${groupType}`);
2600
+ const existingEntry = syncState?.entities?.groupTypeConfigs?.[groupType];
2601
+ // Skip if file hasn't changed since last sync
2602
+ if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
2603
+ skippedCount++;
2604
+ continue;
2605
+ }
2606
+ if (existingEntry) {
2607
+ // Update existing group type config
2608
+ changes.push({ type: "group-type-config", action: "update", key: groupType });
2609
+ if (!options.dryRun) {
2610
+ const expectedModifiedAt = options.force
2611
+ ? undefined
2612
+ : existingEntry.modifiedAt;
2613
+ try {
2614
+ const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
2615
+ ruleSetId: configData.ruleSetId,
2616
+ autoAddCreator: configData.autoAddCreator,
2617
+ }, expectedModifiedAt);
2618
+ info(` Updated group type config: ${groupType}`);
2619
+ if (syncState?.entities?.groupTypeConfigs?.[groupType] && updated?.modifiedAt) {
2620
+ syncState.entities.groupTypeConfigs[groupType].modifiedAt = updated.modifiedAt;
2621
+ syncState.entities.groupTypeConfigs[groupType].contentHash = computeFileHash(filePath);
2622
+ }
2623
+ }
2624
+ catch (err) {
2625
+ if (err instanceof ConflictError) {
2626
+ conflicts.push({
2627
+ type: "group-type-config",
2628
+ key: groupType,
2629
+ serverModifiedAt: err.serverModifiedAt,
2630
+ localModifiedAt: expectedModifiedAt || "unknown",
2631
+ });
2632
+ }
2633
+ else {
2634
+ throw wrapEntityError(err, "update", "group type config", groupType);
2635
+ }
2636
+ }
2637
+ }
2638
+ }
2639
+ else {
2640
+ // Create new group type config
2641
+ changes.push({ type: "group-type-config", action: "create", key: groupType });
2642
+ if (!options.dryRun) {
2643
+ try {
2644
+ const created = await client.createGroupTypeConfig(resolvedAppId, {
2645
+ groupType,
2646
+ ruleSetId: configData.ruleSetId || undefined,
2647
+ autoAddCreator: configData.autoAddCreator,
2648
+ });
2649
+ info(` Created group type config: ${groupType}`);
2650
+ if (syncState) {
2651
+ if (!syncState.entities.groupTypeConfigs) {
2652
+ syncState.entities.groupTypeConfigs = {};
2653
+ }
2654
+ syncState.entities.groupTypeConfigs[groupType] = {
2655
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2656
+ contentHash: computeFileHash(filePath),
2657
+ };
2658
+ }
2659
+ }
2660
+ catch (err) {
2661
+ throw wrapEntityError(err, "create", "group type config", groupType);
2662
+ }
2663
+ }
2664
+ }
2665
+ }
2666
+ }
2667
+ // Process collection type configs
2668
+ const collectionTypeConfigsDir = join(configDir, "collection-type-configs");
2669
+ if (existsSync(collectionTypeConfigsDir)) {
2670
+ const files = readdirSync(collectionTypeConfigsDir).filter((f) => f.endsWith(".toml"));
2671
+ for (const file of files) {
2672
+ const filePath = join(collectionTypeConfigsDir, file);
2673
+ const tomlData = parseTomlFile(filePath);
2674
+ const configData = parseCollectionTypeConfigToml(tomlData);
2675
+ const collectionType = configData.collectionType || basename(file, ".toml");
2676
+ // Resolve ruleSetName → ruleSetId if using key-based reference
2677
+ resolveRuleSetReference(configData, ruleSetNameToId, `collection type ${collectionType}`);
2678
+ const existingEntry = syncState?.entities?.collectionTypeConfigs?.[collectionType];
2679
+ // Skip if file hasn't changed since last sync
2680
+ if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
2681
+ skippedCount++;
2682
+ continue;
2683
+ }
2684
+ if (existingEntry) {
2685
+ // Update existing collection type config
2686
+ changes.push({ type: "collection-type-config", action: "update", key: collectionType });
2687
+ if (!options.dryRun) {
2688
+ const expectedModifiedAt = options.force
2689
+ ? undefined
2690
+ : existingEntry.modifiedAt;
2691
+ try {
2692
+ const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
2693
+ ruleSetId: configData.ruleSetId ?? null,
2694
+ }, expectedModifiedAt);
2695
+ info(` Updated collection type config: ${collectionType}`);
2696
+ if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
2697
+ syncState.entities.collectionTypeConfigs[collectionType].modifiedAt = updated.modifiedAt;
2698
+ syncState.entities.collectionTypeConfigs[collectionType].contentHash = computeFileHash(filePath);
2699
+ }
2700
+ }
2701
+ catch (err) {
2702
+ if (err instanceof ConflictError) {
2703
+ conflicts.push({
2704
+ type: "collection-type-config",
2705
+ key: collectionType,
2706
+ serverModifiedAt: err.serverModifiedAt,
2707
+ localModifiedAt: expectedModifiedAt || "unknown",
2708
+ });
2709
+ }
2710
+ else {
2711
+ throw wrapEntityError(err, "update", "collection type config", collectionType);
2712
+ }
2713
+ }
2714
+ }
2715
+ }
2716
+ else {
2717
+ // Create new collection type config
2718
+ changes.push({ type: "collection-type-config", action: "create", key: collectionType });
2719
+ if (!options.dryRun) {
2720
+ try {
2721
+ const created = await client.createCollectionTypeConfig(resolvedAppId, {
2722
+ collectionType,
2723
+ ruleSetId: configData.ruleSetId || undefined,
2724
+ });
2725
+ info(` Created collection type config: ${collectionType}`);
2726
+ if (syncState) {
2727
+ if (!syncState.entities.collectionTypeConfigs) {
2728
+ syncState.entities.collectionTypeConfigs = {};
2729
+ }
2730
+ syncState.entities.collectionTypeConfigs[collectionType] = {
2731
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
2732
+ contentHash: computeFileHash(filePath),
2733
+ };
2734
+ }
2735
+ }
2736
+ catch (err) {
2737
+ throw wrapEntityError(err, "create", "collection type config", collectionType);
2738
+ }
2739
+ }
2740
+ }
2741
+ }
2742
+ }
2743
+ // Process email templates
2744
+ const emailTemplatesDir = join(configDir, "email-templates");
2745
+ if (existsSync(emailTemplatesDir)) {
2746
+ const files = readdirSync(emailTemplatesDir).filter((f) => f.endsWith(".toml"));
2747
+ for (const file of files) {
2748
+ const filePath = join(emailTemplatesDir, file);
2749
+ const tomlData = parseTomlFile(filePath);
2750
+ const template = tomlData.template || {};
2751
+ const emailType = template.emailType || basename(file, ".toml");
2752
+ const payload = {
2753
+ subject: template.subject || "",
2754
+ htmlBody: template.htmlBody || "",
2755
+ textBody: template.textBody || "",
2756
+ };
2757
+ changes.push({ type: "email-template", action: "update", key: emailType });
2758
+ if (!options.dryRun) {
2759
+ try {
2760
+ const result = await client.upsertEmailTemplate(resolvedAppId, emailType, payload);
2761
+ info(` Updated email template: ${emailType}`);
2762
+ // Update sync state
2763
+ if (syncState) {
2764
+ if (!syncState.entities.emailTemplates) {
2765
+ syncState.entities.emailTemplates = {};
2766
+ }
2767
+ syncState.entities.emailTemplates[emailType] = {
2768
+ id: emailType,
2769
+ modifiedAt: result?.modifiedAt || new Date().toISOString(),
916
2770
  };
917
2771
  }
918
2772
  }
2773
+ catch (err) {
2774
+ warn(` Failed to update email template ${emailType}: ${err.message}`);
2775
+ }
919
2776
  }
920
2777
  }
921
2778
  }
922
2779
  // Push test cases for prompts and workflows
2780
+ const pushMaps = {
2781
+ promptKeyToId,
2782
+ promptConfigNameToId,
2783
+ workflowConfigNameToId,
2784
+ };
923
2785
  const promptsDir2 = join(configDir, "prompts");
924
2786
  if (existsSync(promptsDir2)) {
925
2787
  for (const file of readdirSync(promptsDir2).filter((f) => f.endsWith(".toml"))) {
@@ -927,7 +2789,7 @@ Directory Structure:
927
2789
  const promptKey = tomlData.prompt?.key || basename(file, ".toml");
928
2790
  const blockId = syncState?.entities?.prompts?.[promptKey]?.id;
929
2791
  if (blockId) {
930
- await pushTestCasesForBlock(client, resolvedAppId, "prompt", blockId, promptKey, configDir, syncState, options.dryRun, changes);
2792
+ skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "prompt", blockId, promptKey, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
931
2793
  }
932
2794
  }
933
2795
  }
@@ -938,7 +2800,7 @@ Directory Structure:
938
2800
  const workflowKey = tomlData.workflow?.key || basename(file, ".toml");
939
2801
  const blockId = syncState?.entities?.workflows?.[workflowKey]?.id;
940
2802
  if (blockId) {
941
- await pushTestCasesForBlock(client, resolvedAppId, "workflow", blockId, workflowKey, configDir, syncState, options.dryRun, changes);
2803
+ skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "workflow", blockId, workflowKey, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
942
2804
  }
943
2805
  }
944
2806
  }
@@ -953,33 +2815,118 @@ Directory Structure:
953
2815
  console.log(` ${color(change.action)} ${change.type}: ${change.key}`);
954
2816
  }
955
2817
  }
956
- else if (conflicts.length > 0) {
2818
+ else if (conflicts.length > 0 ||
2819
+ schemaErrors.schemaRequired.length > 0 ||
2820
+ schemaErrors.opRefs.length > 0 ||
2821
+ schemaErrors.schemaBreaks.length > 0 ||
2822
+ schemaErrors.uncheckableOps.length > 0 ||
2823
+ schemaErrors.tomlParse.length > 0 ||
2824
+ schemaErrors.opsExist.length > 0) {
957
2825
  // Handle conflicts
958
- console.log();
959
- warn(`${conflicts.length} conflict(s) detected:`);
960
- console.log();
961
- for (const conflict of conflicts) {
962
- console.log(` ${chalk.red("CONFLICT")} ${conflict.type}: ${chalk.bold(conflict.key)}`);
963
- console.log(` Local last sync: ${chalk.dim(conflict.localModifiedAt)}`);
964
- console.log(` Server modified: ${chalk.yellow(conflict.serverModifiedAt)}`);
2826
+ if (conflicts.length > 0) {
2827
+ console.log();
2828
+ warn(`${conflicts.length} conflict(s) detected:`);
2829
+ console.log();
2830
+ for (const conflict of conflicts) {
2831
+ console.log(` ${chalk.red("CONFLICT")} ${conflict.type}: ${chalk.bold(conflict.key)}`);
2832
+ console.log(` Local last sync: ${chalk.dim(conflict.localModifiedAt)}`);
2833
+ console.log(` Server modified: ${chalk.yellow(conflict.serverModifiedAt)}`);
2834
+ }
2835
+ console.log();
2836
+ warn("These entities were modified on the server since your last pull.");
2837
+ info("Options:");
2838
+ console.log(` 1. Run ${chalk.cyan("primitive sync pull")} to get latest changes`);
2839
+ console.log(` 2. Run ${chalk.cyan("primitive sync push --force")} to overwrite server`);
2840
+ console.log();
2841
+ }
2842
+ // Report schema-feature errors (issue #666).
2843
+ if (schemaErrors.schemaRequired.length > 0) {
2844
+ console.log();
2845
+ warn(`${schemaErrors.schemaRequired.length} operation push(es) blocked: type has no schema set.`);
2846
+ for (const e of schemaErrors.schemaRequired) {
2847
+ const type = e.key.split("/")[0];
2848
+ console.log(` ${chalk.red("SCHEMA_REQUIRED")} ${e.key}`);
2849
+ console.log(` Run ${chalk.cyan(`primitive databases schema generate ${type}`)} to scaffold one, then retry.`);
2850
+ }
2851
+ console.log();
2852
+ }
2853
+ if (schemaErrors.opRefs.length > 0) {
2854
+ console.log();
2855
+ warn(`${schemaErrors.opRefs.length} operation push(es) blocked: unresolved references.`);
2856
+ for (const e of schemaErrors.opRefs) {
2857
+ console.log(` ${chalk.red("OPERATION_REFERENCES_UNDEFINED")} ${e.key}`);
2858
+ for (const ref of e.refs) {
2859
+ console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
2860
+ }
2861
+ }
2862
+ console.log();
2863
+ }
2864
+ if (schemaErrors.schemaBreaks.length > 0) {
2865
+ console.log();
2866
+ warn(`${schemaErrors.schemaBreaks.length} schema push(es) blocked: existing operations would break.`);
2867
+ for (const e of schemaErrors.schemaBreaks) {
2868
+ console.log(` ${chalk.red("SCHEMA_BREAKS_OPERATIONS")} ${e.key}`);
2869
+ for (const op of e.operations) {
2870
+ console.log(` ${chalk.bold(op.operation)}`);
2871
+ for (const ref of op.refs) {
2872
+ console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
2873
+ }
2874
+ }
2875
+ }
2876
+ console.log();
2877
+ }
2878
+ if (schemaErrors.uncheckableOps.length > 0) {
2879
+ console.log();
2880
+ warn(`${schemaErrors.uncheckableOps.length} schema push(es) blocked: operations with dynamic refs.`);
2881
+ for (const e of schemaErrors.uncheckableOps) {
2882
+ console.log(` ${chalk.red("SCHEMA_HAS_UNCHECKABLE_OPS")} ${e.key}`);
2883
+ for (const op of e.operations) {
2884
+ console.log(` ${chalk.bold(op.operation)}`);
2885
+ for (const loc of op.locations) {
2886
+ console.log(` - ${chalk.dim(loc)}`);
2887
+ }
2888
+ }
2889
+ }
2890
+ info(`Re-run with ${chalk.cyan("--accept-warnings")} to commit anyway.`);
2891
+ console.log();
2892
+ }
2893
+ if (schemaErrors.tomlParse.length > 0) {
2894
+ console.log();
2895
+ warn(`${schemaErrors.tomlParse.length} TOML parse error(s):`);
2896
+ for (const e of schemaErrors.tomlParse) {
2897
+ const loc = e.line ? ` (line ${e.line}${e.column ? `, col ${e.column}` : ""})` : "";
2898
+ console.log(` ${chalk.red("TOML_PARSE_ERROR")} ${e.key}${loc}`);
2899
+ console.log(` ${e.message}`);
2900
+ }
2901
+ console.log();
2902
+ }
2903
+ if (schemaErrors.opsExist.length > 0) {
2904
+ console.log();
2905
+ warn(`${schemaErrors.opsExist.length} schema delete(s) blocked: operations still registered.`);
2906
+ for (const e of schemaErrors.opsExist) {
2907
+ console.log(` ${chalk.red("OPS_EXIST")} ${e.key} — ${e.opCount} op(s)`);
2908
+ console.log(` Delete or migrate the affected operations before clearing the schema.`);
2909
+ }
2910
+ console.log();
965
2911
  }
966
- console.log();
967
- warn("These entities were modified on the server since your last pull.");
968
- info("Options:");
969
- console.log(` 1. Run ${chalk.cyan("primitive sync pull")} to get latest changes`);
970
- console.log(` 2. Run ${chalk.cyan("primitive sync push --force")} to overwrite server`);
971
- console.log();
972
2912
  // Update sync state for non-conflicting changes
973
2913
  if (syncState) {
974
2914
  syncState.lastSyncedAt = new Date().toISOString();
975
2915
  saveSyncState(configDir, syncState);
976
2916
  }
977
- const successCount = changes.length - conflicts.length;
2917
+ const totalBlocked = conflicts.length +
2918
+ schemaErrors.schemaRequired.length +
2919
+ schemaErrors.opRefs.length +
2920
+ schemaErrors.schemaBreaks.length +
2921
+ schemaErrors.uncheckableOps.length +
2922
+ schemaErrors.tomlParse.length +
2923
+ schemaErrors.opsExist.length;
2924
+ const successCount = changes.length - totalBlocked;
978
2925
  if (successCount > 0) {
979
- success(`Pushed ${successCount} change(s). ${conflicts.length} conflict(s) skipped.`);
2926
+ success(`Pushed ${successCount} change(s). ${totalBlocked} blocked.`);
980
2927
  }
981
2928
  else {
982
- error(`Push failed: ${conflicts.length} conflict(s). No changes applied.`);
2929
+ error(`Push failed: ${totalBlocked} blocked. No changes applied.`);
983
2930
  }
984
2931
  process.exit(1);
985
2932
  }
@@ -989,11 +2936,30 @@ Directory Structure:
989
2936
  syncState.lastSyncedAt = new Date().toISOString();
990
2937
  saveSyncState(configDir, syncState);
991
2938
  }
992
- success(`Pushed ${changes.length} changes.`);
2939
+ const pushMsg = skippedCount > 0
2940
+ ? `Pushed ${changes.length} change(s), skipped ${skippedCount} unchanged file(s).`
2941
+ : `Pushed ${changes.length} changes.`;
2942
+ success(pushMsg);
993
2943
  }
994
2944
  }
995
2945
  catch (err) {
2946
+ // Save sync state even on failure so partial progress is preserved
2947
+ if (syncState) {
2948
+ try {
2949
+ syncState.lastSyncedAt = new Date().toISOString();
2950
+ saveSyncState(configDir, syncState);
2951
+ }
2952
+ catch {
2953
+ // Don't mask the original error
2954
+ }
2955
+ }
996
2956
  error(err.message);
2957
+ // Print structured server-side validation details (issue #684).
2958
+ if (err instanceof ApiError && Array.isArray(err.details) && err.details.length > 0) {
2959
+ for (const detail of err.details) {
2960
+ console.error(` - ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
2961
+ }
2962
+ }
997
2963
  process.exit(1);
998
2964
  }
999
2965
  });
@@ -1003,10 +2969,16 @@ Directory Structure:
1003
2969
  .description("Show differences between local and remote configuration")
1004
2970
  .argument("[app-id]", "App ID (uses current app if not specified)")
1005
2971
  .option("--app <app-id>", "App ID")
1006
- .option("--dir <path>", "Config directory", "./config")
2972
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
1007
2973
  .action(async (appId, options) => {
1008
2974
  const resolvedAppId = resolveAppId(appId, options);
1009
- const configDir = options.dir;
2975
+ const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
2976
+ if (isAutoResolvedSyncDir(options.dir)) {
2977
+ info(`Using per-environment sync directory: ${configDir}`);
2978
+ const legacyHint = checkLegacySyncMigration(configDir);
2979
+ if (legacyHint)
2980
+ warn(legacyHint);
2981
+ }
1010
2982
  const client = new ApiClient();
1011
2983
  if (!existsSync(configDir)) {
1012
2984
  error(`Config directory not found: ${configDir}`);
@@ -1016,18 +2988,39 @@ Directory Structure:
1016
2988
  info(`Comparing local configuration with app ${resolvedAppId}...`);
1017
2989
  try {
1018
2990
  // Fetch remote state
1019
- const [integrationsResult, promptsResult, workflowsResult] = await Promise.all([
1020
- client.listIntegrations(resolvedAppId, { limit: 100 }),
1021
- client.listPrompts(resolvedAppId, { limit: 100 }),
1022
- client.listWorkflows(resolvedAppId, { limit: 100 }),
2991
+ const [integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
2992
+ fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
2993
+ fetchAll((p) => client.listPrompts(resolvedAppId, p)),
2994
+ fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
2995
+ client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
1023
2996
  ]);
1024
- const remoteIntegrations = new Set(integrationsResult.items.map((i) => i.integrationKey));
1025
- const remotePrompts = new Set(promptsResult.items.map((p) => p.promptKey));
1026
- const remoteWorkflows = new Set(workflowsResult.items.map((w) => w.workflowKey));
2997
+ const webhookItems = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
2998
+ let cronTriggerItemsDiff = [];
2999
+ try {
3000
+ const cronResult = await client.listCronTriggers(resolvedAppId);
3001
+ cronTriggerItemsDiff = cronResult.items || [];
3002
+ }
3003
+ catch {
3004
+ // Cron triggers may not be available on older servers
3005
+ }
3006
+ const blobBucketsDiffResult = await client.listBlobBuckets(resolvedAppId).catch(() => ({ items: [] }));
3007
+ const remoteIntegrations = new Set(integrationItems.map((i) => i.integrationKey));
3008
+ const remoteWebhooks = new Set(webhookItems.map((w) => w.webhookKey));
3009
+ const remoteCronTriggers = new Set(cronTriggerItemsDiff.map((t) => t.triggerKey));
3010
+ const remoteBlobBuckets = new Set((blobBucketsDiffResult.items || []).map((b) => b.bucketKey));
3011
+ const remotePrompts = new Set(promptItems.map((p) => p.promptKey));
3012
+ const remoteWorkflows = new Set(workflowItems.map((w) => w.workflowKey));
3013
+ const remoteEmailTemplates = new Set((emailTemplatesResult.templates || [])
3014
+ .filter((t) => t.hasOverride)
3015
+ .map((t) => t.emailType));
1027
3016
  // Get local files
1028
3017
  const localIntegrations = new Set();
3018
+ const localWebhooks = new Set();
3019
+ const localCronTriggers = new Set();
3020
+ const localBlobBuckets = new Set();
1029
3021
  const localPrompts = new Set();
1030
3022
  const localWorkflows = new Set();
3023
+ const localEmailTemplates = new Set();
1031
3024
  const integrationsDir = join(configDir, "integrations");
1032
3025
  if (existsSync(integrationsDir)) {
1033
3026
  for (const file of readdirSync(integrationsDir).filter((f) => f.endsWith(".toml"))) {
@@ -1036,6 +3029,30 @@ Directory Structure:
1036
3029
  localIntegrations.add(key);
1037
3030
  }
1038
3031
  }
3032
+ const webhooksDirPath = join(configDir, "webhooks");
3033
+ if (existsSync(webhooksDirPath)) {
3034
+ for (const file of readdirSync(webhooksDirPath).filter((f) => f.endsWith(".toml"))) {
3035
+ const tomlData = parseTomlFile(join(webhooksDirPath, file));
3036
+ const key = tomlData.webhook?.key || basename(file, ".toml");
3037
+ localWebhooks.add(key);
3038
+ }
3039
+ }
3040
+ const cronTriggersDirPath = join(configDir, "cron-triggers");
3041
+ if (existsSync(cronTriggersDirPath)) {
3042
+ for (const file of readdirSync(cronTriggersDirPath).filter((f) => f.endsWith(".toml"))) {
3043
+ const tomlData = parseTomlFile(join(cronTriggersDirPath, file));
3044
+ const key = tomlData.cronTrigger?.key || basename(file, ".toml");
3045
+ localCronTriggers.add(key);
3046
+ }
3047
+ }
3048
+ const blobBucketsDiffDir = join(configDir, "blob-buckets");
3049
+ if (existsSync(blobBucketsDiffDir)) {
3050
+ for (const file of readdirSync(blobBucketsDiffDir).filter((f) => f.endsWith(".toml"))) {
3051
+ const tomlData = parseTomlFile(join(blobBucketsDiffDir, file));
3052
+ const key = tomlData.bucket?.key || basename(file, ".toml");
3053
+ localBlobBuckets.add(key);
3054
+ }
3055
+ }
1039
3056
  const promptsDir = join(configDir, "prompts");
1040
3057
  if (existsSync(promptsDir)) {
1041
3058
  for (const file of readdirSync(promptsDir).filter((f) => f.endsWith(".toml"))) {
@@ -1052,6 +3069,14 @@ Directory Structure:
1052
3069
  localWorkflows.add(key);
1053
3070
  }
1054
3071
  }
3072
+ const emailTemplatesDirPath = join(configDir, "email-templates");
3073
+ if (existsSync(emailTemplatesDirPath)) {
3074
+ for (const file of readdirSync(emailTemplatesDirPath).filter((f) => f.endsWith(".toml"))) {
3075
+ const tomlData = parseTomlFile(join(emailTemplatesDirPath, file));
3076
+ const emailType = tomlData.template?.emailType || basename(file, ".toml");
3077
+ localEmailTemplates.add(emailType);
3078
+ }
3079
+ }
1055
3080
  // Compare
1056
3081
  const differences = [];
1057
3082
  // Integrations
@@ -1068,6 +3093,48 @@ Directory Structure:
1068
3093
  differences.push({ type: "integration", key, status: "remote only" });
1069
3094
  }
1070
3095
  }
3096
+ // Webhooks
3097
+ for (const key of localWebhooks) {
3098
+ if (!remoteWebhooks.has(key)) {
3099
+ differences.push({ type: "webhook", key, status: "local only" });
3100
+ }
3101
+ else {
3102
+ differences.push({ type: "webhook", key, status: "exists" });
3103
+ }
3104
+ }
3105
+ for (const key of remoteWebhooks) {
3106
+ if (!localWebhooks.has(key)) {
3107
+ differences.push({ type: "webhook", key, status: "remote only" });
3108
+ }
3109
+ }
3110
+ // Cron Triggers
3111
+ for (const key of localCronTriggers) {
3112
+ if (!remoteCronTriggers.has(key)) {
3113
+ differences.push({ type: "cron-trigger", key, status: "local only" });
3114
+ }
3115
+ else {
3116
+ differences.push({ type: "cron-trigger", key, status: "exists" });
3117
+ }
3118
+ }
3119
+ for (const key of remoteCronTriggers) {
3120
+ if (!localCronTriggers.has(key)) {
3121
+ differences.push({ type: "cron-trigger", key, status: "remote only" });
3122
+ }
3123
+ }
3124
+ // Blob Buckets
3125
+ for (const key of localBlobBuckets) {
3126
+ if (!remoteBlobBuckets.has(key)) {
3127
+ differences.push({ type: "blob-bucket", key, status: "local only" });
3128
+ }
3129
+ else {
3130
+ differences.push({ type: "blob-bucket", key, status: "exists" });
3131
+ }
3132
+ }
3133
+ for (const key of remoteBlobBuckets) {
3134
+ if (!localBlobBuckets.has(key)) {
3135
+ differences.push({ type: "blob-bucket", key, status: "remote only" });
3136
+ }
3137
+ }
1071
3138
  // Prompts
1072
3139
  for (const key of localPrompts) {
1073
3140
  if (!remotePrompts.has(key)) {
@@ -1096,6 +3163,20 @@ Directory Structure:
1096
3163
  differences.push({ type: "workflow", key, status: "remote only" });
1097
3164
  }
1098
3165
  }
3166
+ // Email Templates
3167
+ for (const key of localEmailTemplates) {
3168
+ if (!remoteEmailTemplates.has(key)) {
3169
+ differences.push({ type: "email-template", key, status: "local only" });
3170
+ }
3171
+ else {
3172
+ differences.push({ type: "email-template", key, status: "exists" });
3173
+ }
3174
+ }
3175
+ for (const key of remoteEmailTemplates) {
3176
+ if (!localEmailTemplates.has(key)) {
3177
+ differences.push({ type: "email-template", key, status: "remote only" });
3178
+ }
3179
+ }
1099
3180
  // Compare test cases for synced prompts and workflows
1100
3181
  const testCaseDiffs = [];
1101
3182
  // Helper to compare test cases for a block
@@ -1133,12 +3214,12 @@ Directory Structure:
1133
3214
  }
1134
3215
  };
1135
3216
  // Compare test cases for synced blocks
1136
- for (const p of promptsResult.items) {
3217
+ for (const p of promptItems) {
1137
3218
  if (localPrompts.has(p.promptKey)) {
1138
3219
  await compareTestCases("prompt", p.promptId, p.promptKey);
1139
3220
  }
1140
3221
  }
1141
- for (const w of workflowsResult.items) {
3222
+ for (const w of workflowItems) {
1142
3223
  if (localWorkflows.has(w.workflowKey)) {
1143
3224
  await compareTestCases("workflow", w.workflowId, w.workflowKey);
1144
3225
  }
@@ -1199,5 +3280,105 @@ Directory Structure:
1199
3280
  process.exit(1);
1200
3281
  }
1201
3282
  });
3283
+ // Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
3284
+ // the legacy JSON-string form for `definition`/`params` to the native
3285
+ // nested-table form. Idempotent: ops already in native form are left as
3286
+ // is; ops that are un-TOMLable (mixed-type arrays, null values) fall
3287
+ // back to JSON-string per field with a log message.
3288
+ sync
3289
+ .command("migrate-toml")
3290
+ .description("Rewrite database-type TOML files to native [operations.definition] / [[operations.params]] form")
3291
+ .argument("[app-id]", "App ID (uses current app if not specified)")
3292
+ .option("--app <app-id>", "App ID")
3293
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
3294
+ .option("--dry-run", "Show what would change without writing files")
3295
+ .action(async (appId, options) => {
3296
+ // Resolve appId so we land in the per-env sync dir even when the user
3297
+ // didn't pass `--dir`. The app ID itself isn't used for any API calls
3298
+ // — `migrate-toml` is a purely local rewrite.
3299
+ const resolvedAppId = resolveAppId(appId, options);
3300
+ const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
3301
+ if (isAutoResolvedSyncDir(options.dir)) {
3302
+ info(`Using per-environment sync directory: ${configDir}`);
3303
+ }
3304
+ if (!existsSync(configDir)) {
3305
+ error(`Config directory not found: ${configDir}`);
3306
+ process.exit(1);
3307
+ }
3308
+ const dbTypesDir = join(configDir, "database-types");
3309
+ if (!existsSync(dbTypesDir)) {
3310
+ info("No database-types/ directory found; nothing to migrate.");
3311
+ return;
3312
+ }
3313
+ const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
3314
+ if (files.length === 0) {
3315
+ info("No database-type TOML files found; nothing to migrate.");
3316
+ return;
3317
+ }
3318
+ // Hydrate ruleSetIdToName from local sync state so files that
3319
+ // reference a rule set via the legacy `ruleSetId = "01..."` form
3320
+ // round-trip with the correct `ruleSetName`. Without this, any user
3321
+ // who runs migrate-toml against a file with a rule-set assignment
3322
+ // would silently lose that reference (review feedback r3246633010).
3323
+ //
3324
+ // Files that use the modern key-based `ruleSetName = "..."` form
3325
+ // don't need the map at all — `parseDatabaseTypeToml` stores the
3326
+ // value in `typeConfig._ruleSetName` and the serializer now prefers
3327
+ // that. The map is only load-bearing for the legacy ID-based form.
3328
+ const ruleSetIdToName = new Map();
3329
+ const migrateSyncState = loadSyncState(configDir);
3330
+ if (migrateSyncState?.entities?.ruleSets) {
3331
+ for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
3332
+ if (entry && typeof entry === "object" && "id" in entry && entry.id) {
3333
+ // fileKey is the sanitized rule-set name (see sync pull at
3334
+ // sync.ts:1385). It's the same shape the server returned and
3335
+ // matches what a TOML file's ruleSetName field would carry.
3336
+ ruleSetIdToName.set(entry.id, fileKey);
3337
+ }
3338
+ }
3339
+ }
3340
+ let migratedCount = 0;
3341
+ let unchangedCount = 0;
3342
+ for (const file of files) {
3343
+ const filePath = join(dbTypesDir, file);
3344
+ const rawBefore = readFileSync(filePath, "utf-8");
3345
+ const tomlData = parseTomlFile(filePath);
3346
+ const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
3347
+ // Force-native: ignore existing hints so every TOMLable field
3348
+ // ends up in nested-table form.
3349
+ const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
3350
+ defaultForm: "native",
3351
+ logger: (msg) => info(` ${msg}`),
3352
+ });
3353
+ if (rewritten === rawBefore) {
3354
+ unchangedCount++;
3355
+ continue;
3356
+ }
3357
+ if (options.dryRun) {
3358
+ info(`Would migrate database-types/${file}`);
3359
+ }
3360
+ else {
3361
+ writeFileSync(filePath, rewritten);
3362
+ info(`Migrated database-types/${file}`);
3363
+ // Update content hash in sync state if we have an entry for it.
3364
+ const syncState = loadSyncState(configDir);
3365
+ const dbType = typeConfig.databaseType || basename(file, ".toml");
3366
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
3367
+ syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
3368
+ saveSyncState(configDir, syncState);
3369
+ }
3370
+ }
3371
+ migratedCount++;
3372
+ }
3373
+ divider();
3374
+ keyValue("Migrated", migratedCount);
3375
+ keyValue("Already native (unchanged)", unchangedCount);
3376
+ if (options.dryRun) {
3377
+ info("Dry-run only — no files were modified.");
3378
+ }
3379
+ else if (migratedCount > 0) {
3380
+ success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
3381
+ }
3382
+ });
1202
3383
  }
1203
3384
  //# sourceMappingURL=sync.js.map