primitive-admin 1.0.50 → 1.0.52

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 (59) hide show
  1. package/README.md +27 -8
  2. package/dist/src/commands/admins.js +25 -27
  3. package/dist/src/commands/admins.js.map +1 -1
  4. package/dist/src/commands/apps.js +8 -0
  5. package/dist/src/commands/apps.js.map +1 -1
  6. package/dist/src/commands/blob-buckets.js +30 -26
  7. package/dist/src/commands/blob-buckets.js.map +1 -1
  8. package/dist/src/commands/catalog.js +17 -18
  9. package/dist/src/commands/catalog.js.map +1 -1
  10. package/dist/src/commands/collection-type-configs.js +9 -9
  11. package/dist/src/commands/collection-type-configs.js.map +1 -1
  12. package/dist/src/commands/collections.js +33 -36
  13. package/dist/src/commands/collections.js.map +1 -1
  14. package/dist/src/commands/database-types.js +17 -18
  15. package/dist/src/commands/database-types.js.map +1 -1
  16. package/dist/src/commands/databases.js +41 -45
  17. package/dist/src/commands/databases.js.map +1 -1
  18. package/dist/src/commands/documents.js +17 -18
  19. package/dist/src/commands/documents.js.map +1 -1
  20. package/dist/src/commands/email-templates.js +9 -9
  21. package/dist/src/commands/email-templates.js.map +1 -1
  22. package/dist/src/commands/group-type-configs.js +9 -9
  23. package/dist/src/commands/group-type-configs.js.map +1 -1
  24. package/dist/src/commands/groups.js +17 -18
  25. package/dist/src/commands/groups.js.map +1 -1
  26. package/dist/src/commands/integrations.js +17 -18
  27. package/dist/src/commands/integrations.js.map +1 -1
  28. package/dist/src/commands/llm.js +4 -2
  29. package/dist/src/commands/llm.js.map +1 -1
  30. package/dist/src/commands/prompts.js +33 -36
  31. package/dist/src/commands/prompts.js.map +1 -1
  32. package/dist/src/commands/rule-sets.js +9 -9
  33. package/dist/src/commands/rule-sets.js.map +1 -1
  34. package/dist/src/commands/sync.d.ts +30 -14
  35. package/dist/src/commands/sync.js +289 -95
  36. package/dist/src/commands/sync.js.map +1 -1
  37. package/dist/src/commands/tokens.js +9 -9
  38. package/dist/src/commands/tokens.js.map +1 -1
  39. package/dist/src/commands/users.js +41 -45
  40. package/dist/src/commands/users.js.map +1 -1
  41. package/dist/src/commands/waitlist.js +9 -9
  42. package/dist/src/commands/waitlist.js.map +1 -1
  43. package/dist/src/commands/webhooks.js +9 -9
  44. package/dist/src/commands/webhooks.js.map +1 -1
  45. package/dist/src/commands/workflows.js +62 -36
  46. package/dist/src/commands/workflows.js.map +1 -1
  47. package/dist/src/lib/api-client.d.ts +9 -24
  48. package/dist/src/lib/api-client.js +36 -33
  49. package/dist/src/lib/api-client.js.map +1 -1
  50. package/dist/src/lib/confirm-prompt.d.ts +25 -8
  51. package/dist/src/lib/confirm-prompt.js +44 -19
  52. package/dist/src/lib/confirm-prompt.js.map +1 -1
  53. package/dist/src/lib/generated-allowlist.d.ts +28 -0
  54. package/dist/src/lib/generated-allowlist.js +195 -0
  55. package/dist/src/lib/generated-allowlist.js.map +1 -0
  56. package/dist/src/lib/workflow-toml-validator.d.ts +26 -17
  57. package/dist/src/lib/workflow-toml-validator.js +52 -141
  58. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  59. package/package.json +4 -1
@@ -12,6 +12,7 @@ import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneS
12
12
  import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
13
13
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
14
14
  import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
15
+ import { confirmPrompt } from "../lib/confirm-prompt.js";
15
16
  import chalk from "chalk";
