primitive-admin 1.0.49 → 1.0.50

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 (120) hide show
  1. package/README.md +102 -2
  2. package/assets/skill/skills/primitive-platform/SKILL.md +85 -30
  3. package/dist/bin/primitive.d.ts +2 -0
  4. package/dist/bin/primitive.js +66 -1
  5. package/dist/bin/primitive.js.map +1 -1
  6. package/dist/src/commands/admins.d.ts +2 -0
  7. package/dist/src/commands/analytics.d.ts +2 -0
  8. package/dist/src/commands/apps.d.ts +2 -0
  9. package/dist/src/commands/apps.js +20 -0
  10. package/dist/src/commands/apps.js.map +1 -1
  11. package/dist/src/commands/auth.d.ts +2 -0
  12. package/dist/src/commands/blob-buckets.d.ts +2 -0
  13. package/dist/src/commands/catalog.d.ts +2 -0
  14. package/dist/src/commands/collection-type-configs.d.ts +2 -0
  15. package/dist/src/commands/collections.d.ts +2 -0
  16. package/dist/src/commands/comparisons.d.ts +2 -0
  17. package/dist/src/commands/cron-triggers.d.ts +2 -0
  18. package/dist/src/commands/cron-triggers.js +8 -15
  19. package/dist/src/commands/cron-triggers.js.map +1 -1
  20. package/dist/src/commands/database-types.d.ts +2 -0
  21. package/dist/src/commands/databases.d.ts +2 -0
  22. package/dist/src/commands/databases.js +31 -0
  23. package/dist/src/commands/databases.js.map +1 -1
  24. package/dist/src/commands/documents.d.ts +2 -0
  25. package/dist/src/commands/email-templates.d.ts +2 -0
  26. package/dist/src/commands/env.d.ts +12 -0
  27. package/dist/src/commands/group-type-configs.d.ts +2 -0
  28. package/dist/src/commands/groups.d.ts +2 -0
  29. package/dist/src/commands/guides.d.ts +84 -0
  30. package/dist/src/commands/guides.js +201 -24
  31. package/dist/src/commands/guides.js.map +1 -1
  32. package/dist/src/commands/init.d.ts +17 -0
  33. package/dist/src/commands/init.js +63 -25
  34. package/dist/src/commands/init.js.map +1 -1
  35. package/dist/src/commands/integrations.d.ts +2 -0
  36. package/dist/src/commands/integrations.js +22 -5
  37. package/dist/src/commands/integrations.js.map +1 -1
  38. package/dist/src/commands/llm.d.ts +2 -0
  39. package/dist/src/commands/prompts.d.ts +2 -0
  40. package/dist/src/commands/rule-sets.d.ts +2 -0
  41. package/dist/src/commands/secrets.d.ts +2 -0
  42. package/dist/src/commands/skill.d.ts +2 -0
  43. package/dist/src/commands/sync.d.ts +113 -0
  44. package/dist/src/commands/sync.js +366 -12
  45. package/dist/src/commands/sync.js.map +1 -1
  46. package/dist/src/commands/tokens.d.ts +2 -0
  47. package/dist/src/commands/tokens.js +104 -1
  48. package/dist/src/commands/tokens.js.map +1 -1
  49. package/dist/src/commands/users.d.ts +2 -0
  50. package/dist/src/commands/waitlist.d.ts +2 -0
  51. package/dist/src/commands/waitlist.js +1 -1
  52. package/dist/src/commands/waitlist.js.map +1 -1
  53. package/dist/src/commands/webhooks.d.ts +2 -0
  54. package/dist/src/commands/workflows.d.ts +49 -0
  55. package/dist/src/commands/workflows.js +74 -21
  56. package/dist/src/commands/workflows.js.map +1 -1
  57. package/dist/src/lib/api-client.d.ts +1244 -0
  58. package/dist/src/lib/api-client.js +30 -0
  59. package/dist/src/lib/api-client.js.map +1 -1
  60. package/dist/src/lib/auth-flow.d.ts +8 -0
  61. package/dist/src/lib/cli-manifest.d.ts +60 -0
  62. package/dist/src/lib/cli-manifest.js +70 -0
  63. package/dist/src/lib/cli-manifest.js.map +1 -0
  64. package/dist/src/lib/config.d.ts +37 -0
  65. package/dist/src/lib/confirm-prompt.d.ts +66 -0
  66. package/dist/src/lib/confirm-prompt.js +85 -0
  67. package/dist/src/lib/confirm-prompt.js.map +1 -0
  68. package/dist/src/lib/constants.d.ts +2 -0
  69. package/dist/src/lib/crash-handlers.d.ts +20 -0
  70. package/dist/src/lib/crash-handlers.js +49 -0
  71. package/dist/src/lib/crash-handlers.js.map +1 -0
  72. package/dist/src/lib/credentials-store.d.ts +79 -0
  73. package/dist/src/lib/csv.d.ts +48 -0
  74. package/dist/src/lib/db-codegen/dbFingerprint.d.ts +10 -0
  75. package/dist/src/lib/db-codegen/dbGenerator.d.ts +111 -0
  76. package/dist/src/lib/db-codegen/dbNaming.d.ts +45 -0
  77. package/dist/src/lib/db-codegen/dbTemplates.d.ts +97 -0
  78. package/dist/src/lib/db-codegen/dbTemplates.js +31 -10
  79. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  80. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +78 -0
  81. package/dist/src/lib/db-codegen/dbTsTypes.js +2 -2
  82. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  83. package/dist/src/lib/env-resolver.d.ts +62 -0
  84. package/dist/src/lib/fetch.d.ts +5 -0
  85. package/dist/src/lib/init-config.d.ts +46 -0
  86. package/dist/src/lib/init-config.js +7 -0
  87. package/dist/src/lib/init-config.js.map +1 -1
  88. package/dist/src/lib/migration-nag.d.ts +49 -0
  89. package/dist/src/lib/output.d.ts +49 -0
  90. package/dist/src/lib/output.js +25 -1
  91. package/dist/src/lib/output.js.map +1 -1
  92. package/dist/src/lib/paginate.d.ts +33 -0
  93. package/dist/src/lib/project-config.d.ts +97 -0
  94. package/dist/src/lib/refresh-admin-credentials.d.ts +65 -0
  95. package/dist/src/lib/resolve-platform.d.ts +45 -0
  96. package/dist/src/lib/resolve-platform.js +43 -0
  97. package/dist/src/lib/resolve-platform.js.map +1 -0
  98. package/dist/src/lib/skill-installer.d.ts +23 -0
  99. package/dist/src/lib/snapshots.d.ts +99 -0
  100. package/dist/src/lib/snapshots.js +357 -0
  101. package/dist/src/lib/snapshots.js.map +1 -0
  102. package/dist/src/lib/sync-paths.d.ts +72 -0
  103. package/dist/src/lib/sync-paths.js +29 -1
  104. package/dist/src/lib/sync-paths.js.map +1 -1
  105. package/dist/src/lib/template.d.ts +93 -0
  106. package/dist/src/lib/token-inject.d.ts +56 -0
  107. package/dist/src/lib/token-inject.js +204 -0
  108. package/dist/src/lib/token-inject.js.map +1 -0
  109. package/dist/src/lib/toml-database-config.d.ts +132 -0
  110. package/dist/src/lib/toml-params-validator.d.ts +95 -0
  111. package/dist/src/lib/version-check.d.ts +10 -0
  112. package/dist/src/lib/workflow-fragments.d.ts +41 -0
  113. package/dist/src/lib/workflow-toml-validator.d.ts +86 -0
  114. package/dist/src/lib/workflow-toml-validator.js +31 -1
  115. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  116. package/dist/src/types/index.d.ts +513 -0
  117. package/dist/src/validators.d.ts +64 -0
  118. package/dist/src/validators.js +63 -0
  119. package/dist/src/validators.js.map +1 -0
  120. package/package.json +7 -1
