primitive-admin 1.2.0-alpha.1 → 1.2.0-alpha.2

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 (129) hide show
  1. package/README.md +3 -2
  2. package/dist/bin/primitive.js +22 -24
  3. package/dist/bin/primitive.js.map +1 -1
  4. package/dist/src/commands/agent-sessions.d.ts +20 -0
  5. package/dist/src/commands/agent-sessions.js +275 -0
  6. package/dist/src/commands/agent-sessions.js.map +1 -0
  7. package/dist/src/commands/apps-children.d.ts +54 -0
  8. package/dist/src/commands/apps-children.js +578 -0
  9. package/dist/src/commands/apps-children.js.map +1 -0
  10. package/dist/src/commands/apps.js +88 -9
  11. package/dist/src/commands/apps.js.map +1 -1
  12. package/dist/src/commands/auth-sessions.d.ts +3 -3
  13. package/dist/src/commands/auth-sessions.js +117 -23
  14. package/dist/src/commands/auth-sessions.js.map +1 -1
  15. package/dist/src/commands/auth.js +248 -15
  16. package/dist/src/commands/auth.js.map +1 -1
  17. package/dist/src/commands/documents.d.ts +29 -0
  18. package/dist/src/commands/documents.js +676 -191
  19. package/dist/src/commands/documents.js.map +1 -1
  20. package/dist/src/commands/env.d.ts +9 -1
  21. package/dist/src/commands/env.js +158 -45
  22. package/dist/src/commands/env.js.map +1 -1
  23. package/dist/src/commands/functions.js +7 -1
  24. package/dist/src/commands/functions.js.map +1 -1
  25. package/dist/src/commands/init.js +2 -1
  26. package/dist/src/commands/init.js.map +1 -1
  27. package/dist/src/commands/integrations.js +14 -2
  28. package/dist/src/commands/integrations.js.map +1 -1
  29. package/dist/src/commands/prompts.js +4 -0
  30. package/dist/src/commands/prompts.js.map +1 -1
  31. package/dist/src/commands/scripts.js +1 -1
  32. package/dist/src/commands/scripts.js.map +1 -1
  33. package/dist/src/commands/sync-app-settings.d.ts +25 -7
  34. package/dist/src/commands/sync-app-settings.js +81 -20
  35. package/dist/src/commands/sync-app-settings.js.map +1 -1
  36. package/dist/src/commands/sync.d.ts +107 -9
  37. package/dist/src/commands/sync.js +649 -300
  38. package/dist/src/commands/sync.js.map +1 -1
  39. package/dist/src/commands/users.js +77 -2
  40. package/dist/src/commands/users.js.map +1 -1
  41. package/dist/src/lib/api-client.d.ts +161 -5
  42. package/dist/src/lib/api-client.js +210 -49
  43. package/dist/src/lib/api-client.js.map +1 -1
  44. package/dist/src/lib/app-settings-descriptor.d.ts +9 -1
  45. package/dist/src/lib/app-settings-descriptor.js +22 -10
  46. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  47. package/dist/src/lib/auth-flow.d.ts +41 -1
  48. package/dist/src/lib/auth-flow.js +45 -16
  49. package/dist/src/lib/auth-flow.js.map +1 -1
  50. package/dist/src/lib/child-apps-local.d.ts +184 -0
  51. package/dist/src/lib/child-apps-local.js +282 -0
  52. package/dist/src/lib/child-apps-local.js.map +1 -0
  53. package/dist/src/lib/ci-session-request.d.ts +22 -0
  54. package/dist/src/lib/ci-session-request.js +51 -0
  55. package/dist/src/lib/ci-session-request.js.map +1 -0
  56. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +4 -1
  57. package/dist/src/lib/codegen-shared/generatedFiles.js +19 -13
  58. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  59. package/dist/src/lib/config-object-descriptor.js +5 -4
  60. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  61. package/dist/src/lib/config-payload.js +2 -3
  62. package/dist/src/lib/config-payload.js.map +1 -1
  63. package/dist/src/lib/config-surface.d.ts +2 -1
  64. package/dist/src/lib/config-surface.js +7 -28
  65. package/dist/src/lib/config-surface.js.map +1 -1
  66. package/dist/src/lib/credentials-store.d.ts +17 -2
  67. package/dist/src/lib/credentials-store.js +72 -6
  68. package/dist/src/lib/credentials-store.js.map +1 -1
  69. package/dist/src/lib/document-export-lookups.d.ts +31 -0
  70. package/dist/src/lib/document-export-lookups.js +53 -0
  71. package/dist/src/lib/document-export-lookups.js.map +1 -0
  72. package/dist/src/lib/document-export-permissions.d.ts +7 -5
  73. package/dist/src/lib/document-export-permissions.js +7 -5
  74. package/dist/src/lib/document-export-permissions.js.map +1 -1
  75. package/dist/src/lib/env-resolver-core.d.ts +69 -1
  76. package/dist/src/lib/env-resolver-core.js +137 -20
  77. package/dist/src/lib/env-resolver-core.js.map +1 -1
  78. package/dist/src/lib/env-resolver.d.ts +14 -2
  79. package/dist/src/lib/env-resolver.js +32 -10
  80. package/dist/src/lib/env-resolver.js.map +1 -1
  81. package/dist/src/lib/function-run.d.ts +4 -1
  82. package/dist/src/lib/function-run.js +24 -4
  83. package/dist/src/lib/function-run.js.map +1 -1
  84. package/dist/src/lib/function-sync.d.ts +5 -2
  85. package/dist/src/lib/function-sync.js +7 -4
  86. package/dist/src/lib/function-sync.js.map +1 -1
  87. package/dist/src/lib/generated-config-surfaces.d.ts +49 -52
  88. package/dist/src/lib/generated-config-surfaces.js +211 -140
  89. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  90. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  91. package/dist/src/lib/generated-sdk-types.js +1 -1
  92. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  93. package/dist/src/lib/init-adopt.js +12 -3
  94. package/dist/src/lib/init-adopt.js.map +1 -1
  95. package/dist/src/lib/local-state.d.ts +17 -1
  96. package/dist/src/lib/local-state.js +94 -15
  97. package/dist/src/lib/local-state.js.map +1 -1
  98. package/dist/src/lib/local-test-cases.d.ts +5 -2
  99. package/dist/src/lib/local-test-cases.js +7 -4
  100. package/dist/src/lib/local-test-cases.js.map +1 -1
  101. package/dist/src/lib/log-inspection.d.ts +25 -0
  102. package/dist/src/lib/log-inspection.js +24 -0
  103. package/dist/src/lib/log-inspection.js.map +1 -1
  104. package/dist/src/lib/logout-admin-session.d.ts +2 -1
  105. package/dist/src/lib/logout-admin-session.js +7 -1
  106. package/dist/src/lib/logout-admin-session.js.map +1 -1
  107. package/dist/src/lib/pull-write.d.ts +31 -0
  108. package/dist/src/lib/pull-write.js +49 -0
  109. package/dist/src/lib/pull-write.js.map +1 -0
  110. package/dist/src/lib/root-import-decision.d.ts +59 -0
  111. package/dist/src/lib/root-import-decision.js +95 -0
  112. package/dist/src/lib/root-import-decision.js.map +1 -0
  113. package/dist/src/lib/scope-request.d.ts +69 -0
  114. package/dist/src/lib/scope-request.js +188 -0
  115. package/dist/src/lib/scope-request.js.map +1 -0
  116. package/dist/src/lib/session-id.d.ts +9 -0
  117. package/dist/src/lib/session-id.js +28 -0
  118. package/dist/src/lib/session-id.js.map +1 -0
  119. package/dist/src/lib/snapshots.d.ts +17 -1
  120. package/dist/src/lib/snapshots.js +50 -2
  121. package/dist/src/lib/snapshots.js.map +1 -1
  122. package/dist/src/lib/sync-paths.d.ts +35 -0
  123. package/dist/src/lib/sync-paths.js +57 -5
  124. package/dist/src/lib/sync-paths.js.map +1 -1
  125. package/dist/src/lib/transient-retry.d.ts +34 -0
  126. package/dist/src/lib/transient-retry.js +57 -0
  127. package/dist/src/lib/transient-retry.js.map +1 -0
  128. package/dist/src/types/index.d.ts +21 -4
  129. package/package.json +4 -3
@@ -13,7 +13,8 @@ import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMet
13
13
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
14
14
  import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
15
15
  import { getCurrentAppId, getServerUrl } from "../lib/config.js";
