@openchambery/web 2.1.7-beta.9 → 2.1.8-beta.10
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-mchFM3n-.js} +1 -1
- package/dist/assets/AssistantView-pLVk20s1.js +3 -0
- package/dist/assets/{DiagramView-Bw2sCV3Y.js → DiagramView-DmaKTHol.js} +1 -1
- package/dist/assets/{FadeInOnReveal-kNFcAb-K.js → FadeInOnReveal-BXDM9joC.js} +1 -1
- package/dist/assets/{Icon-DxBvAKXH.js → Icon-BGp_ize2.js} +1 -1
- package/dist/assets/{ImageLoadingPlaceholder-DQvRSYkt.js → ImageLoadingPlaceholder-DmUxjyxq.js} +1 -1
- package/dist/assets/{LoadingImage-CaXJqavb.js → LoadingImage-DGjlKW_O.js} +1 -1
- package/dist/assets/MarkdownRenderer-VYYHEQHc.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-EACzZK2L.js → MarkdownRendererImpl-BQPOlcOs.js} +2 -2
- package/dist/assets/{MarkstreamRendererImpl-yohfk508.js → MarkstreamRendererImpl-B9DtJ2K2.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-ByVEkbBy.js → MobileDetailNavigation-9MnryETM.js} +1 -1
- package/dist/assets/{MobileShareBridge-DddCiczO.js → MobileShareBridge-e3WTrRks.js} +2 -2
- package/dist/assets/MobileTabPageHeader-Fh7QCZc7.js +2 -0
- package/dist/assets/{MultiRunWindow-CQWEGqds.js → MultiRunWindow-sfoabcvx.js} +1 -1
- package/dist/assets/{OnboardingScreen-BGZp5N1h.js → OnboardingScreen-DmJgU0Jc.js} +1 -1
- package/dist/assets/{OverlayScrollbar-OftHd9vR.js → OverlayScrollbar-BkJCkZp3.js} +1 -1
- package/dist/assets/{SettingsView-CDkq_RGv.js → SettingsView-DbNglFY1.js} +10 -9
- package/dist/assets/{SettingsWindow-CQhiLvNH.js → SettingsWindow-BGzCAKgh.js} +1 -1
- package/dist/assets/{TerminalView-CEiDqWoI.js → TerminalView-oT_BTx_W.js} +1 -1
- package/dist/assets/{ToolOutputDialog-jXsCdUsx.js → ToolOutputDialog-D85_cJ2B.js} +1 -1
- package/dist/assets/{appThemeRegistry-C90Vniux.js → appThemeRegistry-CGjHffdt.js} +1 -1
- package/dist/assets/{button-DfHNBl0U.js → button-BKjGCkH3.js} +1 -1
- package/dist/assets/{collapsible-BJ3u2c1m.js → collapsible-03FGLsF4.js} +1 -1
- package/dist/assets/{desktopHosts-BKeEWmL5.js → desktopHosts-BZ7iYE-l.js} +1 -1
- package/dist/assets/{desktopRelayRestore-FWm2fXr6.js → desktopRelayRestore-S9wEiY2U.js} +1 -1
- package/dist/assets/{diagram-KOjGEiKO.js → diagram-D4QHcaSQ.js} +1 -1
- package/dist/assets/{dialog-DBNBD_KB.js → dialog-nm_cQEUZ.js} +1 -1
- package/dist/assets/dropdown-menu.styles-WkR8IcF2.js +1 -0
- package/dist/assets/es-C9PRwAQm.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-zOe2SiY_.js → fileMentionTouchSelection-DujPmFPX.js} +1 -1
- package/dist/assets/{fileQueries-Dhgypzyl.js → fileQueries-DZR-mK8h.js} +1 -1
- package/dist/assets/fr-2tfGTcme.js +13 -0
- package/dist/assets/{gitApi-pv3A0rDM.js → gitApi-CrqGdlbS.js} +1 -1
- package/dist/assets/{image-loading-DLh8WsH4.js → image-loading-CsxDGgek.js} +1 -1
- package/dist/assets/{input-CQ5f8Zqb.js → input-Buww8enN.js} +1 -1
- package/dist/assets/insertionBoundaries-o5RDEXaZ.js +1 -0
- package/dist/assets/ja-D7NHmhJr.js +15 -0
- package/dist/assets/ko-DeGmXmv6.js +15 -0
- package/dist/assets/main-6vgvFImW.js +169 -0
- package/dist/assets/main-Zix8q1Qb.js +2 -0
- package/dist/assets/{markdownHeightCache-BJ7s_IuQ.js → markdownHeightCache-B6wZ-UwD.js} +1 -1
- package/dist/assets/{mcpQueries-BlrZ9jet.js → mcpQueries-BVPwR92u.js} +1 -1
- package/dist/assets/miniChat-BYZJ4yQd.js +2 -0
- package/dist/assets/mobile-CvQVwDyf.js +2 -0
- package/dist/assets/{multirun-CZOOUr7O.js → multirun-BoFWk8S5.js} +1 -1
- package/dist/assets/{persistence-DUPZl7Oy.js → persistence-B4Ni3Gmv.js} +1 -1
- package/dist/assets/{pl-BE8MC0XQ.js → pl-CyWLY1AT.js} +3 -3
- package/dist/assets/{platform-z2erC6Bs.js → platform-BQPsr8Zl.js} +2 -2
- package/dist/assets/{projectMeta-DiTRU5Dp.js → projectMeta-B073H1YC.js} +1 -1
- package/dist/assets/pt-BR-D9VVxvts.js +15 -0
- package/dist/assets/{renderElectronMiniChatApp-DfB14cCR.js → renderElectronMiniChatApp-Bu1k2_XW.js} +2 -2
- package/dist/assets/renderMobileApp-wEjrkNAR.js +28 -0
- package/dist/assets/{runtimeConfig-DHFwtNBi.js → runtimeConfig-DKN9Jyz-.js} +1 -1
- package/dist/assets/runtimeEndpointReset-Cqf421q-.js +149 -0
- package/dist/assets/scheduledTaskNavigation-C-HGgCQA.js +1 -0
- package/dist/assets/sessionChangeSummary-COHW-iNe.js +1 -0
- package/dist/assets/{sessionLookup-BSK3URnU.js → sessionLookup-ByvikQSd.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-D655zJ3T.js → sortable-tabs-strip-cHZM0f0Z.js} +1 -1
- package/dist/assets/speechSettingsQueries-BJ7hAvj0.js +4 -0
- package/dist/assets/src-OjoeiMbd.css +2 -0
- package/dist/assets/store-Ds-7Em7C.js +15 -0
- package/dist/assets/{terminalApi-BnjU_lmF.js → terminalApi-kRMttlcP.js} +1 -1
- package/dist/assets/{textarea-HlFXg84f.js → textarea-BZHD_GND.js} +1 -1
- package/dist/assets/{tooltip-BQgoGUWx.js → tooltip-DCeWvAqN.js} +1 -1
- package/dist/assets/uk-Df44svOl.js +15 -0
- package/dist/assets/{url-DAyfGWPH.js → url-Bd61gNgl.js} +1 -1
- package/dist/assets/{useAppFontEffects-BQbKLVsD.js → useAppFontEffects-BqrJlSC2.js} +51 -53
- package/dist/assets/{useAssistantUIStore-Dt0l5AKe.js → useAssistantUIStore-Dlvb7P0q.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-BIn2hI3V.js → useDesktopWindowControlsLayout-DgB-LNHC.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BycgG8Ov.js → useEffectiveDirectory-axifbbuL.js} +1 -1
- package/dist/assets/{useFontPreferences-B4Cgu3bS.js → useFontPreferences-lAw0Cbza.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DnmY3KH3.js +417 -0
- package/dist/assets/useUIStore-CDMI_Yzu.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-DmI50_3w.js → useWorkerHighlightedLines-D2YpPANs.js} +1 -1
- package/dist/assets/vendor-codemirror-autocomplete-7c2L_4dM.js +12 -0
- package/dist/assets/vendor-codemirror-commands-B8KrBQ9B.js +1 -0
- package/dist/assets/vendor-codemirror-lang-angular-DXOgWEVj.js +23 -0
- package/dist/assets/{vendor-codemirror-lang-cpp-C3UKgJaQ.js → vendor-codemirror-lang-cpp-Dh8C3xqP.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-go-Cv5NYdGm.js → vendor-codemirror-lang-go-BXAJ1_dh.js} +5 -5
- package/dist/assets/{vendor-codemirror-lang-java-D6hBTIgD.js → vendor-codemirror-lang-java-DAyCHRhk.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-jinja-ehGXhAov.js → vendor-codemirror-lang-jinja-0TICrMfO.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-json-tHviF6YN.js → vendor-codemirror-lang-json-mLulNbwB.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-less-B-yDfSDo.js → vendor-codemirror-lang-less-CNvptX3P.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-liquid-BdoLH4KN.js → vendor-codemirror-lang-liquid-8g5seytq.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-markdown-CV1IO6Qs.js +6 -0
- package/dist/assets/{vendor-codemirror-lang-php-Ch8GW4Xz.js → vendor-codemirror-lang-php-xP7rf28_.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-python-YM2WVLHF.js +9 -0
- package/dist/assets/{vendor-codemirror-lang-rust-BEUlElU6.js → vendor-codemirror-lang-rust-CtafR0gt.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-sass-B4iav_Ps.js +1 -0
- package/dist/assets/{vendor-codemirror-lang-sql-BnZql454.js → vendor-codemirror-lang-sql-C6q09UUZ.js} +2 -2
- package/dist/assets/{vendor-codemirror-lang-vue-BCwQBvLR.js → vendor-codemirror-lang-vue-DIB78UsR.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-wast-mrtA2eML.js → vendor-codemirror-lang-wast-C_JVdOCF.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-xml-ClAh-G2D.js → vendor-codemirror-lang-xml-DIuzY_d4.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-yaml-CjL8XEf2.js → vendor-codemirror-lang-yaml-BO0hIfQM.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-DGDYune4.js → vendor-codemirror-language-data-Ctd4WxgW.js} +2 -2
- package/dist/assets/vendor-codemirror-search-ZqtrPLjU.js +2 -0
- package/dist/assets/{vendor-replit-codemirror-vim-CEibx3EP.js → vendor-replit-codemirror-vim-DDj0dSVY.js} +6 -6
- package/dist/assets/zh-CN-wowfzo60.js +15 -0
- package/dist/assets/zh-TW-DwojcgA_.js +15 -0
- 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 +40 -1
- package/server/lib/assistants/harness.js +21 -8
- package/server/lib/assistants/harness.test.js +22 -1
- 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 +38 -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 +17 -11
- 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-auth.js +111 -0
- package/server/lib/config-sync/magpie-auth.test.js +79 -0
- package/server/lib/config-sync/magpie.js +276 -0
- package/server/lib/config-sync/magpie.test.js +265 -0
- package/server/lib/config-sync/plan.js +32 -37
- package/server/lib/config-sync/receiver.js +28 -25
- package/server/lib/config-sync/routes.js +23 -2
- package/server/lib/config-sync/routes.test.js +64 -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 +4 -1
- package/server/lib/opencode/auth.d.ts +1 -0
- package/server/lib/opencode/auth.js +1 -1
- package/server/lib/opencode/config-entities-layering.test.js +2 -1
- package/server/lib/opencode/feature-routes-runtime.js +2 -0
- package/server/lib/opencode/lifecycle.js +4 -0
- package/server/lib/opencode/lifecycle.test.js +7 -2
- package/server/lib/opencode/plugin-routes.test.js +1 -1
- package/server/lib/opencode/plugins.js +31 -21
- package/server/lib/opencode/plugins.test.js +55 -10
- 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/server/lib/session-folders/DOCUMENTATION.md +20 -0
- package/server/lib/session-folders/order-routes.js +69 -0
- package/server/lib/session-folders/routes.js +3 -0
- package/server/lib/session-folders/routes.test.js +49 -0
- package/server/lib/session-index/DOCUMENTATION.md +3 -0
- package/server/lib/session-index/sync-runtime.js +9 -4
- package/server/lib/session-index/sync-runtime.test.js +27 -0
- package/dist/assets/AssistantView-Bnaa8U5L.js +0 -3
- package/dist/assets/MarkdownRenderer-DpXaDH86.js +0 -2
- 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/es-2YvsazK2.js +0 -15
- package/dist/assets/fr-Cy0M48Gw.js +0 -13
- package/dist/assets/insertionBoundaries-CCxWV_ir.js +0 -1
- package/dist/assets/ja-1zLWWJ2R.js +0 -15
- package/dist/assets/ko-BLk_5Dhc.js +0 -15
- package/dist/assets/main-BbqWl6PU.js +0 -2
- package/dist/assets/main-KrnbhzQQ.js +0 -169
- package/dist/assets/miniChat-7ywVBnvf.js +0 -2
- package/dist/assets/mobile-_nwad-rW.js +0 -2
- package/dist/assets/pt-BR-ubhNymyH.js +0 -15
- package/dist/assets/renderMobileApp-fVXPQqNd.js +0 -28
- 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/store-n-H5RvRn.js +0 -15
- package/dist/assets/uk-BNyyigDo.js +0 -15
- package/dist/assets/useMobileNavigationStore-DZUur_4b.js +0 -417
- package/dist/assets/useUIStore-pl39iO82.js +0 -1
- package/dist/assets/vendor-codemirror-autocomplete-DHomsvW7.js +0 -12
- package/dist/assets/vendor-codemirror-commands-C9DCaJ0C.js +0 -1
- package/dist/assets/vendor-codemirror-lang-angular-BDXnzWgq.js +0 -23
- package/dist/assets/vendor-codemirror-lang-markdown-Bt-vLz9k.js +0 -6
- package/dist/assets/vendor-codemirror-lang-python-B4naLXMo.js +0 -9
- package/dist/assets/vendor-codemirror-lang-sass-ibtp2iUB.js +0 -1
- package/dist/assets/vendor-codemirror-search-D2SOgzph.js +0 -2
- package/dist/assets/zh-CN-CNWzMPgL.js +0 -15
- package/dist/assets/zh-TW-DrZ9S1yn.js +0 -15
|
@@ -0,0 +1,265 @@
|
|
|
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('hides config-only sources until Magpie is installed, 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: false, 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
|
+
if (args[0] === 'accounts') return '[]';
|
|
59
|
+
expect(args[0]).toBe('backup');
|
|
60
|
+
expect(ctx.input).toMatch(/^[a-f0-9]{64}\n$/);
|
|
61
|
+
expect(args).not.toContain(ctx.input.trim());
|
|
62
|
+
await write(args[1], JSON.stringify({ format: 'magpie-backup', data: 'fixture-encrypted' }));
|
|
63
|
+
return 'saved';
|
|
64
|
+
});
|
|
65
|
+
const payload = await createMagpieSync({ ...source.options, run: runSource }).download();
|
|
66
|
+
const bytes = Buffer.from('fixture-installed-cli');
|
|
67
|
+
const fetchImpl = vi.fn(async (url) => url === 'https://usemagpie.ai/api/latest'
|
|
68
|
+
? Response.json({ assets: { 'magpie-cli-linux-amd64': {
|
|
69
|
+
url: 'https://github.com/yetone/magpie-releases/releases/download/v0.1.0/magpie-cli-linux-amd64',
|
|
70
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
71
|
+
} } }) : new Response(bytes));
|
|
72
|
+
const runTarget = vi.fn(async (_binary, args, ctx) => {
|
|
73
|
+
if (args[0] === '--version') return 'magpie 0.1.0';
|
|
74
|
+
if (args[0] === 'accounts') return '[]';
|
|
75
|
+
expect(args[0]).toBe('restore');
|
|
76
|
+
expect(ctx.input.trim()).toBe(JSON.parse(payload).passphrase);
|
|
77
|
+
expect(JSON.parse(await fs.readFile(args[1], 'utf8')).format).toBe('magpie-backup');
|
|
78
|
+
await write(path.join(target.home, '.config', 'magpie', 'providers.json'), '{"providers":[]}');
|
|
79
|
+
return 'restored';
|
|
80
|
+
});
|
|
81
|
+
const destination = createMagpieSync({ ...target.options, fetchImpl, run: runTarget });
|
|
82
|
+
await expect(destination.import(payload)).resolves.toEqual({ ok: true });
|
|
83
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
84
|
+
expect(await fs.realpath(path.join(target.home, '.local/bin/magpie'))).toBe(await fs.realpath(path.join(target.home, '.config/openchamber/tools/magpie/magpie')));
|
|
85
|
+
await expect(destination.import(payload)).resolves.toEqual({ ok: true });
|
|
86
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
87
|
+
for (const item of [source, target]) {
|
|
88
|
+
expect((await fs.readdir(item.home)).filter((name) => name.startsWith('openchamber-magpie-'))).toEqual([]);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('repairs a previously managed installation without downloading or overwriting a user command', async () => {
|
|
93
|
+
const { home, options } = await machine();
|
|
94
|
+
const managed = path.join(home, '.config/openchamber/tools/magpie/magpie');
|
|
95
|
+
const entry = path.join(home, '.local/bin/magpie');
|
|
96
|
+
const fetchImpl = vi.fn();
|
|
97
|
+
const run = vi.fn(async () => 'magpie 0.1.1319');
|
|
98
|
+
const sync = createMagpieSync({ ...options, fetchImpl, run });
|
|
99
|
+
await sync.repairInstallation();
|
|
100
|
+
expect(run).not.toHaveBeenCalled();
|
|
101
|
+
await write(managed, 'fixture');
|
|
102
|
+
await sync.repairInstallation();
|
|
103
|
+
await sync.repairInstallation();
|
|
104
|
+
expect(await fs.realpath(entry)).toBe(await fs.realpath(managed));
|
|
105
|
+
await fs.unlink(entry);
|
|
106
|
+
await write(entry, 'user installation');
|
|
107
|
+
await sync.repairInstallation();
|
|
108
|
+
expect(await fs.readFile(entry, 'utf8')).toBe('user installation');
|
|
109
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('round-trips plugin accounts and Codex through v2 and verifies the final account inventory', async () => {
|
|
113
|
+
const source = await machine();
|
|
114
|
+
const target = await machine();
|
|
115
|
+
for (const item of [source, target]) await write(path.join(item.home, '.local/bin/magpie'), 'fixture');
|
|
116
|
+
const sourceDir = path.join(source.home, '.config/magpie');
|
|
117
|
+
const targetDir = path.join(target.home, '.config/magpie');
|
|
118
|
+
await write(path.join(sourceDir, 'providers.json'), '{"providers":[{"id":"astra"},{"id":"codex"}]}');
|
|
119
|
+
await write(path.join(sourceDir, 'plugin-auth.json'), JSON.stringify({ 'astra#one': { type: 'api', key: 'fixture-key' } }));
|
|
120
|
+
await write(path.join(sourceDir, 'logins.json'), JSON.stringify([
|
|
121
|
+
{ agent: 'codex', user: 'fixture@example.invalid', auth: { tokens: { access_token: 'fixture-token' } } },
|
|
122
|
+
]));
|
|
123
|
+
const payload = await createMagpieSync({ ...source.options, run: async (_binary, args) => {
|
|
124
|
+
if (args[0] === '--version') return 'magpie 0.1.1319';
|
|
125
|
+
if (args[0] === 'accounts') return '[{"agent":"astra"},{"agent":"codex","user":"fixture@example.invalid"}]';
|
|
126
|
+
await write(args[1], '{"format":"magpie-backup"}');
|
|
127
|
+
return '';
|
|
128
|
+
} }).download();
|
|
129
|
+
expect(JSON.parse(payload).format).toBe('magpie-sync-v2');
|
|
130
|
+
await write(path.join(targetDir, 'plugin-auth.json'), '{"destination-only":{"type":"api","key":"keep"}}');
|
|
131
|
+
let recognized = true;
|
|
132
|
+
const run = vi.fn(async (_binary, args) => {
|
|
133
|
+
if (args[0] === '--version') return 'magpie 0.1.1319';
|
|
134
|
+
if (args[0] === 'restore') {
|
|
135
|
+
await write(path.join(targetDir, 'providers.json'), await fs.readFile(path.join(sourceDir, 'providers.json')));
|
|
136
|
+
return 'not signed in on this computer';
|
|
137
|
+
}
|
|
138
|
+
if (args[1] === 'import') {
|
|
139
|
+
expect(args).not.toContain('fixture-token');
|
|
140
|
+
expect(JSON.parse(await fs.readFile(args[3], 'utf8')).tokens.access_token).toBe('fixture-token');
|
|
141
|
+
expect((await fs.stat(args[3])).mode & 0o777).toBe(0o600);
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
return JSON.stringify(recognized ? [{ agent: 'astra' }, { agent: 'codex', user: 'fixture@example.invalid' }] : []);
|
|
145
|
+
});
|
|
146
|
+
const sync = createMagpieSync({ ...target.options, run });
|
|
147
|
+
await expect(sync.import(payload)).resolves.toEqual({ ok: true });
|
|
148
|
+
await expect(sync.import(payload)).resolves.toEqual({ ok: true });
|
|
149
|
+
const stored = JSON.parse(await fs.readFile(path.join(targetDir, 'plugin-auth.json'), 'utf8'));
|
|
150
|
+
expect(Object.keys(stored)).toEqual(['destination-only', 'astra#one']);
|
|
151
|
+
recognized = false;
|
|
152
|
+
await expect(sync.import(payload)).rejects.toThrow('authorization sync or verification failed');
|
|
153
|
+
expect((await fs.readdir(target.home)).filter((name) => name.startsWith('openchamber-magpie-'))).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('rejects malformed v2 authorization before installing or restoring', async () => {
|
|
157
|
+
const { options } = await machine();
|
|
158
|
+
const run = vi.fn();
|
|
159
|
+
const fetchImpl = vi.fn();
|
|
160
|
+
const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v2', passphrase: 'a'.repeat(64),
|
|
161
|
+
backup: Buffer.from('{"format":"magpie-backup"}').toString('base64'),
|
|
162
|
+
authorization: { plugins: {}, logins: [{ agent: 'codex', home: '/source/path', auth: {} }], incomplete: false } }));
|
|
163
|
+
await expect(createMagpieSync({ ...options, run, fetchImpl }).import(payload)).rejects.toThrow('Invalid Magpie sync payload');
|
|
164
|
+
expect(run).not.toHaveBeenCalled();
|
|
165
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('retries agent settings after authorizations instead of keeping a pre-login skip warning', async () => {
|
|
169
|
+
const { home, options } = await machine();
|
|
170
|
+
await write(path.join(home, '.local/bin/magpie'), 'fixture');
|
|
171
|
+
let restores = 0;
|
|
172
|
+
const run = async (_binary, args) => {
|
|
173
|
+
if (args[0] === '--version') return 'magpie 0.1.1319';
|
|
174
|
+
if (args[0] === 'accounts') return '[]';
|
|
175
|
+
return ++restores === 1 ? 'Left as they are' : 'restored';
|
|
176
|
+
};
|
|
177
|
+
const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v2', passphrase: 'a'.repeat(64),
|
|
178
|
+
backup: Buffer.from('{"format":"magpie-backup"}').toString('base64'),
|
|
179
|
+
authorization: { plugins: {}, logins: [], incomplete: false } }));
|
|
180
|
+
await expect(createMagpieSync({ ...options, run }).import(payload)).resolves.toEqual({ ok: true });
|
|
181
|
+
expect(restores).toBe(2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('does not install on malformed payload and refuses partial restore without leaking CLI output', async () => {
|
|
185
|
+
const { home, options } = await machine();
|
|
186
|
+
const fetchImpl = vi.fn();
|
|
187
|
+
const run = vi.fn(async (_binary, args) => args[0] === '--version'
|
|
188
|
+
? 'magpie 0.1.0' : "Couldn't add the plugin private-fixture-value");
|
|
189
|
+
const sync = createMagpieSync({ ...options, fetchImpl, run });
|
|
190
|
+
await expect(sync.import(Buffer.from('{}'))).rejects.toThrow('Invalid Magpie sync payload');
|
|
191
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
192
|
+
await write(path.join(home, '.local', 'bin', 'magpie'), 'fixture-cli');
|
|
193
|
+
const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v1', passphrase: 'a'.repeat(64),
|
|
194
|
+
backup: Buffer.from('{"format":"magpie-backup"}').toString('base64') }));
|
|
195
|
+
await expect(sync.import(payload)).rejects.toThrow('Magpie restore partially completed');
|
|
196
|
+
expect((await fs.readdir(home)).filter((name) => name.startsWith('openchamber-magpie-'))).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('reports excluded sign-ins as a warning after a successful native restore', async () => {
|
|
200
|
+
const { home, options } = await machine();
|
|
201
|
+
await write(path.join(home, '.local', 'bin', 'magpie'), 'fixture-cli');
|
|
202
|
+
const run = async (_binary, args) => args[0] === '--version' ? 'magpie 0.1.1319'
|
|
203
|
+
: "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";
|
|
204
|
+
const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v1', passphrase: 'a'.repeat(64),
|
|
205
|
+
backup: Buffer.from('{"format":"magpie-backup"}').toString('base64') }));
|
|
206
|
+
await expect(createMagpieSync({ ...options, run }).import(payload)).resolves.toEqual({
|
|
207
|
+
ok: true, magpieWarnings: ['sign_in_required'],
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('requires current source discovery and supports a Magpie-only plan', async () => {
|
|
212
|
+
const { home } = await machine();
|
|
213
|
+
const selections = { fileGroups: [], singleFiles: [], directories: [], agentsRoot: false, providerCredentials: false, magpie: true };
|
|
214
|
+
expect(() => planOpenCodeConfigSync(home, { selections })).toThrow('unavailable');
|
|
215
|
+
const plan = planOpenCodeConfigSync(home, { selections, magpie: { available: true, installed: true } });
|
|
216
|
+
expect(plan.magpie).toEqual({ bytes: 0 });
|
|
217
|
+
const received = [];
|
|
218
|
+
const executor = { prepare: vi.fn(), putTar: async (value) => received.push(value), finalize: vi.fn(async () => ({ magpieWarnings: ['sign_in_required'] })) };
|
|
219
|
+
const result = await applyConfigSyncPlan({ plan, executor, syncRunId: 'test', sourceHomedir: home, collectMagpie: async () => Buffer.from('fixture') });
|
|
220
|
+
expect(result.magpieWarnings).toEqual(['sign_in_required']);
|
|
221
|
+
expect(received).toEqual([{ kind: 'magpie', payload: Buffer.from('fixture') }]);
|
|
222
|
+
received.length = 0;
|
|
223
|
+
await applyConfigSyncPlan({ plan: { ...plan, files: [{ path: 'opencode.json' }] }, executor, syncRunId: 'ordered', sourceHomedir: home,
|
|
224
|
+
collectTar: async () => Buffer.from('config'), collectMagpie: async () => Buffer.from('fixture') });
|
|
225
|
+
expect(received.map((entry) => entry.kind)).toEqual(['magpie', 'config']);
|
|
226
|
+
const pull = planOpenCodeConfigSyncFromInventory({ files: [], directories: [], magpie: { available: true } }, { selections });
|
|
227
|
+
expect(pull.magpieAvailable).toBe(true);
|
|
228
|
+
expect(pull.magpie).toEqual({ bytes: 0 });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('rejects a corrupt installer before executing or publishing it', async () => {
|
|
232
|
+
const { home, options } = await machine();
|
|
233
|
+
const run = vi.fn();
|
|
234
|
+
const fetchImpl = async (url) => url === 'https://usemagpie.ai/api/latest'
|
|
235
|
+
? Response.json({ assets: { 'magpie-cli-linux-amd64': {
|
|
236
|
+
url: 'https://github.com/yetone/magpie-releases/releases/download/v0.1.0/magpie-cli-linux-amd64',
|
|
237
|
+
sha256: '0'.repeat(64),
|
|
238
|
+
} } }) : new Response('corrupt');
|
|
239
|
+
const sync = createMagpieSync({ ...options, fetchImpl, run });
|
|
240
|
+
const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v1', passphrase: 'a'.repeat(64),
|
|
241
|
+
backup: Buffer.from('{"format":"magpie-backup"}').toString('base64') }));
|
|
242
|
+
await expect(sync.import(payload)).rejects.toThrow('checksum mismatch');
|
|
243
|
+
expect(run).not.toHaveBeenCalled();
|
|
244
|
+
await expect(fs.stat(path.join(home, '.config', 'openchamber', 'tools', 'magpie', 'magpie'))).rejects.toThrow();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it.skipIf(!process.env.OPENCHAMBER_MAGPIE_TEST_BINARY)('round-trips isolated configuration with the installed real CLI', async () => {
|
|
248
|
+
const source = await machine();
|
|
249
|
+
const target = await machine();
|
|
250
|
+
for (const item of [source, target]) {
|
|
251
|
+
item.options.env = { ...item.options.env, MAGPIE_BINARY: process.env.OPENCHAMBER_MAGPIE_TEST_BINARY,
|
|
252
|
+
MAGPIE_NO_ACCOUNTS: '1', PATH: '/usr/bin:/bin', XDG_CACHE_HOME: path.join(item.home, '.cache') };
|
|
253
|
+
await write(path.join(item.home, '.config', 'magpie', 'settings.json'), JSON.stringify({ noAccountDiscovery: true, noStats: true, noUsageStats: true, noAutoUpdate: true }));
|
|
254
|
+
}
|
|
255
|
+
await write(path.join(source.home, '.config', 'magpie', 'providers.json'), JSON.stringify({ providers: [
|
|
256
|
+
{ id: 'sync-fixture', name: 'Sync fixture', chat: 'https://provider.invalid/v1', key: 'fixture-not-a-real-key' },
|
|
257
|
+
{ id: 'fixture-subscription' },
|
|
258
|
+
] }));
|
|
259
|
+
const payload = await createMagpieSync(source.options).download();
|
|
260
|
+
const receipt = await createMagpieSync(target.options).import(payload);
|
|
261
|
+
expect(receipt).toEqual({ ok: true, magpieWarnings: ['sign_in_required'] });
|
|
262
|
+
const restored = JSON.parse(await fs.readFile(path.join(target.home, '.config', 'magpie', 'providers.json'), 'utf8'));
|
|
263
|
+
expect(restored.providers).toContainEqual(expect.objectContaining({ id: 'sync-fixture', key: 'fixture-not-a-real-key' }));
|
|
264
|
+
}, 60_000);
|
|
265
|
+
});
|
|
@@ -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,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 === '
|
|
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
|
+
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 === '
|
|
239
|
-
return
|
|
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
|
};
|
|
@@ -2,7 +2,7 @@ import { createConfigSyncReceiver } from './receiver.js';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Register HTTP config-sync receive/download routes for direct (and later relay) hosts.
|
|
5
|
-
* Auth is the global `/api` UI/client-token gate. Selected kinds (including
|
|
5
|
+
* Auth is the global `/api` UI/client-token gate. Selected kinds (including credentials)
|
|
6
6
|
* sync when present in the plan — no extra credential grant checks.
|
|
7
7
|
*
|
|
8
8
|
* @param {import('express').Express} app
|
|
@@ -70,11 +70,32 @@ export const registerConfigSyncRoutes = (app, deps = {}) => {
|
|
|
70
70
|
}
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
app.get('/api/openchamber/config-sync/magpie/inventory', async (_req, res) => {
|
|
74
|
+
try { res.json({ magpie: await receiver.magpieInventory() }); }
|
|
75
|
+
catch (error) { sendError(res, error); }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
app.put('/api/openchamber/config-sync/magpie', async (req, res) => {
|
|
79
|
+
try { res.json(await receiver.importMagpie(req)); }
|
|
80
|
+
catch (error) { sendError(res, error); }
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.get('/api/openchamber/config-sync/credentials/inventory', async (_req, res) => {
|
|
84
|
+
try { res.json({ providerCredentials: await receiver.credentialInventory() }); }
|
|
85
|
+
catch (error) { sendError(res, error); }
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
app.put('/api/openchamber/config-sync/credentials', async (req, res) => {
|
|
89
|
+
try { res.json(await receiver.importCredentials(req)); }
|
|
90
|
+
catch (error) { sendError(res, error); }
|
|
91
|
+
});
|
|
92
|
+
|
|
73
93
|
app.get('/api/openchamber/config-sync/download/:kind', async (req, res) => {
|
|
74
94
|
try {
|
|
75
95
|
const kind = String(req.params.kind || '').trim();
|
|
76
96
|
const buffer = await receiver.download({ kind });
|
|
77
|
-
res.setHeader('Content-Type', 'application/gzip');
|
|
97
|
+
res.setHeader('Content-Type', kind === 'credentials' || kind === 'magpie' ? 'application/octet-stream' : 'application/gzip');
|
|
98
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
78
99
|
res.setHeader('Content-Length', String(buffer.byteLength));
|
|
79
100
|
res.send(buffer);
|
|
80
101
|
} catch (error) {
|