@@ -7,7 +7,8 @@ import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefEr
7
7
  import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
8
8
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
9
9
  import { getServerUrl, resolveAppId } from "../lib/config.js";
10
- import { resolveSyncDir, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
10
+ import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
11
+ import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
11
12
  import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
12
13
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
13
14
  import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
@@ -17,6 +18,19 @@ function ensureDir(dirPath) {
17
18
  mkdirSync(dirPath, { recursive: true });
18
19
  }
19
20
  }
21
+ /**
22
+ * Issue #974: when a type-config push newly declares fields `unique = true`
23
+ * and the type already has instances, the server returns a
24
+ * `staleInstanceWarning`. Surface it so the operator knows the existing
25
+ * instances still lack the index and how to back-provision them. The push
26
+ * itself still succeeded — this is informational, not a failure.
27
+ */
28
+ function printStaleInstanceWarning(updated) {
29
+ const w = updated?.staleInstanceWarning;
30
+ if (w && typeof w.message === "string") {
31
+ warn(` ${w.message}`);
32
+ }
33
+ }
20
34
  function loadSyncState(configDir) {
21
35
  const syncFile = join(configDir, ".primitive-sync.json");
22
36
  if (!existsSync(syncFile)) {
@@ -720,6 +734,56 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
720
734
  }
721
735
  return { scriptEntities, count: items.length };
722
736
  }
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
+ }
723
787
  async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
