primitive-admin 1.1.0-alpha.74 → 1.1.0-alpha.76

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 (128) hide show
  1. package/dist/bin/primitive.js +110 -21
  2. package/dist/bin/primitive.js.map +1 -1
  3. package/dist/src/commands/collection-type-configs.js +1 -1
  4. package/dist/src/commands/collection-type-configs.js.map +1 -1
  5. package/dist/src/commands/config.js +8 -4
  6. package/dist/src/commands/config.js.map +1 -1
  7. package/dist/src/commands/database-type-configs.js +1 -1
  8. package/dist/src/commands/database-type-configs.js.map +1 -1
  9. package/dist/src/commands/databases.js +25 -31
  10. package/dist/src/commands/databases.js.map +1 -1
  11. package/dist/src/commands/documents.js +9 -24
  12. package/dist/src/commands/documents.js.map +1 -1
  13. package/dist/src/commands/env.d.ts +1 -1
  14. package/dist/src/commands/env.js +14 -11
  15. package/dist/src/commands/env.js.map +1 -1
  16. package/dist/src/commands/functions.js +173 -4
  17. package/dist/src/commands/functions.js.map +1 -1
  18. package/dist/src/commands/groups.js +1 -1
  19. package/dist/src/commands/groups.js.map +1 -1
  20. package/dist/src/commands/init.d.ts +12 -15
  21. package/dist/src/commands/init.js +36 -77
  22. package/dist/src/commands/init.js.map +1 -1
  23. package/dist/src/commands/prompts.js +54 -39
  24. package/dist/src/commands/prompts.js.map +1 -1
  25. package/dist/src/commands/rule-sets.js +1 -1
  26. package/dist/src/commands/rule-sets.js.map +1 -1
  27. package/dist/src/commands/scripts.js +17 -4
  28. package/dist/src/commands/scripts.js.map +1 -1
  29. package/dist/src/commands/sync-app-settings.js +2 -4
  30. package/dist/src/commands/sync-app-settings.js.map +1 -1
  31. package/dist/src/commands/sync.d.ts +41 -2
  32. package/dist/src/commands/sync.js +635 -364
  33. package/dist/src/commands/sync.js.map +1 -1
  34. package/dist/src/commands/workflows.js +62 -31
  35. package/dist/src/commands/workflows.js.map +1 -1
  36. package/dist/src/lib/api-client.d.ts +23 -2
  37. package/dist/src/lib/api-client.js +24 -5
  38. package/dist/src/lib/api-client.js.map +1 -1
  39. package/dist/src/lib/app-match-guard.d.ts +44 -0
  40. package/dist/src/lib/app-match-guard.js +72 -0
  41. package/dist/src/lib/app-match-guard.js.map +1 -0
  42. package/dist/src/lib/block-selector.d.ts +58 -0
  43. package/dist/src/lib/block-selector.js +92 -0
  44. package/dist/src/lib/block-selector.js.map +1 -0
  45. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +11 -8
  46. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +108 -54
  47. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  48. package/dist/src/lib/config-object-descriptor.js +1 -3
  49. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  50. package/dist/src/lib/config.d.ts +2 -2
  51. package/dist/src/lib/config.js +2 -2
  52. package/dist/src/lib/credentials-store.d.ts +3 -3
  53. package/dist/src/lib/credentials-store.js +3 -3
  54. package/dist/src/lib/credentials-store.js.map +1 -1
  55. package/dist/src/lib/document-export-permissions.d.ts +30 -0
  56. package/dist/src/lib/document-export-permissions.js +54 -0
  57. package/dist/src/lib/document-export-permissions.js.map +1 -0
  58. package/dist/src/lib/env-resolver-core.d.ts +41 -9
  59. package/dist/src/lib/env-resolver-core.js +84 -13
  60. package/dist/src/lib/env-resolver-core.js.map +1 -1
  61. package/dist/src/lib/env-resolver.d.ts +3 -3
  62. package/dist/src/lib/env-resolver.js +2 -2
  63. package/dist/src/lib/function-bundle.d.ts +11 -1
  64. package/dist/src/lib/function-bundle.js +13 -1
  65. package/dist/src/lib/function-bundle.js.map +1 -1
  66. package/dist/src/lib/function-collect.d.ts +123 -0
  67. package/dist/src/lib/function-collect.js +463 -0
  68. package/dist/src/lib/function-collect.js.map +1 -0
  69. package/dist/src/lib/function-db-types.d.ts +149 -0
  70. package/dist/src/lib/function-db-types.js +587 -0
  71. package/dist/src/lib/function-db-types.js.map +1 -0
  72. package/dist/src/lib/function-grants-preflight.d.ts +64 -0
  73. package/dist/src/lib/function-grants-preflight.js +100 -0
  74. package/dist/src/lib/function-grants-preflight.js.map +1 -0
  75. package/dist/src/lib/function-sync.d.ts +23 -0
  76. package/dist/src/lib/function-sync.js +65 -1
  77. package/dist/src/lib/function-sync.js.map +1 -1
  78. package/dist/src/lib/function-triggers.d.ts +6 -0
  79. package/dist/src/lib/function-triggers.js +63 -4
  80. package/dist/src/lib/function-triggers.js.map +1 -1
  81. package/dist/src/lib/generated-config-surfaces.d.ts +340 -0
  82. package/dist/src/lib/generated-config-surfaces.js +669 -17
  83. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  84. package/dist/src/lib/generated-sdk-types.d.ts +12 -0
  85. package/dist/src/lib/generated-sdk-types.js +13 -0
  86. package/dist/src/lib/generated-sdk-types.js.map +1 -0
  87. package/dist/src/lib/init-adopt.js +2 -2
  88. package/dist/src/lib/init-adopt.js.map +1 -1
  89. package/dist/src/lib/init-assets.js +3 -3
  90. package/dist/src/lib/init-config.d.ts +1 -1
  91. package/dist/src/lib/init-ios-links.js +1 -1
  92. package/dist/src/lib/init-ios-links.js.map +1 -1
  93. package/dist/src/lib/init-plan.d.ts +1 -1
  94. package/dist/src/lib/init-plan.js +1 -1
  95. package/dist/src/lib/init-xcode.d.ts +2 -2
  96. package/dist/src/lib/init-xcode.js +6 -5
  97. package/dist/src/lib/init-xcode.js.map +1 -1
  98. package/dist/src/lib/local-state.d.ts +1 -1
  99. package/dist/src/lib/local-state.js +1 -1
  100. package/dist/src/lib/local-test-cases.d.ts +1 -1
  101. package/dist/src/lib/local-test-cases.js +2 -1
  102. package/dist/src/lib/local-test-cases.js.map +1 -1
  103. package/dist/src/lib/migration-nag.d.ts +2 -2
  104. package/dist/src/lib/migration-nag.js +4 -3
  105. package/dist/src/lib/migration-nag.js.map +1 -1
  106. package/dist/src/lib/project-config.d.ts +23 -20
  107. package/dist/src/lib/project-config.js +27 -45
  108. package/dist/src/lib/project-config.js.map +1 -1
  109. package/dist/src/lib/snapshots.d.ts +2 -2
  110. package/dist/src/lib/snapshots.js +2 -2
  111. package/dist/src/lib/swift-codegen/generator.d.ts +1 -1
  112. package/dist/src/lib/swift-codegen/generator.js +1 -1
  113. package/dist/src/lib/sync-dir-selector.d.ts +1 -1
  114. package/dist/src/lib/sync-dir-selector.js +1 -1
  115. package/dist/src/lib/sync-paths.d.ts +52 -16
  116. package/dist/src/lib/sync-paths.js +68 -22
  117. package/dist/src/lib/sync-paths.js.map +1 -1
  118. package/dist/src/lib/sync-resource-types.d.ts +2 -2
  119. package/dist/src/lib/sync-resource-types.js +2 -2
  120. package/dist/src/lib/template.d.ts +2 -2
  121. package/dist/src/lib/template.js +2 -2
  122. package/dist/src/lib/test-case-keys.d.ts +1 -1
  123. package/dist/src/lib/test-case-keys.js +1 -1
  124. package/dist/src/lib/toml-database-config.js +19 -2
  125. package/dist/src/lib/toml-database-config.js.map +1 -1
  126. package/dist/src/lib/workflow-codegen/generator.d.ts +1 -1
  127. package/dist/src/lib/workflow-codegen/generator.js +1 -1
  128. package/package.json +3 -3
@@ -9,7 +9,8 @@ import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMet
9
9
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
10
10
  import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
11
11
  import { getServerUrl, resolveAppId } from "../lib/config.js";