16
17
  function ensureDir(dirPath) {
17
18
  if (!existsSync(dirPath)) {
@@ -130,6 +131,74 @@ export function shouldPushExpandedFile(parsed, storedHash) {
130
131
  return true;
131
132
  return computeExpandedContentHash(parsed) !== storedHash;
132
133
  }
134
+ /**
135
+ * Model defaults for the *exact five* workflow-level fields that
136
+ * `serializeWorkflow` emits unconditionally (`sync.ts` workflow serializer:
137
+ * `perUserMaxRunning`, `perUserMaxQueued`, `dequeueOrder`,
138
+ * `requiresClientApply`, `syncCallable`). These mirror `models.yaml`
139
+ * (`WorkflowDefinition`): `perUserMaxRunning=4`, `perUserMaxQueued=100`,
140
+ * `dequeueOrder="fifo"`, `requiresClientApply=true`, `syncCallable=false`.
141
+ *
142
+ * This is deliberately NOT a generic models.yaml default importer — the model
143
+ * carries many defaults the serializer never writes (`perAppMax*`,
144
+ * `queueTtlSeconds`, …). Only the serializer-emitted surface matters for a
145
+ * content comparison, because the remote side is hashed via `serializeWorkflow`
146
+ * and therefore only ever contains these five.
147
+ */
148
+ const WORKFLOW_SERIALIZER_DEFAULTS = {
149
+ perUserMaxRunning: 4,
150
+ perUserMaxQueued: 100,
151
+ dequeueOrder: "fifo",
152
+ requiresClientApply: true,
153
+ syncCallable: false,
154
+ };
155
+ /**
156
+ * Normalize a parsed workflow TOML object so that any of the five
157
+ * serializer-emitted fields the *local* file omits are filled in with the
158
+ * model default. Used by `diff` so a hand-authored workflow that omits e.g.
159
+ * `perUserMaxRunning` compares equal to a server that defaulted it to 4
160
+ * (the #1175 false-positive guard) — while a server value that was
161
+ * *explicitly set* to a non-default (e.g. 8) still shows as Modified.
162
+ *
163
+ * Returns a shallow clone with a normalized `workflow` table; the input is
164
+ * not mutated. Non-workflow TOML (no `workflow` table) is returned unchanged.
165
+ */
166
+ export function normalizeWorkflowTomlDefaults(parsed) {
167
+ if (!parsed || typeof parsed !== "object" || !parsed.workflow) {
168
+ return parsed;
169
+ }
170
+ const workflow = { ...parsed.workflow };
171
+ for (const [field, defaultValue] of Object.entries(WORKFLOW_SERIALIZER_DEFAULTS)) {
172
+ if (workflow[field] === undefined || workflow[field] === null) {
173
+ workflow[field] = defaultValue;
174
+ }
175
+ }
176
+ return { ...parsed, workflow };
177
+ }
178
+ /**
179
+ * Canonical content hash of a workflow TOML for `diff`'s content comparison.
180
+ * Applies `normalizeWorkflowTomlDefaults` first so omitted-vs-defaulted fields
181
+ * don't spuriously diff, then runs the same `computeExpandedContentHash` that
182
+ * pull stores and push compares. Both the local file and the
183
+ * `serializeWorkflow`-produced remote form flow through this single function,
184
+ * so the two sides are normalized identically by construction.
185
+ */
186
+ export function hashWorkflowTomlForDiff(parsed) {
187
+ return computeExpandedContentHash(normalizeWorkflowTomlDefaults(parsed));
188
+ }
189
+ /**
190
+ * Build the canonical content hash for a *server* workflow (as returned by
191
+ * `getWorkflow` + active `getWorkflowConfig`), mirroring exactly what a fresh
192
+ * `sync pull` would write to disk. Serializes via `serializeWorkflow`, parses
193
+ * the resulting TOML (remote serialized TOML never carries `include`s, so a
194
+ * plain `TOML.parse` is sufficient — no fragment path needed), then hashes
195
+ * through `hashWorkflowTomlForDiff` so it lines up with the local-file hash.
196
+ */
197
+ export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
198
+ const serialized = serializeWorkflow(workflow, draft, configs || []);
199
+ const parsed = TOML.parse(serialized);
200
+ return hashWorkflowTomlForDiff(parsed);
201
+ }
133
202
  // TOML serialization helpers
134
203
  function serializeAppSettings(settings) {
135
204
  const data = {
@@ -260,7 +329,13 @@ function serializeBlobBucket(bucket) {
260
329
  name: bucket.name,
261
330
  description: bucket.description || undefined,
262
331
  ttlTier: bucket.ttlTier,
263
- accessPolicy: bucket.accessPolicy,
332
+ // #1020: `preset` is the honest key; a custom bucket carries ruleSetId
333
+ // instead. Omit preset for custom buckets (preset === "custom").
334
+ preset: bucket.preset && bucket.preset !== "custom"
335
+ ? bucket.preset
336
+ : bucket.ruleSetId
337
+ ? undefined
338
+ : bucket.preset,
264
339
  },
265
340
  };
266
341
  if (bucket.ruleSetId) {
@@ -313,6 +388,9 @@ function serializeWorkflow(workflow, draft, configs) {
313
388
  status: workflow.status,
314
389
  activeConfigName: activeConfigName,
315
390
  accessRule: workflow.accessRule || undefined,
391
+ // #1081 — workflow principal mode. Emit only when set so a fresh
392
+ // pull → push round-trips it without writing a noisy `runAs = ""`.
393
+ runAs: workflow.runAs || undefined,
316
394
  perUserMaxRunning: workflow.perUserMaxRunning,
317
395
  perUserMaxQueued: workflow.perUserMaxQueued,
318
396
  dequeueOrder: workflow.dequeueOrder,
@@ -734,56 +812,6 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
734
812
  }
735
813
  return { scriptEntities, count: items.length };
736
814
  }
737
- /**
738
- * Issue #973 — format the "stale referencing workflows" warning for a script
739
- * push. Pure (no I/O) so it can be unit-tested directly.
740
- *
741
- * `staleWorkflows` is the server's report of active workflows whose frozen
742
- * snapshot still pins a DIFFERENT (old) body hash for `scriptName` — i.e. the
743
- * workflows that will keep running the previous script body until republished.
744
- * Returns `null` when nothing is stale (no warning to print). When `dryRun` is
745
- * true the wording reflects that nothing was mutated yet.
746
- */
747
- export function formatStaleWorkflowsWarning(scriptName, staleWorkflows, dryRun = false) {
748
- if (!staleWorkflows || staleWorkflows.length === 0)
749
- return null;
750
- const labels = staleWorkflows.map((w) => w.workflowKey || w.name || "(unknown workflow)");
751
- const count = labels.length;
752
- const noun = count === 1 ? "workflow" : "workflows";
753
- const verb = dryRun ? "would still run" : "still run";
754
- return (`Script "${scriptName}" ${dryRun ? "would be updated" : "updated"}, but ` +
755
- `${count} ${noun} ${verb} the previous body until re-pushed: ` +
756
- `${labels.join(", ")}. ` +
757
- `Re-push (or republish) ${count === 1 ? "it" : "them"} to pick up the change ` +
758
- `(the run path reads each workflow's frozen snapshot, not the live script).`);
759
- }
760
- /**
761
- * Issue #973 — query the server for active workflows left stale by a script
762
- * push and print a warning naming them. Best-effort: a missing route (older
763
- * server) or any transport error is swallowed so it never fails the sync.
764
- *
765
- * `contentHash` is the body hash being pushed (the server's authoritative hash
766
- * after a real push, or the locally-computed would-be hash for `--dry-run`).
767
- */
768
- async function warnStaleWorkflowsForScript(client, appId, scriptId, contentHash, dryRun) {
769
- let report;
770
- try {
771
- report = await client.getStaleWorkflowsForScript(appId, scriptId, contentHash);
772
- }
773
- catch {
774
- // Older server without the route, or a transient failure — staleness
775
- // info is advisory, so we stay silent rather than break the push.
776
- return;
777
- }
778
- const message = formatStaleWorkflowsWarning(report.scriptName, report.staleWorkflows, dryRun);
779
- if (message) {
780
- warn(` ${message}`);
781
- }
782
- }
783
- /** SHA-256 hex of a string body, matching the server's script `contentHash`. */
784
- function sha256HexString(body) {
785
- return createHash("sha256").update(body, "utf-8").digest("hex");
786
- }
787
815
  async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
788
816
  let testCases;
789
817
  try {
@@ -2273,8 +2301,29 @@ Directory Structure:
2273
2301
  continue;
2274
2302
  }
2275
2303
  if (existingId) {
2276
- // Blob buckets don't have an update API - skip if already exists
2277
- info(` Blob bucket already exists, skipping: ${key}`);
2304
+ // #1020 (D8): idempotently apply preset/ruleSet/name/description
2305
+ // changes via PATCH. bucketKey/ttlTier are immutable and not sent.
2306
+ const updatePayload = {};
2307
+ if (bucket.preset)
2308
+ updatePayload.preset = bucket.preset;
2309
+ else if (bucket.accessPolicy)
2310
+ updatePayload.accessPolicy = bucket.accessPolicy;
2311
+ if (bucket.ruleSetId)
2312
+ updatePayload.ruleSetId = bucket.ruleSetId;
2313
+ if (bucket.name)
2314
+ updatePayload.name = bucket.name;
2315
+ if (bucket.description !== undefined)
2316
+ updatePayload.description = bucket.description || null;
2317
+ changes.push({ type: "blob-bucket", action: "update", key });
2318
+ if (!options.dryRun) {
2319
+ try {
2320
+ await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
2321
+ info(` Updated blob bucket: ${key}`);
2322
+ }
2323
+ catch (err) {
2324
+ warn(` Failed to update blob bucket ${key}: ${String(err?.message || err)}`);
2325
+ }
2326
+ }
2278
2327
  if (syncState?.entities?.blobBuckets?.[key]) {
2279
2328
  syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
2280
2329
  }
@@ -2284,8 +2333,11 @@ Directory Structure:
2284
2333
  bucketKey: key,
2285
2334
  name: bucket.name || key,
2286
2335
  ttlTier: bucket.ttlTier,
2287
- accessPolicy: bucket.accessPolicy,
2288
2336
  };
2337
+ if (bucket.preset)
2338
+ payload.preset = bucket.preset;
2339
+ else if (bucket.accessPolicy)
2340
+ payload.accessPolicy = bucket.accessPolicy;
2289
2341
  if (bucket.description)
2290
2342
  payload.description = bucket.description;
2291
2343
  if (bucket.ruleSetId)
@@ -2578,7 +2630,10 @@ Directory Structure:
2578
2630
  changes.push({ type: "script", action: "update", key: name });
2579
2631
  if (!options.dryRun) {
2580
2632
  try {
2581
- const updated = await client.updateScript(resolvedAppId, existingId, { body });
2633
+ // #1000 push a new ScriptConfig + activate it. Referencing
2634
+ // workflows pick up the new body on their next run (no
2635
+ // fan-out, no re-push needed).
2636
+ await client.pushScriptBody(resolvedAppId, existingId, body);
2582
2637
  info(` Updated script: ${name}`);
2583
2638
  if (syncState) {
2584
2639
  if (!syncState.entities.scripts) {
@@ -2586,24 +2641,16 @@ Directory Structure:
2586
2641
  }
2587
2642
  syncState.entities.scripts[name] = {
2588
2643
  id: existingId,
2589
- modifiedAt: updated?.modifiedAt ||
2590
- syncState.entities.scripts[name]?.modifiedAt ||
2644
+ modifiedAt: syncState.entities.scripts[name]?.modifiedAt ||
2591
2645
  new Date().toISOString(),
2592
2646
  contentHash: computeFileHash(filePath),
2593
2647
  };
2594
2648
  }
2595
- // Issue #973 — warn if active workflows still pin the OLD
2596
- // body. Use the server's authoritative post-push hash.
2597
- await warnStaleWorkflowsForScript(client, resolvedAppId, existingId, updated?.contentHash || sha256HexString(body), false);
2598
2649
  }
2599
2650
  catch (err) {
2600
2651
  throw wrapEntityError(err, "update", "script", name);
2601
2652
  }
2602
2653
  }
2603
- else {
2604
- // Dry-run: surface the would-be stale set without mutating.
2605
- await warnStaleWorkflowsForScript(client, resolvedAppId, existingId, sha256HexString(body), true);
2606
- }
2607
2654
  }
2608
2655
  else {
2609
2656
  // No id in local state — could be brand new OR could be
@@ -2616,7 +2663,7 @@ Directory Structure:
2616
2663
  changes.push({ type: "script", action: "update", key: name });
2617
2664
  if (!options.dryRun) {
2618
2665
  try {
2619
- const updated = await client.updateScript(resolvedAppId, match.scriptId, { body });
2666
+ await client.pushScriptBody(resolvedAppId, match.scriptId, body);
2620
2667
  info(` Updated script: ${name} (adopted from server)`);
2621
2668
  if (syncState) {
2622
2669
  if (!syncState.entities.scripts) {
@@ -2624,21 +2671,15 @@ Directory Structure:
2624
2671
  }
2625
2672
  syncState.entities.scripts[name] = {
2626
2673
  id: match.scriptId,
2627
- modifiedAt: updated?.modifiedAt || new Date().toISOString(),
2674
+ modifiedAt: new Date().toISOString(),
2628
2675
  contentHash: computeFileHash(filePath),
2629
2676
  };
2630
2677
  }
2631
- // Issue #973 — same staleness warning on the adopt path.
2632
- await warnStaleWorkflowsForScript(client, resolvedAppId, match.scriptId, updated?.contentHash || sha256HexString(body), false);
2633
2678
  }
2634
2679
  catch (err) {
2635
2680
  throw wrapEntityError(err, "update", "script", name);
2636
2681
  }
2637
2682
  }
2638
- else {
2639
- // Dry-run on the adopt path.
2640
- await warnStaleWorkflowsForScript(client, resolvedAppId, match.scriptId, sha256HexString(body), true);
2641
- }
2642
2683
  }
2643
2684
  else {
2644
2685
  changes.push({ type: "script", action: "create", key: name });
@@ -2738,6 +2779,10 @@ Directory Structure:
2738
2779
  description: workflow.description,
2739
2780
  status: workflow.status,
2740
2781
  accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
2782
+ // #1081 — workflow principal mode. Absent key → undefined
2783
+ // (server leaves it untouched); explicit value/empty is
2784
+ // forwarded so it round-trips.
2785
+ runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
2741
2786
  perUserMaxRunning: workflow.perUserMaxRunning,
2742
2787
  perUserMaxQueued: workflow.perUserMaxQueued,
2743
2788
  dequeueOrder: workflow.dequeueOrder,
@@ -2832,6 +2877,8 @@ Directory Structure:
2832
2877
  inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
2833
2878
  outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
2834
2879
  accessRule: workflow.accessRule || undefined,
2880
+ // #1081 — workflow principal mode (absent → server default).
2881
+ runAs: workflow.runAs || undefined,
2835
2882
  perUserMaxRunning: workflow.perUserMaxRunning,
2836
2883
  perUserMaxQueued: workflow.perUserMaxQueued,
2837
2884
  dequeueOrder: workflow.dequeueOrder,
@@ -2946,6 +2993,15 @@ Directory Structure:
2946
2993
  `\nAborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
2947
2994
  }
2948
2995
  const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
2996
+ // Issue #915 (defect b): track whether the apply path rejected any
2997
+ // op for THIS file via the schema gate (OperationRefError /
2998
+ // SchemaRequiredError). On a non-transactional fresh-type push the
2999
+ // db-type lands but the bad op is blocked and the loop continues; if
3000
+ // we then wrote the file-level contentHash, the next push's
3001
+ // `shouldPushFile` would short-circuit and the missing op would be
3002
+ // invisible drift. When this flag is set we skip the contentHash
3003
+ // write so the drift stays visible and a corrected re-push converges.
3004
+ let fileHadGateRejectedOp = false;
2949
3005
  // Skip if file hasn't changed since last sync
2950
3006
  if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
2951
3007
  skippedCount++;
@@ -3032,8 +3088,7 @@ Directory Structure:
3032
3088
  }
3033
3089
  // 2. Op-edit gate (op create/update dry-run) for every op in the
3034
3090
  // TOML. The server runs `runSchemaGate` and short-circuits
3035
- // before persisting. Skipped for a fresh type (no schema on the
3036
- // server yet → gate is a no-op anyway).
3091
+ // before persisting.
3037
3092
  //
3038
3093
  // The op gate is run against the schema THIS push is landing
3039
3094
  // (`schemaOverride`), not the stale stored schema — the real
@@ -3043,11 +3098,36 @@ Directory Structure:
3043
3098
  // a schema landed earlier in the same push"). When the push
3044
3099
  // isn't changing the schema, leave the override unset so the
3045
3100
  // server uses the stored schema.
3046
- if (existingEntry) {
3047
- const validateUpdateData = computeTypeUpdateData();
3048
- const proposedSchema = "schema" in validateUpdateData
3049
- ? validateUpdateData.schema
3050
- : undefined;
3101
+ //
3102
+ // Issue #915 (defect a): this gate now runs for FRESH types too
3103
+ // (previously guarded by `if (existingEntry)`). For a brand-new
3104
+ // db-type the server has no stored config, so we gate the op
3105
+ // against the PROPOSED local schema via `schemaOverride`. The
3106
+ // server's op-create dry-run accepts a missing type when a
3107
+ // `schemaOverride` is present (see
3108
+ // database-type-operations-controller.ts). A fresh schemaless
3109
+ // type (#666 bootstrap) sends an empty/absent override, and
3110
+ // `runSchemaGate` is a no-op without a schema — so that path
3111
+ // still succeeds.
3112
+ {
3113
+ const localHasSchema = typeof typeConfig.schema === "string" &&
3114
+ typeConfig.schema.trim().length > 0;
3115
+ let proposedSchema;
3116
+ if (existingEntry) {
3117
+ const validateUpdateData = computeTypeUpdateData();
3118
+ proposedSchema =
3119
+ "schema" in validateUpdateData
3120
+ ? validateUpdateData.schema
3121
+ : undefined;
3122
+ }
3123
+ else {
3124
+ // Fresh type: the proposed schema is whatever the local TOML
3125
+ // declares. A schemaless fresh type (no `[models.*]`) sends an
3126
+ // empty-string override so the server takes the no-schema =
3127
+ // no-op gate path rather than falling back to a (nonexistent)
3128
+ // stored config — keeping the #666 bootstrap convergent.
3129
+ proposedSchema = localHasSchema ? typeConfig.schema : "";
3130
+ }
3051
3131
  for (const op of operations) {
3052
3132
  const existingOp = existingOpsForType[op.name];
3053
3133
  opGateContext = {
@@ -3071,7 +3151,20 @@ Directory Structure:
3071
3151
  access: op.access,
3072
3152
  definition: op.definition,
3073
3153
  params: op.params,
3074
- }, { dryRun: true, schemaOverride: proposedSchema });
3154
+ }, {
3155
+ dryRun: true,
3156
+ schemaOverride: proposedSchema,
3157
+ // Issue #915 (defect a, follow-up): for a fresh type the
3158
+ // server has no stored config to read `defaultAccess`
3159
+ // from, so an op that omits `access` to inherit the
3160
+ // TOML-declared type-level `defaultAccess` would be
3161
+ // falsely rejected. Thread the proposed value so the
3162
+ // fresh-type dry-run gates the same way the real push
3163
+ // (which lands the type first) will.
3164
+ defaultAccess: typeof typeConfig.defaultAccess === "string"
3165
+ ? typeConfig.defaultAccess
3166
+ : undefined,
3167
+ });
3075
3168
  }
3076
3169
  }
3077
3170
  // Every op gate passed; clear the context so a later (non-op)
@@ -3563,6 +3656,10 @@ Directory Structure:
3563
3656
  key: `${dbType}/${op.name}`,
3564
3657
  message: err.message,
3565
3658
  });
3659
+ // Issue #915 (defect b): a gate-rejected op means this
3660
+ // file's state did NOT fully land — don't poison the
3661
+ // content hash (see the contentHash write below).
3662
+ fileHadGateRejectedOp = true;
3566
3663
  }
3567
3664
  else if (err instanceof OperationRefError) {
3568
3665
  schemaErrors.opRefs.push({
@@ -3571,6 +3668,7 @@ Directory Structure:
3571
3668
  refs: err.refs,
3572
3669
  message: err.message,
3573
3670
  });
3671
+ fileHadGateRejectedOp = true;
3574
3672
  }
3575
3673
  else {
3576
3674
  throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
@@ -3608,6 +3706,10 @@ Directory Structure:
3608
3706
  key: `${dbType}/${op.name}`,
3609
3707
  message: err.message,
3610
3708
  });
3709
+ // Issue #915 (defect b): a gate-rejected op means this
3710
+ // file's state did NOT fully land — don't poison the
3711
+ // content hash (see the contentHash write below).
3712
+ fileHadGateRejectedOp = true;
3611
3713
  }
3612
3714
  else if (err instanceof OperationRefError) {
3613
3715
  schemaErrors.opRefs.push({
@@ -3616,6 +3718,7 @@ Directory Structure:
3616
3718
  refs: err.refs,
3617
3719
  message: err.message,
3618
3720
  });
3721
+ fileHadGateRejectedOp = true;
3619
3722
  }
3620
3723
  else {
3621
3724
  throw wrapEntityError(err, "create", "operation", `${dbType}/${op.name}`);
@@ -3747,9 +3850,27 @@ Directory Structure:
3747
3850
  // file. Recompute here so a truly-unchanged subsequent push skips
3748
3851
  // the whole file via `shouldPushFile`. `--force` bypasses that
3749
3852
  // skip, so forced re-pushes are unaffected.
3853
+ //
3854
+ // Issue #915 (defect b): when the gate rejected an op for this
3855
+ // file, do NOT record a matching content hash. The db-type itself
3856
+ // may have been created (the push is non-transactional), but the
3857
+ // blocked op never landed — a matching hash would make
3858
+ // `shouldPushFile` short-circuit the next push and hide the missing
3859
+ // op as "in sync". Clear the hash instead of writing it: an empty
3860
+ // hash forces `shouldPushFile` to re-evaluate the file, so a
3861
+ // subsequent `sync push --dry-run` still surfaces the op as drift
3862
+ // and a corrected re-push converges without manual file edits. We
3863
+ // clear (rather than merely skip) so the fresh-type create path's
3864
+ // earlier optimistic hash write (see `createDatabaseTypeConfig`
3865
+ // above) is also invalidated.
3750
3866
  if (!options.dryRun && syncState?.entities?.databaseTypes?.[dbType]) {
3751
- syncState.entities.databaseTypes[dbType].contentHash =
3752
- computeFileHash(filePath);
3867
+ if (fileHadGateRejectedOp) {
3868
+ syncState.entities.databaseTypes[dbType].contentHash = "";
3869
+ }
3870
+ else {
3871
+ syncState.entities.databaseTypes[dbType].contentHash =
3872
+ computeFileHash(filePath);
3873
+ }
3753
3874
  }
3754
3875
  }
3755
3876
  }
@@ -4167,12 +4288,17 @@ Directory Structure:
4167
4288
  localPrompts.add(key);
4168
4289
  }
4169
4290
  }
4291
+ // #1175: capture the parsed (fragment-expanded) local workflow TOML
4292
+ // per key so the content-aware comparison below can hash it without a
4293
+ // second read.
4294
+ const localWorkflowParsed = new Map();
4170
4295
  const workflowsDir = join(configDir, "workflows");
4171
4296
  if (existsSync(workflowsDir)) {
4172
4297
  for (const file of readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))) {
4173
4298
  const tomlData = parseTomlFile(join(workflowsDir, file));
4174
4299
  const key = tomlData.workflow?.key || basename(file, ".toml");
4175
4300
  localWorkflows.add(key);
4301
+ localWorkflowParsed.set(key, tomlData);
4176
4302
  }
4177
4303
  }
4178
4304
  const emailTemplatesDirPath = join(configDir, "email-templates");
@@ -4255,14 +4381,69 @@ Directory Structure:
4255
4381
  differences.push({ type: "prompt", key, status: "remote only" });
4256
4382
  }
4257
4383
  }
4258
- // Workflows
4384
+ // Workflows — #1175: content-aware comparison.
4385
+ //
4386
+ // For workflows present on BOTH sides we no longer stop at key
4387
+ // existence. We fetch the full remote workflow (mirroring `sync pull`:
4388
+ // `getWorkflow` + active `getWorkflowConfig` for steps), serialize it
4389
+ // through the SAME `serializeWorkflow` pull writes to disk, and hash
4390
+ // both sides through the canonical pull/push hash. A difference is
4391
+ // reported as `modified`, which `diff` frames as a preview of
4392
+ // `sync pull` (pull would rewrite the local file to match running
4393
+ // state). It is NOT framed as "push would send": push *preserves*
4394
+ // omitted remote values (sync.ts updateWorkflow), so a remote
4395
+ // non-default vs a local omission shows Modified even though push
4396
+ // wouldn't change it.
4397
+ //
4398
+ // Default-normalization (see `hashWorkflowTomlForDiff`) means a local
4399
+ // file that omits perUserMaxRunning/Queued etc. reads as Synced when
4400
+ // the server holds the model defaults (4/100/…), and only an
4401
+ // explicitly-set non-default server value reads as Modified.
4402
+ const remoteWorkflowIds = new Map(workflowItems.map((w) => [w.workflowKey, w.workflowId]));
4259
4403
  for (const key of localWorkflows) {
4260
4404
  if (!remoteWorkflows.has(key)) {
4261
4405
  differences.push({ type: "workflow", key, status: "local only" });
4406
+ continue;
4262
4407
  }
4263
- else {
4264
- differences.push({ type: "workflow", key, status: "exists" });
4408
+ // Present on both sides → compare content.
4409
+ const workflowId = remoteWorkflowIds.get(key);
4410
+ const localParsed = localWorkflowParsed.get(key);
4411
+ let status = "exists";
4412
+ let hint;
4413
+ try {
4414
+ if (!workflowId || !localParsed) {
4415
+ throw new Error("missing workflow id or local parse");
4416
+ }
4417
+ const remoteData = await client.getWorkflow(resolvedAppId, workflowId);
4418
+ // Fetch active config steps (mirrors pull at the workflows hydrate
4419
+ // block). Failure here is non-fatal — we degrade to key-only.
4420
+ const activeConfigId = remoteData.workflow?.activeConfigId;
4421
+ if (activeConfigId && Array.isArray(remoteData.configs)) {
4422
+ try {
4423
+ const activeConfig = await client.getWorkflowConfig(resolvedAppId, workflowId, activeConfigId);
4424
+ const idx = remoteData.configs.findIndex((c) => c.configId === activeConfigId);
4425
+ if (idx >= 0 && activeConfig) {
4426
+ remoteData.configs[idx] = activeConfig;
4427
+ }
4428
+ }
4429
+ catch {
4430
+ // Ignore — fall back to whatever steps the workflow GET carried.
4431
+ }
4432
+ }
4433
+ const remoteHash = hashRemoteWorkflowForDiff(remoteData.workflow, remoteData.draft, remoteData.configs || []);
4434
+ const localHash = hashWorkflowTomlForDiff(localParsed);
4435
+ if (remoteHash !== localHash) {
4436
+ status = "modified";
4437
+ hint = "run `sync pull` to rewrite the local TOML to match running state";
4438
+ }
4265
4439
  }
4440
+ catch {
4441
+ // Per-workflow fetch failure must not abort the whole diff. Degrade
4442
+ // gracefully to a key-only "Synced (content not compared)" caveat.
4443
+ status = "exists";
4444
+ hint = "content not compared (fetch failed)";
4445
+ }
4446
+ differences.push({ type: "workflow", key, status, hint });
4266
4447
  }
4267
4448
  for (const key of remoteWorkflows) {
4268
4449
  if (!localWorkflows.has(key)) {
@@ -4333,6 +4514,7 @@ Directory Structure:
4333
4514
  divider();
4334
4515
  const localOnly = differences.filter((d) => d.status === "local only");
4335
4516
  const remoteOnly = differences.filter((d) => d.status === "remote only");
4517
+ const modified = differences.filter((d) => d.status === "modified");
4336
4518
  const existing = differences.filter((d) => d.status === "exists");
4337
4519
  if (localOnly.length > 0) {
4338
4520
  info("Local only (will be created on push):");
@@ -4348,10 +4530,22 @@ Directory Structure:
4348
4530
  }
4349
4531
  console.log();
4350
4532
  }
4533
+ // #1175: workflows whose deployed content differs from the local TOML.
4534
+ // Framed as a preview of `sync pull` — pull would rewrite these files
4535
+ // to match what's actually running (NOT "push would send").
4536
+ if (modified.length > 0) {
4537
+ warn("Modified — `sync pull` would rewrite these to match running state:");
4538
+ for (const d of modified) {
4539
+ const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
4540
+ console.log(` ${chalk.yellow("~")} ${d.type}: ${d.key}${hint}`);
4541
+ }
4542
+ console.log();
4543
+ }
4351
4544
  if (existing.length > 0) {
4352
4545
  info("Synced:");
4353
4546
  for (const d of existing) {
4354
- console.log(` ${chalk.dim("=")} ${d.type}: ${d.key}`);
4547
+ const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
4548
+ console.log(` ${chalk.dim("=")} ${d.type}: ${d.key}${hint}`);
4355
4549
  }
4356
4550
  }
4357
4551
  // Show test case differences
@@ -4374,6 +4568,7 @@ Directory Structure:
4374
4568
  divider();
4375
4569
  keyValue("Local only", localOnly.length);
4376
4570
  keyValue("Remote only", remoteOnly.length);
4571
+ keyValue("Modified", modified.length);
4377
4572
  keyValue("Synced", existing.length);
4378
4573
  if (testCaseDiffs.length > 0) {
4379
4574
  keyValue("Test Cases (local only)", tcLocalOnly.length);
@@ -4572,15 +4767,14 @@ Directory Structure:
4572
4767
  warn(`You have uncommitted git changes under ${configDir}. ` +
4573
4768
  "Reverting will overwrite them.");
4574
4769
  }
4575
- const inquirer = await import("inquirer");
4576
- const { confirm } = await inquirer.default.prompt([
4577
- {
4578
- type: "confirm",
4579
- name: "confirm",
4580
- message: `Restore snapshot ${target.id} into ${configDir}? This overwrites the current sync directory.`,
4581
- default: false,
4582
- },
4583
- ]);
4770
+ let confirm;
4771
+ try {
4772
+ confirm = await confirmPrompt(`Restore snapshot ${target.id} into ${configDir}? This overwrites the current sync directory.`);
4773
+ }
4774
+ catch (err) {
4775
+ error(err.message);
4776
+ process.exit(1);
4777
+ }
4584
4778
  if (!confirm) {
4585
4779
  info("Cancelled.");
4586
4780
  return;