primitive-admin 1.1.0-alpha.34 → 1.1.0-alpha.36

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 (153) hide show
  1. package/README.md +129 -10
  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/admins.js +25 -27
  8. package/dist/src/commands/admins.js.map +1 -1
  9. package/dist/src/commands/analytics.d.ts +2 -0
  10. package/dist/src/commands/apps.d.ts +2 -0
  11. package/dist/src/commands/apps.js +28 -0
  12. package/dist/src/commands/apps.js.map +1 -1
  13. package/dist/src/commands/auth.d.ts +2 -0
  14. package/dist/src/commands/blob-buckets.d.ts +2 -0
  15. package/dist/src/commands/blob-buckets.js +30 -26
  16. package/dist/src/commands/blob-buckets.js.map +1 -1
  17. package/dist/src/commands/catalog.d.ts +2 -0
  18. package/dist/src/commands/catalog.js +17 -18
  19. package/dist/src/commands/catalog.js.map +1 -1
  20. package/dist/src/commands/collection-type-configs.d.ts +2 -0
  21. package/dist/src/commands/collection-type-configs.js +9 -9
  22. package/dist/src/commands/collection-type-configs.js.map +1 -1
  23. package/dist/src/commands/collections.d.ts +2 -0
  24. package/dist/src/commands/collections.js +33 -36
  25. package/dist/src/commands/collections.js.map +1 -1
  26. package/dist/src/commands/comparisons.d.ts +2 -0
  27. package/dist/src/commands/cron-triggers.d.ts +2 -0
  28. package/dist/src/commands/cron-triggers.js +8 -15
  29. package/dist/src/commands/cron-triggers.js.map +1 -1
  30. package/dist/src/commands/database-types.d.ts +2 -0
  31. package/dist/src/commands/database-types.js +17 -18
  32. package/dist/src/commands/database-types.js.map +1 -1
  33. package/dist/src/commands/databases.d.ts +2 -0
  34. package/dist/src/commands/databases.js +72 -45
  35. package/dist/src/commands/databases.js.map +1 -1
  36. package/dist/src/commands/documents.d.ts +2 -0
  37. package/dist/src/commands/documents.js +17 -18
  38. package/dist/src/commands/documents.js.map +1 -1
  39. package/dist/src/commands/email-templates.d.ts +2 -0
  40. package/dist/src/commands/email-templates.js +9 -9
  41. package/dist/src/commands/email-templates.js.map +1 -1
  42. package/dist/src/commands/env.d.ts +12 -0
  43. package/dist/src/commands/group-type-configs.d.ts +2 -0
  44. package/dist/src/commands/group-type-configs.js +9 -9
  45. package/dist/src/commands/group-type-configs.js.map +1 -1
  46. package/dist/src/commands/groups.d.ts +2 -0
  47. package/dist/src/commands/groups.js +17 -18
  48. package/dist/src/commands/groups.js.map +1 -1
  49. package/dist/src/commands/guides.d.ts +84 -0
  50. package/dist/src/commands/guides.js +201 -24
  51. package/dist/src/commands/guides.js.map +1 -1
  52. package/dist/src/commands/init.d.ts +17 -0
  53. package/dist/src/commands/init.js +63 -25
  54. package/dist/src/commands/init.js.map +1 -1
  55. package/dist/src/commands/integrations.d.ts +2 -0
  56. package/dist/src/commands/integrations.js +39 -23
  57. package/dist/src/commands/integrations.js.map +1 -1
  58. package/dist/src/commands/llm.d.ts +2 -0
  59. package/dist/src/commands/llm.js +4 -2
  60. package/dist/src/commands/llm.js.map +1 -1
  61. package/dist/src/commands/prompts.d.ts +2 -0
  62. package/dist/src/commands/prompts.js +33 -36
  63. package/dist/src/commands/prompts.js.map +1 -1
  64. package/dist/src/commands/rule-sets.d.ts +2 -0
  65. package/dist/src/commands/rule-sets.js +9 -9
  66. package/dist/src/commands/rule-sets.js.map +1 -1
  67. package/dist/src/commands/secrets.d.ts +2 -0
  68. package/dist/src/commands/skill.d.ts +2 -0
  69. package/dist/src/commands/sync.d.ts +99 -0
  70. package/dist/src/commands/sync.js +437 -31
  71. package/dist/src/commands/sync.js.map +1 -1
  72. package/dist/src/commands/tokens.d.ts +2 -0
  73. package/dist/src/commands/tokens.js +113 -10
  74. package/dist/src/commands/tokens.js.map +1 -1
  75. package/dist/src/commands/users.d.ts +2 -0
  76. package/dist/src/commands/users.js +41 -45
  77. package/dist/src/commands/users.js.map +1 -1
  78. package/dist/src/commands/waitlist.d.ts +2 -0
  79. package/dist/src/commands/waitlist.js +10 -10
  80. package/dist/src/commands/waitlist.js.map +1 -1
  81. package/dist/src/commands/webhooks.d.ts +2 -0
  82. package/dist/src/commands/webhooks.js +9 -9
  83. package/dist/src/commands/webhooks.js.map +1 -1
  84. package/dist/src/commands/workflows.d.ts +49 -0
  85. package/dist/src/commands/workflows.js +136 -57
  86. package/dist/src/commands/workflows.js.map +1 -1
  87. package/dist/src/lib/api-client.d.ts +1229 -0
  88. package/dist/src/lib/api-client.js +44 -11
  89. package/dist/src/lib/api-client.js.map +1 -1
  90. package/dist/src/lib/auth-flow.d.ts +8 -0
  91. package/dist/src/lib/cli-manifest.d.ts +60 -0
  92. package/dist/src/lib/cli-manifest.js +70 -0
  93. package/dist/src/lib/cli-manifest.js.map +1 -0
  94. package/dist/src/lib/config.d.ts +37 -0
  95. package/dist/src/lib/confirm-prompt.d.ts +83 -0
  96. package/dist/src/lib/confirm-prompt.js +110 -0
  97. package/dist/src/lib/confirm-prompt.js.map +1 -0
  98. package/dist/src/lib/constants.d.ts +2 -0
  99. package/dist/src/lib/crash-handlers.d.ts +20 -0
  100. package/dist/src/lib/crash-handlers.js +49 -0
  101. package/dist/src/lib/crash-handlers.js.map +1 -0
  102. package/dist/src/lib/credentials-store.d.ts +79 -0
  103. package/dist/src/lib/csv.d.ts +48 -0
  104. package/dist/src/lib/db-codegen/dbFingerprint.d.ts +10 -0
  105. package/dist/src/lib/db-codegen/dbGenerator.d.ts +111 -0
  106. package/dist/src/lib/db-codegen/dbNaming.d.ts +45 -0
  107. package/dist/src/lib/db-codegen/dbTemplates.d.ts +97 -0
  108. package/dist/src/lib/db-codegen/dbTemplates.js +31 -10
  109. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  110. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +78 -0
  111. package/dist/src/lib/db-codegen/dbTsTypes.js +2 -2
  112. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  113. package/dist/src/lib/env-resolver.d.ts +62 -0
  114. package/dist/src/lib/fetch.d.ts +5 -0
  115. package/dist/src/lib/generated-allowlist.d.ts +28 -0
  116. package/dist/src/lib/generated-allowlist.js +181 -0
  117. package/dist/src/lib/generated-allowlist.js.map +1 -0
  118. package/dist/src/lib/init-config.d.ts +46 -0
  119. package/dist/src/lib/init-config.js +7 -0
  120. package/dist/src/lib/init-config.js.map +1 -1
  121. package/dist/src/lib/migration-nag.d.ts +49 -0
  122. package/dist/src/lib/output.d.ts +49 -0
  123. package/dist/src/lib/output.js +25 -1
  124. package/dist/src/lib/output.js.map +1 -1
  125. package/dist/src/lib/paginate.d.ts +33 -0
  126. package/dist/src/lib/project-config.d.ts +97 -0
  127. package/dist/src/lib/refresh-admin-credentials.d.ts +65 -0
  128. package/dist/src/lib/resolve-platform.d.ts +45 -0
  129. package/dist/src/lib/resolve-platform.js +43 -0
  130. package/dist/src/lib/resolve-platform.js.map +1 -0
  131. package/dist/src/lib/skill-installer.d.ts +23 -0
  132. package/dist/src/lib/snapshots.d.ts +99 -0
  133. package/dist/src/lib/snapshots.js +357 -0
  134. package/dist/src/lib/snapshots.js.map +1 -0
  135. package/dist/src/lib/sync-paths.d.ts +72 -0
  136. package/dist/src/lib/sync-paths.js +29 -1
  137. package/dist/src/lib/sync-paths.js.map +1 -1
  138. package/dist/src/lib/template.d.ts +93 -0
  139. package/dist/src/lib/token-inject.d.ts +56 -0
  140. package/dist/src/lib/token-inject.js +204 -0
  141. package/dist/src/lib/token-inject.js.map +1 -0
  142. package/dist/src/lib/toml-database-config.d.ts +132 -0
  143. package/dist/src/lib/toml-params-validator.d.ts +95 -0
  144. package/dist/src/lib/version-check.d.ts +10 -0
  145. package/dist/src/lib/workflow-fragments.d.ts +41 -0
  146. package/dist/src/lib/workflow-toml-validator.d.ts +95 -0
  147. package/dist/src/lib/workflow-toml-validator.js +71 -130
  148. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  149. package/dist/src/types/index.d.ts +513 -0
  150. package/dist/src/validators.d.ts +64 -0
  151. package/dist/src/validators.js +63 -0
  152. package/dist/src/validators.js.map +1 -0
  153. package/package.json +11 -2