16
- import { resolveSyncDir, resolveSnapshotsRoot, syncStatePath, syncStateReadPath, SYNC_STATE_FILENAME, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
16
+ import { requireCurrentEnvironment } from "../lib/env-resolver.js";
17
+ import { resolveSyncDir, resolveSnapshotsRoot, resolveSyncStateReadPath, resolveSyncStateWritePath, SYNC_STATE_FILENAME, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
17
18
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
18
19
  import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
19
20
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
@@ -22,6 +23,7 @@ import { canonicalizeIntegrationRequestConfig } from "../lib/integration-request
22
23
  import { canonicalizeServerTextFields } from "../lib/server-text-normalization.js";
23
24
  import { canonicalJsonStringify } from "../lib/canonical-json.js";
24
25
  import { reconcileWorkflowIncludes } from "../lib/workflow-include-preserve.js";
26
+ import { newPullWriteTally, writePulledFile, } from "../lib/pull-write.js";
25
27
  import { serializeSection, parseTomlToAppSettings, buildAppSettingsPayload, } from "../lib/app-settings-descriptor.js";
26
28
  import { collectAppSettingsPushErrors, writePulledAppSettings, diffAppSettings, appSettingsFieldDiffs, appSettingsNameExcluded, appSettingsNameExcludedForState, hashAppSettingsForDiff, } from "./sync-app-settings.js";
27
29
  import { applyWorkflowBody, legacyDraftSlotWarning } from "../lib/workflow-apply.js";
@@ -33,12 +35,12 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
33
35
  // payload builder so the diff normalizer and the wire cannot disagree.
34
36
  import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
35
37
  // #2644 — the one definition per configuration object, vendored from the server.
36
- import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
38
+ import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredTomlKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
37
39
  import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
38
40
  import { CONFIG_SURFACES, isEmptyForEmit, tomlGroupsOf, } from "../lib/generated-config-surfaces.js";
39
41
  // #3626 — the prompt KIND rule, vendored with the definitions so the CLI's
40
42
  // preflight refuses exactly what the admin routes refuse.
41
- import { hoistDeprecatedChatKeys, promptKindOf, resolvePromptKind, } from "../lib/generated-config-surfaces.js";
43
+ import { promptKindOf, resolvePromptKind, } from "../lib/generated-config-surfaces.js";
42
44
  // #3798 — the `[prompt.agent]` declaration rule, vendored the same way.
43
45
  import { agentDeclarationFunctionKeys, agentTestCaseRefusal, resolveAgentDeclaration, } from "../lib/generated-config-surfaces.js";
44
46
  // #3799 — the meaning-preserving schema translation, vendored the same way, so
@@ -98,8 +100,13 @@ function printStaleInstanceWarning(updated) {
98
100
  warn(` ${w.message}`);
99
101
  }
100
102
  }
101
- function loadSyncState(configDir) {
102
- const syncFile = syncStateReadPath(configDir);
103
+ /**
104
+ * The current environment's baseline. Every reader goes through
105
+ * `resolveSyncStateReadPath`, so a machine-local environment reads its own
106
+ * machine-local baseline and never the committed tree's (#4117).
107
+ */
108
+ function loadSyncState() {
109
+ const syncFile = resolveSyncStateReadPath();
103
110
  if (!existsSync(syncFile)) {
104
111
  return null;
105
112
  }
@@ -110,8 +117,8 @@ function loadSyncState(configDir) {
110
117
  return null;
111
118
  }
112
119
  }
113
- function saveSyncState(configDir, state) {
114
- const syncFile = syncStatePath(configDir);
120
+ function saveSyncState(state) {
121
+ const syncFile = resolveSyncStateWritePath();
115
122
  writeFileSync(syncFile, JSON.stringify(state, null, 2));
116
123
  }
117
124
  /**
@@ -449,6 +456,30 @@ export function hashRemoteWorkflowForDiff(workflow, draft, configs, logger, opti
449
456
  const parsed = parseConfigToml(serialized);
450
457
  return hashWorkflowTomlForDiff(parsed, undefined, options);
451
458
  }
459
+ /**
460
+ * Whether a workflow file already describes the workflow `remoteToml`
461
+ * serializes (#4160), by `config diff`'s comparison: the normalized hash, with
462
+ * activation compared only when the file names a configuration.
463
+ *
464
+ * Three shapes never match, because pull has work to do on them: a file with
465
+ * `[[include]]` fragments (`reconcileWorkflowIncludes` owns those), one still
466
+ * carrying the retired inline `[[configs]]` list, and any other file push
467
+ * refuses (`pullMustRewrite`).
468
+ */
469
+ export function sameWorkflowAs(workflowKey, remoteToml) {
470
+ return (localContent) => {
471
+ const local = parseConfigToml(localContent);
472
+ if ("include" in local || "configs" in local)
473
+ return false;
474
+ if (pullMustRewrite("workflow", local))
475
+ return false;
476
+ const compareActivation = Boolean(authoredActiveConfigName(local));
477
+ return (hashWorkflowTomlForDiff(local, workflowKey, { compareActivation }) ===
478
+ hashWorkflowTomlForDiff(parseConfigToml(remoteToml), undefined, {
479
+ compareActivation,
480
+ }));
481
+ };
482
+ }
452
483
  /**
453
484
  * Build the [auth] block of app.toml from server settings (pull direction).
454
485
  *
@@ -1600,20 +1631,32 @@ export function buildCronTriggerPushPayload(key, tomlData, { mode = "update" } =
1600
1631
  }
1601
1632
  return payload;
1602
1633
  }
1603
- export function serializeBlobBucket(bucket, logger) {
1634
+ export function serializeBlobBucket(bucket, ruleSetIdToName = new Map(), logger) {
1635
+ const file = `blob-buckets/${bucket?.bucketKey}.toml`;
1604
1636
  // #2644 — the `[bucket]` key set comes from the vendored definition.
1605
- warnUnrecognizedServerKeys(bucket, BLOB_BUCKET_TABLE, `blob-buckets/${bucket?.bucketKey}.toml`, logger);
1637
+ warnUnrecognizedServerKeys(bucket, BLOB_BUCKET_TABLE, file, logger);
1638
+ // #4116 — the rule set is written by NAME, which every app can resolve. An
1639
+ // id the listing cannot name is reported and left out, never written: an id
1640
+ // in the file is the spelling a copied tree cannot resolve (the #3998 rule).
1641
+ const ruleSetName = bucket?.ruleSetId
1642
+ ? ruleSetIdToName.get(bucket.ruleSetId)
1643
+ : undefined;
1644
+ if (bucket?.ruleSetId && !ruleSetName) {
1645
+ logger?.(`${file}: rule set ${bucket.ruleSetId} is not in this app's rule-set listing; ` +
1646
+ "no ruleSetName written (an orphaned or unlisted rule set denies access closed)");
1647
+ }
1606
1648
  const data = {
1607
1649
  bucket: {
1608
1650
  key: bucket.bucketKey,
1609
1651
  ...projectRecordToToml(BLOB_BUCKET_TABLE, bucket, {
1610
- // #1020: `preset` is the honest key; a custom bucket carries ruleSetId
1611
- // instead. Omit preset for custom buckets (preset === "custom").
1652
+ // #1020: `preset` is the honest key; a custom bucket carries its rule
1653
+ // set instead. Omit preset for custom buckets (preset === "custom").
1612
1654
  preset: (record) => record.preset && record.preset !== "custom"
1613
1655
  ? record.preset
1614
1656
  : record.ruleSetId
1615
1657
  ? undefined
1616
1658
  : record.preset,
1659
+ ruleSetId: () => ruleSetName,
1617
1660
  }),
1618
1661
  },
1619
1662
  };
@@ -1624,6 +1667,73 @@ export function serializeBlobBucket(bucket, logger) {
1624
1667
  });
1625
1668
  return stringifyConfigToml(data);
1626
1669
  }
1670
+ /**
1671
+ * The entity a `blob-buckets/<key>.toml` describes (#4116) — the parse
1672
+ * `config push` builds its bodies from and `config diff` compares.
1673
+ *
1674
+ * Read in CREATE mode, so the immutable `ttlTier` stays nameable. The rule set
1675
+ * is left as `_ruleSetName` for `resolveRuleSetReference`; the deprecated
1676
+ * `ruleSetId` spelling is forwarded as the id it already is. A file carrying
1677
+ * both is refused by the preflight (`blobBucketFileErrors`) before this runs.
1678
+ *
1679
+ * An omitted `name` stays omitted: the update leaves the server's name alone,
1680
+ * and only the create body falls back to the key.
1681
+ */
1682
+ export function parseBlobBucketToml(doc) {
1683
+ const section = doc?.bucket ?? {};
1684
+ const entity = dropUndefined({
1685
+ bucketKey: section.key ?? "",
1686
+ ...buildPayloadFromToml(BLOB_BUCKET_TABLE, section, {
1687
+ mode: "create",
1688
+ values: {
1689
+ name: (value) => value || undefined,
1690
+ description: tomlOwned,
1691
+ },
1692
+ }),
1693
+ });
1694
+ // The builder read the `ruleSetName` value onto the `ruleSetId` field.
1695
+ delete entity.ruleSetId;
1696
+ if (typeof section.ruleSetName === "string" && section.ruleSetName.trim() !== "") {
1697
+ // Trimmed, as `applyRuleSetReference` does for the type configs.
1698
+ entity._ruleSetName = section.ruleSetName.trim();
1699
+ }
1700
+ else if (typeof section.ruleSetId === "string" && section.ruleSetId !== "") {
1701
+ entity.ruleSetId = section.ruleSetId;
1702
+ }
1703
+ return entity;
1704
+ }
1705
+ /**
1706
+ * The advisory `config push` prints for a bucket file still naming its rule set
1707
+ * by id (#4116). The key is deprecated, not retired: the file pushes.
1708
+ */
1709
+ export function blobBucketDeprecatedKeyWarning(filePath, tomlData) {
1710
+ const bucket = tomlData?.bucket;
1711
+ if (!bucket || bucket.ruleSetId === undefined)
1712
+ return null;
1713
+ // Beside `ruleSetName` it is a refusal instead (`blobBucketFileErrors`).
1714
+ if (bucket.ruleSetName !== undefined)
1715
+ return null;
1716
+ return (`${filePath}: bucket.ruleSetId is deprecated — a rule-set id belongs to one app, ` +
1717
+ `so a copied tree cannot resolve it; write ruleSetName = "<name>" ` +
1718
+ "(config pull rewrites the file). Retiring the key is a later change.");
1719
+ }
1720
+ /** A bucket file's own refusals, decided from the file alone (#4116). */
1721
+ export function blobBucketFileErrors(filePath, tomlData) {
1722
+ const bucket = tomlData?.bucket;
1723
+ if (bucket && bucket.ruleSetId !== undefined && bucket.ruleSetName !== undefined) {
1724
+ return [`${filePath}: bucket.ruleSetId is set beside bucket.ruleSetName; keep ruleSetName`];
1725
+ }
1726
+ // The deprecated spelling is a TOML-only key, so the declared-type check
1727
+ // reads only `ruleSetName`; the old key keeps the field's type under its
1728
+ // own name, or a mistyped id would be dropped from the body unreported.
1729
+ const field = BLOB_BUCKET_TABLE.fields.find((f) => f.field === "ruleSetId");
1730
+ if (!field)
1731
+ return [];
1732
+ return configDeclaredTypeErrors(filePath, tomlData, {
1733
+ ...BLOB_BUCKET_TABLE,
1734
+ fields: [{ ...field, tomlKey: "ruleSetId" }],
1735
+ });
1736
+ }
1627
1737
  /**
1628
1738
  * Build the PATCH payload for a blob-bucket update from local TOML.
1629
1739
  *
@@ -1639,7 +1749,9 @@ export function serializeBlobBucket(bucket, logger) {
1639
1749
  export function buildBlobBucketUpdatePayload(bucket) {
1640
1750
  // The access model's truthiness checks are the SERVER's rule and stay
1641
1751
  // verbatim; the definition contributes the field list, not the semantics.
1642
- const updatePayload = buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
1752
+ // #4116 — reads the parsed ENTITY (`parseBlobBucketToml`, its rule set
1753
+ // resolved), whose field names are the wire keys.
1754
+ const updatePayload = projectEntityToPayload(BLOB_BUCKET_TABLE, bucket, {
1643
1755
  mode: "update",
1644
1756
  values: {
1645
1757
  // A preset and a rule set are mutually exclusive per request, and a
@@ -1737,7 +1849,8 @@ export function promptConfigPayload(tomlConfig, options = {}) {
1737
1849
  : { description: tomlConfig.description ?? null, ...shared };
1738
1850
  }
1739
1851
  /**
1740
- * Build the POST body for a blob-bucket create from local TOML.
1852
+ * Build the POST body for a blob-bucket create from the parsed entity
1853
+ * (`parseBlobBucketToml`, its rule set resolved).
1741
1854
  *
1742
1855
  * `ttlTier` is here and absent from the update body because the definition says
1743
1856
  * so (`writableOn: ["create"]`) — the retention tier is fixed at creation.
@@ -1745,7 +1858,7 @@ export function promptConfigPayload(tomlConfig, options = {}) {
1745
1858
  export function buildBlobBucketCreatePayload(key, bucket) {
1746
1859
  const payload = {
1747
1860
  bucketKey: key,
1748
- ...buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
1861
+ ...projectEntityToPayload(BLOB_BUCKET_TABLE, bucket, {
1749
1862
  mode: "create",
1750
1863
  values: {
1751
1864
  name: (value) => value || key,
@@ -2059,15 +2172,10 @@ export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
2059
2172
  * differ from the one the server has always received.
2060
2173
  */
2061
2174
  const tomlOwned = (value) => (create ? value : (value ?? null));
2062
- // #3626 §4 — the nine chat keys moved under `[configs.chat]`, and the flat
2063
- // spelling is accepted as DEPRECATED. Hoisting here rather than at each call
2064
- // site means push, `config diff` and the create-seed builder all read a file
2065
- // the same way, so an unmigrated file and its pulled rewrite compare Synced.
2066
- // A key set BOTH ways is refused in preflight
2067
- // (`promptDeprecatedChatKeyErrors`); the hoist leaves the grouped value
2068
- // standing so nothing here has to guess.
2069
- const entry = hoistDeprecatedChatKeys(tomlConfig).entry;
2070
- return buildPayloadFromToml(PROMPT_CONFIG_TABLE, entry, {
2175
+ // #3626 — the chat keys are read from `[configs.chat]` only. Their flat
2176
+ // spelling is retired (#3642) and refused in preflight, so a root chat key
2177
+ // never reaches this builder as a value.
2178
+ return buildPayloadFromToml(PROMPT_CONFIG_TABLE, tomlConfig, {
2071
2179
  mode,
2072
2180
  values: {
2073
2181
  provider: (value) => (create ? value || "openrouter" : value),
@@ -2193,16 +2301,19 @@ export function configUnknownKeyError(filePath, tomlData, table) {
2193
2301
  // once beside the definitions (`src/config-surface/retired-keys.ts`), not the
2194
2302
  // generic "check the spelling, or upgrade the CLI" hint, which is exactly
2195
2303
  // backwards for it. The server's create/update handlers read the same table.
2304
+ // #3642 — a spelling retired from TOML only (a flat chat key under
2305
+ // `[[configs]]`) is answered from the same module, before the misplaced-key
2306
+ // line below could send the author to the block without saying why.
2196
2307
  const prefix = table.tomlPath.join(".");
2197
2308
  const retiredHere = [...unknown]
2198
- .filter((k) => retiredConfigKey(prefix, k))
2309
+ .filter((k) => retiredTomlKey(prefix, k))
2199
2310
  .sort();
2200
2311
  const trulyUnknown = [...unknown]
2201
- .filter((k) => !retiredConfigKey(prefix, k))
2312
+ .filter((k) => !retiredTomlKey(prefix, k))
2202
2313
  .sort();
2203
2314
  const messages = [];
2204
2315
  for (const key of retiredHere) {
2205
- messages.push(`${filePath}: ${prefix}.${key} — ${retiredConfigKey(prefix, key).toml}`);
2316
+ messages.push(`${filePath}: ${prefix}.${key} — ${retiredTomlKey(prefix, key).toml}`);
2206
2317
  }
2207
2318
  // #3626, SO3626-013 — a key the definition DOES declare, written at the
2208
2319
  // table root when it is authored under a group. Before grouping,
@@ -2300,49 +2411,72 @@ export function unknownKeyPreflightTargets() {
2300
2411
  */
2301
2412
  function localConfigRowKey(label, tomlData, file) {
2302
2413
  const fallback = basename(file, ".toml");
2303
- const stated = (table, key) => tomlData?.[table]?.[key] || fallback;
2304
- switch (label) {
2305
- case "integration":
2306
- return stated("integration", "key");
2307
- case "webhook":
2308
- return stated("webhook", "key");
2309
- case "cron-trigger":
2310
- return stated("cronTrigger", "key");
2311
- case "blob-bucket":
2312
- return stated("bucket", "key");
2313
- case "prompt":
2314
- return stated("prompt", "key");
2315
- case "workflow":
2316
- return stated("workflow", "key");
2317
- // #3320 — the declared key, like the function preflight collector, the
2318
- // apply loop and `--only`. A renamed file is one object, so both
2319
- // collectors' messages land on the one comparison row.
2320
- case "function":
2321
- return stated("function", "key");
2322
- case "email-template":
2323
- return stated("template", "emailType");
2324
- case "database-type-config":
2325
- return stated("type", "databaseType");
2326
- case "group-type-config":
2327
- return stated("groupTypeConfig", "groupType");
2328
- case "collection-type-config":
2329
- return stated("collectionTypeConfig", "collectionType");
2330
- case "metadata-category-config": {
2331
- const section = tomlData?.metadataCategoryConfig || {};
2332
- if (section.resourceType && section.category) {
2333
- return `${section.resourceType}#${section.category}`;
2334
- }
2335
- // `resourceType.category.toml`, the filename fallback push and diff share.
2336
- const dot = fallback.indexOf(".");
2337
- return dot > 0
2338
- ? `${fallback.slice(0, dot)}#${fallback.slice(dot + 1)}`
2339
- : fallback;
2414
+ const identity = CONFIG_ROW_IDENTITY[label];
2415
+ if (label === "metadata-category-config") {
2416
+ const section = tomlData?.metadataCategoryConfig || {};
2417
+ if (section.resourceType && section.category) {
2418
+ return `${section.resourceType}#${section.category}`;
2340
2419
  }
2341
- // Rule sets are keyed by FILE NAME on both sides — a renamed `ruleSet.name`
2342
- // still updates the same entity, so the row is the file's.
2343
- default:
2344
- return fallback;
2420
+ // `resourceType.category.toml`, the filename fallback push and diff share.
2421
+ const dot = fallback.indexOf(".");
2422
+ return dot > 0
2423
+ ? `${fallback.slice(0, dot)}#${fallback.slice(dot + 1)}`
2424
+ : fallback;
2345
2425
  }
2426
+ // Rule sets are keyed by FILE NAME on both sides — a renamed `ruleSet.name`
2427
+ // still updates the same entity, so the row is the file's.
2428
+ if (!identity)
2429
+ return fallback;
2430
+ const [table, key] = identity[0];
2431
+ return tomlData?.[table]?.[key] || fallback;
2432
+ }
2433
+ /**
2434
+ * The `[table].key` fields that name the object a config file describes, per
2435
+ * type — what `localConfigRowKey` reads before falling back to the file name.
2436
+ * A type absent here (rule sets, test cases) is identified by its file name
2437
+ * alone.
2438
+ */
2439
+ const CONFIG_ROW_IDENTITY = {
2440
+ integration: [["integration", "key"]],
2441
+ webhook: [["webhook", "key"]],
2442
+ "cron-trigger": [["cronTrigger", "key"]],
2443
+ "blob-bucket": [["bucket", "key"]],
2444
+ prompt: [["prompt", "key"]],
2445
+ workflow: [["workflow", "key"]],
2446
+ // #3320 — the declared key, like the function preflight collector, the
2447
+ // apply loop and `--only`. A renamed file is one object, so both
2448
+ // collectors' messages land on the one comparison row.
2449
+ function: [["function", "key"]],
2450
+ "email-template": [["template", "emailType"]],
2451
+ "database-type-config": [["type", "databaseType"]],
2452
+ "group-type-config": [["groupTypeConfig", "groupType"]],
2453
+ "collection-type-config": [["collectionTypeConfig", "collectionType"]],
2454
+ "metadata-category-config": [
2455
+ ["metadataCategoryConfig", "resourceType"],
2456
+ ["metadataCategoryConfig", "category"],
2457
+ ],
2458
+ };
2459
+ /**
2460
+ * The local file as `config diff` compares it against the server object pull
2461
+ * writes to `file` (#4160), or `null` when the file names a different object.
2462
+ *
2463
+ * Diff pairs a file with a server object by the identity the file states, or
2464
+ * its file name when it states none, and injects that identity before
2465
+ * comparing. So a file whose stated key is another object's is not this
2466
+ * object's file whatever its other values say, and a file that leaves its
2467
+ * identity to the file name is compared with the identity filled in.
2468
+ */
2469
+ function localConfigWithServerIdentity(label, local, server, file) {
2470
+ if (localConfigRowKey(label, local, file) !== localConfigRowKey(label, server, file)) {
2471
+ return null;
2472
+ }
2473
+ const resolved = { ...local };
2474
+ for (const [table, key] of CONFIG_ROW_IDENTITY[label] ?? []) {
2475
+ if (server?.[table]?.[key] === undefined)
2476
+ continue;
2477
+ resolved[table] = { ...(resolved[table] ?? {}), [key]: server[table][key] };
2478
+ }
2479
+ return resolved;
2346
2480
  }
2347
2481
  /**
2348
2482
  * `config push`'s preflight — every check the authored files decide on their
@@ -2373,7 +2507,7 @@ function localConfigRowKey(label, tomlData, file) {
2373
2507
  * that being a visible change to this signature.
2374
2508
  */
2375
2509
  export async function runConfigPushPreflight(input) {
2376
- const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, client, } = input;
2510
+ const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, localEnvironment = false, client, } = input;
2377
2511
  /**
2378
2512
  * Narrow a type's file list to the `--only` selection — the same one-line
2379
2513
  * closure the action holds, rebuilt from the selection it passed in rather
@@ -2514,6 +2648,24 @@ export async function runConfigPushPreflight(input) {
2514
2648
  }
2515
2649
  }
2516
2650
  }
2651
+ // #4116 — the deprecated `ruleSetId` spelling in a bucket file. Advisory
2652
+ // only: the file pushes. Bounded by `--only`, like the notice above.
2653
+ const preflightBucketsDir = join(configDir, "blob-buckets");
2654
+ if (existsSync(preflightBucketsDir)) {
2655
+ for (const file of selectFiles("blob-bucket", preflightBucketsDir, readdirSync(preflightBucketsDir).filter((f) => f.endsWith(".toml")))) {
2656
+ let tomlData;
2657
+ try {
2658
+ tomlData = parseTomlFile(join(preflightBucketsDir, file));
2659
+ }
2660
+ catch {
2661
+ // A file that does not parse is reported by the collector below.
2662
+ continue;
2663
+ }
2664
+ const advice = blobBucketDeprecatedKeyWarning(`blob-buckets/${file}`, tomlData);
2665
+ if (advice)
2666
+ warn(` ${advice}`);
2667
+ }
2668
+ }
2517
2669
  // Validate app.toml settings up-front (issue #1033). A mistyped value
2518
2670
  // is a hard error that must abort BEFORE any mutation — same
2519
2671
  // fail-before-mutate contract as the vars preflight below. The apply
@@ -2530,10 +2682,12 @@ export async function runConfigPushPreflight(input) {
2530
2682
  // #2880 DSO-001 — the same exclusion the apply block computes, so
2531
2683
  // the preflight validates the body that will actually be sent.
2532
2684
  excludeName: appSettingsNameExcluded({
2685
+ localEnvironment,
2533
2686
  isCrossAppPush: !!isCrossAppPush,
2534
2687
  recordedExcludedName: syncState?.entities?.app?.excludedName,
2535
2688
  fileName: parsedApp?.app?.name,
2536
2689
  }),
2690
+ nameOptional: localEnvironment,
2537
2691
  });
2538
2692
  for (const e of appErrors) {
2539
2693
  preflightValidationErrors.push(` app.toml: ${e}`);
@@ -2671,11 +2825,9 @@ export async function runConfigPushPreflight(input) {
2671
2825
  preflightValidationErrors.push(` ${message}`);
2672
2826
  }
2673
2827
  }
2674
- // #3626 §4 — one advisory line per prompt file still written in the flat
2675
- // chat spelling, beside the `celContextAccess` notice above. The push
2676
- // continues and the keys are applied; `config pull` rewrites the file, so a
2677
- // single pull-then-push clears the notice. Printed even under `--dry-run`,
2678
- // which never reaches the apply — the notice is about the FILE.
2828
+ // #3798 — the prompt files this push selects, read once more for the check
2829
+ // that needs app state: an agent's functions must exist. Bounded by `--only`,
2830
+ // and run before anything is applied.
2679
2831
  const preflightPromptsDir = join(configDir, "prompts");
2680
2832
  // #3798 — the functions an agent's references are checked against, read
2681
2833
  // once and only when a selected prompt is an agent that names one.
@@ -2691,9 +2843,6 @@ export async function runConfigPushPreflight(input) {
2691
2843
  // A file that does not parse is reported by the collector above.
2692
2844
  continue;
2693
2845
  }
2694
- const notice = promptFlatChatKeyWarning(filePath, tomlData);
2695
- if (notice)
2696
- warn(` ${notice}`);
2697
2846
  // #3798 — an agent's tool, turn-context and history functions must
2698
2847
  // exist, and a tool's must declare both schemas, before anything is
2699
2848
  // applied. Bounded by `--only` like the rest of this loop.
@@ -2766,22 +2915,16 @@ export async function runConfigPushPreflight(input) {
2766
2915
  /**
2767
2916
  * The blocks one `[[configs]]` entry carries that its prompt's kind does not
2768
2917
  * name — #3798. An agent's entry may carry only `[configs.agent]`; a chat or
2769
- * decisions entry may not carry it. `movedFlat` are the deprecated flat chat
2770
- * keys the hoist moved under `chat`, named as the author wrote them.
2918
+ * decisions entry may not carry it.
2771
2919
  *
2772
2920
  * Only the blocks the other arms of `resolvePromptKind` cannot see are checked
2773
2921
  * here: a decisions block on a chat entry, and chat keys on a decisions entry,
2774
2922
  * stay the resolver's refusals with their #3626 wording.
2775
2923
  */
2776
- function promptEntryBlockErrors(kind, entry, movedFlat) {
2924
+ function promptEntryBlockErrors(kind, entry) {
2777
2925
  const has = (block) => entry != null && typeof entry === "object" && entry[block] !== undefined;
2778
2926
  const errors = [];
2779
2927
  if (kind === "agent") {
2780
- if (movedFlat.length > 0) {
2781
- errors.push(`sets ${movedFlat.join(", ")} flat — the deprecated [configs.chat] ` +
2782
- 'spelling — but prompt kind is "agent", so it may carry only a ' +
2783
- "[configs.agent] block; write them under [configs.agent]");
2784
- }
2785
2928
  for (const block of ["chat", "decisions"]) {
2786
2929
  if (!has(block))
2787
2930
  continue;
@@ -2801,10 +2944,9 @@ function promptEntryBlockErrors(kind, entry, movedFlat) {
2801
2944
  /**
2802
2945
  * A prompt file's KIND errors — #3626, decidable from the file alone.
2803
2946
  *
2804
- * Three refusals, all of them the same rule seen from different angles: a key
2805
- * set both flat and under `[configs.chat]` (the hoist will not guess), a
2806
- * `[[configs]]` entry carrying the block of the OTHER kind, and a malformed
2807
- * decisions declaration. The last two come from `resolvePromptKind`, the same
2947
+ * Two refusals, the same rule seen from different angles: a `[[configs]]`
2948
+ * entry carrying the block of the OTHER kind, and a malformed decisions
2949
+ * declaration. Both come from `resolvePromptKind`, the same
2808
2950
  * vendored function the admin routes validate with, so the CLI's refusal and
2809
2951
  * the server's cannot disagree — and this one lands in the preflight, before
2810
2952
  * the first mutating call.
@@ -2859,27 +3001,28 @@ export function promptKindFileErrors(filePath, tomlData) {
2859
3001
  errors.push(`${filePath}: ${refusal.message}`);
2860
3002
  }
2861
3003
  }
2862
- for (const [index, entry] of entries.entries()) {
2863
- const hoisted = hoistDeprecatedChatKeys(entry);
2864
- for (const key of hoisted.conflicts) {
2865
- errors.push(`${filePath}: configs[${index}].${key} is set both flat and under ` +
2866
- `[configs.chat]; keep the [configs.chat] spelling`);
2867
- }
3004
+ for (const [index, rawEntry] of entries.entries()) {
3005
+ // #3642 — the blocks are read as the file wrote them. A chat key at the
3006
+ // entry's root is the retired flat spelling, which the unknown-key check
3007
+ // refuses on its own; it is not a value any rule here reads.
3008
+ const entry = rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry)
3009
+ ? rawEntry
3010
+ : {};
2868
3011
  // #3798 — the block rule for three kinds, stated per entry: an entry may
2869
3012
  // carry only the block its prompt's kind names. An agent's `[configs.agent]`
2870
3013
  // holds keys chat also holds, so a stray block would otherwise be read (or
2871
3014
  // ignored) in silence by the payload builder.
2872
- const blockErrors = promptEntryBlockErrors(fileKind, entry, hoisted.moved).map((message) => `${filePath}: configs[${index}] ${message}`);
3015
+ const blockErrors = promptEntryBlockErrors(fileKind, entry).map((message) => `${filePath}: configs[${index}] ${message}`);
2873
3016
  if (blockErrors.length > 0) {
2874
3017
  errors.push(...blockErrors);
2875
3018
  continue;
2876
3019
  }
2877
3020
  if (fileKind === "agent") {
2878
- const agent = hoisted.entry.agent ?? {};
3021
+ const agent = entry.agent ?? {};
2879
3022
  const resolution = resolvePromptKind({
2880
3023
  kind: "agent",
2881
- provider: hoisted.entry.provider,
2882
- model: hoisted.entry.model,
3024
+ provider: entry.provider,
3025
+ model: entry.model,
2883
3026
  config: {
2884
3027
  systemPrompt: agent.systemPrompt,
2885
3028
  temperature: agent.temperature,
@@ -2897,8 +3040,8 @@ export function promptKindFileErrors(filePath, tomlData) {
2897
3040
  }
2898
3041
  continue;
2899
3042
  }
2900
- const chat = hoisted.entry.chat ?? {};
2901
- const decisions = hoisted.entry.decisions ?? {};
3043
+ const chat = entry.chat ?? {};
3044
+ const decisions = entry.decisions ?? {};
2902
3045
  // #3626 — the FIRST entry of a chat prompt carries the create seed's
2903
3046
  // placeholder. `buildPromptCreatePayload` sends `{{ input }}` when the
2904
3047
  // file names no template, which is how a prompt authored without one has
@@ -2918,8 +3061,8 @@ export function promptKindFileErrors(filePath, tomlData) {
2918
3061
  : chat.userPromptTemplate;
2919
3062
  const resolution = resolvePromptKind({
2920
3063
  kind: tomlData?.prompt?.kind,
2921
- provider: hoisted.entry.provider,
2922
- model: hoisted.entry.model,
3064
+ provider: entry.provider,
3065
+ model: entry.model,
2923
3066
  config: {
2924
3067
  systemPrompt: chat.systemPrompt,
2925
3068
  userPromptTemplate,
@@ -2944,12 +3087,12 @@ export function promptKindFileErrors(filePath, tomlData) {
2944
3087
  // against what the server holds.
2945
3088
  if (resolution.kind === "chat") {
2946
3089
  const strict = resolvePromptStrictOutput({
2947
- provider: hoisted.entry.provider,
3090
+ provider: entry.provider,
2948
3091
  strictOutput: chat.strictOutput,
2949
3092
  outputFormat: chat.outputFormat,
2950
3093
  configOutputSchema: chat.outputSchema,
2951
3094
  promptOutputSchema: tomlData?.prompt?.outputSchema,
2952
- status: hoisted.entry.status ?? PROMPT_CONFIG_DEFAULT_STATUS,
3095
+ status: entry.status ?? PROMPT_CONFIG_DEFAULT_STATUS,
2953
3096
  });
2954
3097
  if (strict.ok === false) {
2955
3098
  errors.push(`${filePath}: configs[${index}] — ${strict.error} (${strict.code})`);
@@ -3165,26 +3308,64 @@ export function promptKindImmutableOutcome(storedKind, localDoc) {
3165
3308
  };
3166
3309
  }
3167
3310
  /**
3168
- * The one deprecation notice a prompt file using the FLAT chat spelling earns
3169
- * — #3626 §4, beside the `celContextAccess` notice's precedent.
3311
+ * The file-decidable refusals `config push` gives one config file — the
3312
+ * per-file body of `collectConfigFileValidationErrors`, shared with `config
3313
+ * pull` (#4160), which never keeps a local file push would refuse.
3170
3314
  *
3171
- * Advisory: the push continues and the keys are applied. `null` when the file
3172
- * is already written in the grouped spelling, so a migrated tree is silent.
3315
+ * `tables` are the tables whose unrecognized keys are checked here (none for a
3316
+ * workflow, whose own validator owns that message); declared types run over
3317
+ * every table of the surface.
3173
3318
  */
3174
- export function promptFlatChatKeyWarning(filePath, tomlData) {
3175
- const entries = Array.isArray(tomlData?.configs) ? tomlData.configs : [];
3176
- const moved = new Set();
3177
- for (const entry of entries) {
3178
- for (const key of hoistDeprecatedChatKeys(entry).moved)
3179
- moved.add(key);
3319
+ export function configFileValidationMessages(filePath, tomlData, surface, tables) {
3320
+ const messages = [];
3321
+ // The document's own shape first: an unrecognized top-level table makes
3322
+ // every per-table check below read an absent table, so its silence would
3323
+ // be misleading.
3324
+ messages.push(...configDocumentShapeErrors(filePath, tomlData, surface));
3325
+ for (const table of tables) {
3326
+ const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
3327
+ if (unknownKeyError)
3328
+ messages.push(unknownKeyError);
3180
3329
  }
3181
- if (moved.size === 0)
3182
- return null;
3183
- const named = [...moved].sort().join(", ");
3184
- return (`${filePath}: ${named} ${moved.size === 1 ? "is" : "are"} written flat ` +
3185
- "under [[configs]]; this spelling is deprecated — move them under " +
3186
- "[configs.chat] (`config pull` rewrites the file). Rejecting the flat " +
3187
- "spelling is tracked in #3642.");
3330
+ // Declared types run over EVERY table of the surface, workflows included
3331
+ // — `tables` above is pruned for workflows because `validateWorkflowToml`
3332
+ // owns their unrecognized-key message, which is a different check.
3333
+ for (const table of surface.tables) {
3334
+ messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
3335
+ }
3336
+ // #3626 — the prompt kind's own file-decidable refusals, run by the same
3337
+ // collector so `config push` and `config diff` cannot disagree about
3338
+ // which prompt files are pushable.
3339
+ if (surface.label === "prompt") {
3340
+ messages.push(...promptKindFileErrors(filePath, tomlData));
3341
+ }
3342
+ // #4116 — a bucket naming its rule set both ways.
3343
+ if (surface.label === "blob-bucket") {
3344
+ messages.push(...blobBucketFileErrors(filePath, tomlData));
3345
+ }
3346
+ return messages;
3347
+ }
3348
+ /**
3349
+ * Whether `config pull` must rewrite a local file whatever it says (#4160):
3350
+ * `config push` would refuse it, or it spells something in a deprecated form
3351
+ * pull exists to migrate (a bucket naming its rule set by id). Keeping such a
3352
+ * file because its VALUES match would leave the tree unpushable, or the
3353
+ * deprecation unresolved, after a pull that reported success.
3354
+ */
3355
+ export function pullMustRewrite(label, tomlData) {
3356
+ // A test-case sidecar is not one of the preflight's per-directory targets;
3357
+ // push refuses it through its own collector.
3358
+ if (label === "test-case") {
3359
+ return testCaseSidecarPreflightErrors("", tomlData).length > 0;
3360
+ }
3361
+ const target = unknownKeyPreflightTargets().find((t) => t.surface.label === label);
3362
+ if (target) {
3363
+ const tables = label === "workflow" ? [WORKFLOW_TABLE] : target.tables;
3364
+ if (configFileValidationMessages("", tomlData, target.surface, tables).length > 0) {
3365
+ return true;
3366
+ }
3367
+ }
3368
+ return (label === "blob-bucket" && blobBucketDeprecatedKeyWarning("", tomlData) !== null);
3188
3369
  }
3189
3370
  export function collectConfigFileValidationErrors(configDir) {
3190
3371
  const found = [];
@@ -3205,28 +3386,7 @@ export function collectConfigFileValidationErrors(configDir) {
3205
3386
  catch {
3206
3387
  continue;
3207
3388
  }
3208
- const messages = [];
3209
- // The document's own shape first: an unrecognized top-level table makes
3210
- // every per-table check below read an absent table, so its silence would
3211
- // be misleading.
3212
- messages.push(...configDocumentShapeErrors(filePath, tomlData, surface));
3213
- for (const table of tables) {
3214
- const unknownKeyError = configUnknownKeyError(filePath, tomlData, table);
3215
- if (unknownKeyError)
3216
- messages.push(unknownKeyError);
3217
- }
3218
- // Declared types run over EVERY table of the surface, workflows included
3219
- // — `tables` above is pruned for workflows because `validateWorkflowToml`
3220
- // owns their unrecognized-key message, which is a different check.
3221
- for (const table of surface.tables) {
3222
- messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
3223
- }
3224
- // #3626 — the prompt kind's own file-decidable refusals, run by the same
3225
- // collector so `config push` and `config diff` cannot disagree about
3226
- // which prompt files are pushable.
3227
- if (surface.label === "prompt") {
3228
- messages.push(...promptKindFileErrors(filePath, tomlData));
3229
- }
3389
+ const messages = configFileValidationMessages(filePath, tomlData, surface, tables);
3230
3390
  if (messages.length === 0)
3231
3391
  continue;
3232
3392
  found.push({
@@ -4810,20 +4970,14 @@ const CONFIG_DIFF_SPECS = {
4810
4970
  "blob-bucket": {
4811
4971
  label: "blob-bucket",
4812
4972
  table: BLOB_BUCKET_TABLE,
4813
- parse: (doc) => {
4814
- const bucket = { ...(doc?.bucket ?? {}) };
4815
- return {
4816
- bucketKey: doc?.bucket?.key ?? "",
4817
- ...buildPayloadFromToml(BLOB_BUCKET_TABLE, bucket, {
4818
- mode: "create",
4819
- values: {
4820
- name: (value) => value || doc?.bucket?.key || "",
4821
- description: tomlOwned,
4822
- },
4823
- }),
4824
- };
4825
- },
4826
- serialize: (record) => serializeBlobBucket(record),
4973
+ parse: (doc) => parseBlobBucketToml(doc),
4974
+ // #4116 — the rule set is compared by the thing referred to: the record's
4975
+ // id is rendered as its name, and both sides resolve the name back.
4976
+ serialize: (record, maps) => serializeBlobBucket(record, maps.ruleSetIdToName),
4977
+ extras: (entity) => ({
4978
+ _unresolvedRuleSetName: entity._unresolvedRuleSetName,
4979
+ }),
4980
+ ruleSetRef: true,
4827
4981
  // `name` is trimmed on the way in and a blank description is stored as
4828
4982
  // none (DSO-003).
4829
4983
  canonicalize: (entity) => canonicalizeServerTextFields(entity, {
@@ -4847,7 +5001,12 @@ const CONFIG_DIFF_SPECS = {
4847
5001
  keys.push("name");
4848
5002
  if (!bucket.preset)
4849
5003
  keys.push("preset");
4850
- if (!bucket.ruleSetId)
5004
+ // Either spelling manages the rule set (#4116) — read as the parse reads
5005
+ // it, so a blank name is no reference rather than a difference the
5006
+ // update cannot carry.
5007
+ const named = typeof bucket.ruleSetName === "string" && bucket.ruleSetName.trim() !== "";
5008
+ const legacy = typeof bucket.ruleSetId === "string" && bucket.ruleSetId !== "";
5009
+ if (!named && !legacy)
4851
5010
  keys.push("ruleSetId");
4852
5011
  return keys;
4853
5012
  },
@@ -4964,6 +5123,15 @@ export function projectRemoteConfig(spec, record, maps, extra) {
4964
5123
  export function projectConfigPair(spec, localParsed, remoteRecord, maps, extra, localExtra) {
4965
5124
  const local = projectLocalConfig(spec, localParsed, maps, localExtra);
4966
5125
  const remote = projectRemoteConfig(spec, remoteRecord, maps, extra);
5126
+ reconcileUnmanagedFields(spec, localParsed, local, remote);
5127
+ return { local, remote };
5128
+ }
5129
+ /**
5130
+ * Take the server's value for every field the local file leaves unmanaged
5131
+ * (#2880), on `local` in place — the reconciliation `projectConfigPair` makes,
5132
+ * shared with `config pull`'s keep-or-write comparison (#4160).
5133
+ */
5134
+ function reconcileUnmanagedFields(spec, localParsed, local, remote) {
4967
5135
  for (const key of spec.unmanaged?.(localParsed) ?? []) {
4968
5136
  // Key-for-key, so an unmanaged field the server does not hold either is
4969
5137
  // absent on both sides rather than present-and-undefined on one.
@@ -4972,7 +5140,20 @@ export function projectConfigPair(spec, localParsed, remoteRecord, maps, extra,
4972
5140
  else
4973
5141
  delete local[key];
4974
5142
  }
4975
- return { local, remote };
5143
+ }
5144
+ /**
5145
+ * One `config diff` row's verdict once both sides exist (#4116, DSO-4116-003).
5146
+ *
5147
+ * A type whose files name a rule set cannot be compared without the app's
5148
+ * rule-set listing: an unresolvable reference would read as a difference that
5149
+ * is not there. So when the listing failed the row says so rather than
5150
+ * comparing; otherwise `compare` decides.
5151
+ */
5152
+ export function configDiffRowVerdict(parsed, remote, compare, contentComparable) {
5153
+ if (!contentComparable) {
5154
+ return { status: "exists", hint: "content not compared (rule sets unavailable)" };
5155
+ }
5156
+ return compare(parsed, remote);
4976
5157
  }
4977
5158
  /**
4978
5159
  * One `config diff` row's content verdict for a registered type (#2880).
@@ -5486,6 +5667,59 @@ export function configSemanticHash(label, filePath, maps) {
5486
5667
  return undefined;
5487
5668
  }
5488
5669
  }
5670
+ /**
5671
+ * Whether a local config file says what the file `config pull` would write
5672
+ * says (#4160) — the definition-projected field set `config diff` compares, so
5673
+ * a file diff calls Synced keeps its comments, key order and explicit defaults
5674
+ * through a pull.
5675
+ *
5676
+ * Both sides go through the same projection with the same context, so
5677
+ * `extra` is the context a reference is read through (a test case's lookups),
5678
+ * never server-only rows. As in diff, the file is paired by the identity it
5679
+ * states (or `filePath`'s name), and the fields it leaves unmanaged take the
5680
+ * server's values — so a kept file's baseline is the server's projection
5681
+ * (`pulledSemanticHash`), never the file's own.
5682
+ */
5683
+ export function sameConfigAs(label, serverContent, filePath, maps, extra) {
5684
+ const spec = configDiffSpec(label);
5685
+ return (localContent) => {
5686
+ const parsed = parseConfigToml(localContent);
5687
+ if (pullMustRewrite(label, parsed))
5688
+ return false;
5689
+ const server = parseConfigToml(serverContent);
5690
+ const local = localConfigWithServerIdentity(label, parsed, server, filePath);
5691
+ if (local === null)
5692
+ return false;
5693
+ const localProjected = projectLocalConfig(spec, local, maps, extra);
5694
+ const serverProjected = projectLocalConfig(spec, server, maps, extra);
5695
+ reconcileUnmanagedFields(spec, local, localProjected, serverProjected);
5696
+ return (computeExpandedContentHash(localProjected) ===
5697
+ computeExpandedContentHash(serverProjected));
5698
+ };
5699
+ }
5700
+ /**
5701
+ * The semantic baseline `config pull` stamps for a config file (#4160): the
5702
+ * projection of what the server serializes, whether the file was written or
5703
+ * kept. A kept file can leave unmanaged fields or its identity to the server,
5704
+ * so hashing it would record a baseline the server never had.
5705
+ */
5706
+ export function pulledSemanticHash(label, serverContent, maps) {
5707
+ try {
5708
+ return hashLocalConfigForDiff(configDiffSpec(label), parseConfigToml(serverContent), maps);
5709
+ }
5710
+ catch {
5711
+ return undefined;
5712
+ }
5713
+ }
5714
+ /**
5715
+ * Whether a local TOML file parses to the same document as `serverContent`
5716
+ * (#4160): the comparison for `vars.toml`, which has no field definition
5717
+ * behind it, so only comments, key order and spelling are free.
5718
+ */
5719
+ export function sameTomlAs(serverContent) {
5720
+ return (localContent) => canonicalJsonStringify(parseConfigToml(localContent)) ===
5721
+ canonicalJsonStringify(parseConfigToml(serverContent));
5722
+ }
5489
5723
  /**
5490
5724
  * The sync state `config push` writes into (#2731 B6, closing #2374).
5491
5725
  *
@@ -5891,7 +6125,9 @@ export async function pullScripts(client, appId, configDir, logger = () => { },
5891
6125
  * `config pull --only` (issue #2645): which transform names this pull may
5892
6126
  * write. Defaults to every one the server lists.
5893
6127
  */
5894
- selects = () => true) {
6128
+ selects = () => true,
6129
+ /** Counts each transform as written or left unchanged (#4160). */
6130
+ writes) {
5895
6131
  const transformsDir = join(configDir, "transforms");
5896
6132
  ensureDir(transformsDir);
5897
6133
  const scriptEntities = {};
@@ -5944,7 +6180,9 @@ selects = () => true) {
5944
6180
  }
5945
6181
  const filename = `${name}.rhai`;
5946
6182
  const filePath = join(transformsDir, filename);
5947
- writeFileSync(filePath, activeConfig.body);
6183
+ // A transform's comparator is its bytes, so only identical bytes keep the
6184
+ // file (#4160).
6185
+ const fileWritten = writePulledFile(filePath, activeConfig.body, writes);
5948
6186
  scriptEntities[name] = {
5949
6187
  id: script.scriptId,
5950
6188
  modifiedAt: script.modifiedAt || new Date().toISOString(),
@@ -5957,7 +6195,8 @@ selects = () => true) {
5957
6195
  semanticHash: computeScriptBodyHash(activeConfig.body),
5958
6196
  };
5959
6197
  written += 1;
5960
- logger(` Wrote transforms/${filename}`);
6198
+ if (fileWritten)
6199
+ logger(` Wrote transforms/${filename}`);
5961
6200
  }
5962
6201
  const serverKeys = items.map((s) => s?.name).filter(Boolean);
5963
6202
  const presence = detailsOk
@@ -6033,7 +6272,9 @@ export function pushedFunctionBaseline(input) {
6033
6272
  * normalizes outside the tree, and the refusals are reported rather than
6034
6273
  * swallowed.
6035
6274
  */
6036
- export async function pullFunctions(client, appId, configDir, logger = () => { }, selects = () => true) {
6275
+ export async function pullFunctions(client, appId, configDir, logger = () => { }, selects = () => true,
6276
+ /** Counts each function file as written or left unchanged (#4160). */
6277
+ writes) {
6037
6278
  const functionsDir = join(configDir, "functions");
6038
6279
  ensureDir(functionsDir);
6039
6280
  const functionEntities = {};
@@ -6076,12 +6317,17 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
6076
6317
  // The logger carries #1446's one warning: a schema holding a `null`
6077
6318
  // anywhere cannot be a native table, so it is written as a JSON string
6078
6319
  // and the operator is told which key that happened to.
6079
- writeFileSync(filePath, serializeFunction(record, undefined, logger));
6320
+ const content = serializeFunction(record, undefined, logger);
6321
+ const fileWritten = writePulledFile(filePath, content, writes, sameConfigAs("function", content, filePath, {
6322
+ ruleSetIdToName: new Map(),
6323
+ ruleSetNameToId: new Map(),
6324
+ }));
6080
6325
  entity.contentHash = computeFileHash(filePath);
6081
6326
  entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() });
6082
6327
  functionEntities[key] = entity;
6083
6328
  written += 1;
6084
- logger(` Wrote functions/${key}.toml (no version pushed yet)`);
6329
+ if (fileWritten)
6330
+ logger(` Wrote functions/${key}.toml (no version pushed yet)`);
6085
6331
  continue;
6086
6332
  }
6087
6333
  let active;
@@ -6137,7 +6383,7 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
6137
6383
  // this is what says so rather than editing a developer's file in silence.
6138
6384
  for (const note of plan.notes)
6139
6385
  logger(` ${note}`);
6140
- const applied = applyFunctionPull(plan);
6386
+ const applied = applyFunctionPull(plan, writes);
6141
6387
  for (const refusal of applied.refusals)
6142
6388
  logger(` ${refusal}`);
6143
6389
  entity.contentHash = computeFileHash(filePath);
@@ -6175,7 +6421,7 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
6175
6421
  });
6176
6422
  for (const warning of schemaPlan.warnings)
6177
6423
  logger(` ${warning}`);
6178
- const schemaApplied = applyFunctionPull(schemaPlan);
6424
+ const schemaApplied = applyFunctionPull(schemaPlan, writes);
6179
6425
  for (const refusal of schemaApplied.refusals)
6180
6426
  logger(` ${refusal}`);
6181
6427
  const presence = detailsOk
@@ -6347,7 +6593,7 @@ export async function pullTestCasesForBlock(params) {
6347
6593
  pulledSlugs.add(slug);
6348
6594
  // Write test case TOML
6349
6595
  const tomlPath = join(testsDir, `${slug}.toml`);
6350
- writeFileSync(tomlPath, toml);
6596
+ writePulledFile(tomlPath, toml, params.writes, sameConfigAs("test-case", toml, tomlPath, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, { lookupMaps }));
6351
6597
  // Attachments: the sidecar directory is the authored set, so pull makes it
6352
6598
  // match the server — downloading what is there, recording each file's
6353
6599
  // content hash (so push can see a byte change under an unchanged name), and
@@ -6371,7 +6617,8 @@ export async function pullTestCasesForBlock(params) {
6371
6617
  }
6372
6618
  try {
6373
6619
  const downloaded = await client.downloadTestCaseAttachment(appId, blockType, blockId, tc.testCaseId, att.filename);
6374
- writeFileSync(destination, downloaded.data);
6620
+ // An attachment's comparator is its bytes (#4160).
6621
+ writePulledFile(destination, downloaded.data, params.writes);
6375
6622
  (attachments ??= {})[att.filename] = computeFileHash(destination);
6376
6623
  }
6377
6624
  catch (err) {
@@ -6557,6 +6804,7 @@ export async function pullTestCasesForBlocks(params) {
6557
6804
  testCaseEntities: params.testCaseEntities,
6558
6805
  priorTestCaseEntities: params.priorTestCaseEntities,
6559
6806
  lookupMaps: params.lookupMaps,
6807
+ writes: params.writes,
6560
6808
  });
6561
6809
  if (!outcome.ok) {
6562
6810
  skippedBlocks.push(block.key);
@@ -7881,7 +8129,7 @@ export function registerConfigSyncCommands(sync) {
7881
8129
  lastSyncedAt: new Date().toISOString(),
7882
8130
  entities: {},
7883
8131
  };
7884
- saveSyncState(configDir, state);
8132
+ saveSyncState(state);
7885
8133
  success(`Initialized config directory: ${configDir}`);
7886
8134
  info("Run 'primitive config pull' to download current configuration.");
7887
8135
  });
@@ -7900,6 +8148,7 @@ export function registerConfigSyncCommands(sync) {
7900
8148
  .action(async (options) => {
7901
8149
  const resolvedAppId = getCurrentAppId();
7902
8150
  const configDir = resolveSyncDir();
8151
+ const pullsLocalEnvironment = requireCurrentEnvironment().local;
7903
8152
  if (!options.json) {
7904
8153
  info(`Using per-environment config directory: ${configDir}`);
7905
8154
  }
@@ -8106,7 +8355,11 @@ export function registerConfigSyncCommands(sync) {
8106
8355
  // sync dir (nothing to back up). Prune to 28 days after success.
8107
8356
  try {
8108
8357
  const snapshotsRoot = resolveSnapshotsRoot({ appId: resolvedAppId });
8109
- const snapshot = createSnapshot(configDir, snapshotsRoot);
8358
+ // A machine-local environment's baseline is its own file, not the
8359
+ // shared tree's (#4117): that is the one the snapshot carries.
8360
+ const snapshot = createSnapshot(configDir, snapshotsRoot, {
8361
+ baselinePath: pullsLocalEnvironment ? resolveSyncStateReadPath() : undefined,
8362
+ });
8110
8363
  if (snapshot) {
8111
8364
  info(` Snapshot: ${snapshot.path}`);
8112
8365
  pruneSnapshots(snapshotsRoot, 28);
@@ -8135,11 +8388,15 @@ export function registerConfigSyncCommands(sync) {
8135
8388
  ensureDir(join(configDir, "collection-type-configs"));
8136
8389
  ensureDir(join(configDir, "metadata-category-configs"));
8137
8390
  ensureDir(join(configDir, "email-templates"));
8391
+ // #4160 — every pulled file goes through `writePulledFile`, which
8392
+ // leaves a file alone when it already says what the server says, and
8393
+ // counts both outcomes for the summary below.
8394
+ const pullWrites = newPullWriteTally();
8138
8395
  // The prior sync state is the record of which local files a previous
8139
8396
  // pull wrote — the discriminator the prune pass keys off (#1659). Read
8140
8397
  // it now: `saveSyncState` at the end of the pull overwrites it, and the
8141
8398
  // files themselves are about to be rewritten.
8142
- const priorState = loadSyncState(configDir);
8399
+ const priorState = loadSyncState();
8143
8400
  const priorEntities = priorState?.entities ?? {};
8144
8401
  // Write app settings. Under `--only`, `app` has to be named — a pull
8145
8402
  // scoped to one workflow must not overwrite app.toml, which is what
@@ -8166,8 +8423,16 @@ export function registerConfigSyncCommands(sync) {
8166
8423
  // Every writable setting round-trips as of #2891 — the server
8167
8424
  // withholds nothing — so the pulled file is complete and pushable as
8168
8425
  // written, with no accompanying note about what it could not carry.
8169
- writePulledAppSettings(configDir, settings);
8170
- info(" Wrote app.toml");
8426
+ // On a machine-local environment the tree's name is the shared
8427
+ // tree's, and a pull never writes the child's over it (#4117).
8428
+ const written = writePulledAppSettings(configDir, settings, {
8429
+ keepStatedName: pullsLocalEnvironment,
8430
+ tally: pullWrites,
8431
+ });
8432
+ if (written.written)
8433
+ info(" Wrote app.toml");
8434
+ if (written.note)
8435
+ warn(` ${written.note}`);
8171
8436
  }
8172
8437
  // Write config vars (issue #1423). A single flat vars.toml at the
8173
8438
  // sync-dir root (like app.toml), holding the per-environment non-secret
@@ -8207,17 +8472,21 @@ export function registerConfigSyncCommands(sync) {
8207
8472
  ? Object.fromEntries(Object.entries(parseConfigToml(readFileSync(varsTomlPathPull, "utf-8"))).map(([k, v]) => [k, String(v ?? "")]))
8208
8473
  : {};
8209
8474
  const scoped = planScopedVarsPull(varsOutcome.vars, pullSelection.varKeys, localVars, priorEntities.vars);
8210
- writeFileSync(varsTomlPathPull, scoped.content);
8475
+ const varsWritten = writePulledFile(varsTomlPathPull, scoped.content, pullWrites, sameTomlAs(scoped.content));
8211
8476
  Object.assign(varEntities, scoped.varEntities);
8212
- info(` Wrote vars.toml (${pullSelection.varKeys.size} selected var(s))`);
8477
+ if (varsWritten) {
8478
+ info(` Wrote vars.toml (${pullSelection.varKeys.size} selected var(s))`);
8479
+ }
8213
8480
  }
8214
8481
  else {
8215
8482
  const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : priorEntities.vars);
8216
8483
  Object.assign(varEntities, varsPlan.varEntities);
8217
8484
  if (varsPlan.write && varsPlan.content !== null) {
8218
- writeFileSync(varsTomlPathPull, varsPlan.content);
8485
+ const varsWritten = writePulledFile(varsTomlPathPull, varsPlan.content, pullWrites, sameTomlAs(varsPlan.content));
8219
8486
  const count = varsOutcome.vars.length;
8220
- info(` Wrote vars.toml (${count} var${count === 1 ? "" : "s"})`);
8487
+ if (varsWritten) {
8488
+ info(` Wrote vars.toml (${count} var${count === 1 ? "" : "s"})`);
8489
+ }
8221
8490
  }
8222
8491
  else {
8223
8492
  const err = varsOutcome.error;
@@ -8237,16 +8506,18 @@ export function registerConfigSyncCommands(sync) {
8237
8506
  for (const integration of integrations) {
8238
8507
  const filename = `${integration.integrationKey}.toml`;
8239
8508
  const filePath = join(configDir, "integrations", filename);
8240
- writeFileSync(filePath, serializeIntegration(integration, (msg) => info(` ${msg}`)));
8509
+ const content = serializeIntegration(integration, (msg) => info(` ${msg}`));
8510
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("integration", content, filePath, integrationPullMaps));
8241
8511
  integrationEntities[integration.integrationKey] = {
8242
8512
  id: integration.integrationId,
8243
8513
  modifiedAt: integration.modifiedAt || new Date().toISOString(),
8244
8514
  contentHash: computeFileHash(filePath),
8245
8515
  // #2880 — the two sides agree right now, so this is the baseline
8246
8516
  // the next push classifies direction against.
8247
- semanticHash: configSemanticHash("integration", filePath, integrationPullMaps),
8517
+ semanticHash: pulledSemanticHash("integration", content, integrationPullMaps),
8248
8518
  };
8249
- info(` Wrote integrations/${filename}`);
8519
+ if (written)
8520
+ info(` Wrote integrations/${filename}`);
8250
8521
  }
8251
8522
  // Pull webhooks. #2803 — an ARCHIVED webhook is retired, not
8252
8523
  // configuration, so it is not exported: writing a tombstone into the
@@ -8271,14 +8542,15 @@ export function registerConfigSyncCommands(sync) {
8271
8542
  for (const webhook of webhooks) {
8272
8543
  const filename = `${webhook.webhookKey}.toml`;
8273
8544
  const filePath = join(webhooksDir, filename);
8274
- writeFileSync(filePath, serializeWebhook(webhook, (msg) => info(` ${msg}`)));
8545
+ const content = serializeWebhook(webhook, (msg) => info(` ${msg}`));
8546
+ writePulledFile(filePath, content, pullWrites, sameConfigAs("webhook", content, filePath, webhookPullMaps));
8275
8547
  webhookEntities[webhook.webhookKey] = {
8276
8548
  id: webhook.webhookId,
8277
8549
  modifiedAt: webhook.modifiedAt || new Date().toISOString(),
8278
8550
  contentHash: computeFileHash(filePath),
8279
8551
  // #2880 — the file and the server agree right now, so this is the
8280
8552
  // baseline the next push classifies direction against.
8281
- semanticHash: configSemanticHash("webhook", filePath, webhookPullMaps),
8553
+ semanticHash: pulledSemanticHash("webhook", content, webhookPullMaps),
8282
8554
  };
8283
8555
  // A stored value that is not a reference is never written to TOML, so
8284
8556
  // pull time is where an operator finds out: a legacy literal from
@@ -8311,6 +8583,24 @@ export function registerConfigSyncCommands(sync) {
8311
8583
  }
8312
8584
  }
8313
8585
  info(` Pulled ${webhooks.length} webhook(s)`);
8586
+ // #2731 B4 — pull is one of the two moments local and server provably
8587
+ // agree, so it stamps the semantic baseline beside the byte hash (push
8588
+ // stamps the other one, from the entity the server returned). The
8589
+ // rule-set maps the shared projection resolves references through are
8590
+ // filled here, before the first type that names a rule set (blob
8591
+ // buckets, #4116), from the FULL listing rather than the `--only`
8592
+ // selection: a scoped pull of a bucket must still name its rule set.
8593
+ const ruleSetIdToName = new Map();
8594
+ const ruleSetNameToIdPull = new Map();
8595
+ const pullDiffMaps = {
8596
+ ruleSetIdToName,
8597
+ ruleSetNameToId: ruleSetNameToIdPull,
8598
+ };
8599
+ for (const ruleSet of Array.isArray(ruleSetsResult) ? ruleSetsResult : []) {
8600
+ ruleSetIdToName.set(ruleSet.ruleSetId, ruleSet.name);
8601
+ if (ruleSet.name)
8602
+ ruleSetNameToIdPull.set(ruleSet.name, ruleSet.ruleSetId);
8603
+ }
8314
8604
  // Pull cron triggers. An older server without the route (or any other
8315
8605
  // failure) leaves the type unlisted — tolerated as before, but recorded
8316
8606
  // so prune skips it rather than reading it as "no cron triggers".
@@ -8319,8 +8609,7 @@ export function registerConfigSyncCommands(sync) {
8319
8609
  const cronTriggersDir = join(configDir, "cron-triggers");
8320
8610
  mkdirSync(cronTriggersDir, { recursive: true });
8321
8611
  const cronTriggerEntities = {};
8322
- // No rule-set reference on either type, and the pull-wide maps are not
8323
- // filled until the rule-set leg — see the webhook block above.
8612
+ // No rule-set reference on a cron trigger — see the webhook block above.
8324
8613
  const keylessPullMaps = {
8325
8614
  ruleSetIdToName: new Map(),
8326
8615
  ruleSetNameToId: new Map(),
@@ -8328,13 +8617,14 @@ export function registerConfigSyncCommands(sync) {
8328
8617
  for (const trigger of cronTriggerItems) {
8329
8618
  const filename = `${trigger.triggerKey}.toml`;
8330
8619
  const filePath = join(cronTriggersDir, filename);
8331
- writeFileSync(filePath, serializeCronTrigger(trigger, (msg) => info(` ${msg}`)));
8620
+ const content = serializeCronTrigger(trigger, (msg) => info(` ${msg}`));
8621
+ writePulledFile(filePath, content, pullWrites, sameConfigAs("cron-trigger", content, filePath, keylessPullMaps));
8332
8622
  cronTriggerEntities[trigger.triggerKey] = {
8333
8623
  id: trigger.triggerId,
8334
8624
  modifiedAt: trigger.modifiedAt || new Date().toISOString(),
8335
8625
  contentHash: computeFileHash(filePath),
8336
8626
  // #2880 — the baseline the next push classifies direction against.
8337
- semanticHash: configSemanticHash("cron-trigger", filePath, keylessPullMaps),
8627
+ semanticHash: pulledSemanticHash("cron-trigger", content, keylessPullMaps),
8338
8628
  };
8339
8629
  }
8340
8630
  info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
@@ -8344,31 +8634,50 @@ export function registerConfigSyncCommands(sync) {
8344
8634
  const blobBucketsDir = join(configDir, "blob-buckets");
8345
8635
  mkdirSync(blobBucketsDir, { recursive: true });
8346
8636
  const blobBucketEntities = {};
8637
+ // #4116 — a bound bucket's file names its rule set, which only the
8638
+ // rule-set listing can supply. When that listing failed, the bucket's
8639
+ // file and sync-state entry are kept as they are rather than rewritten
8640
+ // without the name.
8641
+ const unwrittenBlobBucketKeys = [];
8347
8642
  for (const bucket of blobBucketItems) {
8643
+ if (!ruleSetsOutcome.ok && bucket.ruleSetId) {
8644
+ unwrittenBlobBucketKeys.push(bucket.bucketKey);
8645
+ continue;
8646
+ }
8348
8647
  const filename = `${bucket.bucketKey}.toml`;
8349
8648
  const filePath = join(blobBucketsDir, filename);
8350
- writeFileSync(filePath, serializeBlobBucket(bucket, (msg) => info(` ${msg}`)));
8649
+ const content = serializeBlobBucket(bucket, ruleSetIdToName, (msg) => info(` ${msg}`));
8650
+ writePulledFile(filePath, content, pullWrites, sameConfigAs("blob-bucket", content, filePath, pullDiffMaps));
8351
8651
  blobBucketEntities[bucket.bucketKey] = {
8352
8652
  id: bucket.bucketId,
8353
8653
  modifiedAt: bucket.modifiedAt || new Date().toISOString(),
8354
8654
  contentHash: computeFileHash(filePath),
8355
- // #2880 — as for cron triggers.
8356
- semanticHash: configSemanticHash("blob-bucket", filePath, keylessPullMaps),
8655
+ // #2880 — the baseline the next push classifies direction against.
8656
+ semanticHash: pulledSemanticHash("blob-bucket", content, pullDiffMaps),
8357
8657
  };
8358
8658
  }
8359
- info(` Pulled ${blobBucketItems.length} blob bucket(s)`);
8659
+ info(` Pulled ${blobBucketItems.length - unwrittenBlobBucketKeys.length} blob bucket(s)`);
8660
+ if (unwrittenBlobBucketKeys.length > 0) {
8661
+ warn(`${unwrittenBlobBucketKeys.length} blob bucket(s) not written — the rule-set ` +
8662
+ `listing failed, so their ruleSetName is unknown; existing files kept ` +
8663
+ `(${unwrittenBlobBucketKeys.join(", ")})`);
8664
+ }
8360
8665
  // Write prompts
8361
8666
  const promptEntities = {};
8362
8667
  for (const prompt of prompts) {
8363
8668
  const filename = `${prompt.promptKey}.toml`;
8364
8669
  const filePath = join(configDir, "prompts", filename);
8365
- writeFileSync(filePath, serializePrompt(prompt, (msg) => info(` ${msg}`)));
8670
+ const content = serializePrompt(prompt, (msg) => info(` ${msg}`));
8671
+ const written = writePulledFile(filePath, content, pullWrites,
8672
+ // Prompts carry no rule-set reference.
8673
+ sameConfigAs("prompt", content, filePath, keylessPullMaps));
8366
8674
  promptEntities[prompt.promptKey] = {
8367
8675
  id: prompt.promptId,
8368
8676
  modifiedAt: prompt.modifiedAt || new Date().toISOString(),
8369
8677
  contentHash: computeFileHash(filePath),
8370
8678
  };
8371
- info(` Wrote prompts/${filename}`);
8679
+ if (written)
8680
+ info(` Wrote prompts/${filename}`);
8372
8681
  }
8373
8682
  // Write workflows
8374
8683
  const workflowEntities = {};
@@ -8394,13 +8703,21 @@ export function registerConfigSyncCommands(sync) {
8394
8703
  if (reconciled.message) {
8395
8704
  info(` ${reconciled.message}`);
8396
8705
  }
8706
+ let workflowWritten = false;
8397
8707
  if (reconciled.action === "write-merged") {
8398
8708
  writeFileSync(filePath, reconciled.content);
8709
+ pullWrites.written += 1;
8710
+ workflowWritten = true;
8399
8711
  }
8400
8712
  else if (reconciled.action === "write-expanded") {
8401
- writeFileSync(filePath, remoteToml);
8713
+ // #4160 — a file with no fragments that already describes the
8714
+ // running workflow keeps its bytes.
8715
+ workflowWritten = writePulledFile(filePath, remoteToml, pullWrites, sameWorkflowAs(workflow.workflowKey, remoteToml));
8716
+ }
8717
+ else {
8718
+ pullWrites.unchanged += 1;
8402
8719
  }
8403
- if (reconciled.action !== "skip") {
8720
+ if (workflowWritten) {
8404
8721
  info(` Wrote workflows/${filename}`);
8405
8722
  }
8406
8723
  // #2645 — the workflow's OTHER named configs, one file each. The
@@ -8412,8 +8729,11 @@ export function registerConfigSyncCommands(sync) {
8412
8729
  const sidecarDir = workflowConfigsDir(configDir, workflow.workflowKey);
8413
8730
  mkdirSync(sidecarDir, { recursive: true });
8414
8731
  for (const sidecar of sidecars) {
8415
- writeFileSync(join(sidecarDir, `${sidecar.name}.toml`), sidecar.content);
8416
- info(` Wrote workflows/${workflow.workflowKey}.configs/${sidecar.name}.toml`);
8732
+ const sidecarWritten = writePulledFile(join(sidecarDir, `${sidecar.name}.toml`), sidecar.content, pullWrites, (local) => hashWorkflowConfigSidecar(parseConfigToml(local)) ===
8733
+ hashWorkflowConfigSidecar(parseConfigToml(sidecar.content)));
8734
+ if (sidecarWritten) {
8735
+ info(` Wrote workflows/${workflow.workflowKey}.configs/${sidecar.name}.toml`);
8736
+ }
8417
8737
  }
8418
8738
  }
8419
8739
  // A sidecar for a config the server no longer has is a file that
@@ -8480,7 +8800,7 @@ export function registerConfigSyncCommands(sync) {
8480
8800
  return false;
8481
8801
  matchedSelectors.add(`transform/${name}`);
8482
8802
  return true;
8483
- });
8803
+ }, pullWrites);
8484
8804
  if (pulledScriptsCount > 0) {
8485
8805
  info(` Pulled ${pulledScriptsCount} transform(s)`);
8486
8806
  }
@@ -8494,7 +8814,7 @@ export function registerConfigSyncCommands(sync) {
8494
8814
  return false;
8495
8815
  matchedSelectors.add(`function/${key}`);
8496
8816
  return true;
8497
- });
8817
+ }, pullWrites);
8498
8818
  if (pulledFunctionsCount > 0) {
8499
8819
  info(` Pulled ${pulledFunctionsCount} function(s)`);
8500
8820
  }
@@ -8530,24 +8850,13 @@ export function registerConfigSyncCommands(sync) {
8530
8850
  prompts: promptDetails,
8531
8851
  configs: blockConfigs,
8532
8852
  });
8533
- // #2731 B4 — pull is one of the two moments local and server provably
8534
- // agree, so it stamps the semantic baseline beside the byte hash (push
8535
- // stamps the other one, from the entity the server returned). The
8536
- // rule-set maps the shared projection resolves references through are
8537
- // declared here and filled below, before the first type that needs
8538
- // them is written.
8539
- const ruleSetIdToName = new Map();
8540
- const ruleSetNameToIdPull = new Map();
8541
- const pullDiffMaps = {
8542
- ruleSetIdToName,
8543
- ruleSetNameToId: ruleSetNameToIdPull,
8544
- };
8545
8853
  // Write email templates (use detailed emailTemplates, not summaries)
8546
8854
  const emailTemplateEntities = {};
8547
8855
  for (const template of emailTemplates) {
8548
8856
  const filename = `${template.emailType}.toml`;
8549
8857
  const templatePath = join(configDir, "email-templates", filename);
8550
- writeFileSync(templatePath, serializeEmailTemplate(template, (msg) => info(` ${msg}`)));
8858
+ const content = serializeEmailTemplate(template, (msg) => info(` ${msg}`));
8859
+ const written = writePulledFile(templatePath, content, pullWrites, sameConfigAs("email-template", content, templatePath, pullDiffMaps));
8551
8860
  emailTemplateEntities[template.emailType] = {
8552
8861
  id: template.emailType,
8553
8862
  modifiedAt: template.override?.modifiedAt || new Date().toISOString(),
@@ -8555,9 +8864,10 @@ export function registerConfigSyncCommands(sync) {
8555
8864
  // live-fetch fallback gates on, and an entry without one fails
8556
8865
  // closed rather than being blind-upserted.
8557
8866
  contentHash: computeFileHash(templatePath),
8558
- semanticHash: configSemanticHash("email-template", templatePath, pullDiffMaps),
8867
+ semanticHash: pulledSemanticHash("email-template", content, pullDiffMaps),
8559
8868
  };
8560
- info(` Wrote email-templates/${filename}`);
8869
+ if (written)
8870
+ info(` Wrote email-templates/${filename}`);
8561
8871
  }
8562
8872
  // Email-template files for removed overrides used to be cleaned up here
8563
8873
  // by a hand-coded loop — the only type that pruned at all (#1659). It is
@@ -8609,6 +8919,7 @@ export function registerConfigSyncCommands(sync) {
8609
8919
  priorTestCaseEntities,
8610
8920
  lookupMaps: testCaseLookupMaps,
8611
8921
  logger: (message) => info(message),
8922
+ writes: pullWrites,
8612
8923
  });
8613
8924
  totalTestCases += count;
8614
8925
  unwrittenTestCases += unwritten.length;
@@ -8620,13 +8931,10 @@ export function registerConfigSyncCommands(sync) {
8620
8931
  if (unwrittenTestCases > 0) {
8621
8932
  warn(`${unwrittenTestCases} test case(s) not written — references could not be resolved; see above`);
8622
8933
  }
8623
- // Build ruleSetId → name map for database types and group type configs
8934
+ // The rule-set files this pull writes. The reference maps above are
8935
+ // filled from the full listing instead, so a scoped pull of a type that
8936
+ // names a rule set still names it (#4116, DSO-4116-001).
8624
8937
  const ruleSets = selectPull("rule-set", Array.isArray(ruleSetsResult) ? ruleSetsResult : [], (rs) => ruleSetFileKey(rs));
8625
- for (const ruleSet of ruleSets) {
8626
- ruleSetIdToName.set(ruleSet.ruleSetId, ruleSet.name);
8627
- if (ruleSet.name)
8628
- ruleSetNameToIdPull.set(ruleSet.name, ruleSet.ruleSetId);
8629
- }
8630
8938
  // Write database types
8631
8939
  const databaseTypeEntities = {};
8632
8940
  for (const { typeConfig, operations, subscriptions } of databaseTypesWithOps) {
@@ -8638,14 +8946,15 @@ export function registerConfigSyncCommands(sync) {
8638
8946
  const hints = existsSync(filePath)
8639
8947
  ? detectExistingOperationForms(readFileSync(filePath, "utf-8"))
8640
8948
  : undefined;
8641
- writeFileSync(filePath, serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
8949
+ const content = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
8642
8950
  hints,
8643
8951
  defaultForm: "native",
8644
8952
  logger: (msg) => info(` ${msg}`),
8645
8953
  // Issue #803: emit `[[subscriptions]]` blocks so a pull → push
8646
8954
  // cycle round-trips subscriptions without a spurious diff.
8647
8955
  subscriptions,
8648
- }));
8956
+ });
8957
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("database-type-config", content, filePath, pullDiffMaps));
8649
8958
  const opsEntities = {};
8650
8959
  for (const op of operations) {
8651
8960
  opsEntities[op.name] = {
@@ -8669,11 +8978,12 @@ export function registerConfigSyncCommands(sync) {
8669
8978
  operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
8670
8979
  subscriptions: Object.keys(subsEntities).length > 0 ? subsEntities : undefined,
8671
8980
  contentHash: computeFileHash(filePath),
8672
- semanticHash: configSemanticHash("database-type-config", filePath, pullDiffMaps),
8981
+ semanticHash: pulledSemanticHash("database-type-config", content, pullDiffMaps),
8673
8982
  hasSchema: typeof typeConfig.schema === "string" &&
8674
8983
  typeConfig.schema.trim().length > 0,
8675
8984
  };
8676
- info(` Wrote database-type-configs/${filename}`);
8985
+ if (written)
8986
+ info(` Wrote database-type-configs/${filename}`);
8677
8987
  }
8678
8988
  // #3182 — the typed-handle artifacts are REGENERATED, never pulled:
8679
8989
  // they are not in the envelope, and a fresh clone must end up with the
@@ -8696,14 +9006,16 @@ export function registerConfigSyncCommands(sync) {
8696
9006
  const fileKey = ruleSetFileKey(ruleSet);
8697
9007
  const filename = `${fileKey}.toml`;
8698
9008
  const filePath = join(configDir, "rule-sets", filename);
8699
- writeFileSync(filePath, serializeRuleSet(ruleSet, (msg) => info(` ${msg}`)));
9009
+ const content = serializeRuleSet(ruleSet, (msg) => info(` ${msg}`));
9010
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("rule-set", content, filePath, pullDiffMaps));
8700
9011
  ruleSetEntities[fileKey] = {
8701
9012
  id: ruleSet.ruleSetId,
8702
9013
  modifiedAt: ruleSet.modifiedAt || new Date().toISOString(),
8703
9014
  contentHash: computeFileHash(filePath),
8704
- semanticHash: configSemanticHash("rule-set", filePath, pullDiffMaps),
9015
+ semanticHash: pulledSemanticHash("rule-set", content, pullDiffMaps),
8705
9016
  };
8706
- info(` Wrote rule-sets/${filename}`);
9017
+ if (written)
9018
+ info(` Wrote rule-sets/${filename}`);
8707
9019
  }
8708
9020
  // Write group type configs
8709
9021
  const groupTypeConfigEntities = {};
@@ -8711,13 +9023,15 @@ export function registerConfigSyncCommands(sync) {
8711
9023
  for (const config of groupTypeConfigs) {
8712
9024
  const filename = `${config.groupType}.toml`;
8713
9025
  const filePath = join(configDir, "group-type-configs", filename);
8714
- writeFileSync(filePath, serializeGroupTypeConfig(config, ruleSetIdToName, (msg) => info(` ${msg}`)));
9026
+ const content = serializeGroupTypeConfig(config, ruleSetIdToName, (msg) => info(` ${msg}`));
9027
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("group-type-config", content, filePath, pullDiffMaps));
8715
9028
  groupTypeConfigEntities[config.groupType] = {
8716
9029
  modifiedAt: config.modifiedAt || new Date().toISOString(),
8717
9030
  contentHash: computeFileHash(filePath),
8718
- semanticHash: configSemanticHash("group-type-config", filePath, pullDiffMaps),
9031
+ semanticHash: pulledSemanticHash("group-type-config", content, pullDiffMaps),
8719
9032
  };
8720
- info(` Wrote group-type-configs/${filename}`);
9033
+ if (written)
9034
+ info(` Wrote group-type-configs/${filename}`);
8721
9035
  }
8722
9036
  // Write collection type configs
8723
9037
  const collectionTypeConfigEntities = {};
@@ -8725,13 +9039,15 @@ export function registerConfigSyncCommands(sync) {
8725
9039
  for (const config of collectionTypeConfigs) {
8726
9040
  const filename = `${config.collectionType}.toml`;
8727
9041
  const filePath = join(configDir, "collection-type-configs", filename);
8728
- writeFileSync(filePath, serializeCollectionTypeConfig(config, ruleSetIdToName, (msg) => info(` ${msg}`)));
9042
+ const content = serializeCollectionTypeConfig(config, ruleSetIdToName, (msg) => info(` ${msg}`));
9043
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("collection-type-config", content, filePath, pullDiffMaps));
8729
9044
  collectionTypeConfigEntities[config.collectionType] = {
8730
9045
  modifiedAt: config.modifiedAt || new Date().toISOString(),
8731
9046
  contentHash: computeFileHash(filePath),
8732
- semanticHash: configSemanticHash("collection-type-config", filePath, pullDiffMaps),
9047
+ semanticHash: pulledSemanticHash("collection-type-config", content, pullDiffMaps),
8733
9048
  };
8734
- info(` Wrote collection-type-configs/${filename}`);
9049
+ if (written)
9050
+ info(` Wrote collection-type-configs/${filename}`);
8735
9051
  }
8736
9052
  // Write metadata category configs (issue #1304, P-B). One file per
8737
9053
  // `{resourceType, category}`; the entity key is the `resourceType#category`
@@ -8741,13 +9057,15 @@ export function registerConfigSyncCommands(sync) {
8741
9057
  for (const config of metadataCategoryConfigs) {
8742
9058
  const filename = `${config.resourceType}.${config.category}.toml`;
8743
9059
  const filePath = join(configDir, "metadata-category-configs", filename);
8744
- writeFileSync(filePath, serializeMetadataCategoryConfig(config, (msg) => info(` ${msg}`)));
9060
+ const content = serializeMetadataCategoryConfig(config, (msg) => info(` ${msg}`));
9061
+ const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("metadata-category-config", content, filePath, pullDiffMaps));
8745
9062
  metadataCategoryConfigEntities[`${config.resourceType}#${config.category}`] = {
8746
9063
  modifiedAt: config.modifiedAt || new Date().toISOString(),
8747
9064
  contentHash: computeFileHash(filePath),
8748
- semanticHash: configSemanticHash("metadata-category-config", filePath, pullDiffMaps),
9065
+ semanticHash: pulledSemanticHash("metadata-category-config", content, pullDiffMaps),
8749
9066
  };
8750
- info(` Wrote metadata-category-configs/${filename}`);
9067
+ if (written)
9068
+ info(` Wrote metadata-category-configs/${filename}`);
8751
9069
  }
8752
9070
  // ── Prune reconciliation (issue #1659) ───────────────────────────────
8753
9071
  //
@@ -8897,6 +9215,7 @@ export function registerConfigSyncCommands(sync) {
8897
9215
  preserveUnwrittenEntries(emailTemplateEntities, priorEntities.emailTemplates, emailTemplatesOutcome.ok
8898
9216
  ? emailTemplateOverrides.map((t) => t.emailType)
8899
9217
  : []);
9218
+ preserveUnwrittenEntries(blobBucketEntities, priorEntities.blobBuckets, unwrittenBlobBucketKeys);
8900
9219
  const finalCronTriggerEntities = keepPrior(cronTriggersOutcome.ok, cronTriggerEntities, "cronTriggers");
8901
9220
  const finalBlobBucketEntities = keepPrior(blobBucketsOutcome.ok, blobBucketEntities, "blobBuckets");
8902
9221
  const finalScriptEntities = keepPrior(scriptListOk, scriptEntities, "scripts");
@@ -9094,8 +9413,12 @@ export function registerConfigSyncCommands(sync) {
9094
9413
  // this is the state both sides agree on: the baseline the
9095
9414
  // next push classifies against. A pull also ends any
9096
9415
  // cross-app name exclusion, because the file it just wrote
9097
- // states the target's own name.
9098
- semanticHash: hashAppSettingsForDiff(settings),
9416
+ // states the target's own name — except on a
9417
+ // machine-local environment, whose file states the shared
9418
+ // tree's name and whose baseline never includes one.
9419
+ semanticHash: hashAppSettingsForDiff(settings, {
9420
+ excludeName: pullsLocalEnvironment,
9421
+ }),
9099
9422
  }
9100
9423
  : pullSelection
9101
9424
  ? priorEntities.app
@@ -9122,7 +9445,7 @@ export function registerConfigSyncCommands(sync) {
9122
9445
  : undefined,
9123
9446
  },
9124
9447
  };
9125
- saveSyncState(configDir, state);
9448
+ saveSyncState(state);
9126
9449
  divider();
9127
9450
  success(`Pulled configuration to ${configDir}`);
9128
9451
  keyValue("Config Vars", Object.keys(varEntities).length);
@@ -9145,6 +9468,8 @@ export function registerConfigSyncCommands(sync) {
9145
9468
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
9146
9469
  keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
9147
9470
  keyValue("Pruned", prunedCount);
9471
+ keyValue("Files written", pullWrites.written);
9472
+ keyValue("Files unchanged", pullWrites.unchanged);
9148
9473
  // A selector the server had nothing for wrote nothing. Say so and fail:
9149
9474
  // otherwise a typo pulls an empty set and reports success, and the
9150
9475
  // operator reads "Pulled configuration" as "my object is now in the
@@ -9252,7 +9577,11 @@ What a push guarantees (two stages):
9252
9577
  // state write holds on the first push and the end-of-run save persists
9253
9578
  // the ids. Without it a successful create-push recorded nothing and the
9254
9579
  // next push re-entered every create path.
9255
- let syncState = ensurePushSyncState(loadSyncState(configDir), resolvedAppId, getServerUrl());
9580
+ let syncState = ensurePushSyncState(loadSyncState(), resolvedAppId, getServerUrl());
9581
+ // A machine-local environment (#4117) shares a committed environment's
9582
+ // tree, so `[app].name` in it is that environment's name: never sent,
9583
+ // never compared, never hashed into this environment's baseline.
9584
+ const localEnvironment = requireCurrentEnvironment().local;
9256
9585
  // Detect cross-app push: if pushing to a different app, clear entity IDs
9257
9586
  // so everything goes through the create path
9258
9587
  const isCrossAppPush = syncState && syncState.appId !== resolvedAppId;
@@ -9688,6 +10017,7 @@ What a push guarantees (two stages):
9688
10017
  syncState,
9689
10018
  force: !!options.force,
9690
10019
  isCrossAppPush: !!isCrossAppPush,
10020
+ localEnvironment,
9691
10021
  client,
9692
10022
  });
9693
10023
  // Re-derived, not returned: both are one-line functions of inputs the
@@ -9864,13 +10194,21 @@ What a push guarantees (two stages):
9864
10194
  // `appNameEdited` — the file has moved away from the recorded name.
9865
10195
  // That is a local rename BY CONSTRUCTION, not by comparison: no
9866
10196
  // baseline hashed without `name` can ever attribute it.
10197
+ //
10198
+ // On a machine-local environment (#4117) all three are settled
10199
+ // before they start: the name is the shared tree's, so it is out of
10200
+ // the body and the comparison, and no edit to it is this app's.
9867
10201
  const payloadExcludesName = appSettingsNameExcluded({
10202
+ localEnvironment,
9868
10203
  isCrossAppPush: !!isCrossAppPush,
9869
10204
  recordedExcludedName,
9870
10205
  fileName: appFileName,
9871
10206
  });
9872
- const compareExcludesName = !!isCrossAppPush || recordedExcludedName !== undefined;
9873
- const appNameEdited = !isCrossAppPush &&
10207
+ const compareExcludesName = localEnvironment ||
10208
+ !!isCrossAppPush ||
10209
+ recordedExcludedName !== undefined;
10210
+ const appNameEdited = !localEnvironment &&
10211
+ !isCrossAppPush &&
9874
10212
  recordedExcludedName !== undefined &&
9875
10213
  !payloadExcludesName;
9876
10214
  // The whole-truth body. Errors (an unrecognized or retired key, a
@@ -9878,6 +10216,7 @@ What a push guarantees (two stages):
9878
10216
  // the preflight pass above, so only warnings remain here.
9879
10217
  const { settings: appPayload, warnings: appWarnings } = buildAppSettingsPayload(tomlData, {
9880
10218
  excludeName: payloadExcludesName,
10219
+ nameOptional: localEnvironment,
9881
10220
  });
9882
10221
  for (const w of appWarnings) {
9883
10222
  warn(` ${w}`);
@@ -9900,8 +10239,12 @@ What a push guarantees (two stages):
9900
10239
  appEntry?.modifiedAt ||
9901
10240
  new Date().toISOString(),
9902
10241
  contentHash: appFileHash,
10242
+ // Every stamp on a machine-local environment is hashed without
10243
+ // the name, or the next comparison — which never sees it —
10244
+ // could not match the baseline (#4117).
9903
10245
  semanticHash: hashAppSettingsForDiff(baselineSettings, {
9904
- excludeName: options.recordExclusion !== undefined ||
10246
+ excludeName: localEnvironment ||
10247
+ options.recordExclusion !== undefined ||
9905
10248
  (options.keepExclusion && compareExcludesName),
9906
10249
  }),
9907
10250
  ...(options.recordExclusion !== undefined
@@ -11109,16 +11452,25 @@ What a push guarantees (two stages):
11109
11452
  const liveBuckets = new Map();
11110
11453
  let bucketsLiveOk = true;
11111
11454
  if (files.length > 0) {
11112
- try {
11113
- const listed = await client.listBlobBuckets(resolvedAppId);
11114
- for (const b of (listed?.items ?? [])) {
11115
- if (b?.bucketKey)
11116
- liveBuckets.set(b.bucketKey, b);
11117
- }
11455
+ // #4116 — a bound bucket's rule set is compared by name, so the
11456
+ // comparison needs the rule-set listing; without it the leg
11457
+ // degrades as the type configs do.
11458
+ bucketsLiveOk = (await loadLiveRuleSets()).ok;
11459
+ if (!bucketsLiveOk) {
11460
+ warnLiveUnavailable("blob-buckets", "rule sets unavailable");
11118
11461
  }
11119
- catch (err) {
11120
- bucketsLiveOk = false;
11121
- warnLiveUnavailable("blob-buckets", err);
11462
+ else {
11463
+ try {
11464
+ const listed = await client.listBlobBuckets(resolvedAppId);
11465
+ for (const b of (listed?.items ?? [])) {
11466
+ if (b?.bucketKey)
11467
+ liveBuckets.set(b.bucketKey, b);
11468
+ }
11469
+ }
11470
+ catch (err) {
11471
+ bucketsLiveOk = false;
11472
+ warnLiveUnavailable("blob-buckets", err);
11473
+ }
11122
11474
  }
11123
11475
  }
11124
11476
  const refetchLiveBucket = async (bucketId) => {
@@ -11130,7 +11482,13 @@ What a push guarantees (two stages):
11130
11482
  const tomlData = parseTomlFile(filePath);
11131
11483
  const key = tomlData.bucket?.key || basename(file, ".toml");
11132
11484
  withDerivedIdentity(tomlData, "bucket", { key });
11133
- const bucket = tomlData.bucket || {};
11485
+ // #4116 — `ruleSetName` resolves to THIS app's rule set before any
11486
+ // request; a name that resolves nowhere fails here, so a bucket is
11487
+ // never created or updated without its rule set.
11488
+ const bucket = parseBlobBucketToml(tomlData);
11489
+ resolveRuleSetReference(bucket, ruleSetNameToId, `blob bucket "${key}"`, {
11490
+ hint: ruleSetSelectionHint,
11491
+ });
11134
11492
  const existingEntry = syncState?.entities?.blobBuckets?.[key];
11135
11493
  const liveBucket = liveBuckets.get(key);
11136
11494
  let gate;
@@ -11601,7 +11959,7 @@ What a push guarantees (two stages):
11601
11959
  const serverConfig = liveServerConfig(tomlConfig.name);
11602
11960
  if (!serverConfig || serverConfig.strictOutput !== true)
11603
11961
  continue;
11604
- const chat = hoistDeprecatedChatKeys(tomlConfig).entry.chat ?? {};
11962
+ const chat = tomlConfig.chat ?? {};
11605
11963
  const retired = String(tomlConfig?.status ?? "").trim().toLowerCase() ===
11606
11964
  "archived";
11607
11965
  // An entry that stays strict AND live is refused in preflight
@@ -15835,7 +16193,7 @@ What a push guarantees (two stages):
15835
16193
  // Update sync state for non-conflicting changes
15836
16194
  if (syncState) {
15837
16195
  syncState.lastSyncedAt = new Date().toISOString();
15838
- saveSyncState(configDir, syncState);
16196
+ saveSyncState(syncState);
15839
16197
  }
15840
16198
  const successCount = countAppliedChanges({
15841
16199
  changes,
@@ -15866,7 +16224,7 @@ What a push guarantees (two stages):
15866
16224
  // Update sync state after push
15867
16225
  if (syncState) {
15868
16226
  syncState.lastSyncedAt = new Date().toISOString();
15869
- saveSyncState(configDir, syncState);
16227
+ saveSyncState(syncState);
15870
16228
  }
15871
16229
  reportDrift();
15872
16230
  success(formatPushSummary({
@@ -15891,7 +16249,7 @@ What a push guarantees (two stages):
15891
16249
  if (syncState && recordedProgress && !options.dryRun) {
15892
16250
  try {
15893
16251
  syncState.lastSyncedAt = new Date().toISOString();
15894
- saveSyncState(configDir, syncState);
16252
+ saveSyncState(syncState);
15895
16253
  }
15896
16254
  catch {
15897
16255
  // Don't mask the original error
@@ -15924,7 +16282,7 @@ What a push guarantees (two stages):
15924
16282
  error(`Config directory not found: ${configDir}`);
15925
16283
  process.exit(1);
15926
16284
  }
15927
- const syncState = loadSyncState(configDir);
16285
+ const syncState = loadSyncState();
15928
16286
  // `--only` scopes the comparison the same way it scopes push and pull.
15929
16287
  const diffSelection = readSelection(options);
15930
16288
  // `--only app` on its own IS the retired `settings diff`: it reports the
@@ -16010,7 +16368,6 @@ What a push guarantees (two stages):
16010
16368
  const remoteIntegrations = new Set(integrationItems.map((i) => i.integrationKey));
16011
16369
  const remoteWebhooks = new Set(webhookItems.map((w) => w.webhookKey));
16012
16370
  const remoteCronTriggers = new Set(cronTriggerItemsDiff.map((t) => t.triggerKey));
16013
- const remoteBlobBuckets = new Set((blobBucketsDiffResult.items || []).map((b) => b.bucketKey));
16014
16371
  const remotePrompts = new Set(promptItems.map((p) => p.promptKey));
16015
16372
  // #1660 — exclude platform-owned workflows (reserved `__internal.*`)
16016
16373
  // from the remote set so `diff` never reports them (they are not app
@@ -16047,7 +16404,6 @@ What a push guarantees (two stages):
16047
16404
  const localIntegrations = new Set();
16048
16405
  const localWebhooks = new Set();
16049
16406
  const localCronTriggers = new Set();
16050
- const localBlobBuckets = new Set();
16051
16407
  const localPrompts = new Set();
16052
16408
  const localWorkflows = new Set();
16053
16409
  const localEmailTemplates = new Set();
@@ -16123,7 +16479,6 @@ What a push guarantees (two stages):
16123
16479
  for (const file of readdirSync(blobBucketsDiffDir).filter((f) => f.endsWith(".toml"))) {
16124
16480
  const tomlData = parseTomlFile(join(blobBucketsDiffDir, file));
16125
16481
  const key = tomlData.bucket?.key || basename(file, ".toml");
16126
- localBlobBuckets.add(key);
16127
16482
  // #2880 — as above.
16128
16483
  if (!tomlData.bucket)
16129
16484
  tomlData.bucket = {};
@@ -16476,23 +16831,6 @@ What a push guarantees (two stages):
16476
16831
  differences.push({ type: "cron-trigger", key, status: remoteOnlyStatus("cronTriggers", key) });
16477
16832
  }
16478
16833
  }
16479
- // Blob Buckets — #2880: the same, and the list row is the record here
16480
- // too.
16481
- const blobBucketSpec = configDiffSpec("blob-bucket");
16482
- const remoteBlobBucketRecords = new Map((blobBucketsDiffResult.items || []).map((b) => [b.bucketKey, b]));
16483
- for (const key of localBlobBuckets) {
16484
- if (!remoteBlobBuckets.has(key)) {
16485
- differences.push({ type: "blob-bucket", key, status: localOnlyStatus("blobBuckets", key) });
16486
- continue;
16487
- }
16488
- const { status, hint } = compareLocalToRemote(blobBucketSpec, localBlobBucketsParsed.get(key), remoteBlobBucketRecords.get(key));
16489
- differences.push({ type: "blob-bucket", key, status, hint });
16490
- }
16491
- for (const key of remoteBlobBuckets) {
16492
- if (!localBlobBuckets.has(key)) {
16493
- differences.push({ type: "blob-bucket", key, status: remoteOnlyStatus("blobBuckets", key) });
16494
- }
16495
- }
16496
16834
  // Prompts — #2748: content-aware comparison through the shared registry
16497
16835
  // spec, for the reason webhooks got one in #2757. Existence-only
16498
16836
  // reporting meant a prompt's `accessRule` (and every other `[prompt]`
@@ -16918,20 +17256,12 @@ What a push guarantees (two stages):
16918
17256
  differences.push({ type: typeLabel, key, status: localOnlyStatus(slot, key) });
16919
17257
  continue;
16920
17258
  }
16921
- let status = "exists";
16922
- let hint;
16923
- if (!contentComparable) {
16924
- // Rule-set list failed, so a `ruleSetName` reference can't be
16925
- // resolved on either side — decline the content comparison rather
16926
- // than risk a false `Modified`.
16927
- hint = "content not compared (rule sets unavailable)";
16928
- }
16929
- else {
16930
- // The shared verdict names the differing field (#3267) and
16931
- // degrades a malformed local file to "not compared" rather than
16932
- // aborting the whole diff.
16933
- ({ status, hint } = compare(parsed, remoteEntities.get(key)));
16934
- }
17259
+ // Rule-set list failed, so a `ruleSetName` reference can't be
17260
+ // resolved on either side — decline the content comparison rather
17261
+ // than risk a false `Modified`. Otherwise the shared verdict names
17262
+ // the differing field (#3267) and degrades a malformed local file
17263
+ // to "not compared" rather than aborting the whole diff.
17264
+ const { status, hint } = configDiffRowVerdict(parsed, remoteEntities.get(key), compare, contentComparable);
16935
17265
  differences.push({ type: typeLabel, key, status, hint });
16936
17266
  }
16937
17267
  for (const key of remoteEntities.keys()) {
@@ -16955,6 +17285,9 @@ What a push guarantees (two stages):
16955
17285
  compareByDefinition("group-type-config", "groupTypeConfigs", localGroupTypeConfigsParsed, remoteGroupTypeConfigs, ruleSetsOk);
16956
17286
  compareByDefinition("collection-type-config", "collectionTypeConfigs", localCollectionTypeConfigsParsed, remoteCollectionTypeConfigs, ruleSetsOk);
16957
17287
  compareByDefinition("metadata-category-config", "metadataCategoryConfigs", localMetadataCategoryParsed, remoteMetadataCategoryConfigs, true);
17288
+ // Blob buckets — #2880: the list row is the record. #4116: a bucket
17289
+ // names its rule set, so its rows take the type configs' gate.
17290
+ compareByDefinition("blob-bucket", "blobBuckets", localBlobBucketsParsed, new Map((blobBucketsDiffResult.items || []).map((b) => [b.bucketKey, b])), blobBucketsOutcomeDiff.ok && ruleSetsOk);
16958
17291
  // database-type-configs: the list carries only the type header, so operations
16959
17292
  // and subscriptions are fetched per type (mirroring pull) before the
16960
17293
  // content comparison. A per-type fetch failure degrades that one row to
@@ -17085,12 +17418,16 @@ What a push guarantees (two stages):
17085
17418
  appRow(VALIDATION_ERROR, `could not parse app.toml: ${err?.message || String(err)}`);
17086
17419
  }
17087
17420
  if (appTomlData) {
17088
- const excludeName = appSettingsNameExcludedForState(syncState, resolvedAppId, appTomlData);
17421
+ const diffsLocalEnvironment = requireCurrentEnvironment().local;
17422
+ const excludeName = appSettingsNameExcludedForState(syncState, resolvedAppId, appTomlData, diffsLocalEnvironment);
17089
17423
  // The SAME validator push's preflight runs (criterion 1): a
17090
17424
  // declared-type mismatch, an unrecognized or retired key, and a
17091
17425
  // missing required key are reported here with push's own message
17092
17426
  // rather than compared past and called Synced.
17093
- const { settings: localAppSettings, errors: appErrors } = buildAppSettingsPayload(appTomlData, { excludeName });
17427
+ const { settings: localAppSettings, errors: appErrors } = buildAppSettingsPayload(appTomlData, {
17428
+ excludeName,
17429
+ nameOptional: diffsLocalEnvironment,
17430
+ });
17094
17431
  if (appErrors.length > 0) {
17095
17432
  appRow(VALIDATION_ERROR, appErrors.join("; "));
17096
17433
  }
@@ -17495,11 +17832,11 @@ What a push guarantees (two stages):
17495
17832
  writeFileSync(filePath, rewritten);
17496
17833
  info(`Migrated database-type-configs/${file}`);
17497
17834
  // Update content hash in sync state if we have an entry for it.
17498
- const syncState = loadSyncState(configDir);
17835
+ const syncState = loadSyncState();
17499
17836
  const dbType = typeConfig.databaseType || basename(file, ".toml");
17500
17837
  if (syncState?.entities?.databaseTypes?.[dbType]) {
17501
17838
  syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
17502
- saveSyncState(configDir, syncState);
17839
+ saveSyncState(syncState);
17503
17840
  }
17504
17841
  }
17505
17842
  migratedCount++;
@@ -17527,11 +17864,11 @@ What a push guarantees (two stages):
17527
17864
  writeFileSync(filePath, rewritten);
17528
17865
  info(`Migrated workflows/${file}`);
17529
17866
  // Update the stored content hash so a subsequent push sees a no-op.
17530
- const syncState = loadSyncState(configDir);
17867
+ const syncState = loadSyncState();
17531
17868
  if (syncState?.entities?.workflows?.[workflowKey]) {
17532
17869
  syncState.entities.workflows[workflowKey].contentHash =
17533
17870
  computeWorkflowContentHash(configDir, workflowKey, parseTomlFile(filePath));
17534
- saveSyncState(configDir, syncState);
17871
+ saveSyncState(syncState);
17535
17872
  }
17536
17873
  }
17537
17874
  migratedCount++;
@@ -17669,14 +18006,26 @@ What a push guarantees (two stages):
17669
18006
  return;
17670
18007
  }
17671
18008
  }
18009
+ // A machine-local environment's baseline lives outside the shared tree
18010
+ // (#4117): the snapshot's baseline goes back there, and the tree's own
18011
+ // baseline — the committed environment's — is left exactly as it is.
18012
+ const baselinePath = requireCurrentEnvironment().local
18013
+ ? resolveSyncStateWritePath()
18014
+ : undefined;
17672
18015
  try {
17673
- restoreSnapshot(target.path, configDir);
18016
+ restoreSnapshot(target.path, configDir, { baselinePath });
17674
18017
  }
17675
18018
  catch (err) {
17676
18019
  error(`Restore failed: ${err?.message ?? err}`);
17677
18020
  process.exit(1);
17678
18021
  }
17679
- success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
18022
+ if (baselinePath) {
18023
+ success(`Restored snapshot ${target.id} into ${configDir} and this environment's baseline into ${baselinePath}.`);
18024
+ info(`The committed baseline in ${configDir} belongs to the shared tree's environment and was left as it was.`);
18025
+ }
18026
+ else {
18027
+ success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
18028
+ }
17680
18029
  info("Run 'primitive config diff' to inspect the restored state versus the server.");
17681
18030
  });
17682
18031
  }