@openchambery/web 2.1.7-beta.9 → 2.1.8-beta.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.
- package/dist/assets/{AgentAvatar-DxS7U-Cp.js → AgentAvatar-ndlnVkjO.js} +1 -1
- package/dist/assets/AssistantView-6ta2Y6q7.js +3 -0
- package/dist/assets/{DiagramView-Bw2sCV3Y.js → DiagramView-DvZU3Yxr.js} +1 -1
- package/dist/assets/{FadeInOnReveal-kNFcAb-K.js → FadeInOnReveal-CcvpP9n1.js} +1 -1
- package/dist/assets/{Icon-DxBvAKXH.js → Icon-2Obqh9Fi.js} +1 -1
- package/dist/assets/{ImageLoadingPlaceholder-DQvRSYkt.js → ImageLoadingPlaceholder-D916bbhl.js} +1 -1
- package/dist/assets/{LoadingImage-CaXJqavb.js → LoadingImage-BeOr4G0q.js} +1 -1
- package/dist/assets/{MarkdownRenderer-DpXaDH86.js → MarkdownRenderer-Cl5V8BKz.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-EACzZK2L.js → MarkdownRendererImpl-losB1IeW.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-yohfk508.js → MarkstreamRendererImpl-PHsV91V_.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-ByVEkbBy.js → MobileDetailNavigation-W-DaFFg5.js} +1 -1
- package/dist/assets/{MobileShareBridge-DddCiczO.js → MobileShareBridge-Bjzb3TK6.js} +2 -2
- package/dist/assets/MobileTabPageHeader-qbGYMq9u.js +2 -0
- package/dist/assets/{MultiRunWindow-CQWEGqds.js → MultiRunWindow-BbfM6ISu.js} +1 -1
- package/dist/assets/{OnboardingScreen-BGZp5N1h.js → OnboardingScreen-DEolMFq_.js} +1 -1
- package/dist/assets/{OverlayScrollbar-OftHd9vR.js → OverlayScrollbar-DB7fWZUw.js} +1 -1
- package/dist/assets/{SettingsView-CDkq_RGv.js → SettingsView-Bjo-fXIC.js} +9 -9
- package/dist/assets/{SettingsWindow-CQhiLvNH.js → SettingsWindow-DdN8IePL.js} +1 -1
- package/dist/assets/{TerminalView-CEiDqWoI.js → TerminalView-BUHkhkkZ.js} +1 -1
- package/dist/assets/{ToolOutputDialog-jXsCdUsx.js → ToolOutputDialog-DGY_JzQg.js} +1 -1
- package/dist/assets/{appThemeRegistry-C90Vniux.js → appThemeRegistry-DiuhF7sJ.js} +1 -1
- package/dist/assets/{button-DfHNBl0U.js → button-Bdq9SUvZ.js} +1 -1
- package/dist/assets/{collapsible-BJ3u2c1m.js → collapsible-C7PP5l16.js} +1 -1
- package/dist/assets/{desktopHosts-BKeEWmL5.js → desktopHosts-DSBZ-9-L.js} +1 -1
- package/dist/assets/{desktopRelayRestore-FWm2fXr6.js → desktopRelayRestore-DK8TzHRx.js} +1 -1
- package/dist/assets/{diagram-KOjGEiKO.js → diagram-BBlwWp3m.js} +1 -1
- package/dist/assets/{dialog-DBNBD_KB.js → dialog-DHmUjm6M.js} +1 -1
- package/dist/assets/dropdown-menu.styles-XSr3iA4S.js +1 -0
- package/dist/assets/{es-2YvsazK2.js → es-BxLn_G_Q.js} +1 -1
- package/dist/assets/{fileMentionTouchSelection-zOe2SiY_.js → fileMentionTouchSelection-BHA_5cBo.js} +1 -1
- package/dist/assets/{fileQueries-Dhgypzyl.js → fileQueries-BGE5l3Jd.js} +1 -1
- package/dist/assets/{fr-Cy0M48Gw.js → fr-LROiZho7.js} +1 -1
- package/dist/assets/{gitApi-pv3A0rDM.js → gitApi-BdyssA-O.js} +1 -1
- package/dist/assets/{image-loading-DLh8WsH4.js → image-loading-qNtUCy69.js} +1 -1
- package/dist/assets/{input-CQ5f8Zqb.js → input-De00oOal.js} +1 -1
- package/dist/assets/{insertionBoundaries-CCxWV_ir.js → insertionBoundaries-DIohQnVE.js} +1 -1
- package/dist/assets/{ja-1zLWWJ2R.js → ja-DiErdLtA.js} +1 -1
- package/dist/assets/{ko-BLk_5Dhc.js → ko-srYeEs4u.js} +1 -1
- package/dist/assets/{main-KrnbhzQQ.js → main-BETi-fsf.js} +12 -12
- package/dist/assets/{main-BbqWl6PU.js → main-DUrFgqZL.js} +2 -2
- package/dist/assets/{markdownHeightCache-BJ7s_IuQ.js → markdownHeightCache-D44E3PrO.js} +1 -1
- package/dist/assets/{mcpQueries-BlrZ9jet.js → mcpQueries-qCvadKyt.js} +1 -1
- package/dist/assets/{miniChat-7ywVBnvf.js → miniChat-jC1_fkBZ.js} +2 -2
- package/dist/assets/mobile-D-5-hKu6.js +2 -0
- package/dist/assets/{multirun-CZOOUr7O.js → multirun-C2aiem4S.js} +1 -1
- package/dist/assets/{persistence-DUPZl7Oy.js → persistence-B7ORJ371.js} +1 -1
- package/dist/assets/{pl-BE8MC0XQ.js → pl-CzKfWMm9.js} +1 -1
- package/dist/assets/{platform-z2erC6Bs.js → platform-CiBU2bCp.js} +2 -2
- package/dist/assets/{projectMeta-DiTRU5Dp.js → projectMeta-DV6HExaM.js} +1 -1
- package/dist/assets/{pt-BR-ubhNymyH.js → pt-BR-CUmrnVae.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-CI-5mVAG.js +2 -0
- package/dist/assets/{renderMobileApp-fVXPQqNd.js → renderMobileApp-Ds-6h0OM.js} +5 -5
- package/dist/assets/{runtimeConfig-DHFwtNBi.js → runtimeConfig-CBy91BoB.js} +1 -1
- package/dist/assets/runtimeEndpointReset-C7wwKdBg.js +149 -0
- package/dist/assets/scheduledTaskNavigation-D7yhTPgz.js +1 -0
- package/dist/assets/sessionChangeSummary-BSFJiKHQ.js +2 -0
- package/dist/assets/{sessionLookup-BSK3URnU.js → sessionLookup-D0uMihAO.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-D655zJ3T.js → sortable-tabs-strip-aIcw2DJh.js} +1 -1
- package/dist/assets/speechSettingsQueries-Bd3icbFp.js +4 -0
- package/dist/assets/src-C9BNQK0O.css +2 -0
- package/dist/assets/{store-n-H5RvRn.js → store-BYLl4QL2.js} +2 -2
- package/dist/assets/{terminalApi-BnjU_lmF.js → terminalApi-bN5IYQau.js} +1 -1
- package/dist/assets/{textarea-HlFXg84f.js → textarea-DNadM0DL.js} +1 -1
- package/dist/assets/{tooltip-BQgoGUWx.js → tooltip-nHGca5md.js} +1 -1
- package/dist/assets/{uk-BNyyigDo.js → uk-DGZ3Hl33.js} +1 -1
- package/dist/assets/{url-DAyfGWPH.js → url-nbXEti6c.js} +1 -1
- package/dist/assets/{useAppFontEffects-BQbKLVsD.js → useAppFontEffects-ByC3DE9D.js} +53 -53
- package/dist/assets/{useAssistantUIStore-Dt0l5AKe.js → useAssistantUIStore-Dlvb7P0q.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-BIn2hI3V.js → useDesktopWindowControlsLayout-CbrmthFZ.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BycgG8Ov.js → useEffectiveDirectory-BEpQGzay.js} +1 -1
- package/dist/assets/{useFontPreferences-B4Cgu3bS.js → useFontPreferences-6LD7d7hP.js} +1 -1
- package/dist/assets/useMobileNavigationStore-Cm0CuwA9.js +417 -0
- package/dist/assets/useUIStore-CDMI_Yzu.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-DmI50_3w.js → useWorkerHighlightedLines-D5mEoRi2.js} +1 -1
- package/dist/assets/{zh-CN-CNWzMPgL.js → zh-CN-UwxgbjmI.js} +1 -1
- package/dist/assets/{zh-TW-DrZ9S1yn.js → zh-TW-Bc9XoqY8.js} +1 -1
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +39 -0
- package/server/lib/assistants/harness.js +20 -7
- package/server/lib/assistants/harness.test.js +21 -0
- package/server/lib/assistants/routes.js +2 -1
- package/server/lib/assistants/service.js +76 -13
- package/server/lib/assistants/service.test.js +53 -0
- package/server/lib/config-sync/DOCUMENTATION.md +33 -11
- package/server/lib/config-sync/config-sync.test.js +11 -11
- package/server/lib/config-sync/constants.js +0 -3
- package/server/lib/config-sync/contract.js +8 -8
- package/server/lib/config-sync/credentials.js +101 -0
- package/server/lib/config-sync/credentials.test.js +98 -0
- package/server/lib/config-sync/engine.js +15 -10
- package/server/lib/config-sync/index.js +0 -3
- package/server/lib/config-sync/local-backup.js +0 -13
- package/server/lib/config-sync/magpie.js +204 -0
- package/server/lib/config-sync/magpie.test.js +150 -0
- package/server/lib/config-sync/plan.js +32 -37
- package/server/lib/config-sync/receiver.js +26 -25
- package/server/lib/config-sync/routes.js +23 -2
- package/server/lib/config-sync/routes.test.js +63 -5
- package/server/lib/config-sync/scripts.js +2 -30
- package/server/lib/config-sync/selections.js +14 -10
- package/server/lib/notifications/DOCUMENTATION.md +5 -0
- package/server/lib/opencode/DOCUMENTATION.md +2 -1
- package/server/lib/opencode/auth.d.ts +1 -0
- package/server/lib/opencode/auth.js +1 -1
- package/server/lib/opencode/feature-routes-runtime.js +1 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +25 -0
- package/server/lib/scheduled-tasks/run-history-store.js +18 -5
- package/server/lib/scheduled-tasks/run-history-store.test.js +51 -0
- package/server/lib/scheduled-tasks/runtime.js +70 -12
- package/server/lib/scheduled-tasks/runtime.test.js +131 -0
- package/dist/assets/AssistantView-Bnaa8U5L.js +0 -3
- package/dist/assets/MobileTabPageHeader-CAtpMLFM.js +0 -2
- package/dist/assets/context-menu-DBNnM4a7.js +0 -1
- package/dist/assets/dropdown-menu.styles-Cbl6jSsR.js +0 -1
- package/dist/assets/mobile-_nwad-rW.js +0 -2
- package/dist/assets/renderElectronMiniChatApp-DfB14cCR.js +0 -2
- package/dist/assets/runtimeEndpointReset-DOHn9DSm.js +0 -149
- package/dist/assets/sessionChangeSummary-Y9NdeIoe.js +0 -2
- package/dist/assets/speechSettingsQueries-ojxskgsA.js +0 -4
- package/dist/assets/src-GOs_VnY-.css +0 -2
- package/dist/assets/useMobileNavigationStore-DZUur_4b.js +0 -417
- package/dist/assets/useUIStore-pl39iO82.js +0 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const MAX_BYTES = 64 * 1024 * 1024;
|
|
8
|
+
const CONFIG_FILES = ['providers.json', 'settings.json', 'profiles.json', 'library.json', 'plugins.json'];
|
|
9
|
+
const exists = async (file) => {
|
|
10
|
+
try { return await fs.stat(file); }
|
|
11
|
+
catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null; throw error; }
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Raw CLI output may contain provider names, paths or credentials. Never forward it.
|
|
15
|
+
const runCli = (binary, args, { input = '', env, timeout = 600_000 } = {}) => new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(binary, args, { env, windowsHide: true, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
17
|
+
let output = '';
|
|
18
|
+
let exceeded = false;
|
|
19
|
+
const stop = () => {
|
|
20
|
+
exceeded = true;
|
|
21
|
+
try {
|
|
22
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL');
|
|
23
|
+
else child.kill('SIGKILL');
|
|
24
|
+
} catch { child.kill('SIGKILL'); }
|
|
25
|
+
};
|
|
26
|
+
const timer = setTimeout(stop, timeout);
|
|
27
|
+
const collect = (chunk) => {
|
|
28
|
+
if (output.length + chunk.length > 1024 * 1024) { stop(); return; }
|
|
29
|
+
output += chunk.toString();
|
|
30
|
+
};
|
|
31
|
+
child.stdout.on('data', collect);
|
|
32
|
+
child.stderr.on('data', collect);
|
|
33
|
+
child.stdin.on('error', () => {});
|
|
34
|
+
child.on('error', () => { clearTimeout(timer); reject(new Error('Unable to start Magpie CLI')); });
|
|
35
|
+
child.on('close', (code) => {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
if (code !== 0 || exceeded) reject(new Error('Magpie CLI operation failed or timed out'));
|
|
38
|
+
else resolve(output.replace(/\x1b\[[0-9;]*m/g, ''));
|
|
39
|
+
});
|
|
40
|
+
child.stdin.end(input);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Host-owned discovery and native backup/restore. No renderer-supplied paths or commands. */
|
|
44
|
+
export function createMagpieSync({
|
|
45
|
+
homedir = os.homedir,
|
|
46
|
+
env = process.env,
|
|
47
|
+
platform = process.platform,
|
|
48
|
+
arch = process.arch,
|
|
49
|
+
fetchImpl = fetch,
|
|
50
|
+
run = runCli,
|
|
51
|
+
tempdir = os.tmpdir,
|
|
52
|
+
} = {}) {
|
|
53
|
+
let installing = null;
|
|
54
|
+
let busy = false;
|
|
55
|
+
const home = () => homedir();
|
|
56
|
+
const configDir = () => path.join(env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME)
|
|
57
|
+
? env.XDG_CONFIG_HOME : path.join(home(), '.config'), 'magpie');
|
|
58
|
+
const managedBinary = () => path.join(env.OPENCHAMBER_DATA_DIR || path.join(home(), '.config', 'openchamber'),
|
|
59
|
+
'tools', 'magpie', platform === 'win32' ? 'magpie.exe' : 'magpie');
|
|
60
|
+
const hasConfig = async (dir) => {
|
|
61
|
+
for (const file of CONFIG_FILES) if ((await exists(path.join(dir, file)))?.isFile()) return true;
|
|
62
|
+
return false;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const locate = async () => {
|
|
66
|
+
const name = platform === 'win32' ? 'magpie.exe' : 'magpie';
|
|
67
|
+
const candidates = [
|
|
68
|
+
env.MAGPIE_BINARY,
|
|
69
|
+
...(env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, name)),
|
|
70
|
+
path.join(home(), '.local', 'bin', name),
|
|
71
|
+
...(platform === 'darwin' ? [
|
|
72
|
+
'/Applications/magpie.app/Contents/MacOS/magpie',
|
|
73
|
+
path.join(home(), 'Applications', 'magpie.app', 'Contents', 'MacOS', 'magpie'),
|
|
74
|
+
'/opt/homebrew/bin/magpie', '/usr/local/bin/magpie',
|
|
75
|
+
] : []),
|
|
76
|
+
...(platform === 'win32' && env.LOCALAPPDATA ? [path.join(env.LOCALAPPDATA, 'Programs', 'magpie', name)] : []),
|
|
77
|
+
managedBinary(),
|
|
78
|
+
].filter(Boolean);
|
|
79
|
+
for (const candidate of new Set(candidates)) {
|
|
80
|
+
if (!(await exists(candidate))?.isFile()) continue;
|
|
81
|
+
const binary = await fs.realpath(candidate);
|
|
82
|
+
const version = await run(binary, ['--version'], { env, timeout: 10_000 });
|
|
83
|
+
if (!/^magpie\s+\S+/m.test(version)) throw new Error('Invalid Magpie executable');
|
|
84
|
+
return binary;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const dataDir = async (binary) => {
|
|
90
|
+
if (!binary) return configDir();
|
|
91
|
+
let beside = path.dirname(binary);
|
|
92
|
+
if (path.basename(beside) === 'MacOS' && path.basename(path.dirname(beside)) === 'Contents') {
|
|
93
|
+
beside = path.dirname(path.dirname(path.dirname(beside)));
|
|
94
|
+
} else if (platform === 'linux' && env.APPIMAGE && env.APPDIR
|
|
95
|
+
&& binary.startsWith(`${env.APPDIR}${path.sep}`)) beside = path.dirname(env.APPIMAGE);
|
|
96
|
+
const portable = path.join(beside, 'data');
|
|
97
|
+
if (await exists(path.join(beside, '.portable'))) return portable;
|
|
98
|
+
if (!(await exists(portable))?.isDirectory()) return configDir();
|
|
99
|
+
const shared = [home(), '/Applications', ...['Applications', 'Downloads', 'Desktop', 'Documents'].map((dir) => path.join(home(), dir))];
|
|
100
|
+
if (!shared.some((dir) => dir.toLowerCase() === beside.toLowerCase())) return portable;
|
|
101
|
+
return await hasConfig(portable) && !await hasConfig(configDir()) ? portable : configDir();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const inventory = async () => {
|
|
105
|
+
const binary = await locate();
|
|
106
|
+
return { available: await hasConfig(await dataDir(binary)), installed: Boolean(binary) };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const downloadBytes = async (url, limit) => {
|
|
110
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(120_000) });
|
|
111
|
+
if (!response.ok || !response.body) throw new Error('Unable to download Magpie CLI');
|
|
112
|
+
const chunks = [];
|
|
113
|
+
let size = 0;
|
|
114
|
+
for await (const chunk of response.body) {
|
|
115
|
+
size += chunk.length;
|
|
116
|
+
if (size > limit) throw new Error('Magpie download exceeds size limit');
|
|
117
|
+
chunks.push(Buffer.from(chunk));
|
|
118
|
+
}
|
|
119
|
+
return Buffer.concat(chunks);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const ensure = async () => {
|
|
123
|
+
const binary = await locate();
|
|
124
|
+
if (binary) return binary;
|
|
125
|
+
if (installing) return installing;
|
|
126
|
+
installing = (async () => {
|
|
127
|
+
const cpu = { x64: 'amd64', arm64: 'arm64' }[arch];
|
|
128
|
+
if (!cpu || !['darwin', 'linux', 'win32'].includes(platform)) throw new Error('Magpie CLI is not available for this platform');
|
|
129
|
+
const assetName = `magpie-cli-${platform === 'win32' ? 'windows' : platform}-${cpu}${platform === 'win32' ? '.exe' : ''}`;
|
|
130
|
+
let feed;
|
|
131
|
+
try { feed = JSON.parse((await downloadBytes('https://usemagpie.ai/api/latest', 2 * 1024 * 1024)).toString()); }
|
|
132
|
+
catch { throw new Error('Unable to read Magpie release metadata'); }
|
|
133
|
+
const asset = feed.assets?.[assetName];
|
|
134
|
+
if (!asset || !/^[a-f0-9]{64}$/i.test(asset.sha256 || '')
|
|
135
|
+
|| typeof asset.url !== 'string' || !asset.url.startsWith('https://github.com/yetone/magpie-releases/releases/download/')) {
|
|
136
|
+
throw new Error('No verified Magpie CLI release for this platform');
|
|
137
|
+
}
|
|
138
|
+
const bytes = await downloadBytes(asset.url, 256 * 1024 * 1024);
|
|
139
|
+
if (createHash('sha256').update(bytes).digest('hex') !== asset.sha256.toLowerCase()) throw new Error('Magpie CLI checksum mismatch');
|
|
140
|
+
const target = managedBinary();
|
|
141
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
142
|
+
const staging = `${target}.${randomBytes(8).toString('hex')}${platform === 'win32' ? '.exe' : ''}`;
|
|
143
|
+
try {
|
|
144
|
+
await fs.writeFile(staging, bytes, { mode: 0o700, flag: 'wx' });
|
|
145
|
+
const version = await run(staging, ['--version'], { env, timeout: 10_000 });
|
|
146
|
+
if (!/^magpie\s+\S+/m.test(version)) throw new Error('Invalid downloaded Magpie CLI');
|
|
147
|
+
await fs.rename(staging, target);
|
|
148
|
+
} finally { await fs.rm(staging, { force: true }); }
|
|
149
|
+
return target;
|
|
150
|
+
})();
|
|
151
|
+
try { return await installing; }
|
|
152
|
+
finally { installing = null; }
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const exclusive = async (work) => {
|
|
156
|
+
if (busy) throw new Error('Magpie sync already in progress');
|
|
157
|
+
busy = true;
|
|
158
|
+
try { return await work(); } finally { busy = false; }
|
|
159
|
+
};
|
|
160
|
+
const withTemp = async (work) => {
|
|
161
|
+
const dir = await fs.mkdtemp(path.join(tempdir(), 'openchamber-magpie-'));
|
|
162
|
+
try { return await work(path.join(dir, 'setup.magpie-backup')); }
|
|
163
|
+
finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
inventory,
|
|
168
|
+
download: () => exclusive(async () => {
|
|
169
|
+
if (!(await inventory()).available) throw new Error('No Magpie configuration found on source');
|
|
170
|
+
const binary = await ensure();
|
|
171
|
+
const passphrase = randomBytes(32).toString('hex');
|
|
172
|
+
return withTemp(async (file) => {
|
|
173
|
+
await run(binary, ['backup', file], { env, input: `${passphrase}\n` });
|
|
174
|
+
const stat = await fs.stat(file);
|
|
175
|
+
if (stat.size > MAX_BYTES) throw new Error('Magpie backup exceeds size limit');
|
|
176
|
+
const backup = await fs.readFile(file);
|
|
177
|
+
return Buffer.from(JSON.stringify({ format: 'magpie-sync-v1', passphrase, backup: backup.toString('base64') }));
|
|
178
|
+
});
|
|
179
|
+
}),
|
|
180
|
+
import: (payload) => exclusive(async () => {
|
|
181
|
+
let decoded;
|
|
182
|
+
let backup;
|
|
183
|
+
try {
|
|
184
|
+
if (payload.length > MAX_BYTES * 2) throw new Error();
|
|
185
|
+
decoded = JSON.parse(Buffer.from(payload).toString());
|
|
186
|
+
if (decoded.format !== 'magpie-sync-v1' || !/^[a-f0-9]{64}$/.test(decoded.passphrase)
|
|
187
|
+
|| typeof decoded.backup !== 'string') throw new Error();
|
|
188
|
+
backup = Buffer.from(decoded.backup, 'base64');
|
|
189
|
+
if (!backup.length || backup.length > MAX_BYTES || JSON.parse(backup.toString()).format !== 'magpie-backup') throw new Error();
|
|
190
|
+
} catch { throw new Error('Invalid Magpie sync payload'); }
|
|
191
|
+
const binary = await ensure();
|
|
192
|
+
return withTemp(async (file) => {
|
|
193
|
+
await fs.writeFile(file, backup, { mode: 0o600, flag: 'wx' });
|
|
194
|
+
let output;
|
|
195
|
+
try { output = await run(binary, ['restore', file], { env, input: `${decoded.passphrase}\n` }); }
|
|
196
|
+
catch { throw new Error('Magpie restore failed; some configuration may already have been applied. Retry to finish.'); }
|
|
197
|
+
if (/Couldn't add the plugin|Needs a key|not signed in on this computer|Left as they are|Library:.*couldn't/i.test(output)) {
|
|
198
|
+
throw new Error('Magpie restore partially completed; check missing sign-ins, plugins or agent settings in Magpie.');
|
|
199
|
+
}
|
|
200
|
+
return { ok: true };
|
|
201
|
+
});
|
|
202
|
+
}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { createMagpieSync } from './magpie.js';
|
|
7
|
+
import { planOpenCodeConfigSync, planOpenCodeConfigSyncFromInventory } from './plan.js';
|
|
8
|
+
import { applyConfigSyncPlan } from './engine.js';
|
|
9
|
+
|
|
10
|
+
const dirs = [];
|
|
11
|
+
const machine = async () => {
|
|
12
|
+
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-magpie-test-'));
|
|
13
|
+
dirs.push(home);
|
|
14
|
+
return { home, options: {
|
|
15
|
+
homedir: () => home, tempdir: () => home, platform: 'linux', arch: 'x64',
|
|
16
|
+
env: { HOME: home, PATH: '', XDG_CONFIG_HOME: path.join(home, '.config') },
|
|
17
|
+
} };
|
|
18
|
+
};
|
|
19
|
+
const write = async (file, content = '{}') => {
|
|
20
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
21
|
+
await fs.writeFile(file, content);
|
|
22
|
+
};
|
|
23
|
+
afterEach(async () => { await Promise.all(dirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); });
|
|
24
|
+
|
|
25
|
+
describe('Magpie discovery and native transfer', () => {
|
|
26
|
+
it('discovers config-only sources through XDG without installing during preview', async () => {
|
|
27
|
+
const { home, options } = await machine();
|
|
28
|
+
const fetchImpl = vi.fn();
|
|
29
|
+
const sync = createMagpieSync({ ...options, fetchImpl });
|
|
30
|
+
expect(await sync.inventory()).toEqual({ available: false, installed: false });
|
|
31
|
+
await write(path.join(home, '.config', 'magpie', 'providers.json'));
|
|
32
|
+
expect(await sync.inventory()).toEqual({ available: true, installed: false });
|
|
33
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('resolves a symlinked client executable and its portable configuration', async () => {
|
|
37
|
+
const { home, options } = await machine();
|
|
38
|
+
const app = path.join(home, 'portable', 'magpie.app', 'Contents', 'MacOS', 'magpie');
|
|
39
|
+
await write(app, 'fixture-binary');
|
|
40
|
+
await write(path.join(home, 'portable', '.portable'), '');
|
|
41
|
+
await write(path.join(home, 'portable', 'data', 'providers.json'));
|
|
42
|
+
const link = path.join(home, 'magpie');
|
|
43
|
+
await fs.symlink(app, link);
|
|
44
|
+
const run = vi.fn(async () => 'magpie 0.1.0');
|
|
45
|
+
const sync = createMagpieSync({ ...options, env: { ...options.env, MAGPIE_BINARY: link }, run });
|
|
46
|
+
expect(await sync.inventory()).toEqual({ available: true, installed: true });
|
|
47
|
+
expect(run.mock.calls[0][0]).toBe(await fs.realpath(app));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('exports noninteractively, installs a verified destination CLI and cleans temporary files', async () => {
|
|
51
|
+
const source = await machine();
|
|
52
|
+
const target = await machine();
|
|
53
|
+
const binary = path.join(source.home, '.local', 'bin', 'magpie');
|
|
54
|
+
await write(binary, 'fixture-cli');
|
|
55
|
+
await write(path.join(source.home, '.config', 'magpie', 'providers.json'));
|
|
56
|
+
const runSource = vi.fn(async (_binary, args, ctx) => {
|
|
57
|
+
if (args[0] === '--version') return 'magpie 0.1.0';
|
|
58
|
+
expect(args[0]).toBe('backup');
|
|
59
|
+
expect(ctx.input).toMatch(/^[a-f0-9]{64}\n$/);
|
|
60
|
+
expect(args).not.toContain(ctx.input.trim());
|
|
61
|
+
await write(args[1], JSON.stringify({ format: 'magpie-backup', data: 'fixture-encrypted' }));
|
|
62
|
+
return 'saved';
|
|
63
|
+
});
|
|
64
|
+
const payload = await createMagpieSync({ ...source.options, run: runSource }).download();
|
|
65
|
+
const bytes = Buffer.from('fixture-installed-cli');
|
|
66
|
+
const fetchImpl = vi.fn(async (url) => url === 'https://usemagpie.ai/api/latest'
|
|
67
|
+
? Response.json({ assets: { 'magpie-cli-linux-amd64': {
|
|
68
|
+
url: 'https://github.com/yetone/magpie-releases/releases/download/v0.1.0/magpie-cli-linux-amd64',
|
|
69
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
70
|
+
} } }) : new Response(bytes));
|
|
71
|
+
const runTarget = vi.fn(async (_binary, args, ctx) => {
|
|
72
|
+
if (args[0] === '--version') return 'magpie 0.1.0';
|
|
73
|
+
expect(args[0]).toBe('restore');
|
|
74
|
+
expect(ctx.input.trim()).toBe(JSON.parse(payload).passphrase);
|
|
75
|
+
expect(JSON.parse(await fs.readFile(args[1], 'utf8')).format).toBe('magpie-backup');
|
|
76
|
+
return 'restored';
|
|
77
|
+
});
|
|
78
|
+
const destination = createMagpieSync({ ...target.options, fetchImpl, run: runTarget });
|
|
79
|
+
await expect(destination.import(payload)).resolves.toEqual({ ok: true });
|
|
80
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
81
|
+
await expect(destination.import(payload)).resolves.toEqual({ ok: true });
|
|
82
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
83
|
+
for (const item of [source, target]) {
|
|
84
|
+
expect((await fs.readdir(item.home)).filter((name) => name.startsWith('openchamber-magpie-'))).toEqual([]);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does not install on malformed payload and refuses partial restore without leaking CLI output', async () => {
|
|
89
|
+
const { home, options } = await machine();
|
|
90
|
+
const fetchImpl = vi.fn();
|
|
91
|
+
const run = vi.fn(async (_binary, args) => args[0] === '--version'
|
|
92
|
+
? 'magpie 0.1.0' : "Couldn't add the plugin private-fixture-value");
|
|
93
|
+
const sync = createMagpieSync({ ...options, fetchImpl, run });
|
|
94
|
+
await expect(sync.import(Buffer.from('{}'))).rejects.toThrow('Invalid Magpie sync payload');
|
|
95
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
96
|
+
await write(path.join(home, '.local', 'bin', 'magpie'), 'fixture-cli');
|
|
97
|
+
const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v1', passphrase: 'a'.repeat(64),
|
|
98
|
+
backup: Buffer.from('{"format":"magpie-backup"}').toString('base64') }));
|
|
99
|
+
await expect(sync.import(payload)).rejects.toThrow('Magpie restore partially completed');
|
|
100
|
+
expect((await fs.readdir(home)).filter((name) => name.startsWith('openchamber-magpie-'))).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('requires current source discovery and supports a Magpie-only plan', async () => {
|
|
104
|
+
const { home } = await machine();
|
|
105
|
+
const selections = { fileGroups: [], singleFiles: [], directories: [], agentsRoot: false, providerCredentials: false, magpie: true };
|
|
106
|
+
expect(() => planOpenCodeConfigSync(home, { selections })).toThrow('unavailable');
|
|
107
|
+
const plan = planOpenCodeConfigSync(home, { selections, magpie: { available: true, installed: true } });
|
|
108
|
+
expect(plan.magpie).toEqual({ bytes: 0 });
|
|
109
|
+
const received = [];
|
|
110
|
+
const executor = { prepare: vi.fn(), putTar: async (value) => received.push(value), finalize: vi.fn() };
|
|
111
|
+
await applyConfigSyncPlan({ plan, executor, syncRunId: 'test', sourceHomedir: home, collectMagpie: async () => Buffer.from('fixture') });
|
|
112
|
+
expect(received).toEqual([{ kind: 'magpie', payload: Buffer.from('fixture') }]);
|
|
113
|
+
const pull = planOpenCodeConfigSyncFromInventory({ files: [], directories: [], magpie: { available: true } }, { selections });
|
|
114
|
+
expect(pull.magpieAvailable).toBe(true);
|
|
115
|
+
expect(pull.magpie).toEqual({ bytes: 0 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('rejects a corrupt installer before executing or publishing it', async () => {
|
|
119
|
+
const { home, options } = await machine();
|
|
120
|
+
const run = vi.fn();
|
|
121
|
+
const fetchImpl = async (url) => url === 'https://usemagpie.ai/api/latest'
|
|
122
|
+
? Response.json({ assets: { 'magpie-cli-linux-amd64': {
|
|
123
|
+
url: 'https://github.com/yetone/magpie-releases/releases/download/v0.1.0/magpie-cli-linux-amd64',
|
|
124
|
+
sha256: '0'.repeat(64),
|
|
125
|
+
} } }) : new Response('corrupt');
|
|
126
|
+
const sync = createMagpieSync({ ...options, fetchImpl, run });
|
|
127
|
+
const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v1', passphrase: 'a'.repeat(64),
|
|
128
|
+
backup: Buffer.from('{"format":"magpie-backup"}').toString('base64') }));
|
|
129
|
+
await expect(sync.import(payload)).rejects.toThrow('checksum mismatch');
|
|
130
|
+
expect(run).not.toHaveBeenCalled();
|
|
131
|
+
await expect(fs.stat(path.join(home, '.config', 'openchamber', 'tools', 'magpie', 'magpie'))).rejects.toThrow();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it.skipIf(!process.env.OPENCHAMBER_MAGPIE_TEST_BINARY)('round-trips isolated configuration with the installed real CLI', async () => {
|
|
135
|
+
const source = await machine();
|
|
136
|
+
const target = await machine();
|
|
137
|
+
for (const item of [source, target]) {
|
|
138
|
+
item.options.env = { ...item.options.env, MAGPIE_BINARY: process.env.OPENCHAMBER_MAGPIE_TEST_BINARY,
|
|
139
|
+
MAGPIE_NO_ACCOUNTS: '1', PATH: '/usr/bin:/bin', XDG_CACHE_HOME: path.join(item.home, '.cache') };
|
|
140
|
+
await write(path.join(item.home, '.config', 'magpie', 'settings.json'), JSON.stringify({ noAccountDiscovery: true, noStats: true, noUsageStats: true, noAutoUpdate: true }));
|
|
141
|
+
}
|
|
142
|
+
await write(path.join(source.home, '.config', 'magpie', 'providers.json'), JSON.stringify({ providers: [
|
|
143
|
+
{ id: 'sync-fixture', name: 'Sync fixture', chat: 'https://provider.invalid/v1', key: 'fixture-not-a-real-key' },
|
|
144
|
+
] }));
|
|
145
|
+
const payload = await createMagpieSync(source.options).download();
|
|
146
|
+
await createMagpieSync(target.options).import(payload);
|
|
147
|
+
const restored = JSON.parse(await fs.readFile(path.join(target.home, '.config', 'magpie', 'providers.json'), 'utf8'));
|
|
148
|
+
expect(restored.providers).toContainEqual(expect.objectContaining({ id: 'sync-fixture', key: 'fixture-not-a-real-key' }));
|
|
149
|
+
}, 60_000);
|
|
150
|
+
});
|
|
@@ -60,7 +60,7 @@ export const walkAllowlistDirectory = (absPath, excludeNames, seenRealpaths) =>
|
|
|
60
60
|
};
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* Pure planner: source-home allowlist + optional `~/.agents` +
|
|
63
|
+
* Pure planner: source-home allowlist + optional `~/.agents` + provider credential metadata.
|
|
64
64
|
* Plan is computed on the source side; executors run it on the target side.
|
|
65
65
|
* `direction` is a first-class field (`push` today; `pull` reuses the same shape).
|
|
66
66
|
*
|
|
@@ -70,7 +70,9 @@ export const walkAllowlistDirectory = (absPath, excludeNames, seenRealpaths) =>
|
|
|
70
70
|
* syncRunId?: string,
|
|
71
71
|
* sourceTargetId?: string,
|
|
72
72
|
* targetId?: string,
|
|
73
|
-
*
|
|
73
|
+
* includeProviderCredentials?: boolean,
|
|
74
|
+
* providerCredentials?: { bytes: number } | null,
|
|
75
|
+
* magpie?: { available: boolean, installed: boolean },
|
|
74
76
|
* selections?: import('./selections.js').SyncSelections,
|
|
75
77
|
* }} [options]
|
|
76
78
|
* @returns {{
|
|
@@ -81,7 +83,7 @@ export const walkAllowlistDirectory = (absPath, excludeNames, seenRealpaths) =>
|
|
|
81
83
|
* files: { path: string, bytes: number }[],
|
|
82
84
|
* directories: { path: string, fileCount: number, bytes: number }[],
|
|
83
85
|
* agentsRoot: { fileCount: number, bytes: number } | null,
|
|
84
|
-
*
|
|
86
|
+
* providerCredentials: { bytes: number } | null,
|
|
85
87
|
* deletes: string[],
|
|
86
88
|
* totalBytes: number,
|
|
87
89
|
* selections?: import('./selections.js').SyncSelections,
|
|
@@ -93,15 +95,13 @@ export const planOpenCodeConfigSync = (sourceHomedir, options = {}) => {
|
|
|
93
95
|
if (!SYNC_DIRECTIONS.includes(direction)) {
|
|
94
96
|
throw new Error(`Unsupported sync direction: ${String(direction)}`);
|
|
95
97
|
}
|
|
96
|
-
// Credential
|
|
97
|
-
// Callers that have not authorized the target pass includeAuthFile: false so
|
|
98
|
-
// auth.json is skipped rather than failing the whole sync.
|
|
98
|
+
// Credential values never enter the filesystem inventory or preview plan.
|
|
99
99
|
const selections = options.selections
|
|
100
|
-
? normalizeSyncSelections(options.selections, {
|
|
100
|
+
? normalizeSyncSelections(options.selections, { includeProviderCredentials: options.selections.providerCredentials === true })
|
|
101
101
|
: null;
|
|
102
|
-
const
|
|
103
|
-
? selections.
|
|
104
|
-
: options.
|
|
102
|
+
const includeProviderCredentials = selections
|
|
103
|
+
? selections.providerCredentials === true
|
|
104
|
+
: options.includeProviderCredentials !== false;
|
|
105
105
|
|
|
106
106
|
const root = path.join(home, '.config', 'opencode');
|
|
107
107
|
/** @type {{ path: string, bytes: number }[]} */
|
|
@@ -191,22 +191,9 @@ export const planOpenCodeConfigSync = (sourceHomedir, options = {}) => {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
/** @type {{ bytes: number } | null} */
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
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
|
-
}
|
|
194
|
+
const providerCredentials = includeProviderCredentials && options.providerCredentials ? { bytes: options.providerCredentials.bytes } : null;
|
|
195
|
+
if (selections?.magpie && !options.magpie?.available) throw new Error('Magpie source configuration is unavailable');
|
|
196
|
+
if (providerCredentials) totalBytes += providerCredentials.bytes;
|
|
210
197
|
|
|
211
198
|
if (totalBytes > OPENCODE_CONFIG_SYNC_MAX_BYTES || totalFileCount > OPENCODE_CONFIG_SYNC_MAX_FILES) {
|
|
212
199
|
throw new Error('OpenCode config sync exceeds size limit (512MB)');
|
|
@@ -222,7 +209,9 @@ export const planOpenCodeConfigSync = (sourceHomedir, options = {}) => {
|
|
|
222
209
|
files,
|
|
223
210
|
directories,
|
|
224
211
|
agentsRoot,
|
|
225
|
-
|
|
212
|
+
providerCredentials,
|
|
213
|
+
magpieAvailable: options.magpie?.available === true,
|
|
214
|
+
magpie: options.selections?.magpie === true && options.magpie?.available === true ? { bytes: 0 } : null,
|
|
226
215
|
deletes,
|
|
227
216
|
totalBytes,
|
|
228
217
|
};
|
|
@@ -237,7 +226,7 @@ export const planOpenCodeConfigSync = (sourceHomedir, options = {}) => {
|
|
|
237
226
|
* syncRunId?: string,
|
|
238
227
|
* sourceTargetId?: string,
|
|
239
228
|
* targetId?: string,
|
|
240
|
-
*
|
|
229
|
+
* includeProviderCredentials?: boolean,
|
|
241
230
|
* selections?: import('./selections.js').SyncSelections,
|
|
242
231
|
* }} [options]
|
|
243
232
|
*/
|
|
@@ -247,21 +236,25 @@ export const planOpenCodeConfigSyncFromInventory = (inventory, options = {}) =>
|
|
|
247
236
|
throw new Error(`Unsupported sync direction: ${String(direction)}`);
|
|
248
237
|
}
|
|
249
238
|
const selections = options.selections
|
|
250
|
-
? normalizeSyncSelections(options.selections, {
|
|
239
|
+
? normalizeSyncSelections(options.selections, { includeProviderCredentials: options.selections.providerCredentials === true })
|
|
251
240
|
: null;
|
|
252
|
-
const
|
|
253
|
-
? selections.
|
|
254
|
-
: options.
|
|
241
|
+
const includeProviderCredentials = selections
|
|
242
|
+
? selections.providerCredentials === true
|
|
243
|
+
: options.includeProviderCredentials !== false;
|
|
255
244
|
|
|
256
245
|
const files = Array.isArray(inventory?.files) ? inventory.files.filter((entry) => entry?.path) : [];
|
|
246
|
+
if (selections?.magpie && !inventory?.magpie?.available) throw new Error('Magpie source configuration is unavailable');
|
|
247
|
+
if (includeProviderCredentials && !Object.hasOwn(inventory || {}, 'providerCredentials')) {
|
|
248
|
+
throw new Error('Provider credential inventory is missing');
|
|
249
|
+
}
|
|
257
250
|
const directories = Array.isArray(inventory?.directories)
|
|
258
251
|
? inventory.directories.filter((entry) => entry?.path)
|
|
259
252
|
: [];
|
|
260
253
|
const agentsRoot = inventory?.agentsRoot && typeof inventory.agentsRoot === 'object'
|
|
261
254
|
? inventory.agentsRoot
|
|
262
255
|
: null;
|
|
263
|
-
const
|
|
264
|
-
? inventory.
|
|
256
|
+
const providerCredentials = includeProviderCredentials && inventory?.providerCredentials && typeof inventory.providerCredentials === 'object'
|
|
257
|
+
? inventory.providerCredentials
|
|
265
258
|
: null;
|
|
266
259
|
|
|
267
260
|
/** @type {string[]} */
|
|
@@ -293,8 +286,8 @@ export const planOpenCodeConfigSyncFromInventory = (inventory, options = {}) =>
|
|
|
293
286
|
totalBytes += Number(agentsRoot.bytes) || 0;
|
|
294
287
|
totalFileCount += Number(agentsRoot.fileCount) || 0;
|
|
295
288
|
}
|
|
296
|
-
if (
|
|
297
|
-
totalBytes += Number(
|
|
289
|
+
if (providerCredentials) {
|
|
290
|
+
totalBytes += Number(providerCredentials.bytes) || 0;
|
|
298
291
|
}
|
|
299
292
|
if (totalBytes > OPENCODE_CONFIG_SYNC_MAX_BYTES || totalFileCount > OPENCODE_CONFIG_SYNC_MAX_FILES) {
|
|
300
293
|
throw new Error('OpenCode config sync exceeds size limit (512MB)');
|
|
@@ -310,7 +303,9 @@ export const planOpenCodeConfigSyncFromInventory = (inventory, options = {}) =>
|
|
|
310
303
|
files,
|
|
311
304
|
directories,
|
|
312
305
|
agentsRoot,
|
|
313
|
-
|
|
306
|
+
providerCredentials,
|
|
307
|
+
magpieAvailable: inventory?.magpie?.available === true,
|
|
308
|
+
magpie: options.selections?.magpie === true && inventory?.magpie?.available === true ? { bytes: 0 } : null,
|
|
314
309
|
deletes,
|
|
315
310
|
totalBytes,
|
|
316
311
|
};
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import fsp from 'node:fs/promises';
|
|
3
2
|
import os from 'node:os';
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
|
|
6
5
|
import {
|
|
7
6
|
OPENCODE_AGENTS_ROOT_PROBE_MARKER,
|
|
8
|
-
OPENCODE_AUTH_FILE_PROBE_MARKER,
|
|
9
7
|
OPENCODE_CONFIG_SYNC_ALLOWLIST,
|
|
10
8
|
OPENCODE_CONFIG_SYNC_MAX_BYTES,
|
|
11
9
|
OPENCODE_CONFIG_SYNC_MAX_FILES,
|
|
@@ -18,6 +16,8 @@ import {
|
|
|
18
16
|
import { walkAllowlistDirectory } from './plan.js';
|
|
19
17
|
import { sanitizeSyncRunIdForPath } from './scripts.js';
|
|
20
18
|
import { collectLocalTarBuffer } from './tar.js';
|
|
19
|
+
import { createCredentialSync } from './credentials.js';
|
|
20
|
+
import { createMagpieSync } from './magpie.js';
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* In-process receiver for HTTP config-sync (direct/relay hosts).
|
|
@@ -26,6 +26,8 @@ import { collectLocalTarBuffer } from './tar.js';
|
|
|
26
26
|
export const createConfigSyncReceiver = (deps = {}) => {
|
|
27
27
|
const {
|
|
28
28
|
homedir = () => os.homedir(),
|
|
29
|
+
credentials = createCredentialSync(),
|
|
30
|
+
magpie = createMagpieSync({ homedir }),
|
|
29
31
|
} = deps;
|
|
30
32
|
|
|
31
33
|
let inflight = null;
|
|
@@ -58,7 +60,7 @@ export const createConfigSyncReceiver = (deps = {}) => {
|
|
|
58
60
|
}
|
|
59
61
|
};
|
|
60
62
|
|
|
61
|
-
const buildInventory = () => {
|
|
63
|
+
const buildInventory = async ({ includeCredentials = false } = {}) => {
|
|
62
64
|
const home = homedir();
|
|
63
65
|
const root = path.join(home, '.config', 'opencode');
|
|
64
66
|
const files = [];
|
|
@@ -118,33 +120,22 @@ export const createConfigSyncReceiver = (deps = {}) => {
|
|
|
118
120
|
// missing
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
const authAbs = path.join(home, '.local', 'share', 'opencode', 'auth.json');
|
|
125
|
-
const st = fs.statSync(authAbs);
|
|
126
|
-
if (st.isFile()) {
|
|
127
|
-
remoteAuthFileExists = true;
|
|
128
|
-
authFile = { bytes: st.size };
|
|
129
|
-
}
|
|
130
|
-
} catch {
|
|
131
|
-
// missing
|
|
132
|
-
}
|
|
123
|
+
const providerCredentials = includeCredentials ? await credentials.inventory() : null;
|
|
124
|
+
const remoteProviderCredentialsExists = Boolean(providerCredentials);
|
|
133
125
|
|
|
134
126
|
return {
|
|
135
|
-
inventory: { files, directories, agentsRoot,
|
|
127
|
+
inventory: { files, directories, agentsRoot, providerCredentials, magpie: await magpie.inventory() },
|
|
136
128
|
remoteExisting,
|
|
137
129
|
remoteAgentsRootExists,
|
|
138
|
-
|
|
130
|
+
remoteProviderCredentialsExists,
|
|
139
131
|
markers: {
|
|
140
132
|
agentsRoot: OPENCODE_AGENTS_ROOT_PROBE_MARKER,
|
|
141
|
-
authFile: OPENCODE_AUTH_FILE_PROBE_MARKER,
|
|
142
133
|
},
|
|
143
134
|
};
|
|
144
135
|
};
|
|
145
136
|
|
|
146
137
|
const probe = async (plan) => {
|
|
147
|
-
const snapshot = buildInventory();
|
|
138
|
+
const snapshot = await buildInventory({ includeCredentials: plan?.selections?.providerCredentials === true || Boolean(plan?.providerCredentials) });
|
|
148
139
|
const existing = new Set(snapshot.remoteExisting);
|
|
149
140
|
const planPaths = [
|
|
150
141
|
...(Array.isArray(plan?.files) ? plan.files.map((entry) => entry.path) : []),
|
|
@@ -199,9 +190,12 @@ export const createConfigSyncReceiver = (deps = {}) => {
|
|
|
199
190
|
await extractTarGzBuffer(buffer, path.join(home, '.config', 'opencode'));
|
|
200
191
|
} else if (kind === 'agents') {
|
|
201
192
|
await extractTarGzBuffer(buffer, home);
|
|
202
|
-
} else if (kind === '
|
|
203
|
-
|
|
204
|
-
await
|
|
193
|
+
} else if (kind === 'credentials') {
|
|
194
|
+
if (!flight.plan?.providerCredentials) throw new Error('Provider credentials were not selected');
|
|
195
|
+
await credentials.import(buffer);
|
|
196
|
+
} else if (kind === 'magpie') {
|
|
197
|
+
if (!flight.plan?.magpie) throw new Error('Magpie was not selected');
|
|
198
|
+
await magpie.import(buffer);
|
|
205
199
|
} else {
|
|
206
200
|
const error = new Error(`Unsupported put kind: ${String(kind)}`);
|
|
207
201
|
error.code = 'sync_invalid_kind';
|
|
@@ -220,7 +214,7 @@ export const createConfigSyncReceiver = (deps = {}) => {
|
|
|
220
214
|
const download = async ({ kind }) => {
|
|
221
215
|
const home = homedir();
|
|
222
216
|
if (kind === 'config') {
|
|
223
|
-
const snapshot = buildInventory();
|
|
217
|
+
const snapshot = await buildInventory();
|
|
224
218
|
const entries = [
|
|
225
219
|
...snapshot.inventory.files.map((entry) => entry.path),
|
|
226
220
|
...snapshot.inventory.directories.map((entry) => entry.path),
|
|
@@ -235,9 +229,10 @@ export const createConfigSyncReceiver = (deps = {}) => {
|
|
|
235
229
|
if (kind === 'agents') {
|
|
236
230
|
return collectTarGz(home, ['.agents']);
|
|
237
231
|
}
|
|
238
|
-
if (kind === '
|
|
239
|
-
return
|
|
232
|
+
if (kind === 'credentials') {
|
|
233
|
+
return credentials.download();
|
|
240
234
|
}
|
|
235
|
+
if (kind === 'magpie') return magpie.download();
|
|
241
236
|
const error = new Error(`Unsupported download kind: ${String(kind)}`);
|
|
242
237
|
error.code = 'sync_invalid_kind';
|
|
243
238
|
throw error;
|
|
@@ -245,6 +240,8 @@ export const createConfigSyncReceiver = (deps = {}) => {
|
|
|
245
240
|
|
|
246
241
|
const finalize = async ({ syncRunId }) => {
|
|
247
242
|
const flight = requireInflight(syncRunId);
|
|
243
|
+
if (flight.plan?.providerCredentials && !flight.kinds.has('credentials')) throw new Error('Provider credentials transfer incomplete');
|
|
244
|
+
if (flight.plan?.magpie && !flight.kinds.has('magpie')) throw new Error('Magpie transfer incomplete');
|
|
248
245
|
await finalizeLocalSyncDestination(homedir(), { syncRunId: flight.syncRunId });
|
|
249
246
|
const receipt = {
|
|
250
247
|
ok: true,
|
|
@@ -274,5 +271,9 @@ export const createConfigSyncReceiver = (deps = {}) => {
|
|
|
274
271
|
abort,
|
|
275
272
|
getInflight: () => inflight,
|
|
276
273
|
buildInventory,
|
|
274
|
+
credentialInventory: () => credentials.inventory(),
|
|
275
|
+
magpieInventory: () => magpie.inventory(),
|
|
276
|
+
importMagpie: async (stream) => magpie.import(await readStreamToBuffer(stream, { maxBytes: OPENCODE_CONFIG_SYNC_MAX_BYTES })),
|
|
277
|
+
importCredentials: async (stream) => credentials.import(await readStreamToBuffer(stream, { maxBytes: OPENCODE_CONFIG_SYNC_MAX_BYTES })),
|
|
277
278
|
};
|
|
278
279
|
};
|