@@ -7,16 +7,31 @@ 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";
15
+ import { confirmPrompt } from "../lib/confirm-prompt.js";
14
16
  import chalk from "chalk";
15
17
  function ensureDir(dirPath) {
16
18
  if (!existsSync(dirPath)) {
17
19
  mkdirSync(dirPath, { recursive: true });
18
20
  }
19
21
  }
22
+ /**
23
+ * Issue #974: when a type-config push newly declares fields `unique = true`
24
+ * and the type already has instances, the server returns a
25
+ * `staleInstanceWarning`. Surface it so the operator knows the existing
26
+ * instances still lack the index and how to back-provision them. The push
27
+ * itself still succeeded — this is informational, not a failure.
28
+ */
29
+ function printStaleInstanceWarning(updated) {
30
+ const w = updated?.staleInstanceWarning;
31
+ if (w && typeof w.message === "string") {
32
+ warn(` ${w.message}`);
33
+ }
34
+ }
20
35
  function loadSyncState(configDir) {
21
36
  const syncFile = join(configDir, ".primitive-sync.json");
22
37
  if (!existsSync(syncFile)) {
@@ -246,7 +261,13 @@ function serializeBlobBucket(bucket) {
246
261
  name: bucket.name,
247
262
  description: bucket.description || undefined,
248
263
  ttlTier: bucket.ttlTier,
249
- accessPolicy: bucket.accessPolicy,
264
+ // #1020: `preset` is the honest key; a custom bucket carries ruleSetId
265
+ // instead. Omit preset for custom buckets (preset === "custom").
266
+ preset: bucket.preset && bucket.preset !== "custom"
267
+ ? bucket.preset
268
+ : bucket.ruleSetId
269
+ ? undefined
270
+ : bucket.preset,
250
271
  },
251
272
  };
252
273
  if (bucket.ruleSetId) {
@@ -299,6 +320,9 @@ function serializeWorkflow(workflow, draft, configs) {
299
320
  status: workflow.status,
300
321
  activeConfigName: activeConfigName,
301
322
  accessRule: workflow.accessRule || undefined,
323
+ // #1081 — workflow principal mode. Emit only when set so a fresh
324
+ // pull → push round-trips it without writing a noisy `runAs = ""`.
325
+ runAs: workflow.runAs || undefined,
302
326
  perUserMaxRunning: workflow.perUserMaxRunning,
303
327
  perUserMaxQueued: workflow.perUserMaxQueued,
304
328
  dequeueOrder: workflow.dequeueOrder,
@@ -1153,6 +1177,35 @@ Directory Structure:
1153
1177
  subscriptions: Array.isArray(subs) ? subs : [],
1154
1178
  };
1155
1179
  }));