12
- import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegacySyncMigration, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
12
+ import { assertTreeApp } from "../lib/app-match-guard.js";
13
+ import { resolveSyncDir, resolveSnapshotsRoot, assertSyncStateLayout, syncStatePath, SYNC_STATE_FILENAME, isAutoResolvedSyncDir, checkLegacySyncMigration, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
13
14
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
14
15
  import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
15
16
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
@@ -36,6 +37,12 @@ import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfac
36
37
  // `config push` sends, and the bytes `config pull` writes back.
37
38
  import { applyFunctionPull, buildFunctionConfigPush, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
38
39
  import { FunctionBuildError } from "../lib/function-bundle.js";
40
+ // #3182 — the local half of capability validation. The grammar is the vendored
41
+ // module the server also imports; this is where an author's own file is read.
42
+ import { configObjectKeysFromTree, functionGrantErrors, mergeKnownGrantTargets, } from "../lib/function-grants-preflight.js";
43
+ // #3182 — the typed handle's generated declarations and the compiler wiring
44
+ // that makes an editor load them.
45
+ import { functionTypeWiringProblems, staleFunctionTypeArtifacts, writeFunctionTypeArtifacts, } from "../lib/function-db-types.js";
39
46
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
40
47
  import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
41
48
  import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
@@ -68,7 +75,7 @@ function printStaleInstanceWarning(updated) {
68
75
  }
69
76
  }
70
77
  function loadSyncState(configDir) {
71
- const syncFile = join(configDir, ".primitive-sync.json");
78
+ const syncFile = syncStatePath(configDir);
72
79
  if (!existsSync(syncFile)) {
73
80
  return null;
74
81
  }
@@ -80,7 +87,7 @@ function loadSyncState(configDir) {
80
87
  }
81
88
  }
82
89
  function saveSyncState(configDir, state) {
83
- const syncFile = join(configDir, ".primitive-sync.json");
90
+ const syncFile = syncStatePath(configDir);
84
91
  writeFileSync(syncFile, JSON.stringify(state, null, 2));
85
92
  }
86
93
  /**
@@ -3202,6 +3209,114 @@ export function diffProjectedRecords(local, remote) {
3202
3209
  }
3203
3210
  return diffs;
3204
3211
  }
3212
+ /**
3213
+ * The paths at which two projected records disagree, named the way the FILE
3214
+ * spells them (#3267) — what a `config diff` Modified row prints.
3215
+ *
3216
+ * `diffProjectedRecords` answers at the granularity of the projection, and
3217
+ * for a database type that is too coarse to act on: the 27 operations arrive
3218
+ * as one `operations` extra, the whole `[models.*]` tree as one `schema`
3219
+ * string. A row that read Modified for a reason the operator could not see —
3220
+ * an empty params set spelled two ways, an operation block the file declares
3221
+ * twice — was undiagnosable from the CLI. So this walks INTO each differing
3222
+ * field: a keyed child collection (`spec.childKeys`) is compared row by row
3223
+ * and named `operations["getInstitution"].params`, with a row only one side
3224
+ * has or that the file repeats named as such; a TOML-string field
3225
+ * (`spec.tomlFields`) is parsed on both sides and named by the path inside it
3226
+ * (`schema (models.Institution.fields.name.required)`); everything else is
3227
+ * walked as a value to its first differing leaf.
3228
+ *
3229
+ * Paths come back in projected-key order, keyed rows in key order, so the
3230
+ * list is stable across runs, and every leaf is its own entry — a schema
3231
+ * that differs in seven model fields is seven paths, so the caller's "+N
3232
+ * more" cap counts them the way it counts operations. A value the walk
3233
+ * cannot descend into (an array of a different length, a scalar against an
3234
+ * object) is named at the level it stopped.
3235
+ */
3236
+ export function describeProjectedDifferences(spec, local, remote) {
3237
+ const paths = [];
3238
+ for (const { field, local: l, server: r } of diffProjectedRecords(local, remote)) {
3239
+ const childKey = spec.childKeys?.[field];
3240
+ if (childKey && Array.isArray(l) && Array.isArray(r)) {
3241
+ paths.push(...keyedChildDifferences(field, childKey, l, r));
3242
+ continue;
3243
+ }
3244
+ if (spec.tomlFields?.includes(field) && typeof l === "string" && typeof r === "string") {
3245
+ let inner;
3246
+ try {
3247
+ inner = valueDifferences("", parseConfigToml(l), parseConfigToml(r));
3248
+ }
3249
+ catch {
3250
+ // A side that does not parse is named as the whole field below.
3251
+ }
3252
+ if (inner) {
3253
+ if (inner.length === 0) {
3254
+ paths.push(field);
3255
+ }
3256
+ else {
3257
+ paths.push(...inner.map((path) => `${field} (${path})`));
3258
+ }
3259
+ continue;
3260
+ }
3261
+ }
3262
+ paths.push(...valueDifferences(field, l, r));
3263
+ }
3264
+ return paths;
3265
+ }
3266
+ /** The leaf paths under `path` at which two values differ (see above). */
3267
+ function valueDifferences(path, a, b) {
3268
+ if (canonicalJsonStringify(a) === canonicalJsonStringify(b))
3269
+ return [];
3270
+ const join = (key) => (path ? `${path}.${key}` : key);
3271
+ if (isPlainObject(a) && isPlainObject(b)) {
3272
+ const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
3273
+ return keys.flatMap((key) => valueDifferences(join(key), a[key], b[key]));
3274
+ }
3275
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
3276
+ return a.flatMap((item, i) => valueDifferences(`${path}[${i}]`, item, b[i]));
3277
+ }
3278
+ return [path || "(document)"];
3279
+ }
3280
+ /**
3281
+ * A keyed child collection's differences, row by row. A key the file repeats
3282
+ * is named as the cause rather than compared: the server keys rows by it, so
3283
+ * only one of the blocks landed and no push can make the two sides agree
3284
+ * while the file says two things under one name.
3285
+ */
3286
+ function keyedChildDifferences(field, key, local, remote) {
3287
+ const label = (k) => `${field}[${JSON.stringify(k)}]`;
3288
+ const localByKey = new Map();
3289
+ for (const row of local) {
3290
+ const k = String(row?.[key] ?? "");
3291
+ localByKey.set(k, [...(localByKey.get(k) ?? []), row]);
3292
+ }
3293
+ const remoteByKey = new Map(remote.map((row) => [String(row?.[key] ?? ""), row]));
3294
+ const keys = [...new Set([...localByKey.keys(), ...remoteByKey.keys()])].sort();
3295
+ const paths = [];
3296
+ for (const k of keys) {
3297
+ const rows = localByKey.get(k);
3298
+ const remoteRow = remoteByKey.get(k);
3299
+ if (rows && rows.length > 1) {
3300
+ paths.push(`${label(k)} (declared ${rows.length} times locally)`);
3301
+ }
3302
+ else if (!rows) {
3303
+ paths.push(`${label(k)} (server only)`);
3304
+ }
3305
+ else if (remoteRow === undefined) {
3306
+ paths.push(`${label(k)} (local only)`);
3307
+ }
3308
+ else {
3309
+ paths.push(...valueDifferences(label(k), rows[0], remoteRow));
3310
+ }
3311
+ }
3312
+ return paths;
3313
+ }
3314
+ function isPlainObject(value) {
3315
+ return (value !== null &&
3316
+ typeof value === "object" &&
3317
+ !Array.isArray(value) &&
3318
+ typeof value.toJSON !== "function");
3319
+ }
3205
3320
  /**
3206
3321
  * The differing fields the definition says an UPDATE does not accept (#2731 B8).
3207
3322
  *
@@ -3402,6 +3517,12 @@ const CONFIG_DIFF_SPECS = {
3402
3517
  entry: entity.entry ?? null,
3403
3518
  durable: entity.durable === true,
3404
3519
  limits: entity.limits ?? null,
3520
+ // #3182 — the grants are version-carried too, and a widened grant set is
3521
+ // exactly the change a reviewer must not read as Synced. Sorted because
3522
+ // the row stores a set.
3523
+ capabilities: Array.isArray(entity.capabilities)
3524
+ ? [...entity.capabilities].map((c) => String(c)).sort()
3525
+ : [],
3405
3526
  }),
3406
3527
  describe: (entity) => `function "${entity?.functionKey}"`,
3407
3528
  },
@@ -3678,6 +3799,8 @@ const CONFIG_DIFF_SPECS = {
3678
3799
  subscriptions: byKey(entity.__subscriptions, "subscriptionKey"),
3679
3800
  _unresolvedRuleSetName: entity._unresolvedRuleSetName,
3680
3801
  }),
3802
+ childKeys: { operations: "name", subscriptions: "subscriptionKey" },
3803
+ tomlFields: ["schema"],
3681
3804
  ruleSetRef: true,
3682
3805
  describe: (entity) => `database type "${entity?.databaseType}"`,
3683
3806
  },
@@ -3772,9 +3895,11 @@ export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
3772
3895
  const localHash = computeExpandedContentHash(pair.local);
3773
3896
  if (remoteHash === localHash)
3774
3897
  return { status: "exists" };
3898
+ // #3267 — WHICH field, ahead of the remedy, so a row that differs for a
3899
+ // reason the operator cannot see in the file is diagnosable from the CLI.
3775
3900
  return {
3776
3901
  status: "modified",
3777
- hint: "run `config pull` to rewrite the local TOML to match running state",
3902
+ hint: namedDifferences(describeProjectedDifferences(spec, pair.local, pair.remote)) + "run `config pull` to rewrite the local TOML to match running state",
3778
3903
  };
3779
3904
  }
3780
3905
  catch {
@@ -3783,6 +3908,24 @@ export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
3783
3908
  return { status: "exists", hint: "content not compared (comparison failed)" };
3784
3909
  }
3785
3910
  }
3911
+ /**
3912
+ * How many differing paths a Modified row names before saying "+N more"
3913
+ * (#3267). Enough to see the shape of a difference at a glance; the row is
3914
+ * one line of a whole-tree diff, not the place to print 27 operations.
3915
+ */
3916
+ const NAMED_DIFFERENCE_LIMIT = 5;
3917
+ /**
3918
+ * The `differs in …; ` prefix of a Modified row's hint (#3267), or nothing
3919
+ * when the walk could name no path — the hash still differs, so the row is
3920
+ * still Modified, but the hint then says only what it always said.
3921
+ */
3922
+ function namedDifferences(paths) {
3923
+ if (paths.length === 0)
3924
+ return "";
3925
+ const shown = paths.slice(0, NAMED_DIFFERENCE_LIMIT).join(", ");
3926
+ const more = paths.length - NAMED_DIFFERENCE_LIMIT;
3927
+ return `differs in ${shown}${more > 0 ? ` (+${more} more)` : ""}; `;
3928
+ }
3786
3929
  /** Hash a LOCAL config file's definition-projected field set (#2644). */
