Merge origin/main into deferred OpenCode restart branch.

Resolve ProvidersPage and lifecycle conflicts with custom providers and
AppImage ARGV0 stripping. Address review follow-ups: OAuth index helper +
tests, single auth-methods load trigger, shared Google env-alias module with
VS Code parity coverage, and deferred restart for custom provider upsert.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 13:57:05 +00:00
co-authored by Serhii Dziupin
94 changed files with 5949 additions and 372 deletions
+74
View File
@@ -0,0 +1,74 @@
/**
* Sanitize environment objects inherited by user-facing child processes.
*
* Linux AppImage runtimes export `ARGV0` as the AppImage path before launching
* the packaged app. zsh treats an exported `ARGV0` as the argv[0] for every
* external command it spawns, which corrupts Python venv detection and any
* other program that reads argv[0]/$0 while leaving `/proc/self/exe` correct.
*
* See openchamber/openchamber#2588 and pingdotgg/t3code#2509.
*/
import { createRequire } from 'node:module';
import { existsSync } from 'node:fs';
const LINUX_ENV_BINARIES = ['/usr/bin/env', '/bin/env'];
/**
* Remove AppImage `ARGV0` from a mutable env object (or `process.env`).
* @param {NodeJS.ProcessEnv | Record<string, string | undefined> | null | undefined} env
* @returns {typeof env}
*/
export function stripAppImageArgv0Leak(env) {
if (!env || typeof env !== 'object') return env;
if (Object.prototype.hasOwnProperty.call(env, 'ARGV0')) {
delete env.ARGV0;
}
return env;
}
/**
* Clear AppImage `ARGV0` from this process.
*
* Bun keeps a native environ that `bun-pty` inherits even after
* `delete process.env.ARGV0`. On Linux under Bun we also call libc `unsetenv`.
*/
export function clearAppImageArgv0FromProcessEnv() {
delete process.env.ARGV0;
if (process.platform !== 'linux' || typeof Bun === 'undefined') return;
try {
const require = createRequire(import.meta.url);
const { dlopen } = require('bun:ffi');
const libc = dlopen('libc.so.6', {
unsetenv: { args: ['cstring'], returns: 'i32' },
});
libc.symbols.unsetenv(Buffer.from('ARGV0\0'));
} catch {
// Node/Electron and environments without bun:ffi rely on explicit child envs.
}
}
/**
* Resolve a Linux PTY launch that drops native `ARGV0` before the shell starts.
*
* `bun-pty` merges the OS environ into the child, so deleting `ARGV0` from the
* JS env object alone is not enough. Wrapping with `env -u ARGV0` unsets it
* before execing the real shell. No-op on non-Linux platforms.
*
* @param {string} executable
* @param {string[]} args
* @returns {{ executable: string, args: string[] }}
*/
export function resolveLinuxPtyLaunch(executable, args = []) {
if (process.platform !== 'linux') {
return { executable, args };
}
const envBinary = LINUX_ENV_BINARIES.find((candidate) => existsSync(candidate));
if (!envBinary) {
return { executable, args };
}
return {
executable: envBinary,
args: ['-u', 'ARGV0', executable, ...args],
};
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
clearAppImageArgv0FromProcessEnv,
resolveLinuxPtyLaunch,
stripAppImageArgv0Leak,
} from './inherited-env.js';
describe('stripAppImageArgv0Leak', () => {
it('removes ARGV0 from a child env object', () => {
const env = {
PATH: '/usr/bin',
ARGV0: '/path/to/OpenChamber-1.17.2-linux-x86_64.AppImage',
SHELL: '/bin/zsh',
};
expect(stripAppImageArgv0Leak(env)).toBe(env);
expect(env).toEqual({
PATH: '/usr/bin',
SHELL: '/bin/zsh',
});
});
it('is a no-op when ARGV0 is absent', () => {
const env = { PATH: '/usr/bin', SHELL: '/bin/bash' };
stripAppImageArgv0Leak(env);
expect(env).toEqual({ PATH: '/usr/bin', SHELL: '/bin/bash' });
});
it('tolerates nullish env values', () => {
expect(stripAppImageArgv0Leak(null)).toBeNull();
expect(stripAppImageArgv0Leak(undefined)).toBeUndefined();
});
});
describe('clearAppImageArgv0FromProcessEnv', () => {
it('removes ARGV0 from process.env', () => {
const previous = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
try {
clearAppImageArgv0FromProcessEnv();
expect(process.env.ARGV0).toBeUndefined();
} finally {
if (previous === undefined) delete process.env.ARGV0;
else process.env.ARGV0 = previous;
}
});
});
describe('resolveLinuxPtyLaunch', () => {
it('wraps the shell with env -u ARGV0 on Linux', () => {
if (process.platform !== 'linux') return;
expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
executable: expect.stringMatching(/\/env$/),
args: ['-u', 'ARGV0', '/bin/zsh', '-l'],
});
});
it('leaves non-Linux launches unchanged', () => {
if (process.platform === 'linux') return;
expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({
executable: '/bin/zsh',
args: ['-l'],
});
});
});
@@ -11,7 +11,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap.
- `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). After readiness it warms the most recently used directories (`getWarmupDirectories` dep, sequential and best-effort) because OpenCode initializes each directory lazily on first request and that cost would otherwise be paid by the user's first interactive session open.
- `packages/web/server/lib/opencode/provider-env-aliases.js`: mirrors known provider credential env aliases into the managed OpenCode process environment (for example `GEMINI_API_KEY` → `GOOGLE_GENERATIVE_AI_API_KEY`) so OpenCode connection detection and the upstream AI SDK agree on the same key names.
- `packages/web/server/lib/opencode/provider-env-aliases.js`: mirrors known provider credential env aliases into the managed OpenCode process environment (for example `GEMINI_API_KEY` → `GOOGLE_GENERATIVE_AI_API_KEY`) so OpenCode connection detection and the upstream AI SDK agree on the same key names. Canonical implementation shared by web lifecycle and the VS Code managed spawn path (`packages/vscode/src/provider-env-aliases.ts` re-exports this module).
- `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
- `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname).
- `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers.
@@ -59,8 +59,14 @@ This module provides OpenCode server integration utilities for the web server ru
- `AUTH_FILE`: Auth file path constant.
- `OPENCODE_DATA_DIR`: OpenCode data directory path constant.
## Public exports (providers.js)
- `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider.
- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override.
- `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, http(s) base URL, models, credentials via `env` or `hasStoredAuth`).
- `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
## Public exports (shared.js)
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants.
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants. `OPENCODE_CONFIG` is resolved at call time for the custom config layer path.
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
- `ensureDirs()`: Creates required OpenCode directories.
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
@@ -84,6 +90,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability)
- `POST /api/opencode/directory`
- `GET /api/provider/:providerId/source`
- `PUT /api/provider` (create/update custom OpenAI-compatible provider config in OpenCode user/project/custom layers via `scope`; secrets stay in auth via the OpenCode auth API)
- `DELETE /api/provider/:providerId/auth`
- Owns lazy auth library loading for provider auth checks/removal.
- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only.
@@ -122,7 +129,9 @@ The runtime maintains active-session count incrementally from idempotent activit
Managed OpenCode launch also merges the environment returned by the agent-tool
runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
be replaced by injected values. External OpenCode processes receive no
OpenChamber tool injection.
OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before
spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage
path (#2588).
Before spawn, `applyProviderEnvAliases` fills unset Google credential aliases
from any present sibling (`GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY`,
@@ -364,6 +373,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
## Public exports (skill-routes.js)
- `registerSkillRoutes(app, dependencies)`: registers skills-related routes:
- Skills config CRUD and metadata under `/api/config/skills*`
- Skill rename via `PATCH /api/config/skills/:name` with `{ renameTo }` (directory rename preserves `SKILL.md` body and supporting files; restricted to managed skill roots under `.opencode/skills|skill`, `.claude/skills`, and `.agents/skills`)
- Skill list responses include authoritative `renamable` derived from the same managed-root policy used by rename
- Skills catalog listing/source pagination, scan, and install routes
- Supporting skill file read/write/delete routes
- Directory resolution prefers an explicit request directory, then soft-falls
@@ -1078,6 +1078,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
req.path.startsWith('/api/push') ||
req.path.startsWith('/api/notifications') ||
req.path.startsWith('/api/permission-auto-accept') ||
req.path.startsWith('/api/provider') ||
req.path.startsWith('/api/session-folders') ||
req.path.startsWith('/api/small-model') ||
req.path.startsWith('/api/walkthrough') ||
@@ -127,6 +127,37 @@ describe('core-routes', () => {
expect(response.body).toEqual({ body: { content: 'Snippet body' } });
});
it('should parse JSON bodies for custom provider upsert routes', async () => {
const app = express();
registerCommonRequestMiddleware(app, { express });
app.put('/api/provider', (req, res) => {
res.json({ body: req.body });
});
const response = await request(app)
.put('/api/provider')
.send({
providerID: 'campus-llm',
config: {
name: 'Campus LLM',
options: { baseURL: 'https://llm.example.edu/v1' },
models: { fast: { name: 'Fast' } },
},
})
.expect(200);
expect(response.body).toEqual({
body: {
providerID: 'campus-llm',
config: {
name: 'Campus LLM',
options: { baseURL: 'https://llm.example.edu/v1' },
models: { fast: { name: 'Fast' } },
},
},
});
});
it('should require API auth before probing loopback preview URLs', async () => {
const app = express();
const originalFetch = globalThis.fetch;
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { clearAppImageArgv0FromProcessEnv } from '../inherited-env.js';
import { mergePathValues } from './path-utils.js';
export const createOpenCodeEnvRuntime = (deps) => {
@@ -227,12 +228,16 @@ export const createOpenCodeEnvRuntime = (deps) => {
};
const applyLoginShellEnvSnapshot = () => {
// Always clear AppImage ARGV0, even when no login-shell snapshot is available.
// Otherwise a leaked process.env.ARGV0 survives into later child spawns (#2588).
clearAppImageArgv0FromProcessEnv();
const snapshot = getLoginShellEnvSnapshot();
if (!snapshot) {
return;
}
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']);
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_', 'ARGV0']);
for (const [key, value] of Object.entries(snapshot)) {
if (skipKeys.has(key)) {
continue;
@@ -131,6 +131,43 @@ describe('OpenCode env runtime', () => {
expect(process.env.PATH).toBe(defaultDir);
});
it('clears AppImage ARGV0 when applying a login-shell env snapshot', () => {
const previousArgv0 = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
delete process.env.OPENCHAMBER_ARGV0_TEST_MARKER;
const { runtime, state } = createRuntime({});
state.cachedLoginShellEnvSnapshot = {
PATH: '/usr/bin',
ARGV0: '/leaked/from/shell.AppImage',
OPENCHAMBER_ARGV0_TEST_MARKER: '1',
};
try {
runtime.applyLoginShellEnvSnapshot();
expect(process.env.ARGV0).toBeUndefined();
expect(process.env.OPENCHAMBER_ARGV0_TEST_MARKER).toBe('1');
} finally {
delete process.env.OPENCHAMBER_ARGV0_TEST_MARKER;
if (previousArgv0 === undefined) delete process.env.ARGV0;
else process.env.ARGV0 = previousArgv0;
}
});
it('clears AppImage ARGV0 even when no login-shell snapshot is available', () => {
const previousArgv0 = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber.AppImage';
const { runtime, state } = createRuntime({});
state.cachedLoginShellEnvSnapshot = null;
try {
runtime.applyLoginShellEnvSnapshot();
expect(process.env.ARGV0).toBeUndefined();
} finally {
if (previousArgv0 === undefined) delete process.env.ARGV0;
else process.env.ARGV0 = previousArgv0;
}
});
it('throws a specific error for a missing configured OpenCode binary in strict mode', async () => {
const { runtime } = createRuntime({ opencodeBinary: '/missing/opencode' });
@@ -19,7 +19,7 @@ import { registerPluginRoutes } from './plugin-routes.js';
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
import { registerOpenCodeRoutes } from './routes.js';
import { getProviderSources, removeProviderConfig } from './providers.js';
import { getProviderSources, removeProviderConfig, upsertProviderConfig } from './providers.js';
import { getAgentSources, getAgentConfig, createAgent, updateAgent, deleteAgent } from './agents.js';
import { getCommandSources, createCommand, updateCommand, deleteCommand } from './commands.js';
import { listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './mcp.js';
@@ -38,7 +38,7 @@ import {
decodePluginId,
} from './plugins.js';
import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js';
import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill } from './skills.js';
import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill, renameSkill, isManagedSkillPath } from './skills.js';
import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js';
import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js';
import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js';
@@ -145,6 +145,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
resolveProjectDirectory,
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
refreshOpenCodeAfterConfigChange,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
@@ -256,6 +257,8 @@ export const createFeatureRoutesRuntime = (dependencies) => {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
+12 -3
View File
@@ -1,5 +1,6 @@
import { spawn, spawnSync } from 'node:child_process';
import net from 'node:net';
import { stripAppImageArgv0Leak } from '../inherited-env.js';
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
import { applyProviderEnvAliases } from './provider-env-aliases.js';
import { recordStartupPerformance } from './startup-performance.js';
@@ -70,7 +71,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
}
};
const hasChildProcessExited = (child) => !child || child.exitCode !== null || child.signalCode !== null;
const hasChildProcessExited = (child) => !child
|| (child.exitCode !== null && child.exitCode !== undefined)
|| (child.signalCode !== null && child.signalCode !== undefined);
const isManagedOpenCodeProcessAlive = () => {
const child = state.openCodeProcess;
@@ -366,6 +369,12 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
return {
url,
pid: child.pid || null,
get exitCode() {
return child.exitCode;
},
get signalCode() {
return child.signalCode;
},
async close() {
await closeManagedOpenCodeChild(child);
},
@@ -519,13 +528,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
timeout: 30000,
cwd: state.openCodeWorkingDirectory,
shellEnvKeysCount: Object.keys(shellEnv).length,
env: applyProviderEnvAliases({
env: stripAppImageArgv0Leak(applyProviderEnvAliases({
...shellEnv,
...process.env,
...managedOpenCodeEnv,
PATH: envPath,
OPENCODE_SERVER_PASSWORD: openCodePassword,
}),
})),
});
if (!serverInstance || !serverInstance.url) {
@@ -233,6 +233,30 @@ describe('OpenCode lifecycle', () => {
warn.mockRestore();
});
it('does not mistake a live managed process wrapper for an exited child', async () => {
const close = vi.fn(async () => {});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
globalThis.fetch = vi.fn(async () => ({
ok: false,
json: async () => null,
}));
const runtime = createRuntime({}, {
openCodePort: 45678,
openCodeProcess: {
pid: process.pid,
close,
},
isOpenCodeReady: true,
});
await runtime.triggerHealthCheck();
expect(close).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('(1/20)'));
warn.mockRestore();
});
it('restarts an exited managed process without waiting for the failure interval', async () => {
const close = vi.fn(async () => {});
const replacement = createMockChild();
@@ -281,8 +305,45 @@ describe('OpenCode lifecycle', () => {
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
expect(options.env.SHELL_ONLY).toBe('yes');
expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password');
expect(server.exitCode).toBeNull();
expect(server.signalCode).toBeNull();
await server.close();
expect(server.signalCode).toBe('SIGTERM');
});
it('strips AppImage ARGV0 from managed OpenCode launch env', async () => {
delete process.env.OPENCODE_BINARY;
const previousArgv0 = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
const child = createMockChild();
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => {
child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
});
return child;
});
try {
const runtime = createRuntime({
getManagedOpenCodeShellEnvSnapshot: vi.fn(() => ({
PATH: '/home/user/.bun/bin:/usr/local/bin:/usr/bin',
ARGV0: '/leaked/from/shell/snapshot.AppImage',
SHELL_ONLY: 'yes',
})),
});
const server = await runtime.startOpenCode();
const [, , options] = spawnMock.mock.calls[0];
expect(options.env).not.toHaveProperty('ARGV0');
expect(options.env.SHELL_ONLY).toBe('yes');
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
await server.close();
} finally {
if (previousArgv0 === undefined) delete process.env.ARGV0;
else process.env.ARGV0 = previousArgv0;
}
});
it('adds managed OpenChamber tool environment without allowing it to replace launch invariants', async () => {
@@ -0,0 +1,3 @@
export function applyProviderEnvAliases(
env: Record<string, string | undefined> | null | undefined,
): Record<string, string | undefined>;
@@ -6,6 +6,10 @@ import {
writeConfig,
} from './shared.js';
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
function getProviderSources(providerId, workingDirectory) {
const layers = readConfigLayers(workingDirectory);
const { userConfig, projectConfig, customConfig, paths } = layers;
@@ -37,6 +41,162 @@ function getProviderSources(providerId, workingDirectory) {
};
}
/**
* Validate a custom OpenAI-compatible provider config payload before persistence.
* Returns { ok: true, value } or { ok: false, error }.
*
* Credentials: either config.env contains a variable name, or hasStoredAuth is true
* (auth.json already has a key — typically after auth.set, or when editing).
*/
function validateCustomProviderConfig(providerId, config, options = {}) {
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
return { ok: false, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
}
if (!isPlainObject(config)) {
return { ok: false, error: 'Provider config must be an object' };
}
const name = typeof config.name === 'string' ? config.name.trim() : '';
if (!name) {
return { ok: false, error: 'Provider name is required' };
}
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
if (npm !== OPENAI_COMPATIBLE_NPM) {
return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
}
const optionsBlock = isPlainObject(config.options) ? config.options : null;
if (!optionsBlock) {
return { ok: false, error: 'Provider options are required' };
}
const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
if (!baseURL) {
return { ok: false, error: 'Base URL is required' };
}
if (!BASE_URL_PATTERN.test(baseURL)) {
return { ok: false, error: 'Base URL must start with http:// or https://' };
}
const models = isPlainObject(config.models) ? config.models : null;
if (!models || Object.keys(models).length === 0) {
return { ok: false, error: 'At least one model is required' };
}
const normalizedModels = {};
for (const [modelId, modelValue] of Object.entries(models)) {
const trimmedId = typeof modelId === 'string' ? modelId.trim() : '';
if (!trimmedId) {
return { ok: false, error: 'Model id is required' };
}
if (!isPlainObject(modelValue)) {
return { ok: false, error: `Model "${trimmedId}" must be an object` };
}
const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : '';
if (!modelName) {
return { ok: false, error: `Model "${trimmedId}" requires a name` };
}
normalizedModels[trimmedId] = { name: modelName };
}
const normalized = {
npm: OPENAI_COMPATIBLE_NPM,
name,
options: {
baseURL,
},
models: normalizedModels,
};
let env = [];
if (Array.isArray(config.env)) {
env = config.env
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim());
if (env.length > 0) {
normalized.env = env;
}
}
const hasStoredAuth = Boolean(options.hasStoredAuth);
if (env.length === 0 && !hasStoredAuth) {
return {
ok: false,
error: 'API key or {env:VAR} credentials are required',
};
}
if (isPlainObject(optionsBlock.headers)) {
const headers = {};
for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
if (typeof headerKey !== 'string' || !headerKey.trim()) {
continue;
}
if (typeof headerValue !== 'string' || !headerValue.trim()) {
return { ok: false, error: `Header "${headerKey}" requires a non-empty value` };
}
headers[headerKey.trim()] = headerValue.trim();
}
if (Object.keys(headers).length > 0) {
normalized.options.headers = headers;
}
}
return { ok: true, value: { providerId, config: normalized } };
}
/**
* Persist (create or update) a custom provider block in OpenCode user/project/custom config.
* Does not write secrets — API keys remain in auth.json via the OpenCode auth API.
*/
function upsertProviderConfig(providerId, config, workingDirectory, scope = 'user', options = {}) {
const validated = validateCustomProviderConfig(providerId, config, options);
if (!validated.ok) {
const error = new Error(validated.error);
error.statusCode = 400;
throw error;
}
const layers = readConfigLayers(workingDirectory);
let targetPath = layers.paths.userPath;
if (scope === 'project') {
if (!workingDirectory) {
throw new Error('Working directory is required for project scope');
}
targetPath = layers.paths.projectPath || targetPath;
} else if (scope === 'custom') {
if (!layers.paths.customPath) {
throw new Error('Custom config path (OPENCODE_CONFIG) is not set');
}
targetPath = layers.paths.customPath;
} else if (scope !== 'user') {
throw new Error('Invalid scope');
}
const targetConfig = getConfigForPath(layers, targetPath);
const providerConfig = isPlainObject(targetConfig.provider) ? { ...targetConfig.provider } : {};
providerConfig[validated.value.providerId] = validated.value.config;
targetConfig.provider = providerConfig;
if (Array.isArray(targetConfig.disabled_providers)) {
targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
(entry) => entry !== validated.value.providerId,
);
}
const writePath = targetPath || CONFIG_FILE;
writeConfig(targetConfig, writePath);
return {
providerId: validated.value.providerId,
path: writePath,
config: validated.value.config,
};
}
function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
if (!providerId || typeof providerId !== 'string') {
throw new Error('Provider ID is required');
@@ -93,4 +253,6 @@ function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
export {
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
validateCustomProviderConfig,
};
@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
upsertProviderConfig,
validateCustomProviderConfig,
getProviderSources,
removeProviderConfig,
} from './providers.js';
let projectDir;
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
describe('custom provider config persistence', () => {
beforeEach(() => {
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-provider-'));
});
afterEach(() => {
fs.rmSync(projectDir, { recursive: true, force: true });
});
test('validateCustomProviderConfig rejects invalid endpoint and credentials shape', () => {
expect(validateCustomProviderConfig('Bad Id', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok).toBe(false);
expect(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'ftp://api.example.com' },
models: { m: { name: 'M' } },
}).error).toContain('http://');
expect(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: {},
}).ok).toBe(false);
});
test('validateCustomProviderConfig rejects missing credentials', () => {
expect(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok).toBe(false);
expect(validateCustomProviderConfig('ok', {
name: 'X',
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}, { hasStoredAuth: true }).ok).toBe(true);
expect(validateCustomProviderConfig('ok', {
name: 'X',
env: ['MY_KEY'],
options: { baseURL: 'https://api.example.com' },
models: { m: { name: 'M' } },
}).ok).toBe(true);
});
test('upsertProviderConfig writes and round-trips project config', () => {
const result = upsertProviderConfig('campus-llm', {
name: 'Campus LLM',
npm: '@ai-sdk/openai-compatible',
options: {
baseURL: 'https://llm.example.edu/v1',
headers: { 'X-Campus': '1' },
},
models: {
'fast-model': { name: 'Fast' },
},
env: ['CAMPUS_KEY'],
}, projectDir, 'project');
expect(result.providerId).toBe('campus-llm');
expect(fs.existsSync(result.path)).toBe(true);
expect(result.path.startsWith(projectDir)).toBe(true);
const written = readJson(result.path);
expect(written.provider['campus-llm']).toEqual({
npm: '@ai-sdk/openai-compatible',
name: 'Campus LLM',
env: ['CAMPUS_KEY'],
options: {
baseURL: 'https://llm.example.edu/v1',
headers: { 'X-Campus': '1' },
},
models: {
'fast-model': { name: 'Fast' },
},
});
const sources = getProviderSources('campus-llm', projectDir);
expect(sources.sources.project.exists).toBe(true);
expect(sources.sources.project.path).toBe(result.path);
});
test('upsertProviderConfig updates existing entry and clears disabled_providers', () => {
const configPath = path.join(projectDir, 'opencode.json');
writeJson(configPath, {
provider: {
'campus-llm': {
npm: '@ai-sdk/openai-compatible',
name: 'Old',
options: { baseURL: 'https://old.example.edu/v1' },
models: { a: { name: 'A' } },
},
},
disabled_providers: ['campus-llm', 'other'],
});
upsertProviderConfig('campus-llm', {
name: 'Campus LLM',
options: { baseURL: 'https://llm.example.edu/v1' },
models: { b: { name: 'B' } },
env: ['CAMPUS_KEY'],
}, projectDir, 'project');
const written = readJson(configPath);
expect(written.provider['campus-llm'].name).toBe('Campus LLM');
expect(written.provider['campus-llm'].models).toEqual({ b: { name: 'B' } });
expect(written.disabled_providers).toEqual(['other']);
});
test('upsert then remove restores absence', () => {
upsertProviderConfig('temp-provider', {
name: 'Temp',
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
env: ['TEMP_KEY'],
}, projectDir, 'project');
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(true);
expect(removeProviderConfig('temp-provider', projectDir, 'project')).toBe(true);
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(false);
});
test('failed validation does not write config', () => {
const configPath = path.join(projectDir, 'opencode.json');
expect(() => upsertProviderConfig('ok', {
name: 'X',
options: { baseURL: 'not-a-url' },
models: { m: { name: 'M' } },
env: ['X'],
}, projectDir, 'project')).toThrow(/Base URL/);
expect(fs.existsSync(configPath)).toBe(false);
});
test('upsert with hasStoredAuth allows config without env', () => {
const result = upsertProviderConfig('keyed-provider', {
name: 'Keyed',
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'project', { hasStoredAuth: true });
expect(result.providerId).toBe('keyed-provider');
expect(result.config.env).toEqual(undefined);
});
test('project-scope edit updates project layer without creating a user entry', () => {
const providerId = `proj-scope-${Date.now()}`;
const configPath = path.join(projectDir, 'opencode.json');
upsertProviderConfig(providerId, {
name: 'Project Scoped',
options: { baseURL: 'https://project.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'project', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Project Scoped Updated',
options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } },
models: { m: { name: 'M2' } },
}, projectDir, 'project', { hasStoredAuth: true });
const written = readJson(configPath);
expect(written.provider[providerId]).toEqual({
npm: '@ai-sdk/openai-compatible',
name: 'Project Scoped Updated',
options: {
baseURL: 'https://project.example.com/v2',
headers: { 'X-Project': '1' },
},
models: { m: { name: 'M2' } },
});
const sources = getProviderSources(providerId, projectDir);
expect(sources.sources.project.exists).toBe(true);
expect(sources.sources.user.exists).toBe(false);
expect(sources.sources.custom.exists).toBe(false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
expect(userConfig.provider?.[providerId]).toBeUndefined();
expect(userConfig.providers?.[providerId]).toBeUndefined();
}
});
test('custom-scope edit updates custom layer without creating a user entry', () => {
const providerId = `custom-scope-${Date.now()}`;
const customPath = path.join(projectDir, 'custom-opencode.json');
const previousEnv = process.env.OPENCODE_CONFIG;
process.env.OPENCODE_CONFIG = customPath;
try {
upsertProviderConfig(providerId, {
name: 'Custom Scoped',
options: { baseURL: 'https://custom.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'custom', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Custom Scoped Updated',
options: { baseURL: 'https://custom.example.com/v2' },
models: { n: { name: 'N' } },
}, projectDir, 'custom', { hasStoredAuth: true });
const written = readJson(customPath);
expect(written.provider[providerId].name).toBe('Custom Scoped Updated');
expect(written.provider[providerId].options.baseURL).toBe('https://custom.example.com/v2');
const sources = getProviderSources(providerId, projectDir);
expect(sources.sources.custom.exists).toBe(true);
expect(sources.sources.user.exists).toBe(false);
expect(sources.sources.project.exists).toBe(false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
expect(userConfig.provider?.[providerId]).toBeUndefined();
expect(userConfig.providers?.[providerId]).toBeUndefined();
}
} finally {
if (previousEnv === undefined) {
delete process.env.OPENCODE_CONFIG;
} else {
process.env.OPENCODE_CONFIG = previousEnv;
}
}
});
});
@@ -20,6 +20,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
resolveProjectDirectory,
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
refreshOpenCodeAfterConfigChange,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
@@ -445,6 +446,63 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
}
});
app.put('/api/provider', async (req, res) => {
try {
const providerID = typeof req.body?.providerID === 'string'
? req.body.providerID.trim()
: (typeof req.body?.providerId === 'string' ? req.body.providerId.trim() : '');
const config = req.body?.config;
const scope = typeof req.body?.scope === 'string' ? req.body.scope : 'user';
if (!providerID) {
return res.status(400).json({ error: 'Provider ID is required' });
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return res.status(400).json({ error: 'Provider config is required' });
}
if (scope !== 'user' && scope !== 'project' && scope !== 'custom') {
return res.status(400).json({ error: 'Invalid scope' });
}
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
if (scope === 'project' || requestedDirectory) {
const resolved = await resolveProjectDirectory(req);
if (!resolved.directory) {
return res.status(400).json({ error: resolved.error || 'Working directory is required' });
}
directory = resolved.directory;
} else {
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
}
}
const { getProviderAuth } = await getAuthLibrary();
const hasStoredAuth = Boolean(getProviderAuth(providerID));
const upsertResult = upsertProviderConfig(providerID, config, directory, scope, { hasStoredAuth });
return res.json({
...buildDeferredRestartResponse(
`Provider ${providerID} saved. Restart OpenCode to apply.`,
),
providerId: upsertResult.providerId,
path: upsertResult.path,
config: upsertResult.config,
});
} catch (error) {
const status = typeof error?.statusCode === 'number' ? error.statusCode : 500;
console.error('Failed to upsert provider config:', error);
return res.status(status).json({ error: error.message || 'Failed to save provider config' });
}
});
app.delete('/api/provider/:providerId/auth', async (req, res) => {
try {
const { providerId } = req.params;
+4 -4
View File
@@ -11,9 +11,6 @@ const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null;
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
// ============== SCOPE TYPE CONSTANTS ==============
@@ -121,7 +118,10 @@ function getConfigPaths(workingDirectory) {
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
],
projectPath: getProjectConfigPath(workingDirectory),
customPath: CUSTOM_CONFIG_FILE
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
customPath: process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null,
};
}
@@ -21,6 +21,8 @@ export const registerSkillRoutes = (app, dependencies) => {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
@@ -236,9 +238,15 @@ export const registerSkillRoutes = (app, dependencies) => {
const enrichedSkills = skills.map((skill) => {
const sources = getSkillSources(skill.name, directory, skill);
const skillPath = typeof skill.path === 'string' ? skill.path : null;
return {
...skill,
sources
sources,
renamable: Boolean(
skillPath
&& skillPath !== '<built-in>'
&& isManagedSkillPath(skillPath, directory)
),
};
});
@@ -628,6 +636,22 @@ export const registerSkillRoutes = (app, dependencies) => {
return res.status(400).json({ error });
}
if (typeof updates?.renameTo === 'string') {
const newName = updates.renameTo.trim();
console.log(`[Server] Renaming skill: ${skillName} -> ${newName}`);
console.log('[Server] Working directory:', directory);
renameSkill(skillName, newName, directory);
await refreshOpenCodeAfterConfigChange('skill rename');
return res.json({
success: true,
name: newName,
requiresReload: true,
message: `Skill renamed to ${newName} successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
}
console.log(`[Server] Updating skill: ${skillName}`);
console.log('[Server] Working directory:', directory);
@@ -9,7 +9,9 @@ import {
deleteSkill,
discoverSkills,
getSkillSources,
isManagedSkillPath,
mergeDiscoveredSkills,
renameSkill,
updateSkill,
} from './skills.js';
import {
@@ -58,6 +60,8 @@ const startSkillsApp = ({ projectRoot }) => {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
@@ -154,4 +158,62 @@ describe('skill-routes directory soft fallback', () => {
const payload = await listResponse.json();
expect(payload.skills.map((skill) => skill.name)).toContain('manual-repo-skill');
});
it('marks managed-root skills renamable and cache skills not renamable', async () => {
projectRoot = createTempProject();
const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-list-skill');
fs.mkdirSync(managedDir, { recursive: true });
fs.writeFileSync(
path.join(managedDir, 'SKILL.md'),
[
'---',
'name: managed-list-skill',
'description: Managed list skill',
'---',
'',
'Managed body',
'',
].join('\n'),
'utf8',
);
const cacheStamp = `oc-skill-routes-${Date.now()}`;
const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-list-skill');
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(
path.join(cacheDir, 'SKILL.md'),
[
'---',
'name: cache-list-skill',
'description: Cache list skill',
'---',
'',
'Cache body',
'',
].join('\n'),
'utf8',
);
try {
appHandle = startSkillsApp({ projectRoot });
const listResponse = await fetch(
`${appHandle.baseUrl}/api/config/skills?directory=${encodeURIComponent(projectRoot)}`,
);
expect(listResponse.status).toBe(200);
const payload = await listResponse.json();
const managed = payload.skills.find((entry) => entry.name === 'managed-list-skill');
const cached = payload.skills.find((entry) => entry.name === 'cache-list-skill');
expect(managed).toBeTruthy();
expect(managed.renamable).toBe(true);
expect(cached).toBeTruthy();
expect(cached.renamable).toBe(false);
} finally {
fs.rmSync(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), {
recursive: true,
force: true,
});
}
});
});
+140 -4
View File
@@ -412,12 +412,22 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
return sources;
}
function createSkill(skillName, config, workingDirectory, scope) {
ensureDirs();
function isValidSkillName(skillName) {
return typeof skillName === 'string'
&& skillName.length > 0
&& skillName.length <= 64
&& /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName);
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
function assertValidSkillName(skillName) {
if (!isValidSkillName(skillName)) {
throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`);
}
}
function createSkill(skillName, config, workingDirectory, scope) {
ensureDirs();
assertValidSkillName(skillName);
const existing = getSkillScope(skillName, workingDirectory);
if (existing.path) {
@@ -505,7 +515,7 @@ function updateSkill(skillName, updates, workingDirectory, targetPath = null) {
let mdModified = false;
for (const [field, value] of Object.entries(updates)) {
if (field === 'scope' || field === 'source' || field === 'targetPath') {
if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') {
continue;
}
@@ -592,6 +602,130 @@ function deleteSkill(skillName, workingDirectory) {
}
}
function isPathInside(candidatePath, parentPath) {
if (!candidatePath || !parentPath) return false;
const resolvedCandidate = path.resolve(candidatePath);
const resolvedParent = path.resolve(parentPath);
return resolvedCandidate === resolvedParent
|| resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`);
}
function getManagedSkillRoots(workingDirectory) {
const roots = [];
const pushRoot = (dir) => {
if (!dir) return;
const resolved = path.resolve(dir);
if (!roots.includes(resolved)) {
roots.push(resolved);
}
};
pushRoot(SKILL_DIR);
pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill'));
pushRoot(path.join(os.homedir(), '.opencode', 'skills'));
pushRoot(path.join(os.homedir(), '.opencode', 'skill'));
pushRoot(path.join(os.homedir(), '.claude', 'skills'));
pushRoot(path.join(os.homedir(), '.agents', 'skills'));
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
: null;
if (customConfigDir) {
pushRoot(path.join(customConfigDir, 'skills'));
pushRoot(path.join(customConfigDir, 'skill'));
}
if (workingDirectory) {
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) {
pushRoot(path.join(ancestor, '.opencode', 'skills'));
pushRoot(path.join(ancestor, '.opencode', 'skill'));
pushRoot(path.join(ancestor, '.claude', 'skills'));
pushRoot(path.join(ancestor, '.agents', 'skills'));
}
}
return roots;
}
function isManagedSkillPath(skillMdPath, workingDirectory) {
if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) {
return false;
}
const skillDir = path.dirname(path.resolve(skillMdPath));
return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root));
}
function renameSkill(oldName, newName, workingDirectory) {
ensureDirs();
assertValidSkillName(newName);
if (oldName === newName) {
return;
}
const existing = getSkillScope(oldName, workingDirectory);
if (!existing.path) {
throw new Error(`Skill "${oldName}" not found`);
}
if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) {
throw new Error(`Skill "${oldName}" cannot be renamed`);
}
if (path.basename(existing.path) !== 'SKILL.md') {
throw new Error(`Skill "${oldName}" target must be a SKILL.md file`);
}
if (!isManagedSkillPath(existing.path, workingDirectory)) {
throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`);
}
const mdDataBeforeMove = parseMdFile(existing.path);
const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string'
? mdDataBeforeMove.frontmatter.name
: oldName;
if (frontmatterName !== oldName) {
throw new Error(`Skill "${oldName}" does not match ${existing.path}`);
}
const conflict = getSkillScope(newName, workingDirectory);
if (conflict.path) {
throw new Error(`Skill ${newName} already exists at ${conflict.path}`);
}
const oldDir = path.dirname(existing.path);
const newDir = path.join(path.dirname(oldDir), newName);
const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir);
if (directoriesDiffer && fs.existsSync(newDir)) {
throw new Error(`Skill directory already exists at ${newDir}`);
}
// Rename the skill directory in place so supporting files and SKILL.md body are preserved.
if (directoriesDiffer) {
fs.renameSync(oldDir, newDir);
}
const newPath = path.join(newDir, 'SKILL.md');
try {
const mdData = parseMdFile(newPath);
mdData.frontmatter = {
...mdData.frontmatter,
name: newName,
};
writeMdFile(newPath, mdData.frontmatter, mdData.body);
} catch (error) {
if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) {
try {
fs.renameSync(newDir, oldDir);
} catch (rollbackError) {
console.error(`Failed to rollback skill rename from ${newDir} to ${oldDir}:`, rollbackError);
}
}
throw error;
}
console.log(`Renamed skill: ${oldName} -> ${newName} (path: ${newPath})`);
}
export {
getSkillSources,
discoverSkills,
@@ -599,4 +733,6 @@ export {
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
};
+196 -1
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import fsPromises from 'fs/promises';
import os from 'os';
import path from 'path';
import { discoverSkills, getSkillSources, mergeDiscoveredSkills } from './skills.js';
import { discoverSkills, getSkillSources, mergeDiscoveredSkills, renameSkill } from './skills.js';
describe('skills', () => {
it('merges locally discovered skills missing from OpenCode live discovery', () => {
@@ -147,4 +148,198 @@ describe('skills', () => {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('renames a skill directory while preserving SKILL.md body and supporting files', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-'));
const projectRoot = path.join(tempRoot, 'project');
const skillDir = path.join(projectRoot, '.opencode', 'skills', 'original-skill');
const skillPath = path.join(skillDir, 'SKILL.md');
const supportPath = path.join(skillDir, 'notes.md');
const body = [
'# Original Skill',
'',
'Preserve this non-trivial body across rename.',
'',
'## Details',
'',
'- step one',
'- step two',
].join('\n');
try {
await fsPromises.mkdir(skillDir, { recursive: true });
await fsPromises.writeFile(
skillPath,
[
'---',
'name: original-skill',
'description: Original skill description',
'license: MIT',
'---',
'',
body,
'',
].join('\n'),
'utf8',
);
await fsPromises.writeFile(supportPath, 'supporting file contents\n', 'utf8');
renameSkill('original-skill', 'renamed-skill', projectRoot);
const renamedDir = path.join(projectRoot, '.opencode', 'skills', 'renamed-skill');
const renamedPath = path.join(renamedDir, 'SKILL.md');
const renamedSupportPath = path.join(renamedDir, 'notes.md');
expect(fs.existsSync(skillDir)).toBe(false);
expect(fs.existsSync(renamedPath)).toBe(true);
expect(fs.existsSync(renamedSupportPath)).toBe(true);
const sources = getSkillSources('renamed-skill', projectRoot, {
name: 'renamed-skill',
path: renamedPath,
scope: 'project',
source: 'opencode',
description: 'fallback',
});
expect(sources.md.exists).toBe(true);
expect(sources.md.name).toBe('renamed-skill');
expect(sources.md.description).toBe('Original skill description');
expect(sources.md.instructions).toBe(body);
expect(await fsPromises.readFile(renamedSupportPath, 'utf8')).toBe('supporting file contents\n');
const raw = await fsPromises.readFile(renamedPath, 'utf8');
expect(raw).toContain('license: MIT');
expect(raw).not.toContain('Renamed skill');
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('rolls back the directory rename when frontmatter write fails', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-rollback-'));
const projectRoot = path.join(tempRoot, 'project');
const skillDir = path.join(projectRoot, '.opencode', 'skills', 'rollback-skill');
const skillPath = path.join(skillDir, 'SKILL.md');
const body = '# Rollback body\n\nMust remain in the original directory.';
try {
await fsPromises.mkdir(skillDir, { recursive: true });
await fsPromises.writeFile(
skillPath,
[
'---',
'name: rollback-skill',
'description: Rollback skill',
'---',
'',
body,
'',
].join('\n'),
'utf8',
);
await fsPromises.chmod(skillPath, 0o444);
expect(() => renameSkill('rollback-skill', 'rollback-skill-renamed', projectRoot)).toThrow();
expect(fs.existsSync(skillDir)).toBe(true);
expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'rollback-skill-renamed'))).toBe(false);
expect(await fsPromises.readFile(skillPath, 'utf8')).toContain(body);
} finally {
try {
await fsPromises.chmod(skillPath, 0o644);
} catch {
// Best-effort cleanup when the file was rolled back under a different mode.
}
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('rejects invalid names, missing skills, conflicts, unmanaged paths, and frontmatter mismatches', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-reject-'));
const projectRoot = path.join(tempRoot, 'project');
const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-skill');
const conflictDir = path.join(projectRoot, '.opencode', 'skills', 'taken-name');
const mismatchDir = path.join(projectRoot, '.opencode', 'skills', 'folder-name');
const cacheStamp = `oc-rename-${Date.now()}`;
const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-skill');
try {
await fsPromises.mkdir(managedDir, { recursive: true });
await fsPromises.writeFile(
path.join(managedDir, 'SKILL.md'),
[
'---',
'name: managed-skill',
'description: Managed',
'---',
'',
'Managed body',
'',
].join('\n'),
'utf8',
);
await fsPromises.mkdir(conflictDir, { recursive: true });
await fsPromises.writeFile(
path.join(conflictDir, 'SKILL.md'),
[
'---',
'name: taken-name',
'description: Taken',
'---',
'',
'Taken body',
'',
].join('\n'),
'utf8',
);
await fsPromises.mkdir(mismatchDir, { recursive: true });
await fsPromises.writeFile(
path.join(mismatchDir, 'SKILL.md'),
[
'---',
'name: frontmatter-name',
'description: Mismatch',
'---',
'',
'Mismatch body',
'',
].join('\n'),
'utf8',
);
await fsPromises.mkdir(cacheDir, { recursive: true });
await fsPromises.writeFile(
path.join(cacheDir, 'SKILL.md'),
[
'---',
'name: cache-skill',
'description: Cache skill',
'---',
'',
'Cache body',
'',
].join('\n'),
'utf8',
);
expect(() => renameSkill('managed-skill', 'Invalid_Name', projectRoot)).toThrow(/Invalid skill name/);
expect(() => renameSkill('missing-skill', 'new-skill', projectRoot)).toThrow(/not found/);
expect(() => renameSkill('managed-skill', 'taken-name', projectRoot)).toThrow(/already exists/);
expect(() => renameSkill('folder-name', 'renamed-mismatch', projectRoot)).toThrow(/does not match/);
expect(() => renameSkill('cache-skill', 'cache-skill-renamed', projectRoot)).toThrow(/managed skill directories/);
expect(fs.existsSync(managedDir)).toBe(true);
expect(fs.existsSync(cacheDir)).toBe(true);
expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'renamed-mismatch'))).toBe(false);
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
await fsPromises.rm(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), {
recursive: true,
force: true,
});
}
});
});
@@ -10,11 +10,12 @@
- `attach` registers a connection for one terminal. One socket may attach to many terminals.
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence.
- A current socket that closes or errors before its initial `open` invalidates its URL-scoped auth token before retrying, so retries mint a fresh token instead of backing off against a rejected upgrade. Hidden or offline clients wait 60 seconds and wake promptly on visibility/online recovery.
- `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed.
- Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery.
- `write` always includes the terminal ID; sockets never have mutable single-terminal binding state.
- `detach` removes only that attachment.
- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, and Mode 2031 queries immediately, including queries emitted before a WebSocket attachment exists. Subscribed TUIs receive a Mode 2031 notification when the appearance changes.
- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, Mode 2031, and primary-device-attribute queries immediately, including queries emitted before a WebSocket attachment exists. The DA1 fallback prevents Fish from waiting ten seconds for a renderer that cannot observe or answer its startup query. Subscribed TUIs receive a Mode 2031 notification when the appearance changes.
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
@@ -23,7 +24,9 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
- IDs are client-provided or generated with `randomUUID()`.
- Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory.
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap.
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
- PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On Linux, PTY spawn is wrapped with `env -u ARGV0` because `bun-pty` merges the native OS environ and would otherwise reintroduce `ARGV0` after a JS-only delete.
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs.
- PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored.
- Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
+8 -3
View File
@@ -10,6 +10,7 @@ import {
import { sanitizeTerminalHistoryChunk } from './history.js';
import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
import { createTerminalShellResolver, getTerminalShellLoginArgs, normalizeTerminalShell } from './shells.js';
import { stripAppImageArgv0Leak, resolveLinuxPtyLaunch } from '../inherited-env.js';
const MAX_SESSIONS = 20;
const MAX_HISTORY_BYTES = 512 * 1024;
@@ -66,8 +67,12 @@ export function createTerminalRuntime({
// required because bun-pty also inherits Bun's native process environment.
env.NODE_CHANNEL_FD = '';
delete env.BASH_XTRACEFD; delete env.BASH_ENV; delete env.ENV; delete env.ELECTRON_RUN_AS_NODE;
// AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
// bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux.
stripAppImageArgv0Leak(env);
const launch = resolveLinuxPtyLaunch(executable, args);
const options = { name: 'xterm-256color', cwd, cols, rows, env, ...(process.platform === 'win32' ? { useConpty: true } : {}) };
return { process: provider.spawn(executable, args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
return { process: provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
} catch (error) { lastError = error; }
}
throw lastError ?? new Error('No executable shell found');
@@ -145,7 +150,7 @@ export function createTerminalRuntime({
background: session.terminalBackground,
foreground: session.terminalForeground,
modeEnabled: session.themeModeEnabled,
});
}, { respondToPrimaryDeviceAttributes: true });
session.pendingThemeControlSequence = theme.pending;
session.themeModeEnabled = theme.modeEnabled;
for (const response of theme.responses) session.process?.write(response);
@@ -331,7 +336,7 @@ export function createTerminalRuntime({
session.process = spawned.process; session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.cwd = cwd; session.cols = cols; session.rows = rows;
session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; session.status = 'running'; session.exitCode = null; session.signal = null; session.eventQueue.length = 0;
session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground;
wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' });
wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' });
});
pendingSessionRestarts.set(session.id, restart);
try {
@@ -154,8 +154,14 @@ describe('terminal runtime', () => {
expect(harness.processes[0].options.cwd).toBe('/repo');
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007');
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']);
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
expect(harness.processes[0].options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE');
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args.slice(0, 3)).toEqual(['-u', 'ARGV0', expect.any(String)]);
}
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c');
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']);
const appearance = createResponse();
harness.routes.post.get('/api/terminal/:sessionId/appearance')({ params: { sessionId: 'term-1' }, body: { themeMode: 'dark' } }, appearance);
@@ -173,6 +179,27 @@ describe('terminal runtime', () => {
} finally { await harness.runtime.shutdown(); }
});
it('strips AppImage ARGV0 from PTY child environments', async () => {
const previousArgv0 = process.env.ARGV0;
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
const harness = createHarness();
try {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-argv0', cwd: '/repo', cols: 80, rows: 24 } }, response);
expect(response.statusCode).toBe(200);
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args[0]).toBe('-u');
expect(harness.processes[0].args[1]).toBe('ARGV0');
}
} finally {
if (previousArgv0 === undefined) delete process.env.ARGV0;
else process.env.ARGV0 = previousArgv0;
await harness.runtime.shutdown();
}
});
it('lists available shells and uses the selected shell for create and restart', async () => {
const executables = new Set(['/bin/zsh', '/bin/bash', '/bin/sh']);
const harness = createHarness({
@@ -198,14 +225,24 @@ describe('terminal runtime', () => {
const created = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-shell', cwd: '/repo', shell: 'zsh', loginShell: true } }, created);
expect(created.statusCode).toBe(200);
expect(harness.processes[0].shell).toBe('/bin/zsh');
expect(harness.processes[0].args).toEqual(['-l']);
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/zsh', '-l']);
} else {
expect(harness.processes[0].shell).toBe('/bin/zsh');
expect(harness.processes[0].args).toEqual(['-l']);
}
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-shell' }, body: { shell: 'bash', loginShell: true } }, restarted);
expect(restarted.statusCode).toBe(200);
expect(harness.processes[1].shell).toBe('/bin/bash');
expect(harness.processes[1].args).toEqual(['-l']);
if (process.platform === 'linux') {
expect(harness.processes[1].shell).toMatch(/\/env$/);
expect(harness.processes[1].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l']);
} else {
expect(harness.processes[1].shell).toBe('/bin/bash');
expect(harness.processes[1].args).toEqual(['-l']);
}
} finally { await harness.runtime.shutdown(); }
});
@@ -2,11 +2,21 @@ const MODE_SET = '\u001b[?2031h';
const MODE_RESET = '\u001b[?2031l';
const CAPABILITY_QUERY = '\u001b[?2031$p';
const MODE_QUERIES = ['\u001b[?996n', '\u001b[?997n'];
// Fish asks this before an unattached browser terminal can reply.
const PRIMARY_DEVICE_ATTRIBUTE_QUERIES = ['\u001b[c', '\u001b[0c'];
const PRIMARY_DEVICE_ATTRIBUTE_RESPONSE = '\u001b[?1;2c';
const OSC_QUERIES = [10, 11].flatMap((code) => [
{ sequence: `\u001b]${code};?\u0007`, code },
{ sequence: `\u001b]${code};?\u001b\\`, code },
]);
const CONTROL_SEQUENCES = [MODE_SET, MODE_RESET, CAPABILITY_QUERY, ...MODE_QUERIES, ...OSC_QUERIES.map(({ sequence }) => sequence)];
const CONTROL_SEQUENCES = [
MODE_SET,
MODE_RESET,
CAPABILITY_QUERY,
...MODE_QUERIES,
...PRIMARY_DEVICE_ATTRIBUTE_QUERIES,
...OSC_QUERIES.map(({ sequence }) => sequence),
];
const parseColor = (value) => {
if (typeof value !== 'string') return null;
@@ -28,7 +38,12 @@ const colorReport = (code, color) => {
export const terminalThemeModeReport = (themeMode) => `\u001b[?997;${themeMode === 'light' ? 2 : 1}n`;
export const consumeTerminalThemeQueries = (pending, data, appearance) => {
export const consumeTerminalThemeQueries = (
pending,
data,
appearance,
{ respondToPrimaryDeviceAttributes = false } = {},
) => {
if (!pending && !data.includes('\u001b')) return { pending: '', responses: [], modeEnabled: appearance.modeEnabled === true };
const input = `${pending}${data}`;
const responses = [];
@@ -56,6 +71,15 @@ export const consumeTerminalThemeQueries = (pending, data, appearance) => {
index += modeQuery.length - 1;
continue;
}
const primaryDeviceAttributeQuery = PRIMARY_DEVICE_ATTRIBUTE_QUERIES.find((query) => input.startsWith(query, index));
if (primaryDeviceAttributeQuery && respondToPrimaryDeviceAttributes) {
// A shell can ask before any browser terminal is attached. Answer with a
// conservative VT100 DA1 response so Fish does not block startup for its
// ten-second query timeout while waiting for a renderer that cannot see it.
responses.push(PRIMARY_DEVICE_ATTRIBUTE_RESPONSE);
index += primaryDeviceAttributeQuery.length - 1;
continue;
}
const oscQuery = OSC_QUERIES.find(({ sequence }) => input.startsWith(sequence, index));
if (oscQuery) {
const response = colorReport(oscQuery.code, oscQuery.code === 10 ? appearance.foreground : appearance.background);
@@ -44,4 +44,27 @@ describe('terminal theme responses', () => {
'\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\',
]);
});
test('answers a primary device attribute query when the fallback is enabled', () => {
const attached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance);
const unattached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance, {
respondToPrimaryDeviceAttributes: true,
});
expect(attached.responses).toEqual([]);
expect(unattached.responses).toEqual(['\u001b[?1;2c']);
});
test('answers a primary device attribute query split across PTY chunks', () => {
const first = consumeTerminalThemeQueries('', '\u001b[0', lightAppearance, {
respondToPrimaryDeviceAttributes: true,
});
const second = consumeTerminalThemeQueries(first.pending, 'c', {
...lightAppearance,
modeEnabled: first.modeEnabled,
}, { respondToPrimaryDeviceAttributes: true });
expect(first.pending).toBe('\u001b[0');
expect(second.responses).toEqual(['\u001b[?1;2c']);
});
});
@@ -136,7 +136,12 @@ silently. The prompt says so explicitly.
`languages.js` owns the accepted tags; they match the UI's `Locale` union, and
anything else — unknown, malformed, absent — resolves to English rather than
failing the request. The default language adds no instruction at all, since the
failing the request. The two lists cannot be one, because the server cannot
import from `packages/ui`, so `languages.test.js` reads `i18n/runtime.ts` and
compares them. That test exists because a locale added to the interface alone
fails silently in the worst way: the picker offers the language, the tag
resolves to English, and the reader pays for a walkthrough written in the wrong
one while the picker still names theirs. The default language adds no instruction at all, since the
system prompt is already English.
The language is part of the cache key. Without that, asking for a translation
@@ -17,6 +17,7 @@ export const DEFAULT_LANGUAGE = 'en';
// which every model handles more reliably than a switch mid-sentence.
const LANGUAGE_NAMES = {
en: 'English',
de: 'German',
fr: 'French',
'zh-CN': 'Simplified Chinese',
'zh-TW': 'Traditional Chinese',
@@ -57,3 +58,9 @@ export function normalizeLanguage(value) {
export function languageName(language) {
return LANGUAGE_NAMES[language] ?? LANGUAGE_NAMES[DEFAULT_LANGUAGE];
}
// The tags this list must agree with live in `packages/ui/src/lib/i18n`, which
// the server cannot import. `languages.test.js` compares the two by reading
// that file, because a locale added on one side only fails silently: the picker
// offers the language and the walkthrough comes back in English.
export const __testing = { LANGUAGE_NAMES };
@@ -0,0 +1,55 @@
import fs from 'fs';
import { fileURLToPath } from 'url';
import { describe, expect, it } from 'vitest';
import { normalizeLanguage, __testing } from './languages.js';
// The languages a walkthrough may be written in have to agree with the locales
// the interface ships, because the picker offers exactly those and the server
// decides what the prompt asks for. The two lists cannot be one list — the
// server cannot import from `packages/ui` — so they are compared here instead.
//
// This exists because German was added to the interface and not here. Nothing
// broke loudly: the picker offered Deutsch, `normalizeLanguage` quietly resolved
// it to English, and a German user paid for a walkthrough written in English
// while the picker still said Deutsch. A drift this quiet needs a test, not
// vigilance.
const RUNTIME_TS = fileURLToPath(new URL('../../../../ui/src/lib/i18n/runtime.ts', import.meta.url));
const interfaceLocales = () => {
const source = fs.readFileSync(RUNTIME_TS, 'utf8');
const match = source.match(/export const LOCALES = \[([^\]]*)\]/);
if (!match) throw new Error(`Could not find LOCALES in ${RUNTIME_TS}`);
return match[1]
.split(',')
.map((entry) => entry.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean);
};
describe('supported languages', () => {
it('covers every locale the interface offers', () => {
const missing = interfaceLocales().filter((locale) => !Object.hasOwn(__testing.LANGUAGE_NAMES, locale));
expect(missing, `add these to LANGUAGE_NAMES in languages.js: ${missing.join(', ')}`).toEqual([]);
});
it('offers nothing the interface cannot label', () => {
const locales = new Set(interfaceLocales());
const extra = Object.keys(__testing.LANGUAGE_NAMES).filter((tag) => !locales.has(tag));
// A language here that the interface does not know is not harmful, but it
// is unreachable: the picker is built from the interface list.
expect(extra, `unreachable from the picker: ${extra.join(', ')}`).toEqual([]);
});
it('resolves every interface locale to itself rather than to the default', () => {
for (const locale of interfaceLocales()) {
expect(normalizeLanguage(locale)).toBe(locale);
}
});
it('names every supported language in English, for the prompt', () => {
for (const [tag, name] of Object.entries(__testing.LANGUAGE_NAMES)) {
expect(name, tag).toMatch(/^[A-Z][A-Za-z ]+$/);
}
});
});