@openchambery/web 2.1.7 → 2.1.8-beta.3

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 (91) hide show
  1. package/dist/assets/{AgentAvatar-CAR4F5Sn.js → AgentAvatar-DTgZob6V.js} +1 -1
  2. package/dist/assets/{AssistantView-DgjRT4M4.js → AssistantView-CzNcYiVL.js} +3 -3
  3. package/dist/assets/{DiagramView-Dd_3w4Hk.js → DiagramView-Cmdi3PAV.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-CCVddfP5.js → FadeInOnReveal-Dcu_ZfKa.js} +1 -1
  5. package/dist/assets/{Icon-DDB7S6Vz.js → Icon-BKVaYkO0.js} +1 -1
  6. package/dist/assets/{ImageLoadingPlaceholder-C6fDEcU1.js → ImageLoadingPlaceholder-CICdqe37.js} +1 -1
  7. package/dist/assets/{LoadingImage-ByBH-5WW.js → LoadingImage-DC63uLZK.js} +1 -1
  8. package/dist/assets/MarkdownRenderer-CIapvMEi.js +2 -0
  9. package/dist/assets/{MarkdownRendererImpl-BXgUthom.js → MarkdownRendererImpl-CuovZBls.js} +1 -1
  10. package/dist/assets/{MarkstreamRendererImpl-5W9P9U-w.js → MarkstreamRendererImpl-DtQlVw4f.js} +1 -1
  11. package/dist/assets/{MobileDetailNavigation-Bk6C9i9l.js → MobileDetailNavigation-BcQ1gCG1.js} +1 -1
  12. package/dist/assets/{MobileShareBridge-B5nF-gT6.js → MobileShareBridge-CZbknYvz.js} +1 -1
  13. package/dist/assets/MobileTabPageHeader-DjbyneLy.js +2 -0
  14. package/dist/assets/{MultiRunWindow-CpuFaAyG.js → MultiRunWindow-CsDXEsR_.js} +1 -1
  15. package/dist/assets/{OnboardingScreen-DINUboLT.js → OnboardingScreen-B8R06QbP.js} +1 -1
  16. package/dist/assets/{OverlayScrollbar-BkxmoyYl.js → OverlayScrollbar-BrYVChrM.js} +1 -1
  17. package/dist/assets/{SettingsView--C3eizlJ.js → SettingsView-bWUvVuP2.js} +3 -2
  18. package/dist/assets/{SettingsWindow-DYUvg92F.js → SettingsWindow-Cs1Z7BAg.js} +1 -1
  19. package/dist/assets/{TerminalView-BqAariXf.js → TerminalView-Cfa5bIj1.js} +1 -1
  20. package/dist/assets/{ToolOutputDialog-D6lPUZrN.js → ToolOutputDialog-ChVqyMGX.js} +1 -1
  21. package/dist/assets/{appThemeRegistry-wWazoyp8.js → appThemeRegistry-CuS2weCQ.js} +1 -1
  22. package/dist/assets/{button-DymteyMv.js → button-NnBwy1et.js} +1 -1
  23. package/dist/assets/{collapsible-Dzp5rO0Q.js → collapsible-Divs2HzS.js} +1 -1
  24. package/dist/assets/{diagram-CQs9px6y.js → diagram-Dw2fVTEr.js} +1 -1
  25. package/dist/assets/{dialog-DfUeXqdE.js → dialog-thtcgEoi.js} +1 -1
  26. package/dist/assets/{dropdown-menu.styles-B6YMNOZz.js → dropdown-menu.styles-DG86fojG.js} +1 -1
  27. package/dist/assets/{es-2YvsazK2.js → es-CyvQWK39.js} +1 -1
  28. package/dist/assets/{fileMentionTouchSelection-CEIhM6Ov.js → fileMentionTouchSelection-D9YYbFDF.js} +1 -1
  29. package/dist/assets/{fr-Cy0M48Gw.js → fr-BgKUUIer.js} +1 -1
  30. package/dist/assets/{gitApi-BP68uLBQ.js → gitApi-q2aVvcM1.js} +1 -1
  31. package/dist/assets/{input-BvpZiRHI.js → input-BFFtmAyl.js} +1 -1
  32. package/dist/assets/{insertionBoundaries-BywXllQ9.js → insertionBoundaries-B2M9QiEF.js} +1 -1
  33. package/dist/assets/{ja-1zLWWJ2R.js → ja-vAsqZ3i2.js} +1 -1
  34. package/dist/assets/{ko-BLk_5Dhc.js → ko-CjgHIGr_.js} +1 -1
  35. package/dist/assets/{main-DdyFxokk.js → main-4h6m9pZ0.js} +4 -4
  36. package/dist/assets/{main-DSY3-ZQZ.js → main-B3grbXe8.js} +2 -2
  37. package/dist/assets/{markdownHeightCache-MW1NYOaG.js → markdownHeightCache-qy-s2Sd8.js} +1 -1
  38. package/dist/assets/{mcpQueries-BaQ2_bdm.js → mcpQueries-BD28dy9B.js} +1 -1
  39. package/dist/assets/{miniChat-B43ASec3.js → miniChat-ChSEmnwp.js} +2 -2
  40. package/dist/assets/{mobile-DloCio4f.js → mobile-CrKlS4SH.js} +2 -2
  41. package/dist/assets/{multirun-Blywdd6A.js → multirun-Dhq8Cx1W.js} +1 -1
  42. package/dist/assets/{pl-BE8MC0XQ.js → pl-CNRmHctC.js} +1 -1
  43. package/dist/assets/{projectMeta-BwPtPeMh.js → projectMeta-2J4Ull4h.js} +1 -1
  44. package/dist/assets/{pt-BR-ubhNymyH.js → pt-BR-B4NRHMT2.js} +1 -1
  45. package/dist/assets/{renderElectronMiniChatApp-DCRctqrx.js → renderElectronMiniChatApp-DsPg8kQs.js} +2 -2
  46. package/dist/assets/{renderMobileApp-DuFdSBXi.js → renderMobileApp-Zl-L5gSE.js} +4 -4
  47. package/dist/assets/{runtimeConfig-CqpbN2lM.js → runtimeConfig-GAVzvQGc.js} +1 -1
  48. package/dist/assets/{runtimeEndpointReset-bWBhgTs_.js → runtimeEndpointReset-DdNzM2Ae.js} +2 -2
  49. package/dist/assets/{scheduledTaskNavigation-BnKGGUlX.js → scheduledTaskNavigation-DqwoOss-.js} +1 -1
  50. package/dist/assets/{sessionChangeSummary-BlR9MyHu.js → sessionChangeSummary-CFlwf4sL.js} +1 -1
  51. package/dist/assets/{sessionLookup-6gcNEZnB.js → sessionLookup-BPCTTSdg.js} +1 -1
  52. package/dist/assets/{sortable-tabs-strip-CWR8KhJB.js → sortable-tabs-strip-BLrK5Ln9.js} +1 -1
  53. package/dist/assets/speechSettingsQueries-D4rx8jJk.js +4 -0
  54. package/dist/assets/{store-n-H5RvRn.js → store-_hKtW1V3.js} +2 -2
  55. package/dist/assets/{textarea-ClPlugKE.js → textarea-CN-vZS5F.js} +1 -1
  56. package/dist/assets/{tooltip-D-1Pgmq1.js → tooltip-uzT2SsYJ.js} +1 -1
  57. package/dist/assets/{uk-BNyyigDo.js → uk-CXna2F44.js} +1 -1
  58. package/dist/assets/{useAppFontEffects-8UJ5f0dG.js → useAppFontEffects-BlstuBSz.js} +7 -7
  59. package/dist/assets/{useDesktopWindowControlsLayout-B_hNd3pD.js → useDesktopWindowControlsLayout-BhS1NmBt.js} +1 -1
  60. package/dist/assets/{useEffectiveDirectory-DISvoVLe.js → useEffectiveDirectory-CiR2SqiL.js} +1 -1
  61. package/dist/assets/{useMobileNavigationStore-BE_8Q8WJ.js → useMobileNavigationStore-BRqifj9r.js} +7 -7
  62. package/dist/assets/{useWorkerHighlightedLines-BFygybD8.js → useWorkerHighlightedLines-Jea_E56b.js} +1 -1
  63. package/dist/assets/{zh-CN-CNWzMPgL.js → zh-CN-C0S7hK8T.js} +1 -1
  64. package/dist/assets/{zh-TW-DrZ9S1yn.js → zh-TW-CVG-Rqd-.js} +1 -1
  65. package/dist/index.html +2 -2
  66. package/dist/mini-chat.html +2 -2
  67. package/dist/mobile.html +2 -2
  68. package/package.json +1 -1
  69. package/server/lib/config-sync/DOCUMENTATION.md +33 -11
  70. package/server/lib/config-sync/config-sync.test.js +11 -11
  71. package/server/lib/config-sync/constants.js +0 -3
  72. package/server/lib/config-sync/contract.js +8 -8
  73. package/server/lib/config-sync/credentials.js +101 -0
  74. package/server/lib/config-sync/credentials.test.js +98 -0
  75. package/server/lib/config-sync/engine.js +17 -11
  76. package/server/lib/config-sync/index.js +0 -3
  77. package/server/lib/config-sync/local-backup.js +0 -13
  78. package/server/lib/config-sync/magpie.js +207 -0
  79. package/server/lib/config-sync/magpie.test.js +165 -0
  80. package/server/lib/config-sync/plan.js +32 -37
  81. package/server/lib/config-sync/receiver.js +28 -25
  82. package/server/lib/config-sync/routes.js +23 -2
  83. package/server/lib/config-sync/routes.test.js +64 -5
  84. package/server/lib/config-sync/scripts.js +2 -30
  85. package/server/lib/config-sync/selections.js +14 -10
  86. package/server/lib/opencode/DOCUMENTATION.md +2 -1
  87. package/server/lib/opencode/auth.d.ts +1 -0
  88. package/server/lib/opencode/auth.js +1 -1
  89. package/dist/assets/MarkdownRenderer-YbG78e0Y.js +0 -2
  90. package/dist/assets/MobileTabPageHeader-D7r6pKco.js +0 -2
  91. package/dist/assets/speechSettingsQueries-Bp5LgcR7.js +0 -4