1180
+ // Snapshot the pre-pull sync tree BEFORE we touch any local file
1181
+ // (issue #578, Phase 1). All server data has been fetched and
1182
+ // validated at this point — a failed pull already threw above, so we
1183
+ // never create empty snapshots — but no local file has been
1184
+ // overwritten yet. This is the proven copy-point: between the last
1185
+ // server fetch and the first directory mutation.
1186
+ //
1187
+ // Fail LOUD: if the snapshot can't be written, abort the pull BEFORE
1188
+ // the ensureDir below. Better to refuse a destructive pull than
1189
+ // perform it without a recoverable backup. No-op on a fresh/empty
1190
+ // sync dir (nothing to back up). Prune to 28 days after success.
1191
+ try {
1192
+ const snapshotsRoot = resolveSnapshotsRoot({
1193
+ appId: resolvedAppId,
1194
+ userDir: options.dir,
1195
+ });
1196
+ const snapshot = createSnapshot(configDir, snapshotsRoot);
1197
+ if (snapshot) {
1198
+ info(` Snapshot: ${snapshot.path}`);
1199
+ pruneSnapshots(snapshotsRoot, 28);
1200
+ }
1201
+ }
1202
+ catch (snapErr) {
1203
+ error(`Failed to create a pre-pull snapshot: ${snapErr?.message ?? snapErr}`);
1204
+ error("Aborting pull before any local file is modified. " +
1205
+ "Resolve the snapshot error (e.g. disk space / permissions) and retry, " +
1206
+ "or pass a writable --dir.");
1207
+ process.exit(1);
1208
+ }
1156
1209
  // Ensure directories exist
1157
1210
  ensureDir(configDir);
1158
1211
  ensureDir(join(configDir, "integrations"));
@@ -1660,6 +1713,82 @@ Directory Structure:
1660
1713
  const promptKeyToId = new Map();
1661
1714
  const promptConfigNameToId = new Map(); // key: "promptKey#configName"
1662
1715
  const workflowConfigNameToId = new Map(); // key: "workflowKey#configName"
1716
+ // ── Issue #976 (fix A): front-load ALL client-side TOML validation
1717
+ // before the first mutating call. Push is apply-as-you-go and processes
1718
+ // entities in a fixed sequence (cron triggers are created *before*
1719
+ // workflows are validated). Previously each entity's validation lived
1720
+ // inside its own apply loop and aborted via `process.exit(1)` — so a
1721
+ // known-bad sibling file (e.g. an invalid workflow TOML) could let an
1722
+ // earlier create (a cron trigger) be applied, then abort. The
1723
+ // `process.exit` also bypassed the save-on-failure catch below, so the
1724
+ // orphaned create never reached `.primitive-sync.json` and the retry
1725
+ // re-issued a CREATE that the server rejected with 409 forever.
1726
+ //
1727
+ // Running every validation up-front means a known-bad file aborts
1728
+ // BEFORE anything is created. We collect all errors (not just the first)
1729
+ // and THROW — so the single save-on-failure catch is the only exit path
1730
+ // (no more `process.exit` bypass class). The per-loop validators below
1731
+ // remain as defense-in-depth but are converted to throws too.
1732
+ const preflightValidationErrors = [];
1733
+ // Validate all workflow TOMLs (issue #685 misnested-header check).
1734
+ const preflightWorkflowsDir = join(configDir, "workflows");
1735
+ if (existsSync(preflightWorkflowsDir)) {
1736
+ for (const file of readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml"))) {
1737
+ const filePath = join(preflightWorkflowsDir, file);
1738
+ try {
1739
+ const tomlData = parseTomlFile(filePath);
1740
+ const tomlErrors = validateWorkflowToml(tomlData);
1741
+ if (tomlErrors.length > 0) {
1742
+ preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
1743
+ }
1744
+ }
1745
+ catch (err) {
1746
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
1747
+ }
1748
+ }
1749
+ }
1750
+ // Validate all database-type TOMLs (issue #803 subscription
1751
+ // access/accessRule conflict + issue #752 $params references).
1752
+ const preflightDbTypesDir = join(configDir, "database-types");
1753
+ if (existsSync(preflightDbTypesDir)) {
1754
+ for (const file of readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml"))) {
1755
+ const filePath = join(preflightDbTypesDir, file);
1756
+ let rawToml;
1757
+ let tomlData;
1758
+ try {
1759
+ rawToml = readFileSync(filePath, "utf-8");
1760
+ tomlData = parseTomlFile(filePath);
1761
+ }
1762
+ catch (err) {
1763
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
1764
+ continue;
1765
+ }
1766
+ let operations = [];
1767
+ try {
1768
+ ({ operations } = parseDatabaseTypeToml(tomlData));
1769
+ }
1770
+ catch (err) {
1771
+ if (err instanceof SubscriptionAccessKeyConflictError) {
1772
+ preflightValidationErrors.push(` ${file}: ${err.message}`);
1773
+ continue;
1774
+ }
1775
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
1776
+ continue;
1777
+ }
1778
+ const validation = validateOperations({
1779
+ filePath,
1780
+ rawToml,
1781
+ operations,
1782
+ });
1783
+ for (const e of validation.errors) {
1784
+ preflightValidationErrors.push(` ${formatIssue(e)}`);
1785
+ }
1786
+ }
1787
+ }
1788
+ if (preflightValidationErrors.length > 0) {
1789
+ throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
1790
+ preflightValidationErrors.join("\n"));
1791
+ }
1663
1792
  // Process app settings