724
788
  let testCases;
725
789
  try {
@@ -1153,6 +1217,35 @@ Directory Structure:
1153
1217
  subscriptions: Array.isArray(subs) ? subs : [],
1154
1218
  };
1155
1219
  }));
1220
+ // Snapshot the pre-pull sync tree BEFORE we touch any local file
1221
+ // (issue #578, Phase 1). All server data has been fetched and
1222
+ // validated at this point — a failed pull already threw above, so we
1223
+ // never create empty snapshots — but no local file has been
1224
+ // overwritten yet. This is the proven copy-point: between the last
1225
+ // server fetch and the first directory mutation.
1226
+ //
1227
+ // Fail LOUD: if the snapshot can't be written, abort the pull BEFORE
1228
+ // the ensureDir below. Better to refuse a destructive pull than
1229
+ // perform it without a recoverable backup. No-op on a fresh/empty
1230
+ // sync dir (nothing to back up). Prune to 28 days after success.
1231
+ try {
1232
+ const snapshotsRoot = resolveSnapshotsRoot({
1233
+ appId: resolvedAppId,
1234
+ userDir: options.dir,
1235
+ });
1236
+ const snapshot = createSnapshot(configDir, snapshotsRoot);
1237
+ if (snapshot) {
1238
+ info(` Snapshot: ${snapshot.path}`);
1239
+ pruneSnapshots(snapshotsRoot, 28);
1240
+ }
1241
+ }
1242
+ catch (snapErr) {
1243
+ error(`Failed to create a pre-pull snapshot: ${snapErr?.message ?? snapErr}`);
1244
+ error("Aborting pull before any local file is modified. " +
1245
+ "Resolve the snapshot error (e.g. disk space / permissions) and retry, " +
1246
+ "or pass a writable --dir.");
1247
+ process.exit(1);
1248
+ }
1156
1249
  // Ensure directories exist
1157
1250
  ensureDir(configDir);
1158
1251
  ensureDir(join(configDir, "integrations"));
@@ -1660,6 +1753,82 @@ Directory Structure:
1660
1753
  const promptKeyToId = new Map();
1661
1754
  const promptConfigNameToId = new Map(); // key: "promptKey#configName"
1662
1755
  const workflowConfigNameToId = new Map(); // key: "workflowKey#configName"
