@yuriai/web 0.1.5 → 0.1.7
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/README.md +3 -1
- package/dist/assets/{AgentAvatar-D6H7fWWf.js → AgentAvatar-BVnp9gE9.js} +1 -1
- package/dist/assets/AssistantView-BkMKCN9c.js +3 -0
- package/dist/assets/{DiagramView-TX1QZ1-S.js → DiagramView-B7_hOmVC.js} +1 -1
- package/dist/assets/{FadeInOnReveal-eVZbrwte.js → FadeInOnReveal-CzC5UKnq.js} +1 -1
- package/dist/assets/{Icon-2cKLcXGe.js → Icon-BXTZRnIO.js} +1 -1
- package/dist/assets/{ImageLoadingPlaceholder-DmCEIw55.js → ImageLoadingPlaceholder-Cv6IaJar.js} +1 -1
- package/dist/assets/{LoadingImage-B-cD_lfU.js → LoadingImage-NxtWlewi.js} +1 -1
- package/dist/assets/{MarkdownRenderer-DGUI8EIG.js → MarkdownRenderer-Va7Wdk98.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-DndseLUT.js → MarkdownRendererImpl-CsjvA5I5.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-Ehxwt6MS.js → MarkstreamRendererImpl-Du38HKce.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-D7uM40jU.js → MobileDetailNavigation-B_PYZ9xi.js} +1 -1
- package/dist/assets/MobileShareBridge-BbbQ15B3.js +4 -0
- package/dist/assets/MobileTabPageHeader-tiWNRKWN.js +2 -0
- package/dist/assets/{MultiRunWindow-CVI6D2LM.js → MultiRunWindow-CFFrKEHW.js} +1 -1
- package/dist/assets/{OnboardingScreen-CG2xI_UT.js → OnboardingScreen-BxS5LEOm.js} +1 -1
- package/dist/assets/{OverlayScrollbar-piA-chKb.js → OverlayScrollbar-CbWvRXm6.js} +1 -1
- package/dist/assets/SettingsView-CKn54R1o.js +26 -0
- package/dist/assets/{SettingsWindow-Cj6sYyvK.js → SettingsWindow-BKatx9Q_.js} +1 -1
- package/dist/assets/{TerminalView-DW4OxMaM.js → TerminalView-h15pBKLx.js} +2 -2
- package/dist/assets/{ToolOutputDialog-DxKGYhQ6.js → ToolOutputDialog-Bj-TWElC.js} +1 -1
- package/dist/assets/{appThemeRegistry-DlEr4DLc.js → appThemeRegistry-nntgbF9E.js} +1 -1
- package/dist/assets/button-D_HdP1Oy.js +1 -0
- package/dist/assets/{capgoAdapter-Onw1miMh.js → capgoAdapter-BV5kJq2r.js} +1 -1
- package/dist/assets/{collapsible-BRKOX_ML.js → collapsible-sUU_ORMe.js} +1 -1
- package/dist/assets/{diagram-C9F3kAJt.js → diagram-BE50CEiR.js} +1 -1
- package/dist/assets/{dialog-NsOVcGdh.js → dialog-B_87Teec.js} +1 -1
- package/dist/assets/{dropdown-menu.styles-CjPDePJF.js → dropdown-menu.styles-C_b80jxk.js} +1 -1
- package/dist/assets/{fileMentionTouchSelection-B26MejYr.js → fileMentionTouchSelection-Bx6vWiqF.js} +1 -1
- package/dist/assets/{gitApi-BXhXdWkh.js → gitApi-B1arMnHR.js} +1 -1
- package/dist/assets/{input-BI3Ya8O_.js → input-DDfJnn8e.js} +1 -1
- package/dist/assets/{insertionBoundaries-BxSGE3B7.js → insertionBoundaries-B8UdVMC9.js} +1 -1
- package/dist/assets/{main-BdV8Awp-.js → main-BLSF9qdf.js} +2 -2
- package/dist/assets/{main-CGpFMQTy.js → main-DJwLyPc3.js} +12 -12
- package/dist/assets/{markdownHeightCache-CHFSvA8u.js → markdownHeightCache-Cbd62oWw.js} +1 -1
- package/dist/assets/{mcpQueries-uZJFBhGj.js → mcpQueries-BxbbmYAd.js} +1 -1
- package/dist/assets/{miniChat-B1aBBLiq.js → miniChat-D_cBND3Z.js} +2 -2
- package/dist/assets/{mobile-B8U8FARq.js → mobile-CZNFYieO.js} +2 -2
- package/dist/assets/{mobileBackNavigation-D2yUxPpG.js → mobileBackNavigation-f8AMDP0N.js} +1 -1
- package/dist/assets/{multirun-DKsVtdZX.js → multirun-Cvl4FMPo.js} +1 -1
- package/dist/assets/{native-config-Bpk4DOW1.js → native-config-DLJCZcXh.js} +1 -1
- package/dist/assets/{projectMeta-yox5XHhN.js → projectMeta-D3cfkJyk.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-CdEZXhzq.js → renderElectronMiniChatApp-_n69Pa_v.js} +2 -2
- package/dist/assets/renderMobileApp-DUiUPyd7.js +28 -0
- package/dist/assets/{runtimeConfig-B8s9sTX_.js → runtimeConfig-DwFJL5bm.js} +1 -1
- package/dist/assets/runtimeEndpointReset-C_1daihM.js +149 -0
- package/dist/assets/{scheduledTaskNavigation-DUskP6qh.js → scheduledTaskNavigation-DvSpMLdG.js} +1 -1
- package/dist/assets/sessionChangeSummary-evl_LmRN.js +1 -0
- package/dist/assets/sessionLookup-CJ8mXxHt.js +1 -0
- package/dist/assets/{sortable-tabs-strip-Benojjgi.js → sortable-tabs-strip-CrjW2aQk.js} +1 -1
- package/dist/assets/{speechSettingsQueries-CjTT-adl.js → speechSettingsQueries-xWFTnUfF.js} +1 -1
- package/dist/assets/src-omVWmz3o.css +2 -0
- package/dist/assets/{textarea-C5vjnZcE.js → textarea-BkOdxUN0.js} +1 -1
- package/dist/assets/{tooltip-C73L9gDf.js → tooltip-BQQoK7L_.js} +1 -1
- package/dist/assets/{types-Bu1xM0fS.js → types-BzF7Dhzq.js} +1 -1
- package/dist/assets/{useAppFontEffects-D6yauGdg.js → useAppFontEffects-lmtncXsi.js} +7 -7
- package/dist/assets/{useDesktopWindowControlsLayout-SKHaFoVu.js → useDesktopWindowControlsLayout-BY0_whx0.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-D6GwXhDS.js → useEffectiveDirectory-CqkTJqRg.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-ByrZJd5L.js → useMobileNavigationStore-BURkCJuM.js} +4 -4
- package/dist/assets/{useWorkerHighlightedLines-CwC34Jql.js → useWorkerHighlightedLines-Bla356tF.js} +1 -1
- package/dist/index.html +6 -6
- package/dist/mini-chat.html +6 -6
- package/dist/mobile.html +6 -6
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +13 -2
- package/server/lib/assistants/contact-store.js +2 -1
- package/server/lib/assistants/contact-store.test.js +13 -3
- package/server/lib/assistants/routes.js +2 -1
- package/server/lib/assistants/service.js +54 -7
- package/server/lib/assistants/service.test.js +59 -10
- package/server/lib/content-search/DOCUMENTATION.md +5 -1
- package/server/lib/opencode/DOCUMENTATION.md +8 -7
- package/server/lib/opencode/config-entity-routes.js +5 -1
- package/server/lib/opencode/config-entity-routes.test.js +23 -0
- package/server/lib/opencode/ensure-cli.js +48 -11
- package/server/lib/opencode/ensure-cli.test.js +53 -1
- package/server/lib/opencode/env-runtime.js +5 -5
- package/server/lib/opencode/feature-routes-runtime.js +1 -1
- package/server/lib/opencode/terminal-path.js +67 -0
- package/server/lib/opencode/terminal-path.test.js +68 -0
- package/server/lib/relay/DOCUMENTATION.md +2 -0
- package/server/lib/relay/service.js +1 -1
- package/server/lib/relay/service.test.js +3 -3
- package/dist/assets/AssistantView-D3j8FlaF.js +0 -3
- package/dist/assets/MobileShareBridge-BMQKnoEb.js +0 -4
- package/dist/assets/MobileTabPageHeader-Cco8ToA6.js +0 -2
- package/dist/assets/SettingsView-Bx-5WRtO.js +0 -26
- package/dist/assets/button-_ReBqWzC.js +0 -1
- package/dist/assets/renderMobileApp-BoV5wjPF.js +0 -28
- package/dist/assets/runtimeEndpointReset-BswmG9Bb.js +0 -149
- package/dist/assets/sessionChangeSummary-BGbmGpQw.js +0 -1
- package/dist/assets/sessionLookup-CGJA6W7O.js +0 -1
- package/dist/assets/src-CmermOKP.css +0 -2
|
@@ -495,6 +495,29 @@ describe('config entity agent metadata route', () => {
|
|
|
495
495
|
describe('global raw configuration routes', () => {
|
|
496
496
|
beforeEach(() => vi.stubEnv('OPENCODE_CONFIG', ''));
|
|
497
497
|
afterEach(() => vi.unstubAllEnvs());
|
|
498
|
+
it('offers an editable JSONC template on a fresh machine and creates it only on save', async () => {
|
|
499
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'yuri-global-config-'));
|
|
500
|
+
const configDirectory = path.join(root, 'opencode');
|
|
501
|
+
const app = express();
|
|
502
|
+
app.use(express.json());
|
|
503
|
+
registerConfigEntityRoutes(app, createDependencies(vi.fn(), configDirectory));
|
|
504
|
+
try {
|
|
505
|
+
const available = await request(app).get('/api/config/global').expect(200);
|
|
506
|
+
expect(available.body.targets).toEqual([{ target: 'opencode', fileName: 'opencode.jsonc' }]);
|
|
507
|
+
const template = await request(app).get('/api/config/global/opencode').expect(200);
|
|
508
|
+
expect(JSON.parse(template.body.content)).toEqual({ $schema: 'https://opencode.ai/config.json' });
|
|
509
|
+
await expect(fs.stat(configDirectory)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
510
|
+
const content = '{\n // first configuration\n "model": "provider/model"\n}\n';
|
|
511
|
+
await request(app).put('/api/config/global/opencode').send({ content }).expect(200);
|
|
512
|
+
expect(await fs.readFile(path.join(configDirectory, 'opencode.jsonc'), 'utf8')).toBe(content);
|
|
513
|
+
const loaded = await request(app).get('/api/config/global/opencode').expect(200);
|
|
514
|
+
expect(loaded.body.content).toBe(content);
|
|
515
|
+
await request(app).get('/api/config/global/oh-my-openagent').expect(404);
|
|
516
|
+
} finally {
|
|
517
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
498
521
|
it('reads and validates the configured OpenCode and oh-my-opencode files', async () => {
|
|
499
522
|
const app = express();
|
|
500
523
|
app.use(express.json());
|
|
@@ -2,8 +2,11 @@ import { execFile, spawnSync } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { createOpenCodeUpdateDiscovery } from './update-discovery.js';
|
|
6
|
+
import { configureOpenCodeTerminalPath } from './terminal-path.js';
|
|
5
7
|
import {
|
|
6
8
|
PINNED_OPENCODE2_VERSION,
|
|
9
|
+
compareOpenCode2Versions,
|
|
7
10
|
isAcceptableOpenCode2HealthVersion,
|
|
8
11
|
isOpenCode2VersionAtLeast,
|
|
9
12
|
npmPackageForOpenCode2,
|
|
@@ -12,6 +15,7 @@ import {
|
|
|
12
15
|
} from './opencode2-pin.js';
|
|
13
16
|
|
|
14
17
|
const INSTALL_FETCH_TIMEOUT_MS = 30_000;
|
|
18
|
+
const discoverLatestVersion = createOpenCodeUpdateDiscovery();
|
|
15
19
|
|
|
16
20
|
export async function upgradeSelectedOpenCodeCli({ binaryPath, version, launchSpec, env = process.env, execFileImpl = execFile }) {
|
|
17
21
|
if (launchSpec?.wrapperType === 'cmd-wrapper') {
|
|
@@ -55,6 +59,15 @@ export function installedOpenCode2BinaryPath(version = PINNED_OPENCODE2_VERSION,
|
|
|
55
59
|
return path.join(dataDir, 'opencode-cli', version, openCodeBinaryName(options.platform));
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
export function installedOpenCode2BinaryCandidates(options = {}) {
|
|
63
|
+
const root = path.dirname(path.dirname(installedOpenCode2BinaryPath(undefined, options)));
|
|
64
|
+
let versions;
|
|
65
|
+
try { versions = fs.readdirSync(root).filter((name) => /^2\.\d+\.\d+$/.test(name)); }
|
|
66
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
67
|
+
return versions.sort((a, b) => compareOpenCode2Versions(b, a))
|
|
68
|
+
.map((version) => installedOpenCode2BinaryPath(version, options));
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
export function readOpenCode2BinaryVersion(binaryPath, options = {}) {
|
|
59
72
|
if (typeof binaryPath !== 'string' || !binaryPath.trim()) return '';
|
|
60
73
|
const run = typeof options.spawnSync === 'function' ? options.spawnSync : spawnSync;
|
|
@@ -217,12 +230,16 @@ export async function installPinnedOpenCode2Cli(options = {}) {
|
|
|
217
230
|
if (preparedVersion !== version) {
|
|
218
231
|
throw new Error(`Installed opencode2 version mismatch: expected ${version}, got ${preparedVersion || 'unknown'}`);
|
|
219
232
|
}
|
|
233
|
+
if (['opencode-path.sh', 'opencode-path.fish', 'opencode-path.windows'].some((name) => fs.existsSync(path.join(dataDir, name)))) {
|
|
234
|
+
try { configureOpenCodeTerminalPath(outputBinary, { ...options, dataDir }); }
|
|
235
|
+
catch (error) { console.warn('[OpenCode] Terminal PATH refresh failed:', error.message); }
|
|
236
|
+
}
|
|
220
237
|
return outputBinary;
|
|
221
238
|
}
|
|
222
239
|
|
|
223
240
|
/**
|
|
224
|
-
* Reuse any discovered acceptable 2.x CLI.
|
|
225
|
-
*
|
|
241
|
+
* Reuse any discovered acceptable 2.x CLI. Fresh installs resolve stable 2.x;
|
|
242
|
+
* an explicit pin or an unavailable registry uses the tested version instead.
|
|
226
243
|
*/
|
|
227
244
|
export async function ensurePinnedOpenCode2Cli(input = {}) {
|
|
228
245
|
const pin = input.pin || PINNED_OPENCODE2_VERSION;
|
|
@@ -232,18 +249,29 @@ export async function ensurePinnedOpenCode2Cli(input = {}) {
|
|
|
232
249
|
? input.readVersion
|
|
233
250
|
: (binaryPath) => readOpenCode2BinaryVersion(binaryPath, input);
|
|
234
251
|
const install = typeof input.install === 'function' ? input.install : installPinnedOpenCode2Cli;
|
|
252
|
+
const finish = (result) => {
|
|
253
|
+
const root = path.dirname(path.dirname(installedOpenCode2BinaryPath(pin, input)));
|
|
254
|
+
const relative = path.relative(root, result.path);
|
|
255
|
+
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
256
|
+
try { configureOpenCodeTerminalPath(result.path, input); }
|
|
257
|
+
catch (error) { console.warn('[OpenCode] CLI is available in Yuri, but terminal PATH setup failed:', error.message); }
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
};
|
|
235
261
|
|
|
236
262
|
if (discoveredPath) {
|
|
237
263
|
const version = readVersion(discoveredPath);
|
|
238
264
|
if (isAcceptableOpenCode2HealthVersion(version)) {
|
|
239
|
-
return { path: discoveredPath, version, source: 'discovered', installed: false };
|
|
265
|
+
return finish({ path: discoveredPath, version, source: 'discovered', installed: false });
|
|
240
266
|
}
|
|
241
267
|
}
|
|
242
268
|
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
269
|
+
const candidates = [...new Set([...installedOpenCode2BinaryCandidates(input), installedOpenCode2BinaryPath(pin, input)])];
|
|
270
|
+
for (const cached of candidates) {
|
|
271
|
+
const cachedVersion = readVersion(cached);
|
|
272
|
+
if (isOpenCode2VersionAtLeast(cachedVersion, pin)) {
|
|
273
|
+
return finish({ path: cached, version: cachedVersion, source: 'installed', installed: false });
|
|
274
|
+
}
|
|
247
275
|
}
|
|
248
276
|
|
|
249
277
|
if (!autoInstall) {
|
|
@@ -254,10 +282,19 @@ export async function ensurePinnedOpenCode2Cli(input = {}) {
|
|
|
254
282
|
throw error;
|
|
255
283
|
}
|
|
256
284
|
|
|
257
|
-
|
|
285
|
+
let target = pin;
|
|
286
|
+
if (!input.pin) {
|
|
287
|
+
try {
|
|
288
|
+
const latest = await (input.resolveLatestVersion || discoverLatestVersion)();
|
|
289
|
+
if (/^2\.\d+\.\d+$/.test(latest) && isOpenCode2VersionAtLeast(latest, pin)) target = latest;
|
|
290
|
+
} catch {
|
|
291
|
+
console.warn('[OpenCode] Latest version lookup failed; using the tested fallback version.');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const installedPath = await install({ ...input, version: target });
|
|
258
295
|
const version = readVersion(installedPath);
|
|
259
|
-
if (version !==
|
|
260
|
-
throw new Error(`Installed OpenCode version mismatch: expected ${
|
|
296
|
+
if (version !== target) {
|
|
297
|
+
throw new Error(`Installed OpenCode version mismatch: expected ${target}, got ${version || 'unknown'}`);
|
|
261
298
|
}
|
|
262
|
-
return { path: installedPath, version, source: 'installed', installed: true };
|
|
299
|
+
return finish({ path: installedPath, version, source: 'installed', installed: true });
|
|
263
300
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { PINNED_OPENCODE2_VERSION } from './opencode2-pin.js';
|
|
6
|
+
import { configureOpenCodeTerminalPath } from './terminal-path.js';
|
|
6
7
|
import {
|
|
7
8
|
ensurePinnedOpenCode2Cli,
|
|
8
9
|
installedOpenCode2BinaryPath,
|
|
@@ -11,6 +12,8 @@ import {
|
|
|
11
12
|
upgradeSelectedOpenCodeCli,
|
|
12
13
|
} from './ensure-cli.js';
|
|
13
14
|
|
|
15
|
+
vi.mock('./terminal-path.js', () => ({ configureOpenCodeTerminalPath: vi.fn() }));
|
|
16
|
+
|
|
14
17
|
const tempDirs = [];
|
|
15
18
|
|
|
16
19
|
const createTempDir = (prefix) => {
|
|
@@ -20,12 +23,60 @@ const createTempDir = (prefix) => {
|
|
|
20
23
|
};
|
|
21
24
|
|
|
22
25
|
afterEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
23
27
|
for (const dir of tempDirs.splice(0)) {
|
|
24
28
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
25
29
|
}
|
|
26
30
|
});
|
|
27
31
|
|
|
28
32
|
describe('ensurePinnedOpenCode2Cli', () => {
|
|
33
|
+
it('resolves the latest stable 2.x for a fresh installation', async () => {
|
|
34
|
+
const dataDir = createTempDir('yuri-latest-cli-');
|
|
35
|
+
const installed = path.join(dataDir, 'opencode-cli', '2.9.1', 'opencode');
|
|
36
|
+
const install = vi.fn(async () => installed);
|
|
37
|
+
const result = await ensurePinnedOpenCode2Cli({ dataDir,
|
|
38
|
+
resolveLatestVersion: async () => '2.9.1', install,
|
|
39
|
+
readVersion: (file) => file === installed ? '2.9.1' : '',
|
|
40
|
+
});
|
|
41
|
+
expect(install).toHaveBeenCalledWith(expect.objectContaining({ version: '2.9.1' }));
|
|
42
|
+
expect(result.version).toBe('2.9.1');
|
|
43
|
+
expect(configureOpenCodeTerminalPath).toHaveBeenCalledWith(installed, expect.objectContaining({ dataDir }));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it.each(['1.18.4', '3.0.0', '2.9.1-beta.1', '2.0.1'])('never chooses an unsuitable fresh-install target %s', async (latest) => {
|
|
47
|
+
const install = vi.fn(async () => '/installed/opencode');
|
|
48
|
+
await ensurePinnedOpenCode2Cli({ dataDir: createTempDir('yuri-invalid-cli-'), install,
|
|
49
|
+
resolveLatestVersion: async () => latest,
|
|
50
|
+
readVersion: (file) => file === '/installed/opencode' ? PINNED_OPENCODE2_VERSION : '',
|
|
51
|
+
});
|
|
52
|
+
expect(install).toHaveBeenCalledWith(expect.objectContaining({ version: PINNED_OPENCODE2_VERSION }));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('reuses the newest verified cache offline instead of reinstalling the pin', async () => {
|
|
56
|
+
const dataDir = createTempDir('yuri-new-cache-');
|
|
57
|
+
for (const version of [PINNED_OPENCODE2_VERSION, '2.9.1', '2.10.0']) {
|
|
58
|
+
fs.mkdirSync(path.join(dataDir, 'opencode-cli', version), { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
const latest = installedOpenCode2BinaryPath('2.10.0', { dataDir });
|
|
61
|
+
const resolveLatestVersion = vi.fn();
|
|
62
|
+
const result = await ensurePinnedOpenCode2Cli({ dataDir, resolveLatestVersion,
|
|
63
|
+
readVersion: (file) => path.basename(path.dirname(file)),
|
|
64
|
+
install: async () => { throw new Error('must not install'); },
|
|
65
|
+
});
|
|
66
|
+
expect(result).toMatchObject({ path: latest, version: '2.10.0', installed: false });
|
|
67
|
+
expect(resolveLatestVersion).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('uses the tested pin when the registry is unavailable', async () => {
|
|
71
|
+
const dataDir = createTempDir('yuri-offline-cli-');
|
|
72
|
+
const install = vi.fn(async () => '/installed/opencode');
|
|
73
|
+
await ensurePinnedOpenCode2Cli({ dataDir, install,
|
|
74
|
+
resolveLatestVersion: async () => { throw new Error('offline'); },
|
|
75
|
+
readVersion: (file) => file === '/installed/opencode' ? PINNED_OPENCODE2_VERSION : '',
|
|
76
|
+
});
|
|
77
|
+
expect(install).toHaveBeenCalledWith(expect.objectContaining({ version: PINNED_OPENCODE2_VERSION }));
|
|
78
|
+
});
|
|
79
|
+
|
|
29
80
|
it('runs upgrade through the selected launch target and preserves its CLI entry path', async () => {
|
|
30
81
|
const dir = createTempDir('yuri-upgrade-cli-');
|
|
31
82
|
const script = path.join(dir, 'cli.cjs');
|
|
@@ -86,6 +137,7 @@ describe('ensurePinnedOpenCode2Cli', () => {
|
|
|
86
137
|
});
|
|
87
138
|
expect(result.source).toBe('discovered');
|
|
88
139
|
expect(result.path).toBe('/opt/old-opencode');
|
|
140
|
+
expect(configureOpenCodeTerminalPath).not.toHaveBeenCalled();
|
|
89
141
|
});
|
|
90
142
|
|
|
91
143
|
it('installs the pin only when no usable 2.x binary is present', async () => {
|
|
@@ -3,8 +3,8 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { mergePathValues } from './path-utils.js';
|
|
6
|
-
import {
|
|
7
|
-
import { ensurePinnedOpenCode2Cli,
|
|
6
|
+
import { isAcceptableOpenCode2HealthVersion, isOpenCode1xVersion } from './opencode2-pin.js';
|
|
7
|
+
import { ensurePinnedOpenCode2Cli, installedOpenCode2BinaryCandidates, readOpenCode2BinaryVersion, upgradeSelectedOpenCodeCli } from './ensure-cli.js';
|
|
8
8
|
|
|
9
9
|
export const createOpenCodeEnvRuntime = (deps) => {
|
|
10
10
|
const {
|
|
@@ -280,12 +280,12 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|
|
280
280
|
};
|
|
281
281
|
|
|
282
282
|
const resolveInstalledOpenCodeCliPath = () => {
|
|
283
|
-
const installed =
|
|
283
|
+
const installed = installedOpenCode2BinaryCandidates({
|
|
284
284
|
env: process.env,
|
|
285
285
|
homedir: resolveHomeDir,
|
|
286
286
|
platform: process.platform,
|
|
287
|
-
});
|
|
288
|
-
if (
|
|
287
|
+
}).find((binary) => isExecutable(binary) && !isWindowsOpenCodeDesktopAppPath(binary));
|
|
288
|
+
if (installed) {
|
|
289
289
|
clearWslOpencodeResolution();
|
|
290
290
|
state.resolvedOpencodeBinarySource = 'installed';
|
|
291
291
|
return installed;
|
|
@@ -319,7 +319,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
319
319
|
},
|
|
320
320
|
readModelPreferences: async () => {
|
|
321
321
|
const settings = await readSettingsFromDiskStrict();
|
|
322
|
-
return { favoriteModels: settings.favoriteModels, recentModels: settings.recentModels };
|
|
322
|
+
return { defaultModel: settings.defaultModel, defaultVariant: settings.defaultVariant, favoriteModels: settings.favoriteModels, recentModels: settings.recentModels };
|
|
323
323
|
},
|
|
324
324
|
listScheduledTasks: (projectID) => projectConfigRuntime.listScheduledTasks(projectID),
|
|
325
325
|
sessionIndexService,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export function configureOpenCodeTerminalPath(binaryPath, options = {}) {
|
|
7
|
+
const env = options.env || process.env;
|
|
8
|
+
const home = typeof options.homedir === 'function' ? options.homedir() : options.homedir || os.homedir();
|
|
9
|
+
const platform = options.platform || process.platform;
|
|
10
|
+
const dataDir = options.dataDir || env.YURI_DATA_DIR || path.join(home, '.config', 'yuri');
|
|
11
|
+
const directory = path.dirname(binaryPath);
|
|
12
|
+
if (platform === 'win32') {
|
|
13
|
+
const run = options.spawnSync || spawnSync;
|
|
14
|
+
const result = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `
|
|
15
|
+
$ErrorActionPreference = 'Stop'
|
|
16
|
+
$root = $env:YURI_CLI_CACHE_ROOT.TrimEnd('\\') + '\\'
|
|
17
|
+
$entries = @([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { $_ -and -not $_.StartsWith($root, [StringComparison]::OrdinalIgnoreCase) })
|
|
18
|
+
$value = (@($env:YURI_CLI_BIN_DIR) + $entries) -join ';'
|
|
19
|
+
[Environment]::SetEnvironmentVariable('Path', $value, 'User')
|
|
20
|
+
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class YuriEnvironment { [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint msg, UIntPtr wParam, string lParam, uint flags, uint timeout, out UIntPtr result); }'
|
|
21
|
+
$result = [UIntPtr]::Zero
|
|
22
|
+
[void][YuriEnvironment]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result)
|
|
23
|
+
`], { env: { ...env, YURI_CLI_CACHE_ROOT: path.join(dataDir, 'opencode-cli'), YURI_CLI_BIN_DIR: directory },
|
|
24
|
+
encoding: 'utf8', timeout: 15_000, windowsHide: true });
|
|
25
|
+
if (result.status !== 0) throw new Error('Cannot add OpenCode to the Windows user PATH');
|
|
26
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
27
|
+
fs.writeFileSync(path.join(dataDir, 'opencode-path.windows'), directory);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const shell = env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/sh');
|
|
32
|
+
const kind = path.basename(shell);
|
|
33
|
+
const files = [];
|
|
34
|
+
if (kind === 'zsh') {
|
|
35
|
+
const run = options.spawnSync || spawnSync;
|
|
36
|
+
const probe = run(shell, ['-c', 'printf "%s" "${ZDOTDIR:-$HOME}"'], { env: { ...env, HOME: home }, encoding: 'utf8', timeout: 5000 });
|
|
37
|
+
if (probe.status !== 0 || !path.isAbsolute(probe.stdout || '')) throw new Error('Cannot resolve zsh startup directory');
|
|
38
|
+
files.push(path.join(probe.stdout, '.zprofile'), path.join(probe.stdout, '.zshrc'));
|
|
39
|
+
} else if (kind === 'bash') {
|
|
40
|
+
files.push(path.join(home, '.bashrc'));
|
|
41
|
+
files.push(['.bash_profile', '.bash_login', '.profile'].map((name) => path.join(home, name)).find((file) => fs.existsSync(file)) || path.join(home, '.profile'));
|
|
42
|
+
} else if (['sh', 'dash', 'ksh'].includes(kind)) {
|
|
43
|
+
files.push(path.join(home, '.profile'));
|
|
44
|
+
} else if (kind === 'fish') {
|
|
45
|
+
files.push(path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'fish', 'config.fish'));
|
|
46
|
+
} else {
|
|
47
|
+
throw new Error(`Cannot configure OpenCode PATH for shell ${kind}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const quote = kind === 'fish'
|
|
51
|
+
? (value) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
52
|
+
: (value) => `'${value.replace(/'/g, "'\\''")}'`;
|
|
53
|
+
const envFile = path.join(dataDir, kind === 'fish' ? 'opencode-path.fish' : 'opencode-path.sh');
|
|
54
|
+
const content = kind === 'fish'
|
|
55
|
+
? `contains -- ${quote(directory)} $PATH; or set -gx PATH ${quote(directory)} $PATH\n`
|
|
56
|
+
: `case ":$PATH:" in *${quote(`:${directory}:`)}*) ;; *) export PATH=${quote(directory)}:"$PATH" ;; esac\n`;
|
|
57
|
+
fs.mkdirSync(path.dirname(envFile), { recursive: true });
|
|
58
|
+
fs.writeFileSync(`${envFile}.tmp`, content, { mode: 0o600 });
|
|
59
|
+
fs.renameSync(`${envFile}.tmp`, envFile);
|
|
60
|
+
const line = `${kind === 'fish' ? 'source' : '.'} ${quote(envFile)} # Yuri OpenCode CLI PATH`;
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
|
+
let existing = '';
|
|
64
|
+
try { existing = fs.readFileSync(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
65
|
+
if (!existing.split('\n').includes(line)) fs.appendFileSync(file, `\n${line}\n`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { afterEach, expect, test, vi } from 'vitest';
|
|
6
|
+
import { ensurePinnedOpenCode2Cli, installPinnedOpenCode2Cli } from './ensure-cli.js';
|
|
7
|
+
import { configureOpenCodeTerminalPath } from './terminal-path.js';
|
|
8
|
+
|
|
9
|
+
const roots = [];
|
|
10
|
+
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
|
|
11
|
+
|
|
12
|
+
test.each(['/bin/bash', '/bin/zsh'].filter((shell) => fs.existsSync(shell)))('fresh installation is available in %s, preserves rc content and is idempotent', async (shell) => {
|
|
13
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "yuri cli's "));
|
|
14
|
+
roots.push(home);
|
|
15
|
+
const dataDir = path.join(home, '.config', 'yuri');
|
|
16
|
+
const binary = path.join(dataDir, 'opencode-cli', '2.9.1', 'opencode');
|
|
17
|
+
const zdotdir = path.join(home, 'zsh');
|
|
18
|
+
fs.mkdirSync(zdotdir);
|
|
19
|
+
const rc = shell.endsWith('zsh') ? path.join(zdotdir, '.zshrc') : path.join(home, '.bashrc');
|
|
20
|
+
fs.writeFileSync(rc, '# user configuration\n');
|
|
21
|
+
const options = { pin: '2.9.1', dataDir, homedir: () => home,
|
|
22
|
+
env: { HOME: home, ZDOTDIR: zdotdir, SHELL: shell, PATH: '/usr/bin:/bin' },
|
|
23
|
+
install: vi.fn(async () => {
|
|
24
|
+
fs.mkdirSync(path.dirname(binary), { recursive: true });
|
|
25
|
+
fs.writeFileSync(binary, '#!/bin/sh\nprintf "2.9.1\\n"\n', { mode: 0o755 });
|
|
26
|
+
return binary;
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
await ensurePinnedOpenCode2Cli(options);
|
|
30
|
+
const first = fs.readFileSync(rc, 'utf8');
|
|
31
|
+
expect(first).toContain('# user configuration\n');
|
|
32
|
+
const result = spawnSync(shell, ['-ic', 'opencode --version'], {
|
|
33
|
+
env: options.env, encoding: 'utf8', timeout: 5000,
|
|
34
|
+
});
|
|
35
|
+
expect(result.status, result.stderr).toBe(0);
|
|
36
|
+
expect(result.stdout.trim()).toBe('2.9.1');
|
|
37
|
+
await ensurePinnedOpenCode2Cli(options);
|
|
38
|
+
expect(fs.readFileSync(rc, 'utf8')).toBe(first);
|
|
39
|
+
expect(options.install).toHaveBeenCalledTimes(1);
|
|
40
|
+
|
|
41
|
+
await installPinnedOpenCode2Cli({ ...options, version: '2.10.0',
|
|
42
|
+
fetch: async (url) => String(url).endsWith('/2.10.0')
|
|
43
|
+
? Response.json({ dist: { tarball: 'https://example.com/cli.tgz' } }) : new Response('fixture'),
|
|
44
|
+
extract: async (_archive, target) => {
|
|
45
|
+
fs.mkdirSync(target, { recursive: true });
|
|
46
|
+
fs.writeFileSync(path.join(target, 'opencode'), '#!/bin/sh\nprintf "2.10.0\\n"\n');
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const upgraded = spawnSync(shell, ['-ic', 'opencode --version'], { env: options.env, encoding: 'utf8', timeout: 5000 });
|
|
50
|
+
expect(upgraded.status, upgraded.stderr).toBe(0);
|
|
51
|
+
expect(upgraded.stdout.trim()).toBe('2.10.0');
|
|
52
|
+
expect(fs.readFileSync(rc, 'utf8')).toBe(first);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('Windows updates only user PATH using a hidden first-level process and reports failure', () => {
|
|
56
|
+
const spawn = vi.fn(() => ({ status: 0 }));
|
|
57
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'yuri-win-path-'));
|
|
58
|
+
roots.push(dataDir);
|
|
59
|
+
const binary = path.join(dataDir, 'opencode-cli', '2.9.1', 'opencode.exe');
|
|
60
|
+
const options = { platform: 'win32', dataDir, homedir: dataDir, spawnSync: spawn, env: {} };
|
|
61
|
+
configureOpenCodeTerminalPath(binary, options);
|
|
62
|
+
expect(spawn).toHaveBeenCalledWith('powershell.exe', expect.arrayContaining(['-NoProfile', '-NonInteractive']), expect.objectContaining({
|
|
63
|
+
windowsHide: true, env: expect.objectContaining({ YURI_CLI_BIN_DIR: path.dirname(binary) }),
|
|
64
|
+
}));
|
|
65
|
+
expect(spawn.mock.calls[0][1].at(-1)).toContain("GetEnvironmentVariable('Path', 'User')");
|
|
66
|
+
spawn.mockReturnValue({ status: 1 });
|
|
67
|
+
expect(() => configureOpenCodeTerminalPath(binary, options)).toThrow('Windows user PATH');
|
|
68
|
+
});
|
|
@@ -18,6 +18,8 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
|
|
|
18
18
|
|
|
19
19
|
## Entrypoints and structure
|
|
20
20
|
|
|
21
|
+
The Host and pairing UI default to `wss://relay.openchamber.dev/ws`. Host endpoint precedence is `YURI_RELAY_URL`, saved `privateRelay.relayUrl`, then the product default. Existing saved pairing endpoints remain authoritative for reconnects.
|
|
22
|
+
|
|
21
23
|
Host side (`packages/web/server/lib/relay/`):
|
|
22
24
|
- `service.js` — thin entrypoint: relay config (enabled flag + endpoints), the management routes (`GET/POST /api/yuri/relay/{status,enable,disable}`, `POST /api/yuri/relay/endpoints`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. **Multi-relay:** `settings.privateRelay = { enabled, relayUrl, extraRelayUrls }` — the host dials EVERY configured endpoint simultaneously with one identity (same serverId); clients treat `serverId + relayUrl` as the instance identity, so the same machine exposed via two domains appears as two separately named instances. The primary (`relayUrl`) owns push registration and stays the default pairing candidate. A pairing link created for an endpoint the host does not have yet APPENDS it as an extra endpoint instead of replacing the primary, so devices paired over the old domain keep working. `POST /relay/endpoints` (owner session / `desktop-local` only) replaces the extra-endpoint list wholesale and starts/stops host connections incrementally. Paired desktop operators may also add an endpoint through pairing creation. Host lifecycle is gated by `isRelayHostRuntime()` (`desktop` or `ssh-remote`): other runtimes (`bun run dev`, `dev:web:hmr`, ordinary CLI `serve`, VS Code, plain `node server`) report `state: 'unavailable'`, refuse `/relay/enable`, `/relay/disable`, `/relay/endpoints`, and relay pairing with 403, never mint a relay identity, never advertise `relayAvailable`, and never call `startRelayHost`. Direct `node server/index.js` forces `YURI_RUNTIME=web` unless it is already `ssh-remote`, so a leftover desktop env cannot open the host-control socket. On host runtimes, started from `packages/web/server/index.js` when demand/opt-in enables the relay. Desktop auto-clears `privateRelay.enabled` when demand drops; `ssh-remote` keeps an explicit opt-in (e.g. `yuri serve --relay-host`) sticky until disabled. The relay endpoint has no hosted default and must be configured in settings or pinned via the `YURI_RELAY_URL` env var; when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. Endpoint identity is scheme/host/path only: accepted schemes are `ws://` and `wss://`; URLs with userinfo are rejected (no silent default fallback); query and fragment are stripped before persistence and pairing candidates. Custom endpoint persistence and Host control-connection switching are explicit authenticated management actions. Pairing creation with `relayUrl` requires an owner UI session, local `desktop-local` shell client, or paired `desktop` operator; `/relay/enable` follows its API auth gate. `derivePushSendUrlFromRelayUrl` maps a canonical Relay `wss://`/`ws://` URL to the same host's `https://`/`http://` `/v1/push/send` path (register is `/v1/push/register-token`); `YURI_PUSH_RELAY_URL` on the Host overrides that derivation. The isolated Push process lives in `packages/relay-server/src/push/` and does not share Layer 1 credentials or tunnel state.
|
|
23
25
|
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
|
|
@@ -23,7 +23,7 @@ import express from 'express';
|
|
|
23
23
|
import { createRelayIdentityRuntime } from './identity.js';
|
|
24
24
|
import { startRelayHost } from './host-client.js';
|
|
25
25
|
|
|
26
|
-
export const DEFAULT_RELAY_URL = '';
|
|
26
|
+
export const DEFAULT_RELAY_URL = 'wss://relay.openchamber.dev/ws';
|
|
27
27
|
|
|
28
28
|
export const RELAY_HOST_DESKTOP_ONLY_MESSAGE =
|
|
29
29
|
'Relay host is only available in the Yuri desktop app or an SSH-managed remote server';
|
|
@@ -33,7 +33,7 @@ vi.mock('./host-client.js', () => ({
|
|
|
33
33
|
|
|
34
34
|
import {
|
|
35
35
|
createRelayService,
|
|
36
|
-
DEFAULT_RELAY_URL as
|
|
36
|
+
DEFAULT_RELAY_URL as PRODUCT_RELAY_URL,
|
|
37
37
|
derivePushSendUrlFromRelayUrl,
|
|
38
38
|
isRelayHostRuntime,
|
|
39
39
|
RELAY_HOST_DESKTOP_ONLY_MESSAGE,
|
|
@@ -91,8 +91,8 @@ describe('resolveEffectiveRelayUrl', () => {
|
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
it('prefers a valid env URL, then settings, then the default', () => {
|
|
94
|
-
expect(resolveEffectiveRelayUrl()).toBe(
|
|
95
|
-
expect(
|
|
94
|
+
expect(resolveEffectiveRelayUrl()).toBe(PRODUCT_RELAY_URL);
|
|
95
|
+
expect(PRODUCT_RELAY_URL).toBe('wss://relay.openchamber.dev/ws');
|
|
96
96
|
expect(
|
|
97
97
|
resolveEffectiveRelayUrl({
|
|
98
98
|
settings: { privateRelay: { relayUrl: 'wss://settings.example/ws' } },
|