3787
3930
  export function hashLocalConfigForDiff(spec, parsedToml, maps, extra) {
3788
3931
  return computeExpandedContentHash(projectLocalConfig(spec, parsedToml, maps, extra));
@@ -4222,7 +4365,7 @@ export function configSemanticHash(label, filePath, maps) {
4222
4365
  /**
4223
4366
  * The sync state `config push` writes into (#2731 B6, closing #2374).
4224
4367
  *
4225
- * `loadSyncState` answers `null` for an absent `.primitive-sync.json`, and
4368
+ * `loadSyncState` answers `null` for an absent sync-state baseline, and
4226
4369
  * every per-entity state write in the push loops is guarded by `if (syncState)`
4227
4370
  * — so a first push created every resource and recorded none of them. The next
4228
4371
  * push then re-entered the create path for everything and depended on 409
@@ -6276,7 +6419,7 @@ function readSelection(options) {
6276
6419
  * `registerConfigCommands` created rather than making one of its own.
6277
6420
  *
6278
6421
  * The module keeps its name and its ~50 exported helpers: `sync.ts`,
6279
- * `resolveSyncDir`, `.primitive-sync.json` and `.primitive/sync/<env>/<appId>/`
6422
+ * `resolveSyncDir`, the sync-state baseline and `primitive/<env>/`
6280
6423
  * are internal identifiers, deliberately left alone (#2759 scope).
6281
6424
  */
6282
6425
  export function registerConfigSyncCommands(sync) {
@@ -6288,8 +6431,14 @@ export function registerConfigSyncCommands(sync) {
6288
6431
  .option("--app <app-id>", "App ID")
6289
6432
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
6290
6433
  .action(async (appId, options) => {
6434
+ // Refused before any tree read/write or request (#3153, F1): the tree
6435
+ // path no longer partitions by app.
6436
+ assertTreeApp(appId || options.app, "config init");
6291
6437
  const resolvedAppId = resolveAppId(appId, options);
6292
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
6438
+ const configDir = resolveSyncDir({ userDir: options.dir });
6439
+ // A baseline that was never renamed means an unmigrated tree; refused
6440
+ // here, before any request, snapshot or directory is made (#3153).
6441
+ assertSyncStateLayout(configDir);
6293
6442
  if (isAutoResolvedSyncDir(options.dir)) {
6294
6443
  info(`Using per-environment config directory: ${configDir}`);
6295
6444
  const legacyHint = checkLegacySyncMigration(configDir);
@@ -6336,8 +6485,14 @@ export function registerConfigSyncCommands(sync) {
6336
6485
  (value, previous) => [...(previous ?? []), value], [])
6337
6486
  .option("--no-prune", "Keep local files for entities that are absent from the sync export (the next push will re-create them)")
6338
6487
  .action(async (appId, options) => {
6488
+ // Refused before any tree read/write or request (#3153, F1): the tree
6489
+ // path no longer partitions by app.
6490
+ assertTreeApp(appId || options.app, "config pull");
6339
6491
  const resolvedAppId = resolveAppId(appId, options);
6340
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
6492
+ const configDir = resolveSyncDir({ userDir: options.dir });
6493
+ // A baseline that was never renamed means an unmigrated tree; refused
6494
+ // here, before any request, snapshot or directory is made (#3153).
6495
+ assertSyncStateLayout(configDir);
6341
6496
  if (isAutoResolvedSyncDir(options.dir) && !options.json) {
6342
6497
  info(`Using per-environment config directory: ${configDir}`);
6343
6498
  const legacyHint = checkLegacySyncMigration(configDir);
@@ -7125,6 +7280,16 @@ export function registerConfigSyncCommands(sync) {
7125
7280
  };
7126
7281
  info(` Wrote database-type-configs/${filename}`);
7127
7282
  }
7283
+ // #3182 — the typed-handle artifacts are REGENERATED, never pulled:
7284
+ // they are not in the envelope, and a fresh clone must end up with the
7285
+ // same three files a push would have written. Written here, after the
7286
+ // database types, so the declaration describes what this pull wrote.
7287
+ {
7288
+ const artifacts = writeFunctionTypeArtifacts(configDir);
7289
+ if (artifacts.length > 0) {
7290
+ info(` Wrote function types: ${artifacts.join(", ")}`);
7291
+ }
7292
+ }
7128
7293
  // Write rule sets
7129
7294
  const ruleSetEntities = {};
7130
7295
  for (const ruleSet of ruleSets) {
@@ -7618,8 +7783,14 @@ export function registerConfigSyncCommands(sync) {
7618
7783
  .option("-y, --yes", "Skip the interactive confirmation before pruning (for CI / automation)")
7619
7784
  .option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
7620
7785
  .action(async (appId, options) => {
7786
+ // Refused before any tree read/write or request (#3153, F1): the tree
7787
+ // path no longer partitions by app.
7788
+ assertTreeApp(appId || options.app, "config push");
7621
7789
  const resolvedAppId = resolveAppId(appId, options);
7622
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
7790
+ const configDir = resolveSyncDir({ userDir: options.dir });
7791
+ // A baseline that was never renamed means an unmigrated tree; refused
7792
+ // here, before any request, snapshot or directory is made (#3153).
7793
+ assertSyncStateLayout(configDir);
7623
7794
  if (isAutoResolvedSyncDir(options.dir)) {
7624
7795
  info(`Using per-environment config directory: ${configDir}`);
7625
7796
  const legacyHint = checkLegacySyncMigration(configDir);
@@ -8086,7 +8257,7 @@ export function registerConfigSyncCommands(sync) {
8086
8257
  // known-bad sibling file (e.g. an invalid workflow TOML) could let an
8087
8258
  // earlier create (a cron trigger) be applied, then abort. The
8088
8259
  // `process.exit` also bypassed the save-on-failure catch below, so the
8089
- // orphaned create never reached `.primitive-sync.json` and the retry
8260
+ // orphaned create never reached the sync-state baseline and the retry
8090
8261
  // re-issued a CREATE that the server rejected with 409 forever.
8091
8262
  //
8092
8263
  // Running every validation up-front means a known-bad file aborts
@@ -8268,6 +8439,56 @@ export function registerConfigSyncCommands(sync) {
8268
8439
  preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
8269
8440
  }
8270
8441
  }
8442
+ // #3182 — a function's capabilities, checked against the author's own
8443
+ // file before anything is applied. The grammar is purely local (and
8444
+ // since #3279 it keeps three kinds: `integration:<key>`,
8445
+ // `secret:<NAME>` and the high-blast opt-ins — a retired grant string
8446
+ // is refused here with the retirement); whether an `integration:`
8447
+ // names an integration that will exist is answered against the
8448
+ // tree's own `integrations/` (applied BEFORE functions) merged with
8449
+ // the integrations the server already has. If the live listing cannot
8450
+ // be fetched and the tree declares none, the target check is skipped
8451
+ // and the server — which is authoritative — has the last word.
8452
+ const preflightFunctionsDir = join(configDir, "functions");
8453
+ if (existsSync(preflightFunctionsDir)) {
8454
+ const functionFiles = selectFiles("function", preflightFunctionsDir, readdirSync(preflightFunctionsDir).filter((f) => f.endsWith(".toml")));
8455
+ if (functionFiles.length > 0) {
8456
+ const treeTargets = configObjectKeysFromTree(configDir);
8457
+ let knownTargets = null;
8458
+ try {
8459
+ const integrations = await client.listIntegrations(resolvedAppId, {
8460
+ status: "active",
8461
+ });
8462
+ knownTargets = mergeKnownGrantTargets({
8463
+ integrations: new Set((integrations?.items ?? [])
8464
+ .map((row) => String(row?.integrationKey ?? ""))
8465
+ .filter(Boolean)),
8466
+ }, treeTargets);
8467
+ }
8468
+ catch {
8469
+ knownTargets =
8470
+ treeTargets.integrations.size > 0 ? treeTargets : null;
8471
+ }
8472
+ for (const file of functionFiles) {
8473
+ const filePath = join(preflightFunctionsDir, file);
8474
+ let tomlData;
8475
+ try {
8476
+ tomlData = parseTomlFile(filePath);
8477
+ }
8478
+ catch (err) {
8479
+ preflightValidationErrors.push(` ${filePath}: ${err?.message || String(err)}`);
8480
+ continue;
8481
+ }
8482
+ for (const message of functionGrantErrors({
8483
+ filePath,
8484
+ tomlData,
8485
+ knownTargets,
8486
+ })) {
8487
+ preflightValidationErrors.push(` ${message}`);
8488
+ }
8489
+ }
8490
+ }
8491
+ }
8271
8492
  // #2644 — reject any key the vendored definition does not recognize,
8272
8493
  // for every migrated configuration type. Same posture the `[workflow]`
8273
8494
  // check above has, generalized: a mistyped key fails here, in the
@@ -10865,332 +11086,77 @@ export function registerConfigSyncCommands(sync) {
10865
11086
  }
10866
11087
  }
10867
11088
  }
10868
- // Process server functions — #3179.
10869
- //
10870
- // A function pushes on TWO axes and they are deliberately independent:
10871
- //
10872
- // - the HEADER (`[function]`'s gate, runAs, schemas, plus the three
10873
- // version-carried keys, all compared through the shared gate), and
10874
- // - the CODE — the authored TOML bytes, every source file and the
10875
- // built bundle, sent as one immutable config VERSION.
10876
- //
10877
- // Independent because a comment-only edit to a source file leaves the
10878
- // header identical and must still make a version (F-003): gating the
10879
- // code push on the header comparison would silently discard it. The
10880
- // code axis compares `envelopeHash`, which is also what the SERVER
10881
- // dedupes on, so an unchanged push makes no version on either side's
10882
- // reckoning.
10883
- //
10884
- // The bundle is built FIRST, so a build error, a missing entry or an
10885
- // import that escapes the config tree aborts this function before
10886
- // anything is sent for it (behavior 12).
10887
- const functionsPushDir = join(configDir, "functions");
10888
- if (existsSync(functionsPushDir)) {
10889
- const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter((f) => f.endsWith(".toml")));
10890
- const functionSpec = configDiffSpec("function");
10891
- const liveFunctions = new Map();
10892
- let functionsLiveOk = true;
10893
- if (files.length > 0) {
10894
- try {
10895
- const listed = await client.listFunctions(resolvedAppId);
10896
- for (const f of (listed?.items ?? [])) {
10897
- if (f?.functionKey)
10898
- liveFunctions.set(f.functionKey, f);
11089
+ // Process workflows
11090
+ const workflowsDir = join(configDir, "workflows");
11091
+ if (existsSync(workflowsDir)) {
11092
+ const files = selectFiles("workflow", workflowsDir, readdirSync(workflowsDir).filter((f) => f.endsWith(".toml")));
11093
+ // #2896 — pairing for a clone with no sync state. A workflow the
11094
+ // manifest has no row for is not thereby a create: the create path
11095
+ // below already adopts an existing workflow by key on the server's
11096
+ // 409 (#1006/#1174), so letting live state answer the same question
11097
+ // one step earlier is that same adopt — reached before a doomed
11098
+ // create is sent, which is what the prompt reconcile does (#2909).
11099
+ // Without it a stateless clone plans "create workflow" for every
11100
+ // workflow the server already holds, and its `.tests/` sidecars —
11101
+ // addressed through the sync-state id the plan never records —
11102
+ // cascade into duplicate test-case creates. Fetched once, and only
11103
+ // when a file is actually untracked; a failed listing degrades to
11104
+ // the create-then-409 path, which still converges.
11105
+ let liveWorkflowIdsByKey;
11106
+ const lookupLiveWorkflowId = async (workflowKey) => {
11107
+ if (liveWorkflowIdsByKey === undefined) {
11108
+ try {
11109
+ const live = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
11110
+ liveWorkflowIdsByKey = new Map(live
11111
+ .filter((w) => w?.workflowKey && w?.workflowId)
11112
+ .map((w) => [
11113
+ w.workflowKey,
11114
+ w.workflowId,
11115
+ ]));
11116
+ }
11117
+ catch (err) {
11118
+ liveWorkflowIdsByKey = null;
11119
+ warn(` workflows: live state unavailable (${err instanceof Error ? err.message : String(err)}) — an untracked workflow plans a create and adopts on the server's conflict`);
10899
11120
  }
10900
11121
  }
10901
- catch (err) {
10902
- functionsLiveOk = false;
10903
- warnLiveUnavailable("functions", err);
10904
- }
10905
- }
10906
- /** The version a function currently runs, or null. */
10907
- const liveActiveVersion = async (functionId, activeConfigId) => {
10908
- if (!activeConfigId)
10909
- return null;
10910
- const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
10911
- return ((items || []).find((c) => String(c.configId) === String(activeConfigId)) ?? null);
11122
+ return liveWorkflowIdsByKey?.get(workflowKey);
10912
11123
  };
10913
11124
  for (const file of files) {
10914
- const filePath = join(functionsPushDir, file);
11125
+ const filePath = join(workflowsDir, file);
10915
11126
  const tomlData = parseTomlFile(filePath);
10916
- const key = tomlData.function?.key || basename(file, ".toml");
10917
- withDerivedIdentity(tomlData, "function", { key });
10918
- const existingEntry = syncState?.entities?.functions?.[key];
10919
- const liveFunction = liveFunctions.get(key);
10920
- // Build first: nothing is sent for a function whose code does not
10921
- // compile or whose imports leave the tree.
10922
- let push;
10923
- try {
10924
- push = await buildFunctionConfigPush({
10925
- configDir,
10926
- functionKey: key,
10927
- filePath,
10928
- tomlData,
10929
- });
10930
- }
10931
- catch (err) {
10932
- if (!(err instanceof FunctionBuildError))
10933
- throw err;
10934
- applyFailures.push({
10935
- type: "function",
10936
- key,
10937
- message: err.message,
10938
- });
11127
+ // #1660 — skip platform-owned workflows (the reserved `__internal.*`
11128
+ // namespace). Resolve the key FIRST and skip BEFORE the workflow
11129
+ // TOML validation below, so a hollow exported `__internal.*.toml`
11130
+ // (steps = [], no active config) is skipped, not failed by
11131
+ // `validateWorkflowToml`, and does not abort the whole push trying
11132
+ // to activate a configuration-less workflow. Match on the resolved
11133
+ // `[workflow].key` (not filename) so a renamed file is still caught
11134
+ // (edge case 2).
11135
+ const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
11136
+ if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
11137
+ info(` Skipped workflows/${file} — platform-owned workflow "${platformOwnedKey}" (managed by the platform)`);
10939
11138
  continue;
10940
11139
  }
10941
- // The active version, needed by BOTH axes: the header comparison
10942
- // reads `entry`/`durable`/`limits` off it, and the code axis
10943
- // compares its `envelopeHash`.
10944
- let activeVersion = null;
10945
- let versionOk = functionsLiveOk;
10946
- if (functionsLiveOk && liveFunction?.functionId) {
10947
- try {
10948
- activeVersion = await liveActiveVersion(liveFunction.functionId, liveFunction.activeConfigId);
10949
- }
10950
- catch (err) {
10951
- versionOk = false;
10952
- warnLiveUnavailable(`function ${key}`, err);
10953
- }
10954
- }
10955
- let headerAction = "skip";
10956
- if (versionOk && functionsLiveOk) {
10957
- const gate = decidePushForConfig({
10958
- spec: functionSpec,
10959
- maps: pushDiffMaps,
10960
- localParsed: tomlData,
10961
- live: liveFunction
10962
- ? {
10963
- record: liveFunction,
10964
- // The version-carried keys the remote side is projected
10965
- // through; the header row does not hold them.
10966
- extra: activeVersion ?? undefined,
10967
- modifiedAt: liveFunction.modifiedAt,
10968
- }
10969
- : null,
10970
- entry: existingEntry,
10971
- localFileHash: computeFileHash(filePath),
10972
- force: options.force,
10973
- // The create leg adopts by key on the server's 409 (#1006), so
10974
- // an untracked function the server already holds is that same
10975
- // adopt reached one step earlier.
10976
- adoptsUntrackedByKey: true,
10977
- });
10978
- if (gate.action === "drift" ||
10979
- gate.action === "conflict" ||
10980
- gate.action === "immutable") {
10981
- recordDeclined("function", key, gate, existingEntry?.modifiedAt);
10982
- continue;
10983
- }
10984
- headerAction = gate.action === "skip"
10985
- ? "skip"
10986
- : liveFunction
10987
- ? "update"
10988
- : "create";
11140
+ // Reject misnested headers (#685, e.g. [steps.<id>.request]) and
11141
+ // the #1447 runAs="system" + accessRule dead-config combo before
11142
+ // pushing. The runtime silently ignores fields outside the
11143
+ // allowlist, so this is the only place to catch the footgun, and
11144
+ // the server rejects a system accessRule (#1258) — flagging it
11145
+ // here keeps the push all-or-nothing. Validation happens BEFORE the
11146
+ // skip-if-unchanged check so a previously-pushed-but-broken
11147
+ // file gets a clear diagnostic on every push attempt.
11148
+ // Issue #976: validation is now front-loaded before any apply
11149
+ // (see the pre-flight pass above). This in-loop check remains as
11150
+ // defense-in-depth, but THROWS rather than `process.exit(1)` so the
11151
+ // single save-on-failure catch is the only exit path — never
11152
+ // bypassing it and orphaning an already-created resource.
11153
+ const tomlErrors = validateWorkflowToml(tomlData);
11154
+ if (tomlErrors.length > 0) {
11155
+ throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
10989
11156
  }
10990
- else {
10991
- const degraded = decideDegradedPush({
10992
- force: options.force,
10993
- storedContentHash: existingEntry?.contentHash,
10994
- currentFileHash: computeFileHash(filePath),
10995
- });
10996
- if (degraded.action === "decline") {
10997
- recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
10998
- continue;
10999
- }
11000
- headerAction = degraded.action === "skip"
11001
- ? "skip"
11002
- : liveFunction
11003
- ? "update"
11004
- : "create";
11005
- }
11006
- // The code axis. `envelopeHash` is the server's own no-op test, so
11007
- // matching it here means the call would create nothing.
11008
- const codeUnchanged = !!liveFunction &&
11009
- !!activeVersion &&
11010
- String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
11011
- if (headerAction === "skip" && codeUnchanged) {
11012
- skippedCount++;
11013
- if (syncState && liveFunction?.functionId) {
11014
- if (!syncState.entities.functions)
11015
- syncState.entities.functions = {};
11016
- syncState.entities.functions[key] = {
11017
- ...(existingEntry ?? {}),
11018
- id: liveFunction.functionId,
11019
- modifiedAt: liveFunction.modifiedAt ||
11020
- existingEntry?.modifiedAt ||
11021
- new Date().toISOString(),
11022
- contentHash: computeFileHash(filePath),
11023
- semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
11024
- envelopeHash: push.envelopeHash,
11025
- activeConfigId: activeVersion?.configId,
11026
- sources: push.sources.map((src) => src.path),
11027
- };
11028
- }
11029
- continue;
11030
- }
11031
- changes.push({
11032
- type: "function",
11033
- action: liveFunction ? "update" : "create",
11034
- key,
11035
- });
11036
- if (options.dryRun)
11037
- continue;
11038
- // Every server refusal below is recorded against THIS function and
11039
- // the loop moves on. A single archived tombstone — or a key the
11040
- // app's namespace already holds — must not abort a push carrying
11041
- // unrelated changes: the run reports what it could not apply, exits
11042
- // non-zero, and everything else in the tree still lands. That is
11043
- // the same contract `applyFailures` gives every other type; a throw
11044
- // here would let one refusal block a whole repository.
11045
- let functionId = liveFunction?.functionId ?? existingEntry?.id;
11046
- let pushedVersion;
11047
- try {
11048
- if (!functionId) {
11049
- const created = await client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {})).catch(async (err) => {
11050
- // #1006 — a function the server already holds under this key
11051
- // is adopted rather than re-created. The conflict may name a
11052
- // WORKFLOW, a script or a webhook instead: those are the
11053
- // single key namespace refusing the key, and they are not
11054
- // adoptable, so `adoptByKeyOnCreate409` re-throws the original
11055
- // error with its holder named.
11056
- const existing = await adoptByKeyOnCreate409({
11057
- err,
11058
- kind: "function",
11059
- key,
11060
- lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
11061
- matchKey: (f) => f?.functionKey === key,
11062
- });
11063
- return existing;
11064
- });
11065
- functionId = created.functionId;
11066
- info(` Created function: ${key}`);
11067
- }
11068
- else if (headerAction !== "skip") {
11069
- const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
11070
- if (Object.keys(updatePayload).length > 0) {
11071
- await client.updateFunction(resolvedAppId, functionId, updatePayload);
11072
- }
11073
- info(` Updated function: ${key}`);
11074
- }
11075
- // The version. `status` is NEVER in this body: pushing code to a
11076
- // disabled function updates it and leaves it out of service
11077
- // (#2803's resurrection guard), and a push to an archived one is
11078
- // refused by the server with the archived-holder message — which
11079
- // names the remedies that release its key.
11080
- pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
11081
- }
11082
- catch (err) {
11083
- applyFailures.push({
11084
- type: "function",
11085
- key,
11086
- message: String(err?.message ?? err),
11087
- });
11088
- continue;
11089
- }
11090
- if (pushedVersion?.created === false) {
11091
- info(` Function ${key}: code unchanged (no new version)`);
11092
- }
11093
- else {
11094
- info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
11095
- }
11096
- if (syncState) {
11097
- if (!syncState.entities.functions)
11098
- syncState.entities.functions = {};
11099
- let refreshed = liveFunction;
11100
- try {
11101
- refreshed = await client.getFunction(resolvedAppId, String(functionId));
11102
- }
11103
- catch {
11104
- // Non-fatal: the baseline falls back to what the listing said.
11105
- // The next push reconciles it.
11106
- }
11107
- syncState.entities.functions[key] = {
11108
- id: String(functionId),
11109
- modifiedAt: refreshed?.modifiedAt ||
11110
- existingEntry?.modifiedAt ||
11111
- new Date().toISOString(),
11112
- contentHash: computeFileHash(filePath),
11113
- semanticHash: refreshed
11114
- ? hashRemoteConfigForDiff(functionSpec, refreshed, pushDiffMaps, pushedVersion ?? undefined)
11115
- : undefined,
11116
- envelopeHash: push.envelopeHash,
11117
- activeConfigId: pushedVersion?.configId,
11118
- sources: push.sources.map((src) => src.path),
11119
- };
11120
- }
11121
- }
11122
- }
11123
- // Process workflows
11124
- const workflowsDir = join(configDir, "workflows");
11125
- if (existsSync(workflowsDir)) {
11126
- const files = selectFiles("workflow", workflowsDir, readdirSync(workflowsDir).filter((f) => f.endsWith(".toml")));
11127
- // #2896 — pairing for a clone with no sync state. A workflow the
11128
- // manifest has no row for is not thereby a create: the create path
11129
- // below already adopts an existing workflow by key on the server's
11130
- // 409 (#1006/#1174), so letting live state answer the same question
11131
- // one step earlier is that same adopt — reached before a doomed
11132
- // create is sent, which is what the prompt reconcile does (#2909).
11133
- // Without it a stateless clone plans "create workflow" for every
11134
- // workflow the server already holds, and its `.tests/` sidecars —
11135
- // addressed through the sync-state id the plan never records —
11136
- // cascade into duplicate test-case creates. Fetched once, and only
11137
- // when a file is actually untracked; a failed listing degrades to
11138
- // the create-then-409 path, which still converges.
11139
- let liveWorkflowIdsByKey;
11140
- const lookupLiveWorkflowId = async (workflowKey) => {
11141
- if (liveWorkflowIdsByKey === undefined) {
11142
- try {
11143
- const live = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
11144
- liveWorkflowIdsByKey = new Map(live
11145
- .filter((w) => w?.workflowKey && w?.workflowId)
11146
- .map((w) => [
11147
- w.workflowKey,
11148
- w.workflowId,
11149
- ]));
11150
- }
11151
- catch (err) {
11152
- liveWorkflowIdsByKey = null;
11153
- warn(` workflows: live state unavailable (${err instanceof Error ? err.message : String(err)}) — an untracked workflow plans a create and adopts on the server's conflict`);
11154
- }
11155
- }
11156
- return liveWorkflowIdsByKey?.get(workflowKey);
11157
- };
11158
- for (const file of files) {
11159
- const filePath = join(workflowsDir, file);
11160
- const tomlData = parseTomlFile(filePath);
11161
- // #1660 — skip platform-owned workflows (the reserved `__internal.*`
11162
- // namespace). Resolve the key FIRST and skip BEFORE the workflow
11163
- // TOML validation below, so a hollow exported `__internal.*.toml`
11164
- // (steps = [], no active config) is skipped, not failed by
11165
- // `validateWorkflowToml`, and does not abort the whole push trying
11166
- // to activate a configuration-less workflow. Match on the resolved
11167
- // `[workflow].key` (not filename) so a renamed file is still caught
11168
- // (edge case 2).
11169
- const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
11170
- if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
11171
- info(` Skipped workflows/${file} — platform-owned workflow "${platformOwnedKey}" (managed by the platform)`);
11172
- continue;
11173
- }
11174
- // Reject misnested headers (#685, e.g. [steps.<id>.request]) and
11175
- // the #1447 runAs="system" + accessRule dead-config combo before
11176
- // pushing. The runtime silently ignores fields outside the
11177
- // allowlist, so this is the only place to catch the footgun, and
11178
- // the server rejects a system accessRule (#1258) — flagging it
11179
- // here keeps the push all-or-nothing. Validation happens BEFORE the
11180
- // skip-if-unchanged check so a previously-pushed-but-broken
11181
- // file gets a clear diagnostic on every push attempt.
11182
- // Issue #976: validation is now front-loaded before any apply
11183
- // (see the pre-flight pass above). This in-loop check remains as
11184
- // defense-in-depth, but THROWS rather than `process.exit(1)` so the
11185
- // single save-on-failure catch is the only exit path — never
11186
- // bypassing it and orphaning an already-created resource.
11187
- const tomlErrors = validateWorkflowToml(tomlData);
11188
- if (tomlErrors.length > 0) {
11189
- throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
11190
- }
11191
- const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
11192
- if (unknownKeyError) {
11193
- throw new Error(unknownKeyError);
11157
+ const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
11158
+ if (unknownKeyError) {
11159
+ throw new Error(unknownKeyError);
11194
11160
  }
11195
11161
  const workflow = tomlData.workflow || {};
11196
11162
  const key = workflow.key || basename(file, ".toml");
@@ -12509,7 +12475,7 @@ export function registerConfigSyncCommands(sync) {
12509
12475
  //
12510
12476
  // But the 409 also fires when the local sync state is merely
12511
12477
  // ABSENT for a type that another admin has already FULLY
12512
- // configured (or whose `.primitive-sync.json` was not copied
12478
+ // configured (or whose sync-state baseline was not copied
12513
12479
  // alongside the TOML). PATCHing that without
12514
12480
  // `expectedModifiedAt` would silently overwrite the existing
12515
12481
  // triggers/access/schema with the local TOML — with no
@@ -12953,6 +12919,279 @@ export function registerConfigSyncCommands(sync) {
12953
12919
  }
12954
12920
  }
12955
12921
  }
12922
+ // Process server functions — #3179.
12923
+ //
12924
+ // A function pushes on TWO axes and they are deliberately independent:
12925
+ //
12926
+ // - the HEADER (`[function]`'s gate, schemas, plus the three
12927
+ // version-carried keys, all compared through the shared gate), and
12928
+ // - the CODE — the authored TOML bytes, every source file and the
12929
+ // built bundle, sent as one immutable config VERSION.
12930
+ //
12931
+ // Independent because a comment-only edit to a source file leaves the
12932
+ // header identical and must still make a version (F-003): gating the
12933
+ // code push on the header comparison would silently discard it. The
12934
+ // code axis compares `envelopeHash`, which is also what the SERVER
12935
+ // dedupes on, so an unchanged push makes no version on either side's
12936
+ // reckoning.
12937
+ //
12938
+ // The bundle is built FIRST, so a build error, a missing entry or an
12939
+ // import that escapes the config tree aborts this function before
12940
+ // anything is sent for it (behavior 12).
12941
+ const functionsPushDir = join(configDir, "functions");
12942
+ if (existsSync(functionsPushDir)) {
12943
+ // #3182 — the typed handle. Written BEFORE the bundles are built, so
12944
+ // an author who runs `config push` after editing a schema gets the
12945
+ // declaration their next edit is typed against, and written from the
12946
+ // TREE rather than from the server: the types describe the schemas
12947
+ // this push is applying. Regenerated every time and never part of the
12948
+ // envelope — `config pull` recreates them the same way.
12949
+ if (!options.dryRun) {
12950
+ const artifacts = writeFunctionTypeArtifacts(configDir);
12951
+ if (artifacts.length > 0) {
12952
+ info(` Wrote function types: ${artifacts.join(", ")}`);
12953
+ }
12954
+ }
12955
+ // A tsconfig push will not rewrite (comments are the author's) or
12956
+ // cannot read is still a tsconfig no compiler loads the handle
12957
+ // through. Say so rather than report success over it (CR3182-003).
12958
+ for (const problem of functionTypeWiringProblems(configDir)) {
12959
+ warn(` functions/tsconfig.json: ${problem}`);
12960
+ }
12961
+ const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter((f) => f.endsWith(".toml")));
12962
+ const functionSpec = configDiffSpec("function");
12963
+ const liveFunctions = new Map();
12964
+ let functionsLiveOk = true;
12965
+ if (files.length > 0) {
12966
+ try {
12967
+ const listed = await client.listFunctions(resolvedAppId);
12968
+ for (const f of (listed?.items ?? [])) {
12969
+ if (f?.functionKey)
12970
+ liveFunctions.set(f.functionKey, f);
12971
+ }
12972
+ }
12973
+ catch (err) {
12974
+ functionsLiveOk = false;
12975
+ warnLiveUnavailable("functions", err);
12976
+ }
12977
+ }
12978
+ /** The version a function currently runs, or null. */
12979
+ const liveActiveVersion = async (functionId, activeConfigId) => {
12980
+ if (!activeConfigId)
12981
+ return null;
12982
+ const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
12983
+ return ((items || []).find((c) => String(c.configId) === String(activeConfigId)) ?? null);
12984
+ };
12985
+ for (const file of files) {
12986
+ const filePath = join(functionsPushDir, file);
12987
+ const tomlData = parseTomlFile(filePath);
12988
+ const key = tomlData.function?.key || basename(file, ".toml");
12989
+ withDerivedIdentity(tomlData, "function", { key });
12990
+ const existingEntry = syncState?.entities?.functions?.[key];
12991
+ const liveFunction = liveFunctions.get(key);
12992
+ // Build first: nothing is sent for a function whose code does not
12993
+ // compile or whose imports leave the tree.
12994
+ let push;
12995
+ try {
12996
+ push = await buildFunctionConfigPush({
12997
+ configDir,
12998
+ functionKey: key,
12999
+ filePath,
13000
+ tomlData,
13001
+ });
13002
+ }
13003
+ catch (err) {
13004
+ if (!(err instanceof FunctionBuildError))
13005
+ throw err;
13006
+ applyFailures.push({
13007
+ type: "function",
13008
+ key,
13009
+ message: err.message,
13010
+ });
13011
+ continue;
13012
+ }
13013
+ // The active version, needed by BOTH axes: the header comparison
13014
+ // reads `entry`/`durable`/`limits` off it, and the code axis
13015
+ // compares its `envelopeHash`.
13016
+ let activeVersion = null;
13017
+ let versionOk = functionsLiveOk;
13018
+ if (functionsLiveOk && liveFunction?.functionId) {
13019
+ try {
13020
+ activeVersion = await liveActiveVersion(liveFunction.functionId, liveFunction.activeConfigId);
13021
+ }
13022
+ catch (err) {
13023
+ versionOk = false;
13024
+ warnLiveUnavailable(`function ${key}`, err);
13025
+ }
13026
+ }
13027
+ let headerAction = "skip";
13028
+ if (versionOk && functionsLiveOk) {
13029
+ const gate = decidePushForConfig({
13030
+ spec: functionSpec,
13031
+ maps: pushDiffMaps,
13032
+ localParsed: tomlData,
13033
+ live: liveFunction
13034
+ ? {
13035
+ record: liveFunction,
13036
+ // The version-carried keys the remote side is projected
13037
+ // through; the header row does not hold them.
13038
+ extra: activeVersion ?? undefined,
13039
+ modifiedAt: liveFunction.modifiedAt,
13040
+ }
13041
+ : null,
13042
+ entry: existingEntry,
13043
+ localFileHash: computeFileHash(filePath),
13044
+ force: options.force,
13045
+ // The create leg adopts by key on the server's 409 (#1006), so
13046
+ // an untracked function the server already holds is that same
13047
+ // adopt reached one step earlier.
13048
+ adoptsUntrackedByKey: true,
13049
+ });
13050
+ if (gate.action === "drift" ||
13051
+ gate.action === "conflict" ||
13052
+ gate.action === "immutable") {
13053
+ recordDeclined("function", key, gate, existingEntry?.modifiedAt);
13054
+ continue;
13055
+ }
13056
+ headerAction = gate.action === "skip"
13057
+ ? "skip"
13058
+ : liveFunction
13059
+ ? "update"
13060
+ : "create";
13061
+ }
13062
+ else {
13063
+ const degraded = decideDegradedPush({
13064
+ force: options.force,
13065
+ storedContentHash: existingEntry?.contentHash,
13066
+ currentFileHash: computeFileHash(filePath),
13067
+ });
13068
+ if (degraded.action === "decline") {
13069
+ recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
13070
+ continue;
13071
+ }
13072
+ headerAction = degraded.action === "skip"
13073
+ ? "skip"
13074
+ : liveFunction
13075
+ ? "update"
13076
+ : "create";
13077
+ }
13078
+ // The code axis. `envelopeHash` is the server's own no-op test, so
13079
+ // matching it here means the call would create nothing.
13080
+ const codeUnchanged = !!liveFunction &&
13081
+ !!activeVersion &&
13082
+ String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
13083
+ if (headerAction === "skip" && codeUnchanged) {
13084
+ skippedCount++;
13085
+ if (syncState && liveFunction?.functionId) {
13086
+ if (!syncState.entities.functions)
13087
+ syncState.entities.functions = {};
13088
+ syncState.entities.functions[key] = {
13089
+ ...(existingEntry ?? {}),
13090
+ id: liveFunction.functionId,
13091
+ modifiedAt: liveFunction.modifiedAt ||
13092
+ existingEntry?.modifiedAt ||
13093
+ new Date().toISOString(),
13094
+ contentHash: computeFileHash(filePath),
13095
+ semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
13096
+ envelopeHash: push.envelopeHash,
13097
+ activeConfigId: activeVersion?.configId,
13098
+ sources: push.sources.map((src) => src.path),
13099
+ };
13100
+ }
13101
+ continue;
13102
+ }
13103
+ changes.push({
13104
+ type: "function",
13105
+ action: liveFunction ? "update" : "create",
13106
+ key,
13107
+ });
13108
+ if (options.dryRun)
13109
+ continue;
13110
+ // Every server refusal below is recorded against THIS function and
13111
+ // the loop moves on. A single archived tombstone — or a key the
13112
+ // app's namespace already holds — must not abort a push carrying
13113
+ // unrelated changes: the run reports what it could not apply, exits
13114
+ // non-zero, and everything else in the tree still lands. That is
13115
+ // the same contract `applyFailures` gives every other type; a throw
13116
+ // here would let one refusal block a whole repository.
13117
+ let functionId = liveFunction?.functionId ?? existingEntry?.id;
13118
+ let pushedVersion;
13119
+ try {
13120
+ if (!functionId) {
13121
+ const created = await client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {})).catch(async (err) => {
13122
+ // #1006 — a function the server already holds under this key
13123
+ // is adopted rather than re-created. The conflict may name a
13124
+ // WORKFLOW, a script or a webhook instead: those are the
13125
+ // single key namespace refusing the key, and they are not
13126
+ // adoptable, so `adoptByKeyOnCreate409` re-throws the original
13127
+ // error with its holder named.
13128
+ const existing = await adoptByKeyOnCreate409({
13129
+ err,
13130
+ kind: "function",
13131
+ key,
13132
+ lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
13133
+ matchKey: (f) => f?.functionKey === key,
13134
+ });
13135
+ return existing;
13136
+ });
13137
+ functionId = created.functionId;
13138
+ info(` Created function: ${key}`);
13139
+ }
13140
+ else if (headerAction !== "skip") {
13141
+ const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
13142
+ if (Object.keys(updatePayload).length > 0) {
13143
+ await client.updateFunction(resolvedAppId, functionId, updatePayload);
13144
+ }
13145
+ info(` Updated function: ${key}`);
13146
+ }
13147
+ // The version. `status` is NEVER in this body: pushing code to a
13148
+ // disabled function updates it and leaves it out of service
13149
+ // (#2803's resurrection guard), and a push to an archived one is
13150
+ // refused by the server with the archived-holder message — which
13151
+ // names the remedies that release its key.
13152
+ pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
13153
+ }
13154
+ catch (err) {
13155
+ applyFailures.push({
13156
+ type: "function",
13157
+ key,
13158
+ message: String(err?.message ?? err),
13159
+ });
13160
+ continue;
13161
+ }
13162
+ if (pushedVersion?.created === false) {
13163
+ info(` Function ${key}: code unchanged (no new version)`);
13164
+ }
13165
+ else {
13166
+ info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
13167
+ }
13168
+ if (syncState) {
13169
+ if (!syncState.entities.functions)
13170
+ syncState.entities.functions = {};
13171
+ let refreshed = liveFunction;
13172
+ try {
13173
+ refreshed = await client.getFunction(resolvedAppId, String(functionId));
13174
+ }
13175
+ catch {
13176
+ // Non-fatal: the baseline falls back to what the listing said.
13177
+ // The next push reconciles it.
13178
+ }
13179
+ syncState.entities.functions[key] = {
13180
+ id: String(functionId),
13181
+ modifiedAt: refreshed?.modifiedAt ||
13182
+ existingEntry?.modifiedAt ||
13183
+ new Date().toISOString(),
13184
+ contentHash: computeFileHash(filePath),
13185
+ semanticHash: refreshed
13186
+ ? hashRemoteConfigForDiff(functionSpec, refreshed, pushDiffMaps, pushedVersion ?? undefined)
13187
+ : undefined,
13188
+ envelopeHash: push.envelopeHash,
13189
+ activeConfigId: pushedVersion?.configId,
13190
+ sources: push.sources.map((src) => src.path),
13191
+ };
13192
+ }
13193
+ }
13194
+ }
12956
13195
  // Process group type configs
12957
13196
  const groupTypeConfigsDir = join(configDir, "group-type-configs");
12958
13197
  if (existsSync(groupTypeConfigsDir)) {
@@ -13134,7 +13373,7 @@ export function registerConfigSyncCommands(sync) {
13134
13373
  // this, that CREATE re-hits the server's 409 "Config for this
13135
13374
  // group type already exists"
13136
13375
  // (group-type-config-controller.ts) on every subsequent push
13137
- // and the operator has to hand-edit `.primitive-sync.json` to
13376
+ // and the operator has to hand-edit the sync-state baseline to
13138
13377
  // recover. The shared helper detects the conflict (409 status /
13139
13378
  // "already exists" message), lists the app's configs — the list
13140
13379
  // route drains every page server-side (#1668), so the candidate
@@ -14275,8 +14514,14 @@ export function registerConfigSyncCommands(sync) {
14275
14514
  .option("--only <selectors>", "Compare just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated).", (value, previous) => [...(previous ?? []), value], [])
14276
14515
  .option("--json", "Output as JSON (app.toml field diff, with --only app)")
14277
14516
  .action(async (appId, options) => {
14517
+ // Refused before any tree read/write or request (#3153, F1): the tree
14518
+ // path no longer partitions by app.
14519
+ assertTreeApp(appId || options.app, "config diff");
14278
14520
  const resolvedAppId = resolveAppId(appId, options);
14279
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
14521
+ const configDir = resolveSyncDir({ userDir: options.dir });
14522
+ // A baseline that was never renamed means an unmigrated tree; refused
14523
+ // here, before any request, snapshot or directory is made (#3153).
14524
+ assertSyncStateLayout(configDir);
14280
14525
  if (isAutoResolvedSyncDir(options.dir)) {
14281
14526
  info(`Using per-environment config directory: ${configDir}`);
14282
14527
  const legacyHint = checkLegacySyncMigration(configDir);
@@ -15262,7 +15507,7 @@ export function registerConfigSyncCommands(sync) {
15262
15507
  // below). Content comparison is a preview of `config pull` (pull would
15263
15508
  // rewrite the local TOML to match running state), exactly like the
15264
15509
  // workflow/transform blocks.
15265
- const compareConfigType = (typeLabel, slot, localParsed, remoteEntities, hashLocal, hashRemote, contentComparable) => {
15510
+ const compareConfigType = (typeLabel, slot, localParsed, remoteEntities, compare, contentComparable) => {
15266
15511
  for (const [key, parsed] of localParsed) {
15267
15512
  if (!remoteEntities.has(key)) {
15268
15513
  differences.push({ type: typeLabel, key, status: localOnlyStatus(slot, key) });
@@ -15277,16 +15522,10 @@ export function registerConfigSyncCommands(sync) {
15277
15522
  hint = "content not compared (rule sets unavailable)";
15278
15523
  }
15279
15524
  else {
15280
- try {
15281
- if (hashLocal(parsed) !== hashRemote(remoteEntities.get(key))) {
15282
- status = "modified";
15283
- hint = "run `config pull` to rewrite the local TOML to match running state";
15284
- }
15285
- }
15286
- catch {
15287
- // A malformed local file must not abort the whole diff.
15288
- hint = "content not compared (comparison failed)";
15289
- }
15525
+ // The shared verdict names the differing field (#3267) and
15526
+ // degrades a malformed local file to "not compared" rather than
15527
+ // aborting the whole diff.
15528
+ ({ status, hint } = compare(parsed, remoteEntities.get(key)));
15290
15529
  }
15291
15530
  differences.push({ type: typeLabel, key, status, hint });
15292
15531
  }
@@ -15305,7 +15544,7 @@ export function registerConfigSyncCommands(sync) {
15305
15544
  };
15306
15545
  const compareByDefinition = (label, slot, localParsed, remoteEntities, contentComparable) => {
15307
15546
  const spec = configDiffSpec(label);
15308
- compareConfigType(label, slot, localParsed, remoteEntities, (p) => hashLocalConfigForDiff(spec, p, diffMaps), (e) => hashRemoteConfigForDiff(spec, e, diffMaps), contentComparable);
15547
+ compareConfigType(label, slot, localParsed, remoteEntities, (p, e) => compareLocalToRemote(spec, p, e, diffMaps), contentComparable);
15309
15548
  };
15310
15549
  compareByDefinition("rule-set", "ruleSets", localRuleSetsParsed, remoteRuleSets, true);
15311
15550
  compareByDefinition("group-type-config", "groupTypeConfigs", localGroupTypeConfigsParsed, remoteGroupTypeConfigs, ruleSetsOk);
@@ -15337,20 +15576,14 @@ export function registerConfigSyncCommands(sync) {
15337
15576
  hint = "content not compared (fetch failed)";
15338
15577
  }
15339
15578
  else {
15340
- try {
15341
- const operations = Array.isArray(opsOutcome.value) ? opsOutcome.value : [];
15342
- const subscriptions = Array.isArray(subsOutcome.value) ? subsOutcome.value : [];
15343
- const dbSpec = configDiffSpec("database-type-config");
15344
- const remoteHash = hashRemoteConfigForDiff(dbSpec, remote, diffMaps, { operations, subscriptions });
15345
- const localHash = hashLocalConfigForDiff(dbSpec, parsed, diffMaps);
15346
- if (remoteHash !== localHash) {
15347
- status = "modified";
15348
- hint = "run `config pull` to rewrite the local TOML to match running state";
15349
- }
15350
- }
15351
- catch {
15352
- hint = "content not compared (comparison failed)";
15353
- }
15579
+ const operations = Array.isArray(opsOutcome.value) ? opsOutcome.value : [];
15580
+ const subscriptions = Array.isArray(subsOutcome.value) ? subsOutcome.value : [];
15581
+ // The shared verdict (#3267): a Modified row names the
15582
+ // operation, subscription, model field or `[type]` scalar it
15583
+ // differs in, and a comparison that throws degrades this one
15584
+ // row to "not compared". The ops/subscriptions are the remote
15585
+ // side's extra only — the file already carries its own.
15586
+ ({ status, hint } = compareLocalToRemote(configDiffSpec("database-type-config"), parsed, remote, diffMaps, { operations, subscriptions }));
15354
15587
  }
15355
15588
  }
15356
15589
  differences.push({ type: "database-type-config", key, status, hint });
@@ -15650,6 +15883,29 @@ export function registerConfigSyncCommands(sync) {
15650
15883
  console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
15651
15884
  }
15652
15885
  }
15886
+ // #3182 — the generated function types are part of what a push would
15887
+ // change, so `config diff` is where their freshness is answered: an
15888
+ // editor or a CI job asks the same command it already asks "what would
15889
+ // push do?", and gets these named alongside everything else.
15890
+ const staleFunctionTypes = staleFunctionTypeArtifacts(configDir);
15891
+ if (staleFunctionTypes.length > 0) {
15892
+ console.log();
15893
+ info("Function types (regenerated by `config push`):");
15894
+ for (const relative of staleFunctionTypes) {
15895
+ console.log(` ${chalk.yellow("~")} ${relative}`);
15896
+ }
15897
+ }
15898
+ // Freshness is not the only way the handle stops working: a tsconfig
15899
+ // push will not rewrite can be perfectly current and still load none of
15900
+ // it. `config diff` is where CI asks, so it answers both (CR3182-003).
15901
+ const wiringProblems = functionTypeWiringProblems(configDir);
15902
+ if (wiringProblems.length > 0) {
15903
+ console.log();
15904
+ info("Function types (compiler wiring `config push` cannot repair):");
15905
+ for (const problem of wiringProblems) {
15906
+ console.log(` ${chalk.yellow("!")} functions/tsconfig.json: ${problem}`);
15907
+ }
15908
+ }
15653
15909
  divider();
15654
15910
  keyValue("Local only (new)", localOnly.length);
15655
15911
  keyValue("Local only (absent from export)", localOnlyAbsent.length);
@@ -15698,8 +15954,14 @@ export function registerConfigSyncCommands(sync) {
15698
15954
  // Resolve appId so we land in the per-env sync dir even when the user
15699
15955
  // didn't pass `--dir`. The app ID itself isn't used for any API calls
15700
15956
  // — `migrate-toml` is a purely local rewrite.
15957
+ // Refused before any tree read/write or request (#3153, F1): the tree
15958
+ // path no longer partitions by app.
15959
+ assertTreeApp(appId || options.app, "config migrate-toml");
15701
15960
  const resolvedAppId = resolveAppId(appId, options);
15702
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
15961
+ const configDir = resolveSyncDir({ userDir: options.dir });
15962
+ // A baseline that was never renamed means an unmigrated tree; refused
15963
+ // here, before any request, snapshot or directory is made (#3153).
15964
+ assertSyncStateLayout(configDir);
15703
15965
  if (isAutoResolvedSyncDir(options.dir)) {
15704
15966
  info(`Using per-environment config directory: ${configDir}`);
15705
15967
  }
@@ -15862,8 +16124,14 @@ export function registerConfigSyncCommands(sync) {
15862
16124
  // Resolve appId so we land in the per-env sync dir even when the user
15863
16125
  // didn't pass `--dir`. The app ID itself isn't used for any API calls —
15864
16126
  // migrate-v2 is a purely local rewrite (like migrate-toml).
16127
+ // Refused before any tree read/write or request (#3153, F1): the tree
16128
+ // path no longer partitions by app.
16129
+ assertTreeApp(appId || options.app, "config migrate-v2");
15865
16130
  const resolvedAppId = resolveAppId(appId, options);
15866
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
16131
+ const configDir = resolveSyncDir({ userDir: options.dir });
16132
+ // A baseline that was never renamed means an unmigrated tree; refused
16133
+ // here, before any request, snapshot or directory is made (#3153).
16134
+ assertSyncStateLayout(configDir);
15867
16135
  if (isAutoResolvedSyncDir(options.dir)) {
15868
16136
  info(`Using per-environment config directory: ${configDir}`);
15869
16137
  }
@@ -15913,11 +16181,14 @@ export function registerConfigSyncCommands(sync) {
15913
16181
  .option("--list", "List available snapshots without restoring")
15914
16182
  .option("-y, --yes", "Skip the confirmation prompt")
15915
16183
  .action(async (appId, options) => {
16184
+ // Refused before any tree read/write or request (#3153, F1): the tree
16185
+ // path no longer partitions by app.
16186
+ assertTreeApp(appId || options.app, "config revert");
15916
16187
  const resolvedAppId = resolveAppId(appId, options);
15917
- const configDir = resolveSyncDir({
15918
- appId: resolvedAppId,
15919
- userDir: options.dir,
15920
- });
16188
+ const configDir = resolveSyncDir({ userDir: options.dir });
16189
+ // A baseline that was never renamed means an unmigrated tree; refused
16190
+ // here, before any request, snapshot or directory is made (#3153).
16191
+ assertSyncStateLayout(configDir);
15921
16192
  const snapshotsRoot = resolveSnapshotsRoot({
15922
16193
  appId: resolvedAppId,
15923
16194
  userDir: options.dir,
@@ -15992,7 +16263,7 @@ export function registerConfigSyncCommands(sync) {
15992
16263
  error(`Restore failed: ${err?.message ?? err}`);
15993
16264
  process.exit(1);
15994
16265
  }
15995
- success(`Restored snapshot ${target.id} into ${configDir} (including .primitive-sync.json).`);
16266
+ success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
15996
16267
  info("Run 'primitive config diff' to inspect the restored state versus the server.");
15997
16268
  });
15998
16269
  }