@openchambery/web 1.18.2-beta.2 → 1.18.2-beta.21

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 (148) hide show
  1. package/dist/assets/{AgentAvatar-B2kZjgaf.js → AgentAvatar-B4fj9PIe.js} +1 -1
  2. package/dist/assets/{AssistantView-DvoyOGkx.js → AssistantView-C00aiEHK.js} +1 -1
  3. package/dist/assets/{ChatContainer-D6yfuigV.js → ChatContainer-BDFeDKu-.js} +39 -39
  4. package/dist/assets/{DiagramView-MGGo9iME.js → DiagramView-CUfSFI3e.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-aeWlaqVB.js → FadeInOnReveal-CKKU6fKI.js} +1 -1
  6. package/dist/assets/{Icon-AXqa-fYB.js → Icon-DiIuWOuC.js} +1 -1
  7. package/dist/assets/{MarkdownRendererImpl-jVOkZkyX.js → MarkdownRendererImpl-Cen6E9Lj.js} +2 -2
  8. package/dist/assets/{MobileDetailNavigation-Q2IzHqGA.js → MobileDetailNavigation-DILi0tN7.js} +1 -1
  9. package/dist/assets/{MobileShareBridge-apXM34Lb.js → MobileShareBridge-PZMRiieI.js} +1 -1
  10. package/dist/assets/MobileSurface-BLYcR0tO.js +2 -0
  11. package/dist/assets/{MultiRunWindow-B-l_O-bd.js → MultiRunWindow-D_2mWZyv.js} +1 -1
  12. package/dist/assets/{OnboardingScreen-CUsgwt4y.js → OnboardingScreen-CYeRZznl.js} +1 -1
  13. package/dist/assets/{OverlayScrollbar-D6NySAxi.js → OverlayScrollbar-BgMLtMIq.js} +1 -1
  14. package/dist/assets/SettingsView-CbDBfQp1.js +25 -0
  15. package/dist/assets/{SettingsWindow-DfVgTQ4I.js → SettingsWindow-Br7XLcSd.js} +1 -1
  16. package/dist/assets/{TerminalView-DNyjfkvT.js → TerminalView-CM5jRKlj.js} +1 -1
  17. package/dist/assets/{ToolOutputDialog-DLG1iThm.js → ToolOutputDialog-D3U6aTZ4.js} +1 -1
  18. package/dist/assets/appThemeRegistry-CDxBJNNB.js +2 -0
  19. package/dist/assets/assistant-guide-hero-BeUHQcxE.jpg +0 -0
  20. package/dist/assets/{button-CDvMVaiO.js → button-DaAoVF4X.js} +1 -1
  21. package/dist/assets/{clipboard-BW7nk_Cx.js → clipboard-BnElHSnO.js} +1 -1
  22. package/dist/assets/{collapsible-C2aamDvD.js → collapsible-BmuQu0dq.js} +1 -1
  23. package/dist/assets/{context-menu-BaxmodD8.js → context-menu-DBOMervd.js} +1 -1
  24. package/dist/assets/{desktopHosts-CBoBpJ1Z.js → desktopHosts-ZK_vkdzL.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-l2R9muux.js → desktopRelayRestore-CewM6-g5.js} +1 -1
  26. package/dist/assets/{diagram-CEon0BQs.js → diagram-DAE4CZy2.js} +1 -1
  27. package/dist/assets/{dialog-DjYfj1Sn.js → dialog-COrLBm9O.js} +1 -1
  28. package/dist/assets/{es-5Lhudh0n.js → es-DIVYdoxi.js} +1 -1
  29. package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-CmWYnRVz.js} +1 -1
  30. package/dist/assets/{fr-Q_UhFmKT.js → fr-CNTlVypq.js} +2 -2
  31. package/dist/assets/{gitApi-BYxg_qGJ.js → gitApi-BQT3QXD2.js} +1 -1
  32. package/dist/assets/gitApiHttp-BxGAFLTf.js +2 -0
  33. package/dist/assets/{input-k9DGGyZ6.js → input-XiL2-2gG.js} +1 -1
  34. package/dist/assets/insertionBoundaries-9a0RuIDc.js +1 -0
  35. package/dist/assets/{ja-CzMPz7bT.js → ja-C_eW5ZM8.js} +1 -1
  36. package/dist/assets/{ko-B-EslUsR.js → ko-CE3cOv7v.js} +1 -1
  37. package/dist/assets/{main-LDphyc-v.js → main-BGfX81Y9.js} +20 -20
  38. package/dist/assets/{main-CO6z5yRB.js → main-CEqSDnE2.js} +2 -2
  39. package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-BDyY0vt6.js} +1 -1
  40. package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-D-aLQZaM.js} +1 -1
  41. package/dist/assets/{miniChat-BZQwTZpx.js → miniChat-CKucSAhg.js} +2 -2
  42. package/dist/assets/mobile-C4bTWKry.js +2 -0
  43. package/dist/assets/{multirun-ZlwlkIy5.js → multirun-DeNtSkgb.js} +1 -1
  44. package/dist/assets/{persistence-DPNMIbF5.js → persistence-D1lIuyqO.js} +1 -1
  45. package/dist/assets/{pl-Dnea6Ppc.js → pl-BZKZOE99.js} +1 -1
  46. package/dist/assets/projectMeta-BunN9pH5.js +3 -0
  47. package/dist/assets/{pt-BR-Ca6QTem7.js → pt-BR-DHDgp8S3.js} +1 -1
  48. package/dist/assets/{radio-CJuhbQ5a.js → radio-Bziafaje.js} +1 -1
  49. package/dist/assets/renderElectronMiniChatApp-D8-FDgWE.js +2 -0
  50. package/dist/assets/renderMobileApp-CdU0aJ-e.js +23 -0
  51. package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-BXCt7ZsQ.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-CK1GhIsm.js +145 -0
  53. package/dist/assets/{sortable-tabs-strip-D8JHUI0z.js → sortable-tabs-strip-pWlBorwQ.js} +1 -1
  54. package/dist/assets/src-8kQFmtjw.css +2 -0
  55. package/dist/assets/{store-wa1ersRf.js → store-CUWqzRt7.js} +2 -2
  56. package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-DpiAJlil.js} +1 -1
  57. package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-BLugzWq-.js} +1 -1
  58. package/dist/assets/{uk-xGc9Pzge.js → uk-CfQK6cz1.js} +1 -1
  59. package/dist/assets/{url-7MqoLs9X.js → url-DMs_PgIC.js} +1 -1
  60. package/dist/assets/useAppFontEffects-IEUQDvE8.js +7 -0
  61. package/dist/assets/useDesktopWindowControlsLayout-MlqJX89k.js +1 -0
  62. package/dist/assets/{useEffectiveDirectory-aUC3zzl_.js → useEffectiveDirectory-GbhkdO6m.js} +1 -1
  63. package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-92Qg-98H.js} +1 -1
  64. package/dist/assets/useMobileNavigationStore-DQHF_ETu.js +476 -0
  65. package/dist/assets/useSessionAutoCleanup-BuU_S_Sh.js +1 -0
  66. package/dist/assets/{useWorkerHighlightedLines-CiYQjBe6.js → useWorkerHighlightedLines-Ca6OgNHF.js} +1 -1
  67. package/dist/assets/{utils-Co19EJEL.js → utils-BxJEIWv5.js} +1 -1
  68. package/dist/assets/{zh-CN-sL4ktGB3.js → zh-CN-DvY2fKWp.js} +1 -1
  69. package/dist/assets/{zh-TW-Cfxps4gq.js → zh-TW-eWe0lm3-.js} +1 -1
  70. package/dist/index.html +8 -8
  71. package/dist/mini-chat.html +8 -8
  72. package/dist/mobile.html +8 -8
  73. package/package.json +1 -1
  74. package/server/index.d.ts +6 -0
  75. package/server/index.js +13 -0
  76. package/server/lib/config-sync/DOCUMENTATION.md +70 -0
  77. package/server/lib/config-sync/config-sync.test.js +197 -0
  78. package/server/lib/config-sync/constants.js +39 -0
  79. package/server/lib/config-sync/contract.js +130 -0
  80. package/server/lib/config-sync/credential-auth.js +39 -0
  81. package/server/lib/config-sync/engine.js +108 -0
  82. package/server/lib/config-sync/index.js +67 -0
  83. package/server/lib/config-sync/local-backup.js +171 -0
  84. package/server/lib/config-sync/plan.js +318 -0
  85. package/server/lib/config-sync/receiver.js +330 -0
  86. package/server/lib/config-sync/routes.js +141 -0
  87. package/server/lib/config-sync/routes.test.js +154 -0
  88. package/server/lib/config-sync/scripts.js +264 -0
  89. package/server/lib/config-sync/selections.js +112 -0
  90. package/server/lib/config-sync/tar.js +35 -0
  91. package/server/lib/config-sync/target-id.js +24 -0
  92. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  93. package/server/lib/event-stream/diff-summary.js +39 -11
  94. package/server/lib/event-stream/diff-summary.test.js +73 -6
  95. package/server/lib/event-stream/protocol.test.js +70 -7
  96. package/server/lib/notifications/DOCUMENTATION.md +2 -2
  97. package/server/lib/notifications/runtime.js +68 -27
  98. package/server/lib/notifications/runtime.test.js +176 -0
  99. package/server/lib/opencode/DOCUMENTATION.md +4 -1
  100. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  101. package/server/lib/opencode/core-routes.js +1 -0
  102. package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
  103. package/server/lib/opencode/openchamber-routes.js +7 -0
  104. package/server/lib/opencode/settings-runtime.js +82 -51
  105. package/server/lib/opencode/settings-runtime.test.js +42 -0
  106. package/server/lib/relay/DOCUMENTATION.md +1 -1
  107. package/server/lib/session-index/DOCUMENTATION.md +12 -6
  108. package/server/lib/session-index/routes.js +34 -0
  109. package/server/lib/session-index/routes.test.js +76 -0
  110. package/server/lib/session-index/service.js +53 -4
  111. package/server/lib/session-index/service.test.js +72 -2
  112. package/server/lib/session-title/DOCUMENTATION.md +19 -14
  113. package/server/lib/session-title/runtime.js +8 -8
  114. package/server/lib/session-title/runtime.test.js +231 -0
  115. package/server/lib/session-turn-pages/DOCUMENTATION.md +26 -6
  116. package/server/lib/session-turn-pages/changes.service.js +156 -0
  117. package/server/lib/session-turn-pages/changes.service.test.js +188 -0
  118. package/server/lib/session-turn-pages/reconcile.routes.test.js +52 -0
  119. package/server/lib/session-turn-pages/reconcile.service.js +3 -1
  120. package/server/lib/session-turn-pages/reconcile.service.test.js +44 -0
  121. package/server/lib/session-turn-pages/routes.js +273 -12
  122. package/server/lib/session-turn-pages/routes.test.js +387 -0
  123. package/server/lib/session-turn-pages/service.js +31 -7
  124. package/server/lib/session-turn-pages/service.test.js +40 -0
  125. package/server/lib/small-model/DOCUMENTATION.md +93 -38
  126. package/server/lib/small-model/catalog.js +10 -2
  127. package/server/lib/small-model/catalog.test.js +3 -1
  128. package/server/lib/small-model/index.js +52 -28
  129. package/server/lib/small-model/opencode-session.js +339 -0
  130. package/server/lib/small-model/opencode-session.test.js +177 -0
  131. package/server/lib/small-model/resolve.js +116 -5
  132. package/server/lib/small-model/resolve.test.js +76 -10
  133. package/server/lib/small-model/summary-settings.test.js +60 -3
  134. package/dist/assets/MobileSurface-htGkf7Ua.js +0 -2
  135. package/dist/assets/SettingsView-DxMpVLlt.js +0 -25
  136. package/dist/assets/appThemeRegistry-CgfOR_0L.js +0 -2
  137. package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
  138. package/dist/assets/insertionBoundaries-DzXMTleb.js +0 -1
  139. package/dist/assets/mobile-BktO3_4R.js +0 -2
  140. package/dist/assets/projectMeta-v-LP2mzz.js +0 -3
  141. package/dist/assets/renderElectronMiniChatApp-Db8uO-6q.js +0 -2
  142. package/dist/assets/renderMobileApp-BCV81Ryl.js +0 -23
  143. package/dist/assets/runtimeEndpointReset-Dg3DMy9v.js +0 -145
  144. package/dist/assets/src-oHrYIqHF.css +0 -2
  145. package/dist/assets/useAppFontEffects-CM0VKsdo.js +0 -7
  146. package/dist/assets/useDesktopWindowControlsLayout-Cq-r4Hcb.js +0 -1
  147. package/dist/assets/useMobileNavigationStore-DnzT4jO2.js +0 -475
  148. package/dist/assets/useSessionAutoCleanup-DxZeceKO.js +0 -1