@@ -0,0 +1,207 @@
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/i.test(output)) throw new Error('Magpie restore partially completed: plugin installation failed. Check the target Magpie plugins.');
198
+ if (/Library:.*couldn't/i.test(output)) throw new Error('Magpie restore partially completed: library application failed. Check the target Magpie library.');
199
+ if (/Needs a key/i.test(output)) throw new Error('Magpie restore partially completed: some providers still require API keys.');
200
+ const magpieWarnings = [];
201
+ if (/not signed in on this computer/i.test(output)) magpieWarnings.push('sign_in_required');
202
+ if (/Left as they are/i.test(output)) magpieWarnings.push('agent_settings_skipped');
203
+ return { ok: true, ...(magpieWarnings.length ? { magpieWarnings } : {}) };
204
+ });
205
+ }),
206
+ };
207
+ }
@@ -0,0 +1,165 @@
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('reports excluded sign-ins as a warning after a successful native restore', async () => {
104
+ const { home, options } = await machine();
105
+ await write(path.join(home, '.local', 'bin', 'magpie'), 'fixture-cli');
106
+ const run = async (_binary, args) => args[0] === '--version' ? 'magpie 0.1.1319'
107
+ : "restored providers: 0 added, 8 replaced\nThe backup's settings use these, not signed in on this computer (a backup carries settings, not sign-ins; to use one here: magpie accounts add <id>): private-fixture-account\n";
108
+ const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v1', passphrase: 'a'.repeat(64),
109
+ backup: Buffer.from('{"format":"magpie-backup"}').toString('base64') }));
110
+ await expect(createMagpieSync({ ...options, run }).import(payload)).resolves.toEqual({
111
+ ok: true, magpieWarnings: ['sign_in_required'],
112
+ });
113
+ });
114
+
115
+ it('requires current source discovery and supports a Magpie-only plan', async () => {
116
+ const { home } = await machine();
117
+ const selections = { fileGroups: [], singleFiles: [], directories: [], agentsRoot: false, providerCredentials: false, magpie: true };
118
+ expect(() => planOpenCodeConfigSync(home, { selections })).toThrow('unavailable');
119
+ const plan = planOpenCodeConfigSync(home, { selections, magpie: { available: true, installed: true } });
120
+ expect(plan.magpie).toEqual({ bytes: 0 });
121
+ const received = [];
122
+ const executor = { prepare: vi.fn(), putTar: async (value) => received.push(value), finalize: vi.fn(async () => ({ magpieWarnings: ['sign_in_required'] })) };
123
+ const result = await applyConfigSyncPlan({ plan, executor, syncRunId: 'test', sourceHomedir: home, collectMagpie: async () => Buffer.from('fixture') });
124
+ expect(result.magpieWarnings).toEqual(['sign_in_required']);
125
+ expect(received).toEqual([{ kind: 'magpie', payload: Buffer.from('fixture') }]);
126
+ const pull = planOpenCodeConfigSyncFromInventory({ files: [], directories: [], magpie: { available: true } }, { selections });
127
+ expect(pull.magpieAvailable).toBe(true);
128
+ expect(pull.magpie).toEqual({ bytes: 0 });
129
+ });
130
+
131
+ it('rejects a corrupt installer before executing or publishing it', async () => {
132
+ const { home, options } = await machine();
133
+ const run = vi.fn();
134
+ const fetchImpl = async (url) => url === 'https://usemagpie.ai/api/latest'
135
+ ? Response.json({ assets: { 'magpie-cli-linux-amd64': {
136
+ url: 'https://github.com/yetone/magpie-releases/releases/download/v0.1.0/magpie-cli-linux-amd64',
137
+ sha256: '0'.repeat(64),
138
+ } } }) : new Response('corrupt');
139
+ const sync = createMagpieSync({ ...options, fetchImpl, run });
140
+ const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v1', passphrase: 'a'.repeat(64),
141
+ backup: Buffer.from('{"format":"magpie-backup"}').toString('base64') }));
142
+ await expect(sync.import(payload)).rejects.toThrow('checksum mismatch');
143
+ expect(run).not.toHaveBeenCalled();
144
+ await expect(fs.stat(path.join(home, '.config', 'openchamber', 'tools', 'magpie', 'magpie'))).rejects.toThrow();
145
+ });
146
+
147
+ it.skipIf(!process.env.OPENCHAMBER_MAGPIE_TEST_BINARY)('round-trips isolated configuration with the installed real CLI', async () => {
148
+ const source = await machine();
149
+ const target = await machine();
150
+ for (const item of [source, target]) {
151
+ item.options.env = { ...item.options.env, MAGPIE_BINARY: process.env.OPENCHAMBER_MAGPIE_TEST_BINARY,
152
+ MAGPIE_NO_ACCOUNTS: '1', PATH: '/usr/bin:/bin', XDG_CACHE_HOME: path.join(item.home, '.cache') };
153
+ await write(path.join(item.home, '.config', 'magpie', 'settings.json'), JSON.stringify({ noAccountDiscovery: true, noStats: true, noUsageStats: true, noAutoUpdate: true }));
154
+ }
155
+ await write(path.join(source.home, '.config', 'magpie', 'providers.json'), JSON.stringify({ providers: [
156
+ { id: 'sync-fixture', name: 'Sync fixture', chat: 'https://provider.invalid/v1', key: 'fixture-not-a-real-key' },
157
+ { id: 'fixture-subscription' },
158
+ ] }));
159
+ const payload = await createMagpieSync(source.options).download();
160
+ const receipt = await createMagpieSync(target.options).import(payload);
161
+ expect(receipt).toEqual({ ok: true, magpieWarnings: ['sign_in_required'] });
162
+ const restored = JSON.parse(await fs.readFile(path.join(target.home, '.config', 'magpie', 'providers.json'), 'utf8'));
163
+ expect(restored.providers).toContainEqual(expect.objectContaining({ id: 'sync-fixture', key: 'fixture-not-a-real-key' }));
164
+ }, 60_000);
165
+ });
@@ -60,7 +60,7 @@ export const walkAllowlistDirectory = (absPath, excludeNames, seenRealpaths) =>
60
60
  };