1756
+ // ── Issue #976 (fix A): front-load ALL client-side TOML validation
1757
+ // before the first mutating call. Push is apply-as-you-go and processes
1758
+ // entities in a fixed sequence (cron triggers are created *before*
1759
+ // workflows are validated). Previously each entity's validation lived
1760
+ // inside its own apply loop and aborted via `process.exit(1)` — so a
1761
+ // known-bad sibling file (e.g. an invalid workflow TOML) could let an
1762
+ // earlier create (a cron trigger) be applied, then abort. The
1763
+ // `process.exit` also bypassed the save-on-failure catch below, so the
1764
+ // orphaned create never reached `.primitive-sync.json` and the retry
1765
+ // re-issued a CREATE that the server rejected with 409 forever.
1766
+ //
1767
+ // Running every validation up-front means a known-bad file aborts
1768
+ // BEFORE anything is created. We collect all errors (not just the first)
1769
+ // and THROW — so the single save-on-failure catch is the only exit path
1770
+ // (no more `process.exit` bypass class). The per-loop validators below
1771
+ // remain as defense-in-depth but are converted to throws too.
1772
+ const preflightValidationErrors = [];
1773
+ // Validate all workflow TOMLs (issue #685 misnested-header check).
1774
+ const preflightWorkflowsDir = join(configDir, "workflows");
1775
+ if (existsSync(preflightWorkflowsDir)) {
1776
+ for (const file of readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml"))) {
1777
+ const filePath = join(preflightWorkflowsDir, file);
1778
+ try {
1779
+ const tomlData = parseTomlFile(filePath);
1780
+ const tomlErrors = validateWorkflowToml(tomlData);
1781
+ if (tomlErrors.length > 0) {
1782
+ preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
1783
+ }
1784
+ }
1785
+ catch (err) {
1786
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
1787
+ }
1788
+ }
1789
+ }
1790
+ // Validate all database-type TOMLs (issue #803 subscription
1791
+ // access/accessRule conflict + issue #752 $params references).
1792
+ const preflightDbTypesDir = join(configDir, "database-types");
1793
+ if (existsSync(preflightDbTypesDir)) {
1794
+ for (const file of readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml"))) {
1795
+ const filePath = join(preflightDbTypesDir, file);
1796
+ let rawToml;
1797
+ let tomlData;
1798
+ try {
1799
+ rawToml = readFileSync(filePath, "utf-8");
1800
+ tomlData = parseTomlFile(filePath);
1801
+ }
1802
+ catch (err) {
1803
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
1804
+ continue;
1805
+ }
1806
+ let operations = [];
1807
+ try {
1808
+ ({ operations } = parseDatabaseTypeToml(tomlData));
1809
+ }
1810
+ catch (err) {
1811
+ if (err instanceof SubscriptionAccessKeyConflictError) {
1812
+ preflightValidationErrors.push(` ${file}: ${err.message}`);
1813
+ continue;
1814
+ }
1815
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
1816
+ continue;
1817
+ }
1818
+ const validation = validateOperations({
1819
+ filePath,
1820
+ rawToml,
1821
+ operations,
1822
+ });
1823
+ for (const e of validation.errors) {
1824
+ preflightValidationErrors.push(` ${formatIssue(e)}`);
1825
+ }
1826
+ }
1827
+ }
1828
+ if (preflightValidationErrors.length > 0) {
1829
+ throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
1830
+ preflightValidationErrors.join("\n"));
1831
+ }
1663
1832
  // Process app settings
1664
1833
  const appTomlPath = join(configDir, "app.toml");
1665
1834
  if (existsSync(appTomlPath)) {
@@ -2031,7 +2200,58 @@ Directory Structure:
2031
2200
  }
2032
2201
  }