@@ -0,0 +1,108 @@
1
+ import path from 'node:path';
2
+
3
+ import { assertPlanDirection } from './contract.js';
4
+ import { assertCredentialSyncAuthorized } from './credential-auth.js';
5
+ import { collectLocalTarBuffer } from './tar.js';
6
+
7
+ /**
8
+ * Run a source-side plan against a TargetExecutor (probe → prepare → putTar* → finalize).
9
+ * Direction-agnostic orchestration; ticket 02 only drives push from a local home snapshot.
10
+ * Credential-bearing plans require an explicit per-target grant (ticket 03).
11
+ *
12
+ * @param {{
13
+ * plan: object,
14
+ * executor: import('./contract.js').TargetExecutor,
15
+ * syncRunId: string,
16
+ * sourceHomedir: string,
17
+ * collectTar?: typeof collectLocalTarBuffer,
18
+ * credentialSyncAuthorized?: boolean,
19
+ * }} args
20
+ */
21
+ export const applyConfigSyncPlan = async ({
22
+ plan,
23
+ executor,
24
+ syncRunId,
25
+ sourceHomedir,
26
+ collectTar = collectLocalTarBuffer,
27
+ credentialSyncAuthorized = false,
28
+ }) => {
29
+ assertPlanDirection(plan);
30
+ assertCredentialSyncAuthorized(plan, {
31
+ targetId: plan?.targetId || 'unknown',
32
+ authorized: credentialSyncAuthorized === true,
33
+ });
34
+ const home = String(sourceHomedir || '');
35
+ const configDir = path.join(home, '.config', 'opencode');
36
+ const tarEntries = [
37
+ ...plan.files.map((entry) => entry.path),
38
+ ...plan.directories.map((entry) => entry.path),
39
+ ];
40
+ const hasConfigPayload = tarEntries.length > 0;
41
+ const hasAgentsPayload = Boolean(plan.agentsRoot);
42
+ const hasAuthPayload = Boolean(plan.authFile);
43
+
44
+ if (!hasConfigPayload && !hasAgentsPayload && !hasAuthPayload) {
45
+ // Nothing to upload — preserve the historical short-circuit (no remote prepare).
46
+ return {
47
+ ok: true,
48
+ files: plan.files.length,
49
+ directories: plan.directories.length,
50
+ deletes: plan.deletes.length,
51
+ totalBytes: plan.totalBytes,
52
+ agentsRoot: null,
53
+ authFile: null,
54
+ plan,
55
+ };
56
+ }
57
+
58
+ const windowsHide = process.platform === 'win32';
59
+ const configTarBuffer = hasConfigPayload
60
+ ? await collectTar(['-h', '-czf', '-', '-C', configDir, ...tarEntries], { windowsHide })
61
+ : null;
62
+ const agentsTarBuffer = hasAgentsPayload
63
+ ? await collectTar(['-h', '-czf', '-', '-C', home, '.agents'], { windowsHide })
64
+ : null;
65
+ const authShareDir = path.join(home, '.local', 'share', 'opencode');
66
+ const authTarBuffer = hasAuthPayload
67
+ ? await collectTar(['-h', '-czf', '-', '-C', authShareDir, 'auth.json'], { windowsHide })
68
+ : null;
69
+
70
+ await executor.prepare(plan, { syncRunId });
71
+
72
+ if (configTarBuffer) {
73
+ await executor.putTar({ kind: 'config', payload: configTarBuffer });
74
+ }
75
+ if (agentsTarBuffer) {
76
+ await executor.putTar({ kind: 'agents', payload: agentsTarBuffer });
77
+ }
78
+ if (authTarBuffer) {
79
+ // Only auth.json — never the rest of ~/.local/share/opencode (session DBs live there).
80
+ await executor.putTar({ kind: 'auth', payload: authTarBuffer });
81
+ }
82
+
83
+ await executor.finalize(plan, { syncRunId });
84
+
85
+ return {
86
+ ok: true,
87
+ files: plan.files.length,
88
+ directories: plan.directories.length,
89
+ deletes: plan.deletes.length,
90
+ totalBytes: plan.totalBytes,
91
+ agentsRoot: plan.agentsRoot ? { fileCount: plan.agentsRoot.fileCount } : null,
92
+ authFile: plan.authFile ? { bytes: plan.authFile.bytes } : null,
93
+ plan,
94
+ };
95
+ };
96
+
97
+ /**
98
+ * Preview helper: compute probe path list from a plan.
99
+ * @param {object} plan
100
+ */
101
+ export const probePathsForPlan = (plan) => {
102
+ const probePaths = [
103
+ ...(Array.isArray(plan?.files) ? plan.files.map((entry) => entry.path) : []),
104
+ ...(Array.isArray(plan?.directories) ? plan.directories.map((entry) => entry.path) : []),
105
+ ...(Array.isArray(plan?.deletes) ? plan.deletes : []),
106
+ ];
107
+ return [...new Set(probePaths)];
108
+ };
@@ -0,0 +1,67 @@
1
+ export {
2
+ OPENCODE_CONFIG_SYNC_ALLOWLIST,
3
+ OPENCODE_CONFIG_SYNC_BACKUP_DIR,
4
+ OPENCODE_AGENTS_SYNC_BACKUP_DIR,
5
+ OPENCODE_AUTH_SYNC_BACKUP_DIR,
6
+ OPENCODE_AGENTS_ROOT_PROBE_MARKER,
7
+ OPENCODE_AUTH_FILE_PROBE_MARKER,
8
+ OPENCODE_CONFIG_SYNC_MAX_BYTES,
9
+ OPENCODE_CONFIG_SYNC_MAX_FILES,
10
+ OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS,
11
+ SYNC_DIRECTION_PUSH,
12
+ SYNC_DIRECTION_PULL,
13
+ SYNC_DIRECTIONS,
14
+ } from './constants.js';
15
+
16
+ export {
17
+ EMPTY_SYNC_CAPABILITIES,
18
+ MANAGED_SSH_SYNC_CAPABILITIES,
19
+ DIRECT_HOST_SYNC_CAPABILITIES,
20
+ RELAY_HOST_SYNC_CAPABILITIES,
21
+ createSshSyncTarget,
22
+ createDirectHostSyncTarget,
23
+ createRelayHostSyncTarget,
24
+ assertTargetCapability,
25
+ assertPlanDirection,
26
+ } from './contract.js';
27
+
28
+ export {
29
+ syncTargetIdForSshInstance,
30
+ syncTargetIdForDirectHost,
31
+ syncTargetIdForRelayServer,
32
+ } from './target-id.js';
33
+ export {
34
+ planOpenCodeConfigSync,
35
+ planOpenCodeConfigSyncFromInventory,
36
+ walkAllowlistDirectory,
37
+ } from './plan.js';
38
+ export {
39
+ buildRemoteSyncPrepareScript,
40
+ buildRemoteSyncProbeScript,
41
+ buildRemoteSyncFinalizeScript,
42
+ buildRemoteSyncInventoryScript,
43
+ buildRemoteConfigTarScript,
44
+ buildRemoteAgentsTarScript,
45
+ buildRemoteAuthTarScript,
46
+ sanitizeSyncRunIdForPath,
47
+ shellQuote,
48
+ } from './scripts.js';
49
+ export { collectLocalTarBuffer } from './tar.js';
50
+ export { applyConfigSyncPlan, probePathsForPlan } from './engine.js';
51
+ export {
52
+ CREDENTIAL_SYNC_UNAUTHORIZED_CODE,
53
+ CREDENTIAL_SYNC_GRANT_CHANNEL_INSTANCE_SETTINGS,
54
+ CredentialSyncUnauthorizedError,
55
+ planIncludesCredentials,
56
+ assertCredentialSyncAuthorized,
57
+ } from './credential-auth.js';
58
+ export {
59
+ buildDefaultSyncSelections,
60
+ normalizeSyncSelections,
61
+ filterPlanBySelections,
62
+ } from './selections.js';
63
+ export {
64
+ prepareLocalSyncDestination,
65
+ finalizeLocalSyncDestination,
66
+ extractTarGzBuffer,
67
+ } from './local-backup.js';
@@ -0,0 +1,171 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+
6
+ import {
7
+ OPENCODE_AGENTS_SYNC_BACKUP_DIR,
8
+ OPENCODE_AUTH_SYNC_BACKUP_DIR,
9
+ OPENCODE_CONFIG_SYNC_ALLOWLIST,
10
+ OPENCODE_CONFIG_SYNC_BACKUP_DIR,
11
+ OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS,
12
+ } from './constants.js';
13
+ import { sanitizeSyncRunIdForPath } from './scripts.js';
14
+
15
+ const copyPathIfExists = async (from, to) => {
16
+ try {
17
+ await fsp.access(from);
18
+ } catch {
19
+ return false;
20
+ }
21
+ await fsp.mkdir(path.dirname(to), { recursive: true });
22
+ await fsp.cp(from, to, { recursive: true, force: true });
23
+ return true;
24
+ };
25
+
26
+ const pruneGenerations = async (backupRoot, syncRunId, generations) => {
27
+ await fsp.mkdir(backupRoot, { recursive: true });
28
+ let entries = [];
29
+ try {
30
+ entries = await fsp.readdir(backupRoot, { withFileTypes: true });
31
+ } catch {
32
+ return;
33
+ }
34
+ const dirs = [];
35
+ for (const entry of entries) {
36
+ if (!entry.isDirectory()) continue;
37
+ const abs = path.join(backupRoot, entry.name);
38
+ let mtimeMs = 0;
39
+ try {
40
+ mtimeMs = (await fsp.stat(abs)).mtimeMs;
41
+ } catch {
42
+ mtimeMs = 0;
43
+ }
44
+ dirs.push({ name: entry.name, abs, mtimeMs });
45
+ }
46
+ dirs.sort((left, right) => right.mtimeMs - left.mtimeMs);
47
+ for (const [index, entry] of dirs.entries()) {
48
+ if (index < generations) continue;
49
+ if (entry.name === syncRunId) continue;
50
+ await fsp.rm(entry.abs, { recursive: true, force: true });
51
+ }
52
+ };
53
+
54
+ /**
55
+ * Local (pull destination) generational backup + delete stale counterparts.
56
+ * Mirrors remote prepare semantics under the local home.
57
+ *
58
+ * @param {string} homedir
59
+ * @param {object} plan
60
+ * @param {{ syncRunId: string, generations?: number }} options
61
+ */
62
+ export const prepareLocalSyncDestination = async (homedir, plan, options) => {
63
+ const home = String(homedir || '');
64
+ const syncRunId = sanitizeSyncRunIdForPath(options?.syncRunId);
65
+ const generations = Number.isFinite(options?.generations) && Number(options.generations) > 0
66
+ ? Math.trunc(Number(options.generations))
67
+ : OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS;
68
+ const configDir = path.join(home, '.config', 'opencode');
69
+ const backupRoot = path.join(configDir, OPENCODE_CONFIG_SYNC_BACKUP_DIR);
70
+ const runBackup = path.join(backupRoot, syncRunId);
71
+
72
+ await fsp.mkdir(configDir, { recursive: true });
73
+ await fsp.rm(runBackup, { recursive: true, force: true });
74
+ await fsp.mkdir(runBackup, { recursive: true });
75
+ await pruneGenerations(backupRoot, syncRunId, generations);
76
+
77
+ const backupPaths = [
78
+ ...(Array.isArray(plan?.files) ? plan.files.map((entry) => entry.path) : []),
79
+ ...(Array.isArray(plan?.directories) ? plan.directories.map((entry) => entry.path) : []),
80
+ ];
81
+ for (const rel of backupPaths) {
82
+ await copyPathIfExists(path.join(configDir, rel), path.join(runBackup, rel));
83
+ }
84
+
85
+ const directoryDeleteNames = new Set();
86
+ for (const dirSpec of OPENCODE_CONFIG_SYNC_ALLOWLIST.directories) {
87
+ directoryDeleteNames.add(dirSpec.path);
88
+ if (typeof dirSpec.legacy === 'string' && dirSpec.legacy) {
89
+ directoryDeleteNames.add(dirSpec.legacy);
90
+ }
91
+ }
92
+ for (const rel of Array.isArray(plan?.deletes) ? plan.deletes : []) {
93
+ const abs = path.join(configDir, rel);
94
+ if (directoryDeleteNames.has(rel)) {
95
+ await fsp.rm(abs, { recursive: true, force: true });
96
+ } else {
97
+ await fsp.rm(abs, { force: true });
98
+ }
99
+ }
100
+
101
+ if (plan?.agentsRoot) {
102
+ const agentsBackupRoot = path.join(home, OPENCODE_AGENTS_SYNC_BACKUP_DIR);
103
+ const agentsRun = path.join(agentsBackupRoot, syncRunId);
104
+ await fsp.rm(agentsRun, { recursive: true, force: true });
105
+ await fsp.mkdir(agentsRun, { recursive: true });
106
+ await pruneGenerations(agentsBackupRoot, syncRunId, generations);
107
+ await copyPathIfExists(path.join(home, '.agents'), path.join(agentsRun, 'agents'));
108
+ await fsp.rm(path.join(home, '.agents'), { recursive: true, force: true });
109
+ }
110
+
111
+ if (plan?.authFile) {
112
+ const authBackupRoot = path.join(home, OPENCODE_AUTH_SYNC_BACKUP_DIR);
113
+ const authRun = path.join(authBackupRoot, syncRunId);
114
+ await fsp.rm(authRun, { recursive: true, force: true });
115
+ await fsp.mkdir(authRun, { recursive: true });
116
+ await pruneGenerations(authBackupRoot, syncRunId, generations);
117
+ await copyPathIfExists(
118
+ path.join(home, '.local', 'share', 'opencode', 'auth.json'),
119
+ path.join(authRun, 'auth.json'),
120
+ );
121
+ }
122
+
123
+ return { runBackup };
124
+ };
125
+
126
+ /**
127
+ * Confirm local pull backup generation exists.
128
+ * @param {string} homedir
129
+ * @param {{ syncRunId: string }} options
130
+ */
131
+ export const finalizeLocalSyncDestination = async (homedir, options) => {
132
+ const syncRunId = sanitizeSyncRunIdForPath(options?.syncRunId);
133
+ const runBackup = path.join(
134
+ String(homedir || ''),
135
+ '.config',
136
+ 'opencode',
137
+ OPENCODE_CONFIG_SYNC_BACKUP_DIR,
138
+ syncRunId,
139
+ );
140
+ const st = await fsp.stat(runBackup);
141
+ if (!st.isDirectory()) {
142
+ throw new Error('Local sync finalize missing backup generation');
143
+ }
144
+ return { ok: true };
145
+ };
146
+
147
+ /**
148
+ * Extract a tar.gz buffer into a destination directory.
149
+ * @param {Buffer} buffer
150
+ * @param {string} destinationDir
151
+ */
152
+ export const extractTarGzBuffer = (buffer, destinationDir) => new Promise((resolve, reject) => {
153
+ fs.mkdirSync(destinationDir, { recursive: true });
154
+ const child = spawn('tar', ['-xzf', '-', '-C', destinationDir], {
155
+ stdio: ['pipe', 'ignore', 'pipe'],
156
+ ...(process.platform === 'win32' ? { windowsHide: true } : {}),
157
+ });
158
+ let stderr = '';
159
+ child.stderr?.on('data', (chunk) => {
160
+ stderr += chunk.toString();
161
+ });
162
+ child.on('error', reject);
163
+ child.on('close', (code) => {
164
+ if (code !== 0) {
165
+ reject(new Error((stderr || 'Local tar extract failed').trim()));
166
+ return;
167
+ }
168
+ resolve();
169
+ });
170
+ child.stdin?.end(buffer);
171
+ });
@@ -0,0 +1,318 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ OPENCODE_CONFIG_SYNC_ALLOWLIST,
6
+ OPENCODE_CONFIG_SYNC_MAX_BYTES,
7
+ OPENCODE_CONFIG_SYNC_MAX_FILES,
8
+ SYNC_DIRECTION_PUSH,
9
+ SYNC_DIRECTIONS,
10
+ } from './constants.js';
11
+ import { filterPlanBySelections, normalizeSyncSelections } from './selections.js';
12
+
13
+ /**
14
+ * Walk an allowlist directory with symlink dereference (fs.stat), cycle-guard, excludeNames, and `.backup` skip.
15
+ * @param {string} absPath
16
+ * @param {Set<string>} excludeNames
17
+ * @param {Set<string>} seenRealpaths
18
+ * @returns {{ fileCount: number, bytes: number }}
19
+ */
20
+ export const walkAllowlistDirectory = (absPath, excludeNames, seenRealpaths) => {
21
+ let real;
22
+ try {
23
+ real = fs.realpathSync(absPath);
24
+ } catch {
25
+ return { fileCount: 0, bytes: 0 };
26
+ }
27
+ if (seenRealpaths.has(real)) return { fileCount: 0, bytes: 0 };
28
+ seenRealpaths.add(real);
29
+
30
+ let fileCount = 0;
31
+ let bytes = 0;
32
+ let names;
33
+ try {
34
+ names = fs.readdirSync(absPath);
35
+ } catch {
36
+ return { fileCount: 0, bytes: 0 };
37
+ }
38
+
39
+ for (const name of names) {
40
+ if (excludeNames.has(name) || name === 'node_modules') continue;
41
+ if (/\.backup$/.test(name)) continue;
42
+ const childAbs = path.join(absPath, name);
43
+ let st;
44
+ try {
45
+ st = fs.statSync(childAbs);
46
+ } catch {
47
+ // Broken symlink or unreadable entry — skip.
48
+ continue;
49
+ }
50
+ if (st.isDirectory()) {
51
+ const nested = walkAllowlistDirectory(childAbs, excludeNames, seenRealpaths);
52
+ fileCount += nested.fileCount;
53
+ bytes += nested.bytes;
54
+ } else if (st.isFile()) {
55
+ fileCount += 1;
56
+ bytes += st.size;
57
+ }
58
+ }
59
+ return { fileCount, bytes };
60
+ };
61
+
62
+ /**
63
+ * Pure planner: source-home allowlist + optional `~/.agents` + optional provider auth.json.
64
+ * Plan is computed on the source side; executors run it on the target side.
65
+ * `direction` is a first-class field (`push` today; `pull` reuses the same shape).
66
+ *
67
+ * @param {string} sourceHomedir
68
+ * @param {{
69
+ * direction?: 'push' | 'pull',
70
+ * syncRunId?: string,
71
+ * sourceTargetId?: string,
72
+ * targetId?: string,
73
+ * includeAuthFile?: boolean,
74
+ * selections?: import('./selections.js').SyncSelections,
75
+ * }} [options]
76
+ * @returns {{
77
+ * direction: 'push' | 'pull',
78
+ * syncRunId?: string,
79
+ * sourceTargetId?: string,
80
+ * targetId?: string,
81
+ * files: { path: string, bytes: number }[],
82
+ * directories: { path: string, fileCount: number, bytes: number }[],
83
+ * agentsRoot: { fileCount: number, bytes: number } | null,
84
+ * authFile: { bytes: number } | null,
85
+ * deletes: string[],
86
+ * totalBytes: number,
87
+ * selections?: import('./selections.js').SyncSelections,
88
+ * }}
89
+ */
90
+ export const planOpenCodeConfigSync = (sourceHomedir, options = {}) => {
91
+ const home = String(sourceHomedir || '');
92
+ const direction = options.direction || SYNC_DIRECTION_PUSH;
93
+ if (!SYNC_DIRECTIONS.includes(direction)) {
94
+ throw new Error(`Unsupported sync direction: ${String(direction)}`);
95
+ }
96
+ // Credential transfer requires an explicit per-target grant (ticket 03).
97
+ // Callers that have not authorized the target pass includeAuthFile: false so
98
+ // auth.json is skipped rather than failing the whole sync.
99
+ const selections = options.selections
100
+ ? normalizeSyncSelections(options.selections, { includeAuthFile: options.selections.authFile === true })
101
+ : null;
102
+ const includeAuthFile = selections
103
+ ? selections.authFile === true
104
+ : options.includeAuthFile !== false;
105
+
106
+ const root = path.join(home, '.config', 'opencode');
107
+ /** @type {{ path: string, bytes: number }[]} */
108
+ const files = [];
109
+ /** @type {{ path: string, fileCount: number, bytes: number }[]} */
110
+ const directories = [];
111
+ /** @type {string[]} */
112
+ const deletes = [];
113
+ let totalBytes = 0;
114
+ let totalFileCount = 0;
115
+
116
+ for (const group of OPENCODE_CONFIG_SYNC_ALLOWLIST.fileGroups) {
117
+ let winner = null;
118
+ for (const member of group) {
119
+ const abs = path.join(root, member);
120
+ let st;
121
+ try {
122
+ st = fs.statSync(abs);
123
+ } catch {
124
+ continue;
125
+ }
126
+ if (!st.isFile()) continue;
127
+ winner = { path: member, bytes: st.size };
128
+ break;
129
+ }
130
+ if (!winner) continue;
131
+ files.push(winner);
132
+ totalBytes += winner.bytes;
133
+ totalFileCount += 1;
134
+ for (const member of group) {
135
+ if (member !== winner.path) deletes.push(member);
136
+ }
137
+ }
138
+
139
+ for (const name of OPENCODE_CONFIG_SYNC_ALLOWLIST.singleFiles) {
140
+ const abs = path.join(root, name);
141
+ let st;
142
+ try {
143
+ st = fs.statSync(abs);
144
+ } catch {
145
+ continue;
146
+ }
147
+ if (!st.isFile()) continue;
148
+ files.push({ path: name, bytes: st.size });
149
+ totalBytes += st.size;
150
+ totalFileCount += 1;
151
+ }
152
+
153
+ for (const dirSpec of OPENCODE_CONFIG_SYNC_ALLOWLIST.directories) {
154
+ const abs = path.join(root, dirSpec.path);
155
+ let st;
156
+ try {
157
+ st = fs.statSync(abs);
158
+ } catch {
159
+ continue;
160
+ }
161
+ if (!st.isDirectory()) continue;
162
+ const excludeNames = new Set(Array.isArray(dirSpec.excludeNames) ? dirSpec.excludeNames : []);
163
+ const walked = walkAllowlistDirectory(abs, excludeNames, new Set());
164
+ directories.push({
165
+ path: dirSpec.path,
166
+ fileCount: walked.fileCount,
167
+ bytes: walked.bytes,
168
+ });
169
+ totalBytes += walked.bytes;
170
+ totalFileCount += walked.fileCount;
171
+ deletes.push(dirSpec.path);
172
+ if (typeof dirSpec.legacy === 'string' && dirSpec.legacy) {
173
+ deletes.push(dirSpec.legacy);
174
+ }
175
+ }
176
+
177
+ /** @type {{ fileCount: number, bytes: number } | null} */
178
+ let agentsRoot = null;
179
+ const agentsAbs = path.join(home, '.agents');
180
+ let agentsStat;
181
+ try {
182
+ agentsStat = fs.statSync(agentsAbs);
183
+ } catch {
184
+ agentsStat = null;
185
+ }
186
+ if (agentsStat?.isDirectory()) {
187
+ const walked = walkAllowlistDirectory(agentsAbs, new Set(), new Set());
188
+ agentsRoot = { fileCount: walked.fileCount, bytes: walked.bytes };
189
+ totalBytes += walked.bytes;
190
+ totalFileCount += walked.fileCount;
191
+ }
192
+
193
+ /** @type {{ bytes: number } | null} */
194
+ let authFile = null;
195
+ if (includeAuthFile) {
196
+ const authAbs = path.join(home, '.local', 'share', 'opencode', 'auth.json');
197
+ let authStat;
198
+ try {
199
+ // fs.stat follows symlinks (auth.json is sometimes a symlink).
200
+ authStat = fs.statSync(authAbs);
201
+ } catch {
202
+ authStat = null;
203
+ }
204
+ if (authStat?.isFile()) {
205
+ authFile = { bytes: authStat.size };
206
+ totalBytes += authStat.size;
207
+ // Single credential file — does not count against the 20000 file-count cap.
208
+ }
209
+ }
210
+
211
+ if (totalBytes > OPENCODE_CONFIG_SYNC_MAX_BYTES || totalFileCount > OPENCODE_CONFIG_SYNC_MAX_FILES) {
212
+ throw new Error('OpenCode config sync exceeds size limit (512MB)');
213
+ }
214
+
215
+ const full = {
216
+ direction,
217
+ ...(typeof options.syncRunId === 'string' && options.syncRunId ? { syncRunId: options.syncRunId } : {}),
218
+ ...(typeof options.sourceTargetId === 'string' && options.sourceTargetId
219
+ ? { sourceTargetId: options.sourceTargetId }
220
+ : {}),
221
+ ...(typeof options.targetId === 'string' && options.targetId ? { targetId: options.targetId } : {}),
222
+ files,
223
+ directories,
224
+ agentsRoot,
225
+ authFile,
226
+ deletes,
227
+ totalBytes,
228
+ };
229
+ return selections ? filterPlanBySelections(full, selections) : full;
230
+ };
231
+
232
+ /**
233
+ * Build a pull plan from a remote inventory payload (no local filesystem walk).
234
+ * @param {object} inventory
235
+ * @param {{
236
+ * direction?: 'pull',
237
+ * syncRunId?: string,
238
+ * sourceTargetId?: string,
239
+ * targetId?: string,
240
+ * includeAuthFile?: boolean,
241
+ * selections?: import('./selections.js').SyncSelections,
242
+ * }} [options]
243
+ */
244
+ export const planOpenCodeConfigSyncFromInventory = (inventory, options = {}) => {
245
+ const direction = options.direction || 'pull';
246
+ if (!SYNC_DIRECTIONS.includes(direction)) {
247
+ throw new Error(`Unsupported sync direction: ${String(direction)}`);
248
+ }
249
+ const selections = options.selections
250
+ ? normalizeSyncSelections(options.selections, { includeAuthFile: options.selections.authFile === true })
251
+ : null;
252
+ const includeAuthFile = selections
253
+ ? selections.authFile === true
254
+ : options.includeAuthFile !== false;
255
+
256
+ const files = Array.isArray(inventory?.files) ? inventory.files.filter((entry) => entry?.path) : [];
257
+ const directories = Array.isArray(inventory?.directories)
258
+ ? inventory.directories.filter((entry) => entry?.path)
259
+ : [];
260
+ const agentsRoot = inventory?.agentsRoot && typeof inventory.agentsRoot === 'object'
261
+ ? inventory.agentsRoot
262
+ : null;
263
+ const authFile = includeAuthFile && inventory?.authFile && typeof inventory.authFile === 'object'
264
+ ? inventory.authFile
265
+ : null;
266
+
267
+ /** @type {string[]} */
268
+ const deletes = [];
269
+ for (const group of OPENCODE_CONFIG_SYNC_ALLOWLIST.fileGroups) {
270
+ const winner = files.find((entry) => group.includes(entry.path));
271
+ if (!winner) continue;
272
+ for (const member of group) {
273
+ if (member !== winner.path) deletes.push(member);
274
+ }
275
+ }
276
+ for (const dir of directories) {
277
+ deletes.push(dir.path);
278
+ const spec = OPENCODE_CONFIG_SYNC_ALLOWLIST.directories.find((entry) => entry.path === dir.path);
279
+ if (typeof spec?.legacy === 'string' && spec.legacy) deletes.push(spec.legacy);
280
+ }
281
+
282
+ let totalBytes = 0;
283
+ let totalFileCount = 0;
284
+ for (const entry of files) {
285
+ totalBytes += Number(entry.bytes) || 0;
286
+ totalFileCount += 1;
287
+ }
288
+ for (const entry of directories) {
289
+ totalBytes += Number(entry.bytes) || 0;
290
+ totalFileCount += Number(entry.fileCount) || 0;
291
+ }
292
+ if (agentsRoot) {
293
+ totalBytes += Number(agentsRoot.bytes) || 0;
294
+ totalFileCount += Number(agentsRoot.fileCount) || 0;
295
+ }
296
+ if (authFile) {
297
+ totalBytes += Number(authFile.bytes) || 0;
298
+ }
299
+ if (totalBytes > OPENCODE_CONFIG_SYNC_MAX_BYTES || totalFileCount > OPENCODE_CONFIG_SYNC_MAX_FILES) {
300
+ throw new Error('OpenCode config sync exceeds size limit (512MB)');
301
+ }
302
+
303
+ const full = {
304
+ direction,
305
+ ...(typeof options.syncRunId === 'string' && options.syncRunId ? { syncRunId: options.syncRunId } : {}),
306
+ ...(typeof options.sourceTargetId === 'string' && options.sourceTargetId
307
+ ? { sourceTargetId: options.sourceTargetId }
308
+ : {}),
309
+ ...(typeof options.targetId === 'string' && options.targetId ? { targetId: options.targetId } : {}),
310
+ files,
311
+ directories,
312
+ agentsRoot,
313
+ authFile,
314
+ deletes,
315
+ totalBytes,
316
+ };
317
+ return selections ? filterPlanBySelections(full, selections) : full;
318
+ };