1664
1793
  const appTomlPath = join(configDir, "app.toml");
1665
1794
  if (existsSync(appTomlPath)) {
@@ -2031,7 +2160,58 @@ Directory Structure:
2031
2160
  }
2032
2161
  }
2033
2162
  catch (err) {
2034
- throw wrapEntityError(err, "create", "cron trigger", key);
2163
+ // Issue #976 (fix B): idempotent create adopt-by-key on 409.
2164
+ // A cron trigger can be orphaned on the server (created by a
2165
+ // prior push that aborted before recording it in sync state,
2166
+ // a mid-apply crash, or an out-of-band create with the same
2167
+ // key). On retry this CREATE path then hits the
2168
+ // `triggerKeyPerApp` unique constraint and the server returns
2169
+ // 409 "A cron trigger with this key already exists". Rather
2170
+ // than hard-fail forever, look up the existing trigger by key
2171
+ // (the list endpoint is app-scoped, so every item is owned by
2172
+ // this app), verify the SAME triggerKey, adopt its id into
2173
+ // sync state, and re-issue as an UPDATE so the push converges.
2174
+ const msg = String(err?.message || err);
2175
+ const is409 = err?.statusCode === 409 || msg.includes("already exists");
2176
+ if (is409) {
2177
+ info(` Cron trigger already exists on server, adopting by key: ${key}`);
2178
+ let adoptedId;
2179
+ try {
2180
+ const { items } = await client.listCronTriggers(resolvedAppId);
2181
+ // Verify same key + app ownership: the list endpoint only
2182
+ // returns triggers for `resolvedAppId`, so a triggerKey
2183
+ // match is also an ownership match. Never overwrite an
2184
+ // unrelated resource — require an exact key equality.
2185
+ const existing = (items || []).find((t) => t?.triggerKey === key);
2186
+ if (existing?.triggerId) {
2187
+ adoptedId = existing.triggerId;
2188
+ }
2189
+ }
2190
+ catch (lookupErr) {
2191
+ throw wrapEntityError(new Error(`cron trigger "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "cron trigger", key);
2192
+ }
2193
+ if (!adoptedId) {
2194
+ // 409 but no matching key found on the server — surface
2195
+ // the original error rather than silently overwriting.
2196
+ throw wrapEntityError(err, "create", "cron trigger", key);
2197
+ }
2198
+ // Switch to UPDATE on the adopted trigger.
2199
+ const updated = await client.updateCronTrigger(resolvedAppId, adoptedId, payload);
2200
+ info(` Adopted + updated cron trigger: ${key}`);
2201
+ if (syncState) {
2202
+ if (!syncState.entities.cronTriggers) {
2203
+ syncState.entities.cronTriggers = {};
2204
+ }
2205
+ syncState.entities.cronTriggers[key] = {
2206
+ id: adoptedId,
2207
+ modifiedAt: updated?.modifiedAt || new Date().toISOString(),
2208
+ contentHash: computeFileHash(filePath),
2209
+ };
2210
+ }
2211
+ }
2212
+ else {
2213
+ throw wrapEntityError(err, "create", "cron trigger", key);
2214
+ }
2035
2215
  }
2036
2216
  }
2037
2217
  }
@@ -2053,8 +2233,29 @@ Directory Structure:
2053
2233
  continue;
2054
2234
  }
2055
2235
  if (existingId) {
2056
- // Blob buckets don't have an update API - skip if already exists
2057
- info(` Blob bucket already exists, skipping: ${key}`);
2236
+ // #1020 (D8): idempotently apply preset/ruleSet/name/description
2237
+ // changes via PATCH. bucketKey/ttlTier are immutable and not sent.
2238
+ const updatePayload = {};
2239
+ if (bucket.preset)
2240
+ updatePayload.preset = bucket.preset;
2241
+ else if (bucket.accessPolicy)
2242
+ updatePayload.accessPolicy = bucket.accessPolicy;
2243
+ if (bucket.ruleSetId)
2244
+ updatePayload.ruleSetId = bucket.ruleSetId;
2245
+ if (bucket.name)
2246
+ updatePayload.name = bucket.name;
2247
+ if (bucket.description !== undefined)
2248
+ updatePayload.description = bucket.description || null;
2249
+ changes.push({ type: "blob-bucket", action: "update", key });
2250
+ if (!options.dryRun) {
2251
+ try {
2252
+ await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
2253
+ info(` Updated blob bucket: ${key}`);
2254
+ }
2255
+ catch (err) {
2256
+ warn(` Failed to update blob bucket ${key}: ${String(err?.message || err)}`);
2257
+ }
2258
+ }
2058
2259
  if (syncState?.entities?.blobBuckets?.[key]) {
2059
2260
  syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
2060
2261
  }
@@ -2064,8 +2265,11 @@ Directory Structure:
2064
2265
  bucketKey: key,
2065
2266
  name: bucket.name || key,
2066
2267
  ttlTier: bucket.ttlTier,
2067
- accessPolicy: bucket.accessPolicy,
2068
2268
  };
2269
+ if (bucket.preset)
2270
+ payload.preset = bucket.preset;
2271
+ else if (bucket.accessPolicy)
2272
+ payload.accessPolicy = bucket.accessPolicy;
2069
2273
  if (bucket.description)
2070
2274
  payload.description = bucket.description;
2071
2275
  if (bucket.ruleSetId)
@@ -2358,7 +2562,10 @@ Directory Structure:
2358
2562
  changes.push({ type: "script", action: "update", key: name });
2359
2563
  if (!options.dryRun) {
2360
2564
  try {
2361
- const updated = await client.updateScript(resolvedAppId, existingId, { body });
2565
+ // #1000 push a new ScriptConfig + activate it. Referencing
2566
+ // workflows pick up the new body on their next run (no
2567
+ // fan-out, no re-push needed).
2568
+ await client.pushScriptBody(resolvedAppId, existingId, body);
2362
2569
  info(` Updated script: ${name}`);
2363
2570
  if (syncState) {
2364
2571
  if (!syncState.entities.scripts) {
@@ -2366,8 +2573,7 @@ Directory Structure:
2366
2573
  }
2367
2574
  syncState.entities.scripts[name] = {
2368
2575
  id: existingId,
2369
- modifiedAt: updated?.modifiedAt ||
2370
- syncState.entities.scripts[name]?.modifiedAt ||
2576
+ modifiedAt: syncState.entities.scripts[name]?.modifiedAt ||
2371
2577
  new Date().toISOString(),
2372
2578
  contentHash: computeFileHash(filePath),
2373
2579
  };
@@ -2389,7 +2595,7 @@ Directory Structure:
2389
2595
  changes.push({ type: "script", action: "update", key: name });
2390
2596
  if (!options.dryRun) {
2391
2597
  try {
2392
- const updated = await client.updateScript(resolvedAppId, match.scriptId, { body });
2598
+ await client.pushScriptBody(resolvedAppId, match.scriptId, body);
2393
2599
  info(` Updated script: ${name} (adopted from server)`);
2394
2600
  if (syncState) {
2395
2601
  if (!syncState.entities.scripts) {
@@ -2397,7 +2603,7 @@ Directory Structure:
2397
2603
  }
2398
2604
  syncState.entities.scripts[name] = {
2399
2605
  id: match.scriptId,
2400
- modifiedAt: updated?.modifiedAt || new Date().toISOString(),
2606
+ modifiedAt: new Date().toISOString(),
2401
2607
  contentHash: computeFileHash(filePath),
2402
2608
  };
2403
2609
  }
@@ -2445,10 +2651,14 @@ Directory Structure:
2445
2651
  // place to catch the footgun. Validation happens BEFORE the
2446
2652
  // skip-if-unchanged check so a previously-pushed-but-broken
2447
2653
  // file gets a clear diagnostic on every push attempt.
2654
+ // Issue #976: validation is now front-loaded before any apply
2655
+ // (see the pre-flight pass above). This in-loop check remains as
2656
+ // defense-in-depth, but THROWS rather than `process.exit(1)` so the
2657
+ // single save-on-failure catch is the only exit path — never
2658
+ // bypassing it and orphaning an already-created resource.
2448
2659
  const tomlErrors = validateWorkflowToml(tomlData);
2449
2660
  if (tomlErrors.length > 0) {
2450
- error(formatWorkflowTomlErrors(filePath, tomlErrors));
2451
- process.exit(1);
2661
+ throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
2452
2662
  }
2453
2663
  const workflow = tomlData.workflow || {};
2454
2664
  const key = workflow.key || basename(file, ".toml");
@@ -2501,6 +2711,10 @@ Directory Structure:
2501
2711
  description: workflow.description,
2502
2712
  status: workflow.status,
2503
2713
  accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
2714
+ // #1081 — workflow principal mode. Absent key → undefined
2715
+ // (server leaves it untouched); explicit value/empty is
2716
+ // forwarded so it round-trips.
2717
+ runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
2504
2718
  perUserMaxRunning: workflow.perUserMaxRunning,
2505
2719
  perUserMaxQueued: workflow.perUserMaxQueued,
2506
2720
  dequeueOrder: workflow.dequeueOrder,
@@ -2595,6 +2809,8 @@ Directory Structure:
2595
2809
  inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
2596
2810
  outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
2597
2811
  accessRule: workflow.accessRule || undefined,
2812
+ // #1081 — workflow principal mode (absent → server default).
2813
+ runAs: workflow.runAs || undefined,
2598
2814
  perUserMaxRunning: workflow.perUserMaxRunning,
2599
2815
  perUserMaxQueued: workflow.perUserMaxQueued,
2600
2816
  dequeueOrder: workflow.dequeueOrder,
@@ -2679,9 +2895,10 @@ Directory Structure:
2679
2895
  // (issue #803). Surface it with the file name and abort the
2680
2896
  // push rather than crashing with an opaque stack.
2681
2897
  if (err instanceof SubscriptionAccessKeyConflictError) {
2682
- error(` ${file}: ${err.message}`);
2683
- error(`Aborting push: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
2684
- process.exit(1);
2898
+ // Issue #976: throw (not `process.exit`) so the save-on-failure
2899
+ // catch is the only exit path. Validation is also front-loaded
2900
+ // above, so this is defense-in-depth.
2901
+ throw new Error(` ${file}: ${err.message}\nAborting push: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
2685
2902
  }
2686
2903
  throw err;
2687
2904
  }
@@ -2701,13 +2918,22 @@ Directory Structure:
2701
2918
  warn(` ${formatIssue(w)}`);
2702
2919
  }
2703
2920
  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);
2921
+ // Issue #976: throw (not `process.exit`) so the save-on-failure
2922
+ // catch is the only exit path. Validation is also front-loaded
2923
+ // above, so this is defense-in-depth.
2924
+ throw new Error(validation.errors.map((e) => ` ${formatIssue(e)}`).join("\n") +
2925
+ `\nAborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
2709
2926
  }
2710
2927
  const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
2928
+ // Issue #915 (defect b): track whether the apply path rejected any
2929
+ // op for THIS file via the schema gate (OperationRefError /
2930
+ // SchemaRequiredError). On a non-transactional fresh-type push the
2931
+ // db-type lands but the bad op is blocked and the loop continues; if
2932
+ // we then wrote the file-level contentHash, the next push's
2933
+ // `shouldPushFile` would short-circuit and the missing op would be
2934
+ // invisible drift. When this flag is set we skip the contentHash
2935
+ // write so the drift stays visible and a corrected re-push converges.
2936
+ let fileHadGateRejectedOp = false;
2711
2937
  // Skip if file hasn't changed since last sync
2712
2938
  if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
2713
2939
  skippedCount++;
@@ -2794,8 +3020,7 @@ Directory Structure:
2794
3020
  }
2795
3021
  // 2. Op-edit gate (op create/update dry-run) for every op in the
2796
3022
  // TOML. The server runs `runSchemaGate` and short-circuits
2797
- // before persisting. Skipped for a fresh type (no schema on the
2798
- // server yet → gate is a no-op anyway).
3023
+ // before persisting.
2799
3024
  //
2800
3025
  // The op gate is run against the schema THIS push is landing
2801
3026
  // (`schemaOverride`), not the stale stored schema — the real
@@ -2805,11 +3030,36 @@ Directory Structure:
2805
3030
  // a schema landed earlier in the same push"). When the push
2806
3031
  // isn't changing the schema, leave the override unset so the
2807
3032
  // server uses the stored schema.
2808
- if (existingEntry) {
2809
- const validateUpdateData = computeTypeUpdateData();
2810
- const proposedSchema = "schema" in validateUpdateData
2811
- ? validateUpdateData.schema
2812
- : undefined;
3033
+ //
3034
+ // Issue #915 (defect a): this gate now runs for FRESH types too
3035
+ // (previously guarded by `if (existingEntry)`). For a brand-new
3036
+ // db-type the server has no stored config, so we gate the op
3037
+ // against the PROPOSED local schema via `schemaOverride`. The
3038
+ // server's op-create dry-run accepts a missing type when a
3039
+ // `schemaOverride` is present (see
3040
+ // database-type-operations-controller.ts). A fresh schemaless
3041
+ // type (#666 bootstrap) sends an empty/absent override, and
3042
+ // `runSchemaGate` is a no-op without a schema — so that path
3043
+ // still succeeds.
3044
+ {
3045
+ const localHasSchema = typeof typeConfig.schema === "string" &&
3046
+ typeConfig.schema.trim().length > 0;
3047
+ let proposedSchema;
3048
+ if (existingEntry) {
3049
+ const validateUpdateData = computeTypeUpdateData();
3050
+ proposedSchema =
3051
+ "schema" in validateUpdateData
3052
+ ? validateUpdateData.schema
3053
+ : undefined;
3054
+ }
3055
+ else {
3056
+ // Fresh type: the proposed schema is whatever the local TOML
3057
+ // declares. A schemaless fresh type (no `[models.*]`) sends an
3058
+ // empty-string override so the server takes the no-schema =
3059
+ // no-op gate path rather than falling back to a (nonexistent)
3060
+ // stored config — keeping the #666 bootstrap convergent.
3061
+ proposedSchema = localHasSchema ? typeConfig.schema : "";
3062
+ }
2813
3063
  for (const op of operations) {
2814
3064
  const existingOp = existingOpsForType[op.name];
2815
3065
  opGateContext = {
@@ -2833,7 +3083,20 @@ Directory Structure:
2833
3083
  access: op.access,
2834
3084
  definition: op.definition,
2835
3085
  params: op.params,
2836
- }, { dryRun: true, schemaOverride: proposedSchema });
3086
+ }, {
3087
+ dryRun: true,
3088
+ schemaOverride: proposedSchema,
3089
+ // Issue #915 (defect a, follow-up): for a fresh type the
3090
+ // server has no stored config to read `defaultAccess`
3091
+ // from, so an op that omits `access` to inherit the
3092
+ // TOML-declared type-level `defaultAccess` would be
3093
+ // falsely rejected. Thread the proposed value so the
3094
+ // fresh-type dry-run gates the same way the real push
3095
+ // (which lands the type first) will.
3096
+ defaultAccess: typeof typeConfig.defaultAccess === "string"
3097
+ ? typeConfig.defaultAccess
3098
+ : undefined,
3099
+ });
2837
3100
  }
2838
3101
  }
2839
3102
  // Every op gate passed; clear the context so a later (non-op)
@@ -3016,6 +3279,7 @@ Directory Structure:
3016
3279
  acceptWarnings: !!options.acceptWarnings,
3017
3280
  });