61
61
 
62
62
  /**
63
- * Pure planner: source-home allowlist + optional `~/.agents` + optional provider auth.json.
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
- * includeAuthFile?: boolean,
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
- * authFile: { bytes: number } | null,
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 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.
98
+ // Credential values never enter the filesystem inventory or preview plan.
99
99
  const selections = options.selections
100
- ? normalizeSyncSelections(options.selections, { includeAuthFile: options.selections.authFile === true })
100
+ ? normalizeSyncSelections(options.selections, { includeProviderCredentials: options.selections.providerCredentials === true })
101
101
  : null;
102
- const includeAuthFile = selections
103
- ? selections.authFile === true
104
- : options.includeAuthFile !== false;
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
- 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
- }
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
- authFile,
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
- * includeAuthFile?: boolean,
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, { includeAuthFile: options.selections.authFile === true })
239
+ ? normalizeSyncSelections(options.selections, { includeProviderCredentials: options.selections.providerCredentials === true })
251
240
  : null;
252
- const includeAuthFile = selections
253
- ? selections.authFile === true
254
- : options.includeAuthFile !== false;
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 authFile = includeAuthFile && inventory?.authFile && typeof inventory.authFile === 'object'
264
- ? inventory.authFile
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 (authFile) {
297
- totalBytes += Number(authFile.bytes) || 0;
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
- authFile,
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
- let authFile = null;
122
- let remoteAuthFileExists = false;
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, authFile },
127
+ inventory: { files, directories, agentsRoot, providerCredentials, magpie: await magpie.inventory() },
136
128
  remoteExisting,
137
129
  remoteAgentsRootExists,
138
- remoteAuthFileExists,
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,13 @@ 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 === 'auth') {
203
- await fsp.mkdir(path.join(home, '.local', 'share', 'opencode'), { recursive: true });
204
- await extractTarGzBuffer(buffer, path.join(home, '.local', 'share', 'opencode'));
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
+ const result = await magpie.import(buffer);
199
+ flight.magpieWarnings = result.magpieWarnings;
205
200
  } else {
206
201
  const error = new Error(`Unsupported put kind: ${String(kind)}`);
207
202
  error.code = 'sync_invalid_kind';
@@ -220,7 +215,7 @@ export const createConfigSyncReceiver = (deps = {}) => {
220
215
  const download = async ({ kind }) => {
221
216
  const home = homedir();
222
217
  if (kind === 'config') {
223
- const snapshot = buildInventory();
218
+ const snapshot = await buildInventory();
224
219
  const entries = [
225
220
  ...snapshot.inventory.files.map((entry) => entry.path),
226
221
  ...snapshot.inventory.directories.map((entry) => entry.path),
@@ -235,9 +230,10 @@ export const createConfigSyncReceiver = (deps = {}) => {
235
230
  if (kind === 'agents') {
236
231
  return collectTarGz(home, ['.agents']);
237
232
  }
238
- if (kind === 'auth') {
239
- return collectTarGz(path.join(home, '.local', 'share', 'opencode'), ['auth.json']);
233
+ if (kind === 'credentials') {
234
+ return credentials.download();
240
235
  }
236
+ if (kind === 'magpie') return magpie.download();
241
237
  const error = new Error(`Unsupported download kind: ${String(kind)}`);
242
238
  error.code = 'sync_invalid_kind';
243
239
  throw error;
@@ -245,12 +241,15 @@ export const createConfigSyncReceiver = (deps = {}) => {
245
241
 
246
242
  const finalize = async ({ syncRunId }) => {
247
243
  const flight = requireInflight(syncRunId);
244
+ if (flight.plan?.providerCredentials && !flight.kinds.has('credentials')) throw new Error('Provider credentials transfer incomplete');
245
+ if (flight.plan?.magpie && !flight.kinds.has('magpie')) throw new Error('Magpie transfer incomplete');
248
246
  await finalizeLocalSyncDestination(homedir(), { syncRunId: flight.syncRunId });
249
247
  const receipt = {
250
248
  ok: true,
251
249
  syncRunId: flight.syncRunId,
252
250
  kinds: [...flight.kinds],
253
251
  endedAt: new Date().toISOString(),
252
+ ...(flight.magpieWarnings?.length ? { magpieWarnings: flight.magpieWarnings } : {}),
254
253
  };
255
254
  clearInflight(syncRunId);
256
255
  return receipt;
@@ -274,5 +273,9 @@ export const createConfigSyncReceiver = (deps = {}) => {
274
273
  abort,
275
274
  getInflight: () => inflight,
276
275
  buildInventory,
276
+ credentialInventory: () => credentials.inventory(),
277
+ magpieInventory: () => magpie.inventory(),
278
+ importMagpie: async (stream) => magpie.import(await readStreamToBuffer(stream, { maxBytes: OPENCODE_CONFIG_SYNC_MAX_BYTES })),
279
+ importCredentials: async (stream) => credentials.import(await readStreamToBuffer(stream, { maxBytes: OPENCODE_CONFIG_SYNC_MAX_BYTES })),
277
280
  };
278
281
  };