2033
2202
  catch (err) {
2034
- throw wrapEntityError(err, "create", "cron trigger", key);
2203
+ // Issue #976 (fix B): idempotent create adopt-by-key on 409.
2204
+ // A cron trigger can be orphaned on the server (created by a
2205
+ // prior push that aborted before recording it in sync state,
2206
+ // a mid-apply crash, or an out-of-band create with the same
2207
+ // key). On retry this CREATE path then hits the
2208
+ // `triggerKeyPerApp` unique constraint and the server returns
2209
+ // 409 "A cron trigger with this key already exists". Rather
2210
+ // than hard-fail forever, look up the existing trigger by key
2211
+ // (the list endpoint is app-scoped, so every item is owned by
2212
+ // this app), verify the SAME triggerKey, adopt its id into
2213
+ // sync state, and re-issue as an UPDATE so the push converges.
2214
+ const msg = String(err?.message || err);
2215
+ const is409 = err?.statusCode === 409 || msg.includes("already exists");
2216
+ if (is409) {
2217
+ info(` Cron trigger already exists on server, adopting by key: ${key}`);
2218
+ let adoptedId;
2219
+ try {
2220
+ const { items } = await client.listCronTriggers(resolvedAppId);
2221
+ // Verify same key + app ownership: the list endpoint only
2222
+ // returns triggers for `resolvedAppId`, so a triggerKey
2223
+ // match is also an ownership match. Never overwrite an
2224
+ // unrelated resource — require an exact key equality.
2225
+ const existing = (items || []).find((t) => t?.triggerKey === key);
2226
+ if (existing?.triggerId) {
2227
+ adoptedId = existing.triggerId;
2228
+ }
2229
+ }
2230
+ catch (lookupErr) {
2231
+ throw wrapEntityError(new Error(`cron trigger "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "cron trigger", key);
2232
+ }
2233
+ if (!adoptedId) {
2234
+ // 409 but no matching key found on the server — surface
2235
+ // the original error rather than silently overwriting.
2236
+ throw wrapEntityError(err, "create", "cron trigger", key);
2237
+ }
2238
+ // Switch to UPDATE on the adopted trigger.
2239
+ const updated = await client.updateCronTrigger(resolvedAppId, adoptedId, payload);
2240
+ info(` Adopted + updated cron trigger: ${key}`);
2241
+ if (syncState) {
2242
+ if (!syncState.entities.cronTriggers) {
2243
+ syncState.entities.cronTriggers = {};
2244
+ }
2245
+ syncState.entities.cronTriggers[key] = {
2246
+ id: adoptedId,
2247
+ modifiedAt: updated?.modifiedAt || new Date().toISOString(),
2248
+ contentHash: computeFileHash(filePath),
2249
+ };
2250
+ }
2251
+ }
2252
+ else {
2253
+ throw wrapEntityError(err, "create", "cron trigger", key);
2254
+ }
2035
2255
  }
2036
2256
  }
2037
2257
  }
@@ -2372,11 +2592,18 @@ Directory Structure:
2372
2592
  contentHash: computeFileHash(filePath),
2373
2593
  };
2374
2594
  }
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);
2375
2598
  }
2376
2599
  catch (err) {
2377
2600
  throw wrapEntityError(err, "update", "script", name);
2378
2601
  }
2379
2602
  }
2603
+ else {
2604
+ // Dry-run: surface the would-be stale set without mutating.
2605
+ await warnStaleWorkflowsForScript(client, resolvedAppId, existingId, sha256HexString(body), true);
2606
+ }
2380
2607
  }
2381
2608
  else {
2382
2609
  // No id in local state — could be brand new OR could be
@@ -2401,11 +2628,17 @@ Directory Structure:
2401
2628
  contentHash: computeFileHash(filePath),
2402
2629
  };
2403
2630
  }
2631
+ // Issue #973 — same staleness warning on the adopt path.
2632
+ await warnStaleWorkflowsForScript(client, resolvedAppId, match.scriptId, updated?.contentHash || sha256HexString(body), false);
2404
2633
  }
2405
2634
  catch (err) {
2406
2635
  throw wrapEntityError(err, "update", "script", name);
2407
2636
  }
2408
2637
  }
2638
+ else {
2639
+ // Dry-run on the adopt path.
2640
+ await warnStaleWorkflowsForScript(client, resolvedAppId, match.scriptId, sha256HexString(body), true);
2641
+ }
2409
2642
  }
2410
2643
  else {
2411
2644
  changes.push({ type: "script", action: "create", key: name });
@@ -2445,10 +2678,14 @@ Directory Structure:
2445
2678
  // place to catch the footgun. Validation happens BEFORE the
2446
2679
  // skip-if-unchanged check so a previously-pushed-but-broken
2447
2680
  // file gets a clear diagnostic on every push attempt.
2681
+ // Issue #976: validation is now front-loaded before any apply
2682
+ // (see the pre-flight pass above). This in-loop check remains as
2683
+ // defense-in-depth, but THROWS rather than `process.exit(1)` so the
2684
+ // single save-on-failure catch is the only exit path — never
2685
+ // bypassing it and orphaning an already-created resource.
2448
2686
  const tomlErrors = validateWorkflowToml(tomlData);
2449
2687
  if (tomlErrors.length > 0) {
2450
- error(formatWorkflowTomlErrors(filePath, tomlErrors));
2451
- process.exit(1);
2688
+ throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
2452
2689
  }
2453
2690
  const workflow = tomlData.workflow || {};
2454
2691
  const key = workflow.key || basename(file, ".toml");
@@ -2679,9 +2916,10 @@ Directory Structure:
2679
2916
  // (issue #803). Surface it with the file name and abort the
2680
2917
  // push rather than crashing with an opaque stack.
2681
2918
  if (err instanceof SubscriptionAccessKeyConflictError) {
2682
- error(` ${file}: ${err.message}`);
2683
- error(`Aborting push: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
2684
- process.exit(1);
2919
+ // Issue #976: throw (not `process.exit`) so the save-on-failure
2920
+ // catch is the only exit path. Validation is also front-loaded
2921
+ // above, so this is defense-in-depth.
2922
+ throw new Error(` ${file}: ${err.message}\nAborting push: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
2685
2923
  }
2686
2924
  throw err;
2687
2925
  }
@@ -2701,11 +2939,11 @@ Directory Structure:
2701
2939
  warn(` ${formatIssue(w)}`);
2702
2940
  }
2703
2941
  if (validation.errors.length > 0) {
2704
- for (const e of validation.errors) {
2705
- error(` ${formatIssue(e)}`);
2706
- }
2707
- error(`Aborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
2708
- process.exit(1);
2942
+ // Issue #976: throw (not `process.exit`) so the save-on-failure
2943
+ // catch is the only exit path. Validation is also front-loaded
2944
+ // above, so this is defense-in-depth.
2945
+ throw new Error(validation.errors.map((e) => ` ${formatIssue(e)}`).join("\n") +
2946
+ `\nAborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
2709
2947
  }
2710
2948
  const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
2711
2949
  // Skip if file hasn't changed since last sync
@@ -3016,6 +3254,7 @@ Directory Structure:
3016
3254
  acceptWarnings: !!options.acceptWarnings,
3017
3255
  });
3018
3256
  info(` Updated database type: ${dbType}`);
3257
+ printStaleInstanceWarning(updated);
3019
3258
  if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
3020
3259
  syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
3021
3260
  syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
@@ -3258,6 +3497,7 @@ Directory Structure:
3258
3497
  dryRun: false,
3259
3498
  acceptWarnings: !!options.acceptWarnings,
3260
3499
  });
3500
+ printStaleInstanceWarning(reconciled);
3261
3501
  if (syncState?.entities?.databaseTypes?.[dbType] &&
3262
3502
  reconciled?.modifiedAt) {
3263
3503
  syncState.entities.databaseTypes[dbType].modifiedAt =
@@ -4264,5 +4504,119 @@ Directory Structure:
4264
4504
  success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
4265
4505
  }
4266
4506
  });
4507
+ // Revert — restore a pre-pull snapshot (issue #578, Phase 1).
4508
+ sync
4509
+ .command("revert")
4510
+ .description("Restore the sync directory from a snapshot taken before a previous pull")
4511
+ .argument("[app-id]", "App ID (uses current app if not specified)")
4512
+ .option("--app <app-id>", "App ID")
4513
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
4514
+ .option("--snapshot <id>", "Snapshot id (timestamp dirname or a unique >=8-char prefix)")
4515
+ .option("--list", "List available snapshots without restoring")
4516
+ .option("-y, --yes", "Skip the confirmation prompt")
4517
+ .action(async (appId, options) => {
4518
+ const resolvedAppId = resolveAppId(appId, options);
4519
+ const configDir = resolveSyncDir({
4520
+ appId: resolvedAppId,
4521
+ userDir: options.dir,
4522
+ });
4523
+ const snapshotsRoot = resolveSnapshotsRoot({
4524
+ appId: resolvedAppId,
4525
+ userDir: options.dir,
4526
+ });
4527
+ if (isAutoResolvedSyncDir(options.dir)) {
4528
+ info(`Using per-environment sync directory: ${configDir}`);
4529
+ }
4530
+ const snapshots = listSnapshots(snapshotsRoot);
4531
+ // --list, or no snapshots at all: enumerate and stop.
4532
+ if (options.list || snapshots.length === 0) {
4533
+ if (snapshots.length === 0) {
4534
+ info(`No snapshots found in ${snapshotsRoot}`);
4535
+ info("Snapshots are created automatically before each 'sync pull'.");
4536
+ return;
4537
+ }
4538
+ divider();
4539
+ info(`Snapshots for this slot (${snapshotsRoot}), newest first:`);
4540
+ for (const snap of snapshots) {
4541
+ const flags = [];
4542
+ if (!snap.complete)
4543
+ flags.push("INCOMPLETE");
4544
+ if (snap.auditId)
4545
+ flags.push(`audit=${snap.auditId}`);
4546
+ const suffix = flags.length ? ` (${flags.join(", ")})` : "";
4547
+ keyValue(snap.id, `${snap.createdAt.toISOString()}${suffix}`);
4548
+ }
4549
+ if (!options.list) {
4550
+ info("");
4551
+ info("Run 'primitive sync revert --snapshot <id>' to restore one, " +
4552
+ "or 'primitive sync revert' to restore the most recent.");
4553
+ }
4554
+ return;
4555
+ }
4556
+ // Resolve the target snapshot (exact id, unique prefix, or most-recent).
4557
+ let target;
4558
+ try {
4559
+ target = resolveSnapshot(snapshotsRoot, options.snapshot);
4560
+ }
4561
+ catch (err) {
4562
+ error(err?.message ?? String(err));
4563
+ process.exit(1);
4564
+ }
4565
+ if (!target.complete) {
4566
+ error(`Snapshot ${target.id} is incomplete (missing the integrity marker) and cannot be restored safely.`);
4567
+ process.exit(1);
4568
+ }
4569
+ // Confirmation, with a dirty-git warning.
4570
+ if (!options.yes) {
4571
+ if (await hasUncommittedChanges(configDir)) {
4572
+ warn(`You have uncommitted git changes under ${configDir}. ` +
4573
+ "Reverting will overwrite them.");
4574
+ }
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
+ ]);
4584
+ if (!confirm) {
4585
+ info("Cancelled.");
4586
+ return;
4587
+ }
4588
+ }
4589
+ try {
4590
+ // Under legacy `--dir`, snapshotsRoot lives inside configDir; preserve
4591
+ // it across the full-tree swap so we don't wipe backup history.
4592
+ restoreSnapshot(target.path, configDir, { preserveDir: snapshotsRoot });
4593
+ }
4594
+ catch (err) {
4595
+ error(`Restore failed: ${err?.message ?? err}`);
4596
+ process.exit(1);
4597
+ }
4598
+ success(`Restored snapshot ${target.id} into ${configDir} (including .primitive-sync.json).`);
4599
+ info("Run 'primitive sync diff' to inspect the restored state versus the server.");
4600
+ });
4601
+ }
4602
+ /**
4603
+ * Best-effort check for uncommitted git changes under `dir`. Used only to warn
4604
+ * before a revert overwrites local edits — never fatal. Returns false if git
4605
+ * isn't available, the dir isn't in a repo, or anything goes wrong.
4606
+ */
4607
+ async function hasUncommittedChanges(dir) {
4608
+ if (!existsSync(dir))
4609
+ return false;
4610
+ try {
4611
+ const { execSync } = await import("child_process");
4612
+ const out = execSync(`git status --porcelain -- "${dir}"`, {
4613
+ stdio: ["ignore", "pipe", "ignore"],
4614
+ encoding: "utf-8",
4615
+ });
4616
+ return out.trim().length > 0;
4617
+ }
4618
+ catch {
4619
+ return false;
4620
+ }
4267
4621
  }
4268
4622
  //# sourceMappingURL=sync.js.map