3018
3281
  info(` Updated database type: ${dbType}`);
3282
+ printStaleInstanceWarning(updated);
3019
3283
  if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
3020
3284
  syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
3021
3285
  syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
@@ -3258,6 +3522,7 @@ Directory Structure:
3258
3522
  dryRun: false,
3259
3523
  acceptWarnings: !!options.acceptWarnings,
3260
3524
  });
3525
+ printStaleInstanceWarning(reconciled);
3261
3526
  if (syncState?.entities?.databaseTypes?.[dbType] &&
3262
3527
  reconciled?.modifiedAt) {
3263
3528
  syncState.entities.databaseTypes[dbType].modifiedAt =
@@ -3323,6 +3588,10 @@ Directory Structure:
3323
3588
  key: `${dbType}/${op.name}`,
3324
3589
  message: err.message,
3325
3590
  });
3591
+ // Issue #915 (defect b): a gate-rejected op means this
3592
+ // file's state did NOT fully land — don't poison the
3593
+ // content hash (see the contentHash write below).
3594
+ fileHadGateRejectedOp = true;
3326
3595
  }
3327
3596
  else if (err instanceof OperationRefError) {
3328
3597
  schemaErrors.opRefs.push({
@@ -3331,6 +3600,7 @@ Directory Structure:
3331
3600
  refs: err.refs,
3332
3601
  message: err.message,
3333
3602
  });
3603
+ fileHadGateRejectedOp = true;
3334
3604
  }
