@narumitw/pi-sync 0.51.1 → 0.52.0

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 (181) hide show
  1. package/README.md +30 -5
  2. package/dist/chunks/{chunk-7KHKWL3Q.ts → chunk-2CBF6SMT.ts} +8 -7
  3. package/dist/chunks/chunk-2CBF6SMT.ts.map +7 -0
  4. package/dist/chunks/{chunk-YFADF5ZQ.ts → chunk-2DI6PDZV.ts} +5 -1
  5. package/dist/chunks/chunk-2DI6PDZV.ts.map +7 -0
  6. package/dist/chunks/chunk-2GUXI2XT.ts +1702 -0
  7. package/dist/chunks/chunk-2GUXI2XT.ts.map +7 -0
  8. package/dist/chunks/{chunk-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
  9. package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
  10. package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
  11. package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
  12. package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
  13. package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
  14. package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
  15. package/dist/chunks/chunk-76NB4R7U.ts +107 -0
  16. package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
  17. package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
  18. package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
  19. package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
  20. package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
  21. package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
  22. package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
  23. package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
  24. package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
  25. package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
  26. package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
  27. package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
  28. package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
  29. package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
  30. package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
  31. package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
  32. package/dist/chunks/chunk-IJ6VV6FN.ts.map +7 -0
  33. package/dist/chunks/chunk-ORQRHEEO.ts +362 -0
  34. package/dist/chunks/chunk-ORQRHEEO.ts.map +7 -0
  35. package/dist/chunks/chunk-QE7NZZWT.ts +380 -0
  36. package/dist/chunks/chunk-QE7NZZWT.ts.map +7 -0
  37. package/dist/chunks/{chunk-HGJH56KR.ts → chunk-QHSQCK7L.ts} +4 -4
  38. package/dist/chunks/chunk-QOFMXPM5.ts +735 -0
  39. package/dist/chunks/chunk-QOFMXPM5.ts.map +7 -0
  40. package/dist/chunks/{chunk-DZUSTGQV.ts → chunk-UQ3ALBIB.ts} +4 -4
  41. package/dist/chunks/{chunk-Q32CQWPM.ts → chunk-V6VZ5TLG.ts} +13 -6
  42. package/dist/chunks/chunk-V6VZ5TLG.ts.map +7 -0
  43. package/dist/chunks/{chunk-E42GXD6D.ts → chunk-WKPX2SIC.ts} +4 -4
  44. package/dist/chunks/chunk-ZW5ZLGBI.ts +154 -0
  45. package/dist/chunks/chunk-ZW5ZLGBI.ts.map +7 -0
  46. package/dist/chunks/{chunk-6NT3YFGY.ts → chunk-ZX2Q6THB.ts} +8 -3
  47. package/dist/chunks/{chunk-6NT3YFGY.ts.map → chunk-ZX2Q6THB.ts.map} +2 -2
  48. package/dist/chunks/conflict-artifacts-BB2FZMIE.ts +27 -0
  49. package/dist/chunks/conflict-review-T3PB7ZSD.ts +175 -0
  50. package/dist/chunks/conflict-review-T3PB7ZSD.ts.map +7 -0
  51. package/dist/chunks/{file-selection-RAVHRYFG.ts → file-selection-WZX7HAY2.ts} +8 -7
  52. package/dist/chunks/{file-selection-RAVHRYFG.ts.map → file-selection-WZX7HAY2.ts.map} +1 -1
  53. package/dist/chunks/{git-backend-IAANDL3K.ts → git-backend-HLGKUQJT.ts} +19 -7
  54. package/dist/chunks/git-backend-HLGKUQJT.ts.map +7 -0
  55. package/dist/chunks/local-fields-6DGYOOIK.ts +22 -0
  56. package/dist/chunks/manager-result-dispatcher-VIDT3QBU.ts +23 -0
  57. package/dist/chunks/{manager-ui-763NEKJY.ts → manager-ui-HSMHS3OK.ts} +236 -57
  58. package/dist/chunks/manager-ui-HSMHS3OK.ts.map +7 -0
  59. package/dist/chunks/merge-baseline-store-EVQAAML6.ts +22 -0
  60. package/dist/chunks/remote-selection-ui-SMF5CLFO.ts +22 -0
  61. package/dist/chunks/{s3-backend-XGXUOPUN.ts → s3-backend-DJ2NT3SZ.ts} +16 -9
  62. package/dist/chunks/s3-backend-DJ2NT3SZ.ts.map +7 -0
  63. package/dist/chunks/{setup-switch-5G2VNJHL.ts → setup-switch-KTUWL6DE.ts} +9 -6
  64. package/dist/chunks/{setup-wizard-YIEWGX32.ts → setup-wizard-GSDPDFC4.ts} +10 -7
  65. package/dist/chunks/{setup-wizard-YIEWGX32.ts.map → setup-wizard-GSDPDFC4.ts.map} +1 -1
  66. package/dist/chunks/{snapshot-JL5DFSXI.ts → snapshot-THS5EY2L.ts} +6 -9
  67. package/dist/chunks/snapshot-THS5EY2L.ts.map +7 -0
  68. package/dist/chunks/{sync-inspection-P3ENJLNF.ts → sync-inspection-VBJF2FWC.ts} +10 -10
  69. package/dist/chunks/sync-inspection-VBJF2FWC.ts.map +7 -0
  70. package/dist/chunks/{sync-operations-AR76EB6C.ts → sync-operations-27VQUDH2.ts} +309 -508
  71. package/dist/chunks/sync-operations-27VQUDH2.ts.map +7 -0
  72. package/dist/chunks/{sync-state-VHJUZ4YG.ts → sync-state-CFL4RRRB.ts} +6 -6
  73. package/dist/chunks/sync-state-CFL4RRRB.ts.map +7 -0
  74. package/dist/chunks/{webdav-backend-3EGUONAT.ts → webdav-backend-ETQGSI6I.ts} +13 -7
  75. package/dist/chunks/webdav-backend-ETQGSI6I.ts.map +7 -0
  76. package/dist/index.ts +142 -43
  77. package/dist/index.ts.map +2 -2
  78. package/docs/content-merge-audit.md +156 -0
  79. package/docs/merge-implementation-audit.md +312 -0
  80. package/docs/settings-merge-audit.md +153 -0
  81. package/docs/settings.md +36 -0
  82. package/package.json +5 -5
  83. package/src/backends/git/git-backend.ts +2 -0
  84. package/src/backends/git/git-storage.ts +8 -3
  85. package/src/backends/s3/s3-backend.ts +2 -1
  86. package/src/backends/webdav/webdav-backend.ts +2 -1
  87. package/src/commands/command-execution.ts +10 -0
  88. package/src/commands/command.ts +4 -0
  89. package/src/settings/config.ts +12 -0
  90. package/src/settings/settings-management.ts +2 -2
  91. package/src/settings/settings-types.ts +14 -1
  92. package/src/settings/settings-validation.ts +24 -3
  93. package/src/snapshot/session-paths.ts +34 -7
  94. package/src/snapshot/snapshot-apply.ts +81 -32
  95. package/src/snapshot/snapshot-case-replacement.ts +80 -0
  96. package/src/snapshot/snapshot-codec.ts +6 -1
  97. package/src/snapshot/snapshot-root-transition.ts +154 -0
  98. package/src/snapshot/snapshot-transaction-plan.ts +72 -0
  99. package/src/snapshot/snapshot-transaction.ts +844 -102
  100. package/src/snapshot/snapshot-types.ts +5 -0
  101. package/src/state/json-file.ts +32 -5
  102. package/src/state/merge-baseline-store.ts +182 -0
  103. package/src/state/mutation-directory-sync.ts +34 -0
  104. package/src/state/state-types.ts +4 -0
  105. package/src/state/sync-state-store.ts +14 -1
  106. package/src/sync/automatic-sync.ts +11 -3
  107. package/src/sync/conflict-artifact-ownership.ts +63 -0
  108. package/src/sync/conflict-artifacts.ts +195 -0
  109. package/src/sync/conflict-groups.ts +68 -0
  110. package/src/sync/conflict-preview.ts +56 -0
  111. package/src/sync/conflict-resolution.ts +93 -0
  112. package/src/sync/conflict-retention.ts +72 -0
  113. package/src/sync/conflict-review.ts +95 -0
  114. package/src/sync/content-conflicts.ts +73 -0
  115. package/src/sync/field-migration.ts +39 -0
  116. package/src/sync/file-merge-planner.ts +143 -0
  117. package/src/sync/json-document.ts +184 -0
  118. package/src/sync/local-fields.ts +134 -0
  119. package/src/sync/merge-apply.ts +380 -0
  120. package/src/sync/merge-journal.ts +219 -0
  121. package/src/sync/merged-sync.ts +723 -0
  122. package/src/sync/partial-progress.ts +54 -0
  123. package/src/sync/remote-snapshot.ts +6 -0
  124. package/src/sync/session-merge.ts +194 -0
  125. package/src/sync/settings-conflicts.ts +52 -0
  126. package/src/sync/settings-merge.ts +57 -0
  127. package/src/sync/startup-check.ts +90 -5
  128. package/src/sync/sync-inspection.ts +1 -0
  129. package/src/sync/sync-local.ts +61 -0
  130. package/src/sync/sync-mutations.ts +256 -114
  131. package/src/sync/sync-queries.ts +6 -2
  132. package/src/sync/sync-state.ts +14 -5
  133. package/src/sync/text-merge.ts +133 -0
  134. package/src/sync-extension.ts +8 -0
  135. package/src/ui/manager-state.ts +12 -1
  136. package/src/ui/manager-ui.ts +10 -0
  137. package/src/ui/merge-review.ts +28 -0
  138. package/src/ui/remote-selection-ui.ts +1 -0
  139. package/src/ui/settings-ui.ts +183 -2
  140. package/src/ui/setup/setup-actions.ts +2 -2
  141. package/src/ui/setup/setup-switcher.ts +2 -1
  142. package/src/ui/storage-connections-ui.ts +11 -7
  143. package/src/ui/sync-attention.ts +3 -0
  144. package/src/ui/sync-format.ts +16 -12
  145. package/src/ui/sync-setups-ui.ts +1 -0
  146. package/src/ui/terminal-text.ts +9 -2
  147. package/dist/chunks/chunk-4AIHXRKE.ts +0 -197
  148. package/dist/chunks/chunk-4AIHXRKE.ts.map +0 -7
  149. package/dist/chunks/chunk-4MPIH46N.ts.map +0 -7
  150. package/dist/chunks/chunk-7KHKWL3Q.ts.map +0 -7
  151. package/dist/chunks/chunk-EEJPYTWE.ts +0 -348
  152. package/dist/chunks/chunk-EEJPYTWE.ts.map +0 -7
  153. package/dist/chunks/chunk-IVXEDPAD.ts +0 -370
  154. package/dist/chunks/chunk-IVXEDPAD.ts.map +0 -7
  155. package/dist/chunks/chunk-MSSW6F3R.ts +0 -12
  156. package/dist/chunks/chunk-MSSW6F3R.ts.map +0 -7
  157. package/dist/chunks/chunk-OVDTF5CH.ts +0 -90
  158. package/dist/chunks/chunk-OVDTF5CH.ts.map +0 -7
  159. package/dist/chunks/chunk-Q32CQWPM.ts.map +0 -7
  160. package/dist/chunks/chunk-QLSN7AGX.ts.map +0 -7
  161. package/dist/chunks/chunk-SWUXA3MH.ts +0 -18
  162. package/dist/chunks/chunk-SWUXA3MH.ts.map +0 -7
  163. package/dist/chunks/chunk-T56RNRZJ.ts.map +0 -7
  164. package/dist/chunks/chunk-YFADF5ZQ.ts.map +0 -7
  165. package/dist/chunks/git-backend-IAANDL3K.ts.map +0 -7
  166. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +0 -23
  167. package/dist/chunks/manager-ui-763NEKJY.ts.map +0 -7
  168. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +0 -22
  169. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +0 -7
  170. package/dist/chunks/sync-operations-AR76EB6C.ts.map +0 -7
  171. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +0 -7
  172. /package/dist/chunks/{chunk-UA7RXOIT.ts.map → chunk-4SBTAVFL.ts.map} +0 -0
  173. /package/dist/chunks/{chunk-HGJH56KR.ts.map → chunk-QHSQCK7L.ts.map} +0 -0
  174. /package/dist/chunks/{chunk-DZUSTGQV.ts.map → chunk-UQ3ALBIB.ts.map} +0 -0
  175. /package/dist/chunks/{chunk-E42GXD6D.ts.map → chunk-WKPX2SIC.ts.map} +0 -0
  176. /package/dist/chunks/{manager-result-dispatcher-OTUMQM26.ts.map → conflict-artifacts-BB2FZMIE.ts.map} +0 -0
  177. /package/dist/chunks/{remote-selection-ui-TZTJF32Y.ts.map → local-fields-6DGYOOIK.ts.map} +0 -0
  178. /package/dist/chunks/{setup-switch-5G2VNJHL.ts.map → manager-result-dispatcher-VIDT3QBU.ts.map} +0 -0
  179. /package/dist/chunks/{snapshot-JL5DFSXI.ts.map → merge-baseline-store-EVQAAML6.ts.map} +0 -0
  180. /package/dist/chunks/{sync-inspection-P3ENJLNF.ts.map → remote-selection-ui-SMF5CLFO.ts.map} +0 -0
  181. /package/dist/chunks/{sync-state-VHJUZ4YG.ts.map → setup-switch-KTUWL6DE.ts.map} +0 -0
@@ -0,0 +1,184 @@
1
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject;
4
+ export interface JsonObject {
5
+ [key: string]: JsonValue;
6
+ }
7
+ export interface JsonNode {
8
+ value: JsonValue;
9
+ start: number;
10
+ end: number;
11
+ members?: Map<string, { start: number; end: number; node: JsonNode }>;
12
+ }
13
+ export interface JsonDocument {
14
+ text: string;
15
+ root: JsonNode & { members: NonNullable<JsonNode["members"]> };
16
+ }
17
+
18
+ /** Strict UTF-8 JSON, with Pi's supported BOM; duplicates and prototype hazards require review. */
19
+ export function parseSettingsDocument(bytes: Buffer): JsonDocument {
20
+ try {
21
+ return parseDocument(bytes);
22
+ } catch {
23
+ throw new Error(
24
+ "Unsupported settings JSON syntax, encoding, migration or structure; review without modifying the original file.",
25
+ );
26
+ }
27
+ }
28
+ export function parseJsonObjectDocument(bytes: Buffer): JsonDocument {
29
+ try {
30
+ return parseDocument(bytes, false);
31
+ } catch {
32
+ throw new Error("Unsupported JSON object syntax, encoding or structure; preserve original bytes.");
33
+ }
34
+ }
35
+ function parseDocument(bytes: Buffer, checkSettingsMigration = true): JsonDocument {
36
+ if (bytes.length > 1024 * 1024) throw new Error("Settings merge input exceeds 1 MiB.");
37
+ const text = bytes.toString("utf8");
38
+ if (!Buffer.from(text).equals(bytes)) throw new Error("Settings merge requires valid UTF-8.");
39
+ let position = text.startsWith("\uFEFF") ? 1 : 0;
40
+ let nodes = 0;
41
+ JSON.parse(text.slice(position));
42
+ const whitespace = () => {
43
+ while (/^[\t\n\r ]$/.test(text[position] ?? "")) position++;
44
+ };
45
+ const stringEnd = () => {
46
+ position++;
47
+ while (position < text.length) {
48
+ const character = text[position++];
49
+ if (character === "\\") position++;
50
+ else if (character === '"') return;
51
+ }
52
+ throw new Error("Invalid JSON string.");
53
+ };
54
+ const parse = (depth: number): JsonNode => {
55
+ if (depth > 64 || ++nodes > 16_384) throw new Error("Settings merge structure exceeds its bound.");
56
+ whitespace();
57
+ const start = position;
58
+ let members: JsonNode["members"];
59
+ if (text[position] === "{") {
60
+ position++;
61
+ members = new Map();
62
+ whitespace();
63
+ while (text[position] !== "}") {
64
+ const memberStart = position;
65
+ stringEnd();
66
+ const key = JSON.parse(text.slice(memberStart, position)) as string;
67
+ if (members.has(key) || ["__proto__", "constructor", "prototype"].includes(key))
68
+ throw new Error("Duplicate or prototype-sensitive JSON key; review required.");
69
+ whitespace();
70
+ position++; // Colon: whole-document JSON.parse already validated grammar.
71
+ const node = parse(depth + 1);
72
+ members.set(key, { start: memberStart, end: position, node });
73
+ whitespace();
74
+ if (text[position] !== ",") break;
75
+ position++;
76
+ whitespace();
77
+ }
78
+ position++;
79
+ } else if (text[position] === "[") {
80
+ position++;
81
+ whitespace();
82
+ while (text[position] !== "]") {
83
+ parse(depth + 1);
84
+ whitespace();
85
+ if (text[position] !== ",") break;
86
+ position++;
87
+ }
88
+ position++;
89
+ } else if (text[position] === '"') stringEnd();
90
+ else while (position < text.length && !/[\s,\]}]/u.test(text.charAt(position))) position++;
91
+ const source = text.slice(start, position);
92
+ const value = JSON.parse(source) as JsonValue;
93
+ if (
94
+ typeof value === "number" &&
95
+ (!Number.isFinite(value) ||
96
+ (Number.isInteger(value) && !Number.isSafeInteger(value)) ||
97
+ decimalIdentity(source) !== decimalIdentity(String(value)))
98
+ )
99
+ throw new Error("Numeric settings value cannot round-trip without loss; review required.");
100
+ return { start, end: position, value, ...(members ? { members } : {}) };
101
+ };
102
+ const root = parse(0);
103
+ if (!root.members) throw new Error("Settings merge requires a JSON object.");
104
+ // Use the public API rather than reproducing private migration branches. Migrating input
105
+ // is withheld, including legacy queue/transport/skills and retry-delay formats.
106
+ if (checkSettingsMigration) {
107
+ const migrated = SettingsManager.inMemory(
108
+ root.value as Parameters<typeof SettingsManager.inMemory>[0],
109
+ ).getGlobalSettings();
110
+ if (!jsonEqual(root.value, migrated as unknown as JsonValue))
111
+ throw new Error("Legacy settings migration requires review before content merge.");
112
+ }
113
+ return { text, root: { ...root, members: root.members } };
114
+ }
115
+
116
+ /** Compare decimal literals without expanding exponents or rounding through Number. */
117
+ function decimalIdentity(source: string) {
118
+ const match = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/u.exec(source);
119
+ if (!match) throw new Error("Invalid numeric token.");
120
+ const fraction = match[3] ?? "";
121
+ const digits = `${match[2]}${fraction}`.replace(/^0+/u, "");
122
+ if (!digits) return "0";
123
+ const coefficient = digits.replace(/0+$/u, "");
124
+ const exponent = Number(match[4] ?? 0) - fraction.length + digits.length - coefficient.length;
125
+ if (!Number.isSafeInteger(exponent)) throw new Error("Unsupported numeric exponent.");
126
+ return `${match[1]}${coefficient}e${exponent}`;
127
+ }
128
+
129
+ export function jsonEqual(left: JsonValue | undefined, right: JsonValue | undefined): boolean {
130
+ if (left === right) return true;
131
+ if (left === undefined || right === undefined || left === null || right === null) return false;
132
+ if (Array.isArray(left) || Array.isArray(right))
133
+ return (
134
+ Array.isArray(left) &&
135
+ Array.isArray(right) &&
136
+ left.length === right.length &&
137
+ left.every((value, index) => jsonEqual(value, right[index]))
138
+ );
139
+ if (typeof left !== "object" || typeof right !== "object") return false;
140
+ const keys = Object.keys(left);
141
+ return (
142
+ keys.length === Object.keys(right).length &&
143
+ keys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key]))
144
+ );
145
+ }
146
+
147
+ /** Preserve the local prefix/suffix, order, unchanged member bytes, and every chosen value's spelling. */
148
+ export function renderSettingsMembers(
149
+ local: JsonDocument,
150
+ selected: Map<string, { document: JsonDocument; node: JsonNode }>,
151
+ ): Buffer {
152
+ const members = local.root.members;
153
+ const keys = [...members.keys(), ...[...selected.keys()].filter((key) => !members.has(key))].filter((key) =>
154
+ selected.has(key),
155
+ );
156
+ const opening = local.text.slice(local.root.start, members.values().next().value?.start ?? local.root.end - 1);
157
+ const last = [...members.values()].at(-1);
158
+ const closing = last ? local.text.slice(last.end, local.root.end) : "}";
159
+ const separator = /\r\n/.test(local.text) ? "\r\n" : "\n";
160
+ const indent = local.text.match(/\n([\t ]+)"/)?.[1] ?? " ";
161
+ const multiline = opening.includes("\n") || closing.includes("\n");
162
+ const originalSeparators = new Map<string, string>();
163
+ let previous: { end: number } | undefined;
164
+ for (const [key, member] of members) {
165
+ if (previous) originalSeparators.set(key, local.text.slice(previous.end, member.start));
166
+ previous = member;
167
+ }
168
+ const newSeparator = multiline ? `,${separator}${indent}` : ", ";
169
+ const rendered = keys
170
+ .map((key, index) => {
171
+ const choice = selected.get(key);
172
+ if (!choice) throw new Error("Missing selected settings member.");
173
+ const existing = members.get(key);
174
+ const prefix = existing ? local.text.slice(existing.start, existing.node.start) : `${JSON.stringify(key)}: `;
175
+ const gap = index ? (originalSeparators.get(key) ?? newSeparator) : "";
176
+ return gap + prefix + choice.document.text.slice(choice.node.start, choice.node.end);
177
+ })
178
+ .join("");
179
+ const result = Buffer.from(
180
+ local.text.slice(0, local.root.start) + opening + rendered + closing + local.text.slice(local.root.end),
181
+ );
182
+ parseSettingsDocument(result);
183
+ return result;
184
+ }
@@ -0,0 +1,134 @@
1
+ import { createHash } from "node:crypto";
2
+ import { regenerateSnapshotIdentity } from "../snapshot/snapshot.js";
3
+ import type { Snapshot, SnapshotFile } from "../snapshot/snapshot-types.js";
4
+ import {
5
+ type JsonDocument,
6
+ type JsonNode,
7
+ jsonEqual,
8
+ parseSettingsDocument,
9
+ renderSettingsMembers,
10
+ } from "./json-document.js";
11
+
12
+ /** Initial rules select whole root fields of the global settings.json only. */
13
+ export function normalizeLocalFields(value: unknown): string[] {
14
+ if (value === undefined) return [];
15
+ if (
16
+ !Array.isArray(value) ||
17
+ value.length > 128 ||
18
+ value.some(
19
+ (field) =>
20
+ typeof field !== "string" ||
21
+ !field ||
22
+ field.length > 128 ||
23
+ /[\p{Cc}\p{Cf}\p{Cs}]/u.test(field) ||
24
+ ["__proto__", "constructor", "prototype"].includes(field),
25
+ )
26
+ )
27
+ throw new Error("localFields must be at most 128 safe settings.json root field names.");
28
+ if (new Set(value).size !== value.length) throw new Error("Duplicate localFields rule.");
29
+ for (const [first, second] of [
30
+ ["defaultProvider", "defaultModel"],
31
+ ["skills", "enableSkillCommands"],
32
+ ["queueMode", "steeringMode"],
33
+ ["websockets", "transport"],
34
+ ["enableAnalytics", "trackingId"],
35
+ ]) {
36
+ if (value.includes(first) !== value.includes(second))
37
+ throw new Error("Coupled settings fields must be excluded together.");
38
+ }
39
+ return [...value].sort();
40
+ }
41
+ /** Shared transport contract: older snapshots cannot carry portable policy metadata. */
42
+ export function validateSnapshotFieldPolicy(snapshot: Pick<Snapshot, "version" | "localFields">) {
43
+ if (snapshot.version !== 1 && snapshot.version !== 2 && snapshot.version !== 3)
44
+ throw new Error("Unsupported snapshot format.");
45
+ if (snapshot.version === 1 && snapshot.localFields !== undefined)
46
+ throw new Error("Portable field policy requires snapshot version 2.");
47
+ if (snapshot.version === 2 || snapshot.version === 3) {
48
+ if (snapshot.version === 2 && snapshot.localFields === undefined)
49
+ throw new Error("Snapshot version 2 requires explicit localFields rules.");
50
+ normalizeLocalFields(snapshot.localFields);
51
+ }
52
+ }
53
+ /** Portable transport images must not carry values declared machine-local. Physical journal images are different. */
54
+ export function validatePortableSnapshot(snapshot: Snapshot) {
55
+ validateSnapshotFieldPolicy(snapshot);
56
+ if ((snapshot.version !== 2 && snapshot.version !== 3) || !snapshot.localFields?.length) return;
57
+ try {
58
+ for (const entry of snapshot.files) {
59
+ if (typeof entry?.path !== "string" || entry.path.toLowerCase() !== "settings.json") continue;
60
+ const document = parseSettingsDocument(Buffer.from(entry.contentBase64, "base64"));
61
+ if (snapshot.localFields.some((field) => document.root.members.has(field))) throw new Error("Excluded field.");
62
+ }
63
+ } catch {
64
+ throw new Error("Portable snapshot settings.json retains excluded fields or has an unsupported document.");
65
+ }
66
+ }
67
+ export function sameLocalFields(left: unknown, right: unknown) {
68
+ if (left === undefined || right === undefined) return left === right;
69
+ return JSON.stringify(normalizeLocalFields(left)) === JSON.stringify(normalizeLocalFields(right));
70
+ }
71
+ function file(content: Buffer): SnapshotFile {
72
+ return {
73
+ path: "settings.json",
74
+ contentBase64: content.toString("base64"),
75
+ sha256: createHash("sha256").update(content).digest("hex"),
76
+ };
77
+ }
78
+ export function portableSnapshot(snapshot: Snapshot, fields: string[] | undefined): Snapshot {
79
+ if (fields === undefined) return snapshot;
80
+ const excluded = new Set(normalizeLocalFields(fields));
81
+ const files = snapshot.files.map((entry) => {
82
+ if (entry.path !== "settings.json" || excluded.size === 0) return entry;
83
+ const document = parseSettingsDocument(Buffer.from(entry.contentBase64, "base64"));
84
+ const object = Object.create(null);
85
+ for (const key of [...document.root.members.keys()].sort())
86
+ if (!excluded.has(key)) object[key] = document.root.members.get(key)?.node.value;
87
+ const canonical = (value: unknown): unknown =>
88
+ Array.isArray(value)
89
+ ? value.map(canonical)
90
+ : value && typeof value === "object"
91
+ ? Object.fromEntries(
92
+ Object.entries(value)
93
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
94
+ .map(([key, child]) => [key, canonical(child)]),
95
+ )
96
+ : value;
97
+ return file(Buffer.from(`${JSON.stringify(canonical(object))}\n`));
98
+ });
99
+ return regenerateSnapshotIdentity({ ...snapshot, version: 2, localFields: [...excluded], files });
100
+ }
101
+ export function overlayLocalFields(portable: Snapshot, local: Snapshot, fields: string[] | undefined): Snapshot {
102
+ if (!fields?.length) return portable;
103
+ const target = portable.files.find((entry) => entry.path === "settings.json");
104
+ const current = local.files.find((entry) => entry.path === "settings.json");
105
+ if (!target && !current) return portable;
106
+ const original = current
107
+ ? parseSettingsDocument(Buffer.from(current.contentBase64, "base64"))
108
+ : parseSettingsDocument(Buffer.from("{}"));
109
+ if (!target) {
110
+ if (fields.some((field) => original.root.members.has(field)))
111
+ throw new Error("Portable settings deletion requires manual review; local-only fields were preserved.");
112
+ return portable;
113
+ }
114
+ const incoming = parseSettingsDocument(Buffer.from(target.contentBase64, "base64"));
115
+ const selected = new Map<string, { document: JsonDocument; node: JsonNode }>();
116
+ const excluded = new Set(fields);
117
+ for (const [key, member] of incoming.root.members) {
118
+ if (excluded.has(key)) continue;
119
+ const existing = original.root.members.get(key);
120
+ selected.set(
121
+ key,
122
+ existing && jsonEqual(existing.node.value, member.node.value)
123
+ ? { document: original, node: existing.node }
124
+ : { document: incoming, node: member.node },
125
+ );
126
+ }
127
+ for (const [key, member] of original.root.members)
128
+ if (excluded.has(key)) selected.set(key, { document: original, node: member.node });
129
+ const content = renderSettingsMembers(original, selected);
130
+ return regenerateSnapshotIdentity({
131
+ ...portable,
132
+ files: portable.files.map((entry) => (entry.path === "settings.json" ? file(content) : entry)),
133
+ });
134
+ }