3335
3605
  else {
3336
3606
  throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
@@ -3368,6 +3638,10 @@ Directory Structure:
3368
3638
  key: `${dbType}/${op.name}`,
3369
3639
  message: err.message,
3370
3640
  });
3641
+ // Issue #915 (defect b): a gate-rejected op means this
3642
+ // file's state did NOT fully land — don't poison the
3643
+ // content hash (see the contentHash write below).
3644
+ fileHadGateRejectedOp = true;
3371
3645
  }
3372
3646
  else if (err instanceof OperationRefError) {
3373
3647
  schemaErrors.opRefs.push({
@@ -3376,6 +3650,7 @@ Directory Structure:
3376
3650
  refs: err.refs,
3377
3651
  message: err.message,
3378
3652
  });
3653
+ fileHadGateRejectedOp = true;
3379
3654
  }
3380
3655
  else {
3381
3656
  throw wrapEntityError(err, "create", "operation", `${dbType}/${op.name}`);
@@ -3507,9 +3782,27 @@ Directory Structure:
3507
3782
  // file. Recompute here so a truly-unchanged subsequent push skips
3508
3783
  // the whole file via `shouldPushFile`. `--force` bypasses that
3509
3784
  // skip, so forced re-pushes are unaffected.
3785
+ //
3786
+ // Issue #915 (defect b): when the gate rejected an op for this
3787
+ // file, do NOT record a matching content hash. The db-type itself
3788
+ // may have been created (the push is non-transactional), but the
3789
+ // blocked op never landed — a matching hash would make
3790
+ // `shouldPushFile` short-circuit the next push and hide the missing
3791
+ // op as "in sync". Clear the hash instead of writing it: an empty
3792
+ // hash forces `shouldPushFile` to re-evaluate the file, so a
3793
+ // subsequent `sync push --dry-run` still surfaces the op as drift
3794
+ // and a corrected re-push converges without manual file edits. We
3795
+ // clear (rather than merely skip) so the fresh-type create path's
3796
+ // earlier optimistic hash write (see `createDatabaseTypeConfig`
3797
+ // above) is also invalidated.
3510
3798
  if (!options.dryRun && syncState?.entities?.databaseTypes?.[dbType]) {
3511
- syncState.entities.databaseTypes[dbType].contentHash =
3512
- computeFileHash(filePath);
3799
+ if (fileHadGateRejectedOp) {
3800
+ syncState.entities.databaseTypes[dbType].contentHash = "";
3801
+ }
3802
+ else {
3803
+ syncState.entities.databaseTypes[dbType].contentHash =
3804
+ computeFileHash(filePath);
3805
+ }
3513
3806
  }
3514
3807
  }
3515
3808
  }
@@ -4264,5 +4557,118 @@ Directory Structure:
4264
4557
  success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
4265
4558
  }
4266
4559
  });
4560
+ // Revert — restore a pre-pull snapshot (issue #578, Phase 1).
4561
+ sync
4562
+ .command("revert")
4563
+ .description("Restore the sync directory from a snapshot taken before a previous pull")
4564
+ .argument("[app-id]", "App ID (uses current app if not specified)")
4565
+ .option("--app <app-id>", "App ID")
4566
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
4567
+ .option("--snapshot <id>", "Snapshot id (timestamp dirname or a unique >=8-char prefix)")
4568
+ .option("--list", "List available snapshots without restoring")
4569
+ .option("-y, --yes", "Skip the confirmation prompt")
4570
+ .action(async (appId, options) => {
4571
+ const resolvedAppId = resolveAppId(appId, options);
4572
+ const configDir = resolveSyncDir({
4573
+ appId: resolvedAppId,
4574
+ userDir: options.dir,
4575
+ });
4576
+ const snapshotsRoot = resolveSnapshotsRoot({
4577
+ appId: resolvedAppId,
4578
+ userDir: options.dir,
4579
+ });
4580
+ if (isAutoResolvedSyncDir(options.dir)) {
4581
+ info(`Using per-environment sync directory: ${configDir}`);
4582
+ }
4583
+ const snapshots = listSnapshots(snapshotsRoot);
4584
+ // --list, or no snapshots at all: enumerate and stop.
4585
+ if (options.list || snapshots.length === 0) {
4586
+ if (snapshots.length === 0) {
4587
+ info(`No snapshots found in ${snapshotsRoot}`);
4588
+ info("Snapshots are created automatically before each 'sync pull'.");
4589
+ return;
4590
+ }
4591
+ divider();
4592
+ info(`Snapshots for this slot (${snapshotsRoot}), newest first:`);
4593
+ for (const snap of snapshots) {
4594
+ const flags = [];
4595
+ if (!snap.complete)
4596
+ flags.push("INCOMPLETE");
4597
+ if (snap.auditId)
4598
+ flags.push(`audit=${snap.auditId}`);
4599
+ const suffix = flags.length ? ` (${flags.join(", ")})` : "";
4600
+ keyValue(snap.id, `${snap.createdAt.toISOString()}${suffix}`);
4601
+ }
4602
+ if (!options.list) {
4603
+ info("");
4604
+ info("Run 'primitive sync revert --snapshot <id>' to restore one, " +
4605
+ "or 'primitive sync revert' to restore the most recent.");
4606
+ }
4607
+ return;
4608
+ }
4609
+ // Resolve the target snapshot (exact id, unique prefix, or most-recent).
4610
+ let target;
4611
+ try {
4612
+ target = resolveSnapshot(snapshotsRoot, options.snapshot);
4613
+ }
4614
+ catch (err) {
4615
+ error(err?.message ?? String(err));
4616
+ process.exit(1);
4617
+ }
4618
+ if (!target.complete) {
4619
+ error(`Snapshot ${target.id} is incomplete (missing the integrity marker) and cannot be restored safely.`);
4620
+ process.exit(1);
4621
+ }
4622
+ // Confirmation, with a dirty-git warning.
4623
+ if (!options.yes) {
4624
+ if (await hasUncommittedChanges(configDir)) {
4625
+ warn(`You have uncommitted git changes under ${configDir}. ` +
4626
+ "Reverting will overwrite them.");
4627
+ }
4628
+ let confirm;
4629
+ try {
4630
+ confirm = await confirmPrompt(`Restore snapshot ${target.id} into ${configDir}? This overwrites the current sync directory.`);
4631
+ }
4632
+ catch (err) {
4633
+ error(err.message);
4634
+ process.exit(1);
4635
+ }
4636
+ if (!confirm) {
4637
+ info("Cancelled.");
4638
+ return;
4639
+ }
4640
+ }
4641
+ try {
4642
+ // Under legacy `--dir`, snapshotsRoot lives inside configDir; preserve
4643
+ // it across the full-tree swap so we don't wipe backup history.
4644
+ restoreSnapshot(target.path, configDir, { preserveDir: snapshotsRoot });
4645
+ }
4646
+ catch (err) {
4647
+ error(`Restore failed: ${err?.message ?? err}`);
4648
+ process.exit(1);
4649
+ }
4650
+ success(`Restored snapshot ${target.id} into ${configDir} (including .primitive-sync.json).`);
4651
+ info("Run 'primitive sync diff' to inspect the restored state versus the server.");
4652
+ });
4653
+ }
4654
+ /**
4655
+ * Best-effort check for uncommitted git changes under `dir`. Used only to warn
4656
+ * before a revert overwrites local edits — never fatal. Returns false if git
4657
+ * isn't available, the dir isn't in a repo, or anything goes wrong.
4658
+ */
4659
+ async function hasUncommittedChanges(dir) {
4660
+ if (!existsSync(dir))
4661
+ return false;
4662
+ try {
4663
+ const { execSync } = await import("child_process");
4664
+ const out = execSync(`git status --porcelain -- "${dir}"`, {
4665
+ stdio: ["ignore", "pipe", "ignore"],
4666
+ encoding: "utf-8",
4667
+ });
4668
+ return out.trim().length > 0;
4669
+ }
4670
+ catch {
4671
+ return false;
4672
+ }
4267
4673
  }
4268
4674
  //# sourceMappingURL=sync.js.map