opencode-agent-skills-md 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +770 -0
- package/dist/plugin.mjs +1138 -0
- package/dist/src/cli/config.d.ts +144 -0
- package/dist/src/cli/install.d.ts +33 -0
- package/dist/src/cli/main.d.ts +11 -0
- package/dist/src/cli/real-fs.d.ts +6 -0
- package/dist/src/cli/status.d.ts +34 -0
- package/dist/src/cli/uninstall.d.ts +22 -0
- package/dist/src/host.d.ts +51 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/plugin.d.ts +35 -0
- package/dist/src/sdk.d.ts +51 -0
- package/dist/src/tools.d.ts +86 -0
- package/package.json +48 -18
- package/{packages/opencode-agent-skills-md/src → src}/cli/main.ts +20 -4
- package/.beads/.local_version +0 -1
- package/.beads/README.md +0 -81
- package/.beads/config.yaml +0 -61
- package/.beads/deletions.jsonl +0 -1
- package/.beads/issues.jsonl +0 -64
- package/.beads/metadata.json +0 -4
- package/.gitattributes +0 -3
- package/.github/CODEOWNERS +0 -1
- package/.github/copilot-instructions.md +0 -78
- package/.github/dependabot.yml +0 -13
- package/.github/workflows/release.yml +0 -51
- package/.opencode/command/test-compaction.md +0 -9
- package/.opencode/command/test-find-skills.md +0 -7
- package/.opencode/command/test-read-skill-file.md +0 -14
- package/.opencode/command/test-run-skill-script.md +0 -13
- package/.opencode/command/test-skills.md +0 -14
- package/.opencode/command/test-use-skill.md +0 -10
- package/.opencode/skills/git-helper/SKILL.md +0 -65
- package/.opencode/skills/test-skill/SKILL.md +0 -43
- package/.opencode/skills/test-skill/example-config.json +0 -16
- package/.opencode/skills/test-skill/helper-docs.md +0 -29
- package/.opencode/skills/test-skill/scripts/echo-args +0 -14
- package/.opencode/skills/test-skill/scripts/greet +0 -6
- package/AGENTS.md +0 -43
- package/CHANGELOG.md +0 -178
- package/Justfile +0 -39
- package/README.md +0 -189
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +0 -74
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +0 -64
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +0 -75
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +0 -136
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +0 -77
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +0 -89
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +0 -65
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +0 -77
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +0 -65
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +0 -165
- package/openspec/specs/core-decoupling/spec.md +0 -110
- package/packages/core/package.json +0 -30
- package/packages/core/src/content.d.ts +0 -16
- package/packages/core/src/content.ts +0 -30
- package/packages/core/src/debug.ts +0 -16
- package/packages/core/src/discovery.d.ts +0 -86
- package/packages/core/src/discovery.ts +0 -257
- package/packages/core/src/index.d.ts +0 -20
- package/packages/core/src/index.ts +0 -55
- package/packages/core/src/match.d.ts +0 -19
- package/packages/core/src/match.ts +0 -75
- package/packages/core/src/parse.d.ts +0 -26
- package/packages/core/src/parse.ts +0 -141
- package/packages/core/src/scripts.d.ts +0 -17
- package/packages/core/src/scripts.ts +0 -79
- package/packages/core/src/search.d.ts +0 -83
- package/packages/core/src/search.ts +0 -188
- package/packages/core/src/types.d.ts +0 -82
- package/packages/core/src/types.ts +0 -131
- package/packages/core/src/walk.ts +0 -109
- package/packages/core/tests/agnostic.test.ts +0 -346
- package/packages/core/tests/content.test.ts +0 -65
- package/packages/core/tests/discovery.test.ts +0 -370
- package/packages/core/tests/package-boundary.test.ts +0 -310
- package/packages/core/tests/parse-trigger.test.ts +0 -282
- package/packages/core/tests/search.test.ts +0 -374
- package/packages/core/tests/subpath.test.ts +0 -87
- package/packages/core/tsconfig.json +0 -10
- package/packages/opencode-agent-skills-md/package.json +0 -42
- package/packages/opencode-agent-skills-md/rolldown.config.js +0 -48
- package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +0 -1423
- package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +0 -66
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +0 -12
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +0 -11
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +0 -2
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +0 -1
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +0 -114
- package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +0 -316
- package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +0 -315
- package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +0 -179
- package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +0 -551
- package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +0 -66
- package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +0 -213
- package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +0 -346
- package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +0 -72
- package/packages/opencode-agent-skills-md/tsconfig.build.json +0 -11
- package/packages/opencode-agent-skills-md/tsconfig.json +0 -10
- package/plans/001-ci-gate.md +0 -177
- package/plans/002-is-path-safe.md +0 -243
- package/plans/003-escape-prompts.md +0 -310
- package/plans/004-test-security-paths.md +0 -228
- package/plans/005-stop-swallowing-errors.md +0 -246
- package/plans/006-preserve-jsonc-commas.md +0 -144
- package/plans/007-write-before-purge.md +0 -144
- package/plans/008-reuse-walkdir-for-list-skill-files.md +0 -164
- package/plans/README.md +0 -43
- package/pnpm-workspace.yaml +0 -6
- package/tests/workspace.test.ts +0 -367
- package/tsconfig.json +0 -15
- /package/{packages/opencode-agent-skills-md/src → src}/cli/config.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/install.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/real-fs.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/status.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/uninstall.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/host.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/index.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/plugin.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/sdk.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/tools.ts +0 -0
|
@@ -1,1423 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the `oas` CLI command surface (Phases 1 → 3).
|
|
3
|
-
*
|
|
4
|
-
* Covers:
|
|
5
|
-
* - `parseJsonc`, `normalizePlugin`, `dedupePlugins`, `buildSpecifier`,
|
|
6
|
-
* `matchesPlugin` — pure helpers exercised with handcrafted inputs.
|
|
7
|
-
* - `backupIfWritable`, `rotateBackups`, `writeAtomically` — disk-side
|
|
8
|
-
* helpers exercised against an in-memory `CliFs`.
|
|
9
|
-
* - `loadGlobalConfig`, `resolveGlobalConfigPath` — loader path that
|
|
10
|
-
* uses the injected filesystem and env override.
|
|
11
|
-
* - `runInstall` — fresh install, idempotent re-run with same version,
|
|
12
|
-
* `--dry-run` no-write path, malformed-config abort, dedupe across
|
|
13
|
-
* legacy variants.
|
|
14
|
-
* - `runUninstall` — fresh uninstall, idempotent no-op, partial removal
|
|
15
|
-
* preserving unrelated entries, `--purge` candidate path reporting
|
|
16
|
-
* (dry-run only), `--dry-run` no-write, malformed-config abort.
|
|
17
|
-
* - `runStatus` — installed vs. uninstalled states, `extras` reporting,
|
|
18
|
-
* `.jsonc` format detection.
|
|
19
|
-
* - `runDoctor` — Node version OK path, config shape validation, plugin
|
|
20
|
-
* duplicate-count warning, writability-probe natural "missing dir" path.
|
|
21
|
-
* - `runMain` — valid dispatch (exit 0), missing command (exit 2),
|
|
22
|
-
* unknown command (exit 2), unknown option (exit 2), `--help` / `-h`
|
|
23
|
-
* short-circuit (exit 0).
|
|
24
|
-
*
|
|
25
|
-
* The in-memory adapter (`createMemoryFs`) lives in this file so the tests
|
|
26
|
-
* own its shape and can extend it freely. It mirrors the `CliFs` interface
|
|
27
|
-
* 1:1 — every method is a pure function over the in-memory file map, so
|
|
28
|
-
* tests are deterministic, isolated, and run in milliseconds.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import assert from "node:assert/strict";
|
|
32
|
-
import { afterEach, beforeEach, describe, test } from "node:test";
|
|
33
|
-
import {
|
|
34
|
-
BACKUP_LIMIT,
|
|
35
|
-
PLUGIN_NAME,
|
|
36
|
-
backupIfWritable,
|
|
37
|
-
buildSpecifier,
|
|
38
|
-
type CliFs,
|
|
39
|
-
dedupePlugins,
|
|
40
|
-
loadGlobalConfig,
|
|
41
|
-
matchesPlugin,
|
|
42
|
-
normalizePlugin,
|
|
43
|
-
parseJsonc,
|
|
44
|
-
resolveConfigDir,
|
|
45
|
-
resolveGlobalConfigPath,
|
|
46
|
-
rotateBackups,
|
|
47
|
-
writeAtomically,
|
|
48
|
-
} from "../src/cli/config";
|
|
49
|
-
import { runInstall, type InstallOptions } from "../src/cli/install";
|
|
50
|
-
import { runMain, type MainResult } from "../src/cli/main";
|
|
51
|
-
import { type DoctorResult, runDoctor, runStatus, type StatusResult } from "../src/cli/status";
|
|
52
|
-
import { cachePath, pluginConfigPath, runUninstall, type UninstallOptions } from "../src/cli/uninstall";
|
|
53
|
-
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
// In-memory CliFs
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Build an in-memory `CliFs` adapter.
|
|
60
|
-
*
|
|
61
|
-
* Files are stored as `path → content` string pairs and directories are
|
|
62
|
-
* tracked implicitly: a directory exists iff at least one file below it is
|
|
63
|
-
* recorded. `existsSync` returns true for any recorded file or for any
|
|
64
|
-
* directory that has recorded descendants. `readdirSync` lists files
|
|
65
|
-
* directly under the requested path (one level, matching `node:fs`).
|
|
66
|
-
*
|
|
67
|
-
* Failure injection (write/rename/unlink/copy/mkdir/read) lets tests
|
|
68
|
-
* exercise the cleanup branches without monkey-patching `node:fs`.
|
|
69
|
-
*/
|
|
70
|
-
const createMemoryFs = (initial: Record<string, string> = {}): CliFs & {
|
|
71
|
-
files: () => Record<string, string>;
|
|
72
|
-
setFailNext: (op: "rename" | "write" | null) => void;
|
|
73
|
-
} => {
|
|
74
|
-
const files = new Map<string, string>(Object.entries(initial));
|
|
75
|
-
let failNext: "rename" | "write" | null = null;
|
|
76
|
-
|
|
77
|
-
const listDir = (dir: string): string[] => {
|
|
78
|
-
const out = new Set<string>();
|
|
79
|
-
const prefix = dir.endsWith("/") ? dir : `${dir}/`;
|
|
80
|
-
for (const key of files.keys()) {
|
|
81
|
-
if (!key.startsWith(prefix)) continue;
|
|
82
|
-
const rest = key.slice(prefix.length);
|
|
83
|
-
if (rest.length === 0) continue;
|
|
84
|
-
const first = rest.split("/")[0];
|
|
85
|
-
if (first) out.add(first);
|
|
86
|
-
}
|
|
87
|
-
return Array.from(out);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const hasAny = (path: string): boolean => {
|
|
91
|
-
if (files.has(path)) return true;
|
|
92
|
-
const prefix = path.endsWith("/") ? path : `${path}/`;
|
|
93
|
-
for (const key of files.keys()) {
|
|
94
|
-
if (key.startsWith(prefix)) return true;
|
|
95
|
-
}
|
|
96
|
-
return false;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const fs: CliFs = {
|
|
100
|
-
readFileSync(path) {
|
|
101
|
-
const value = files.get(path);
|
|
102
|
-
if (value === undefined) {
|
|
103
|
-
throw new Error(`ENOENT: no such file '${path}'`);
|
|
104
|
-
}
|
|
105
|
-
return value;
|
|
106
|
-
},
|
|
107
|
-
writeFileSync(path, content) {
|
|
108
|
-
if (failNext === "write") {
|
|
109
|
-
failNext = null;
|
|
110
|
-
throw new Error("synthetic write failure");
|
|
111
|
-
}
|
|
112
|
-
files.set(path, content);
|
|
113
|
-
},
|
|
114
|
-
renameSync(from, to) {
|
|
115
|
-
if (failNext === "rename") {
|
|
116
|
-
failNext = null;
|
|
117
|
-
throw new Error("synthetic rename failure");
|
|
118
|
-
}
|
|
119
|
-
const value = files.get(from);
|
|
120
|
-
if (value === undefined) {
|
|
121
|
-
throw new Error(`ENOENT: no such file '${from}'`);
|
|
122
|
-
}
|
|
123
|
-
files.set(to, value);
|
|
124
|
-
files.delete(from);
|
|
125
|
-
},
|
|
126
|
-
copyFileSync(from, to) {
|
|
127
|
-
const value = files.get(from);
|
|
128
|
-
if (value === undefined) {
|
|
129
|
-
throw new Error(`ENOENT: no such file '${from}'`);
|
|
130
|
-
}
|
|
131
|
-
files.set(to, value);
|
|
132
|
-
},
|
|
133
|
-
unlinkSync(path) {
|
|
134
|
-
if (!files.has(path)) {
|
|
135
|
-
throw new Error(`ENOENT: no such file '${path}'`);
|
|
136
|
-
}
|
|
137
|
-
files.delete(path);
|
|
138
|
-
},
|
|
139
|
-
mkdirSync(_path, _opts) {
|
|
140
|
-
// Implicit: directories exist as soon as a file below them is written.
|
|
141
|
-
},
|
|
142
|
-
readdirSync(path) {
|
|
143
|
-
return listDir(path);
|
|
144
|
-
},
|
|
145
|
-
existsSync(path) {
|
|
146
|
-
return hasAny(path);
|
|
147
|
-
},
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
...fs,
|
|
152
|
-
files: () => Object.fromEntries(files.entries()),
|
|
153
|
-
setFailNext: (op) => {
|
|
154
|
-
failNext = op;
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
// Capture/suppress console.* during CLI runs so test output stays clean.
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
|
|
163
|
-
type ConsoleSnapshot = {
|
|
164
|
-
log: (...args: unknown[]) => void;
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const captureConsole = (): { restore: () => void; output: () => string } => {
|
|
168
|
-
let buffer = "";
|
|
169
|
-
const original = console.log as unknown as (...args: unknown[]) => void;
|
|
170
|
-
console.log = (...args: unknown[]) => {
|
|
171
|
-
buffer += `${args.map(String).join(" ")}\n`;
|
|
172
|
-
};
|
|
173
|
-
return {
|
|
174
|
-
restore: () => {
|
|
175
|
-
console.log = original as ConsoleSnapshot["log"];
|
|
176
|
-
},
|
|
177
|
-
output: () => buffer,
|
|
178
|
-
};
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Multi-channel capture for CLI commands that emit on `console.log`,
|
|
183
|
-
* `console.warn`, and `console.error` (e.g. `runStatus`, `runDoctor`,
|
|
184
|
-
* `runMain` error paths). Each accessor returns the cumulative output of
|
|
185
|
-
* its channel at call time — safe to query after the wrapped command has
|
|
186
|
-
* returned but before `restore()` runs.
|
|
187
|
-
*/
|
|
188
|
-
const captureConsoleAll = (): {
|
|
189
|
-
restore: () => void;
|
|
190
|
-
log: () => string;
|
|
191
|
-
warn: () => string;
|
|
192
|
-
error: () => string;
|
|
193
|
-
all: () => string;
|
|
194
|
-
} => {
|
|
195
|
-
let logBuf = "";
|
|
196
|
-
let warnBuf = "";
|
|
197
|
-
let errorBuf = "";
|
|
198
|
-
const origLog = console.log;
|
|
199
|
-
const origWarn = console.warn;
|
|
200
|
-
const origError = console.error;
|
|
201
|
-
console.log = ((...args: unknown[]) => {
|
|
202
|
-
logBuf += `${args.map(String).join(" ")}\n`;
|
|
203
|
-
}) as typeof console.log;
|
|
204
|
-
console.warn = ((...args: unknown[]) => {
|
|
205
|
-
warnBuf += `${args.map(String).join(" ")}\n`;
|
|
206
|
-
}) as typeof console.warn;
|
|
207
|
-
console.error = ((...args: unknown[]) => {
|
|
208
|
-
errorBuf += `${args.map(String).join(" ")}\n`;
|
|
209
|
-
}) as typeof console.error;
|
|
210
|
-
return {
|
|
211
|
-
restore: () => {
|
|
212
|
-
console.log = origLog;
|
|
213
|
-
console.warn = origWarn;
|
|
214
|
-
console.error = origError;
|
|
215
|
-
},
|
|
216
|
-
log: () => logBuf,
|
|
217
|
-
warn: () => warnBuf,
|
|
218
|
-
error: () => errorBuf,
|
|
219
|
-
all: () => `${logBuf}${warnBuf}${errorBuf}`,
|
|
220
|
-
};
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
// ---------------------------------------------------------------------------
|
|
224
|
-
// Env handling — keep tests hermetic. `resolveConfigDir` reads
|
|
225
|
-
// `process.env`, so each test sets and restores HOME/OPENCODE_CONFIG_DIR.
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
227
|
-
|
|
228
|
-
const ENV_BACKUP = { ...process.env };
|
|
229
|
-
|
|
230
|
-
const restoreEnv = () => {
|
|
231
|
-
for (const key of new Set([...Object.keys(process.env), ...Object.keys(ENV_BACKUP)])) {
|
|
232
|
-
const original = ENV_BACKUP[key];
|
|
233
|
-
if (original === undefined) {
|
|
234
|
-
delete process.env[key];
|
|
235
|
-
} else {
|
|
236
|
-
process.env[key] = original;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
beforeEach(() => {
|
|
242
|
-
restoreEnv();
|
|
243
|
-
delete process.env.OPENCODE_CONFIG_DIR;
|
|
244
|
-
// Tests hard-code `/home/x` paths; this keeps `process.env.HOME`-based
|
|
245
|
-
// resolution aligned with those constants.
|
|
246
|
-
process.env.HOME = "/home/x";
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
afterEach(() => {
|
|
250
|
-
restoreEnv();
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
// ---------------------------------------------------------------------------
|
|
254
|
-
// Constants
|
|
255
|
-
// ---------------------------------------------------------------------------
|
|
256
|
-
|
|
257
|
-
describe("constants", () => {
|
|
258
|
-
test("PLUGIN_NAME matches the npm package name", () => {
|
|
259
|
-
assert.equal(PLUGIN_NAME, "opencode-agent-skills-md");
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
test("BACKUP_LIMIT is a positive integer", () => {
|
|
263
|
-
assert.ok(Number.isInteger(BACKUP_LIMIT));
|
|
264
|
-
assert.ok(BACKUP_LIMIT >= 1);
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
// ---------------------------------------------------------------------------
|
|
269
|
-
// parseJsonc
|
|
270
|
-
// ---------------------------------------------------------------------------
|
|
271
|
-
|
|
272
|
-
describe("parseJsonc", () => {
|
|
273
|
-
test("parses empty / whitespace-only input as empty object", () => {
|
|
274
|
-
assert.deepEqual(parseJsonc(""), {});
|
|
275
|
-
assert.deepEqual(parseJsonc(" \n\t "), {});
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
test("parses plain JSON", () => {
|
|
279
|
-
assert.deepEqual(parseJsonc('{"plugin":["a","b"]}'), { plugin: ["a", "b"] });
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
test("strips single-line comments", () => {
|
|
283
|
-
const raw = `{
|
|
284
|
-
// list of plugins
|
|
285
|
-
"plugin": ["opencode-agent-skills-md"]
|
|
286
|
-
}`;
|
|
287
|
-
assert.deepEqual(parseJsonc(raw), { plugin: ["opencode-agent-skills-md"] });
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
test("strips block comments", () => {
|
|
291
|
-
const raw = `{
|
|
292
|
-
/* primary plugin list */
|
|
293
|
-
"plugin": ["opencode-agent-skills-md"]
|
|
294
|
-
}`;
|
|
295
|
-
assert.deepEqual(parseJsonc(raw), { plugin: ["opencode-agent-skills-md"] });
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
test("preserves double-slashes inside string literals (URLs)", () => {
|
|
299
|
-
const raw = '{"doc": "see https://example.com/docs for more"}';
|
|
300
|
-
assert.deepEqual(parseJsonc(raw), { doc: "see https://example.com/docs for more" });
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
test("handles escaped quotes inside strings without exiting the string", () => {
|
|
304
|
-
const raw = '{"doc": "escaped \\"quote\\" inside"}';
|
|
305
|
-
assert.deepEqual(parseJsonc(raw), { doc: 'escaped "quote" inside' });
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
test("removes trailing commas before } and ]", () => {
|
|
309
|
-
assert.deepEqual(parseJsonc('{"plugin":["a","b",]}'), { plugin: ["a", "b"] });
|
|
310
|
-
assert.deepEqual(parseJsonc('{"nested":{"x":1,}}'), { nested: { x: 1 } });
|
|
311
|
-
assert.deepEqual(parseJsonc('{"list":[1,2,3,]}'), { list: [1, 2, 3] });
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
test("throws on malformed JSON (caller is expected to handle the error)", () => {
|
|
315
|
-
assert.throws(() => parseJsonc("{ broken"), /JSON|Unexpected|broke/i);
|
|
316
|
-
assert.throws(() => parseJsonc('"just a string"'), /must be a JSON object/i);
|
|
317
|
-
assert.throws(() => parseJsonc("[1,2,3]"), /must be a JSON object/i);
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
test("preserves comma inside a string value before closing brace", () => {
|
|
321
|
-
const raw = '{"doc":"keep ,} inside string","plugin":["a",]}';
|
|
322
|
-
assert.deepEqual(parseJsonc(raw), { doc: "keep ,} inside string", plugin: ["a"] });
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
test("preserves comma inside a string value before closing bracket", () => {
|
|
326
|
-
const raw = '{"doc":"keep ,] inside string","list":[1,2,],}';
|
|
327
|
-
assert.deepEqual(parseJsonc(raw), { doc: "keep ,] inside string", list: [1, 2] });
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
test("preserves mixed patterns: string with comma-bracket, structural trailing commas", () => {
|
|
331
|
-
const raw = `{
|
|
332
|
-
"a": "has ,}",
|
|
333
|
-
"b": ["x", "y ,]",],
|
|
334
|
-
"c": {"d":1,}
|
|
335
|
-
}`;
|
|
336
|
-
assert.deepEqual(parseJsonc(raw), { a: "has ,}", b: ["x", "y ,]"], c: { d: 1 } });
|
|
337
|
-
});
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
// ---------------------------------------------------------------------------
|
|
341
|
-
// matchesPlugin / buildSpecifier
|
|
342
|
-
// ---------------------------------------------------------------------------
|
|
343
|
-
|
|
344
|
-
describe("matchesPlugin", () => {
|
|
345
|
-
test("matches bare PLUGIN_NAME", () => {
|
|
346
|
-
assert.equal(matchesPlugin(PLUGIN_NAME), true);
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
test("matches PLUGIN_NAME with version specifier", () => {
|
|
350
|
-
assert.equal(matchesPlugin(`${PLUGIN_NAME}@1.2.3`), true);
|
|
351
|
-
assert.equal(matchesPlugin(`${PLUGIN_NAME}@latest`), true);
|
|
352
|
-
assert.equal(matchesPlugin(`${PLUGIN_NAME}@next`), true);
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
test("does not match unrelated names or partial prefix matches", () => {
|
|
356
|
-
assert.equal(matchesPlugin("opencode-agent-skills-md-other"), false);
|
|
357
|
-
assert.equal(matchesPlugin("other-plugin"), false);
|
|
358
|
-
assert.equal(matchesPlugin(""), false);
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
test("non-string entries return false (legacy object-form leftovers)", () => {
|
|
362
|
-
assert.equal(matchesPlugin(42), false);
|
|
363
|
-
assert.equal(matchesPlugin(null), false);
|
|
364
|
-
assert.equal(matchesPlugin(undefined), false);
|
|
365
|
-
assert.equal(matchesPlugin({ "opencode-agent-skills-md": {} }), false);
|
|
366
|
-
assert.equal(matchesPlugin(["opencode-agent-skills-md"]), false);
|
|
367
|
-
});
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
describe("buildSpecifier", () => {
|
|
371
|
-
test("returns bare PLUGIN_NAME when no version supplied", () => {
|
|
372
|
-
assert.equal(buildSpecifier(), PLUGIN_NAME);
|
|
373
|
-
assert.equal(buildSpecifier(undefined), PLUGIN_NAME);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
test("treats empty and whitespace-only versions as 'no version'", () => {
|
|
377
|
-
assert.equal(buildSpecifier(""), PLUGIN_NAME);
|
|
378
|
-
assert.equal(buildSpecifier(" "), PLUGIN_NAME);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
test("appends @<version> when one is supplied", () => {
|
|
382
|
-
assert.equal(buildSpecifier("1.2.3"), `${PLUGIN_NAME}@1.2.3`);
|
|
383
|
-
assert.equal(buildSpecifier(" 1.2.3 "), `${PLUGIN_NAME}@1.2.3`);
|
|
384
|
-
assert.equal(buildSpecifier("latest"), `${PLUGIN_NAME}@latest`);
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
test("preserves caller-supplied tags and dist-tags", () => {
|
|
388
|
-
assert.equal(buildSpecifier("beta"), `${PLUGIN_NAME}@beta`);
|
|
389
|
-
assert.equal(buildSpecifier("next"), `${PLUGIN_NAME}@next`);
|
|
390
|
-
});
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
// ---------------------------------------------------------------------------
|
|
394
|
-
// normalizePlugin
|
|
395
|
-
// ---------------------------------------------------------------------------
|
|
396
|
-
|
|
397
|
-
describe("normalizePlugin", () => {
|
|
398
|
-
test("returns [] for undefined / null", () => {
|
|
399
|
-
assert.deepEqual(normalizePlugin(undefined), []);
|
|
400
|
-
assert.deepEqual(normalizePlugin(null), []);
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
test("returns [] for non-object, non-array scalars (doctor surfaces these)", () => {
|
|
404
|
-
assert.deepEqual(normalizePlugin(42), []);
|
|
405
|
-
assert.deepEqual(normalizePlugin(true), []);
|
|
406
|
-
assert.deepEqual(normalizePlugin("not-an-array"), []);
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
test("keeps only string entries in an array form", () => {
|
|
410
|
-
const out = normalizePlugin([PLUGIN_NAME, 42, null, "other-plugin"]);
|
|
411
|
-
assert.deepEqual(out, [PLUGIN_NAME, "other-plugin"]);
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
test("converts the legacy object form to its keys, in declaration order", () => {
|
|
415
|
-
const out = normalizePlugin({
|
|
416
|
-
[PLUGIN_NAME]: { foo: 1 },
|
|
417
|
-
"other-plugin": { bar: 2 },
|
|
418
|
-
});
|
|
419
|
-
// Object key ordering is stable in modern engines; we only check the set.
|
|
420
|
-
assert.equal(out.length, 2);
|
|
421
|
-
assert.ok(out.includes(PLUGIN_NAME));
|
|
422
|
-
assert.ok(out.includes("other-plugin"));
|
|
423
|
-
});
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
// ---------------------------------------------------------------------------
|
|
427
|
-
// dedupePlugins
|
|
428
|
-
// ---------------------------------------------------------------------------
|
|
429
|
-
|
|
430
|
-
describe("dedupePlugins", () => {
|
|
431
|
-
test("returns [] for empty input", () => {
|
|
432
|
-
assert.deepEqual(dedupePlugins([]), []);
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
test("drops every PLUGIN_NAME variant", () => {
|
|
436
|
-
const out = dedupePlugins([
|
|
437
|
-
PLUGIN_NAME,
|
|
438
|
-
`${PLUGIN_NAME}@1.0.0`,
|
|
439
|
-
`${PLUGIN_NAME}@2.0.0`,
|
|
440
|
-
"other-plugin",
|
|
441
|
-
]);
|
|
442
|
-
assert.deepEqual(out, ["other-plugin"]);
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
test("dedupes non-target entries by base name, keeping the LAST occurrence", () => {
|
|
446
|
-
const out = dedupePlugins([
|
|
447
|
-
"alpha@1.0.0",
|
|
448
|
-
"alpha@2.0.0",
|
|
449
|
-
"beta@1.0.0",
|
|
450
|
-
"alpha@3.0.0",
|
|
451
|
-
]);
|
|
452
|
-
assert.deepEqual(out, ["alpha@3.0.0", "beta@1.0.0"]);
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
test("ignores empty or non-string entries defensively", () => {
|
|
456
|
-
const out = dedupePlugins([PLUGIN_NAME, "", "alpha@1.0.0", null as unknown as string]);
|
|
457
|
-
assert.deepEqual(out, ["alpha@1.0.0"]);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
test("preserves bare names without a version suffix", () => {
|
|
461
|
-
const out = dedupePlugins(["alpha", PLUGIN_NAME, "alpha@9.9.9"]);
|
|
462
|
-
// last occurrence of "alpha" wins
|
|
463
|
-
assert.deepEqual(out, ["alpha@9.9.9"]);
|
|
464
|
-
});
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
// ---------------------------------------------------------------------------
|
|
468
|
-
// Path resolution — resolveConfigDir / resolveGlobalConfigPath
|
|
469
|
-
// ---------------------------------------------------------------------------
|
|
470
|
-
|
|
471
|
-
describe("resolveConfigDir", () => {
|
|
472
|
-
test("OPENCODE_CONFIG_DIR wins", () => {
|
|
473
|
-
assert.equal(
|
|
474
|
-
resolveConfigDir({ OPENCODE_CONFIG_DIR: "/etc/opencode" }),
|
|
475
|
-
"/etc/opencode",
|
|
476
|
-
);
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
test("falls back to $HOME/.config/opencode", () => {
|
|
480
|
-
assert.equal(
|
|
481
|
-
resolveConfigDir({ HOME: "/home/x" }),
|
|
482
|
-
"/home/x/.config/opencode",
|
|
483
|
-
);
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
test("ignores empty / whitespace-only env values", () => {
|
|
487
|
-
assert.equal(
|
|
488
|
-
resolveConfigDir({ OPENCODE_CONFIG_DIR: " ", HOME: "/home/y" }),
|
|
489
|
-
"/home/y/.config/opencode",
|
|
490
|
-
);
|
|
491
|
-
});
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
describe("resolveGlobalConfigPath", () => {
|
|
495
|
-
test("returns the preferred `.json` target when no file exists", () => {
|
|
496
|
-
const fs = createMemoryFs();
|
|
497
|
-
const out = resolveGlobalConfigPath(fs, { HOME: "/home/x", OPENCODE_CONFIG_DIR: "/custom" });
|
|
498
|
-
assert.equal(out.path, "/custom/opencode.json");
|
|
499
|
-
assert.equal(out.format, "json");
|
|
500
|
-
assert.equal(out.existed, false);
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
test("prefers existing `.json` over `.jsonc` in the same directory", () => {
|
|
504
|
-
const fs = createMemoryFs({
|
|
505
|
-
"/home/x/.config/opencode/opencode.json": "{}",
|
|
506
|
-
"/home/x/.config/opencode/opencode.jsonc": "{}",
|
|
507
|
-
});
|
|
508
|
-
const out = resolveGlobalConfigPath(fs, { HOME: "/home/x" });
|
|
509
|
-
assert.equal(out.path, "/home/x/.config/opencode/opencode.json");
|
|
510
|
-
assert.equal(out.format, "json");
|
|
511
|
-
assert.equal(out.existed, true);
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
test("falls back to `.jsonc` when `.json` is missing", () => {
|
|
515
|
-
const fs = createMemoryFs({
|
|
516
|
-
"/home/x/.config/opencode/opencode.jsonc": "{}",
|
|
517
|
-
});
|
|
518
|
-
const out = resolveGlobalConfigPath(fs, { HOME: "/home/x" });
|
|
519
|
-
assert.equal(out.path, "/home/x/.config/opencode/opencode.jsonc");
|
|
520
|
-
assert.equal(out.format, "jsonc");
|
|
521
|
-
assert.equal(out.existed, true);
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
test("$OPENCODE_CONFIG_DIR takes precedence over $HOME", () => {
|
|
525
|
-
const fs = createMemoryFs({
|
|
526
|
-
"/etc/opencode/opencode.json": "{}",
|
|
527
|
-
"/home/x/.config/opencode/opencode.json": "{}",
|
|
528
|
-
});
|
|
529
|
-
const out = resolveGlobalConfigPath(fs, {
|
|
530
|
-
OPENCODE_CONFIG_DIR: "/etc/opencode",
|
|
531
|
-
HOME: "/home/x",
|
|
532
|
-
});
|
|
533
|
-
assert.equal(out.path, "/etc/opencode/opencode.json");
|
|
534
|
-
});
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
// ---------------------------------------------------------------------------
|
|
538
|
-
// loadGlobalConfig
|
|
539
|
-
// ---------------------------------------------------------------------------
|
|
540
|
-
|
|
541
|
-
describe("loadGlobalConfig", () => {
|
|
542
|
-
test("returns { config: {}, existed: false } when no config file exists", () => {
|
|
543
|
-
const fs = createMemoryFs();
|
|
544
|
-
const out = loadGlobalConfig(fs, { HOME: "/home/x" });
|
|
545
|
-
assert.equal(out.existed, false);
|
|
546
|
-
assert.deepEqual(out.config, {});
|
|
547
|
-
assert.match(out.path, /opencode\.json$/);
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
test("parses a normal config and surfaces the parsed object", () => {
|
|
551
|
-
const fs = createMemoryFs({
|
|
552
|
-
"/home/x/.config/opencode/opencode.json": JSON.stringify({ plugin: ["alpha"] }),
|
|
553
|
-
});
|
|
554
|
-
const out = loadGlobalConfig(fs, { HOME: "/home/x" });
|
|
555
|
-
assert.equal(out.existed, true);
|
|
556
|
-
assert.deepEqual(out.config, { plugin: ["alpha"] });
|
|
557
|
-
assert.equal(out.parseError, undefined);
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
test("parses JSONC configs (comments + trailing commas)", () => {
|
|
561
|
-
const raw = `{
|
|
562
|
-
// primary plugin list
|
|
563
|
-
"plugin": ["alpha", /* inline */ "beta",],
|
|
564
|
-
}`;
|
|
565
|
-
const fs = createMemoryFs({
|
|
566
|
-
"/home/x/.config/opencode/opencode.jsonc": raw,
|
|
567
|
-
});
|
|
568
|
-
const out = loadGlobalConfig(fs, { HOME: "/home/x" });
|
|
569
|
-
assert.equal(out.existed, true);
|
|
570
|
-
assert.deepEqual(out.config, { plugin: ["alpha", "beta"] });
|
|
571
|
-
assert.equal(out.parseError, undefined);
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
test("surfaces parseError instead of silently overwriting", () => {
|
|
575
|
-
const fs = createMemoryFs({
|
|
576
|
-
"/home/x/.config/opencode/opencode.json": "{ broken json",
|
|
577
|
-
});
|
|
578
|
-
const out = loadGlobalConfig(fs, { HOME: "/home/x" });
|
|
579
|
-
assert.equal(out.existed, true);
|
|
580
|
-
assert.deepEqual(out.config, {});
|
|
581
|
-
assert.ok(typeof out.parseError === "string" && out.parseError.length > 0);
|
|
582
|
-
});
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
// ---------------------------------------------------------------------------
|
|
586
|
-
// backupIfWritable / rotateBackups
|
|
587
|
-
// ---------------------------------------------------------------------------
|
|
588
|
-
|
|
589
|
-
describe("backupIfWritable", () => {
|
|
590
|
-
test("returns null when the target file does not exist (no backup needed)", () => {
|
|
591
|
-
const fs = createMemoryFs();
|
|
592
|
-
const backup = backupIfWritable("/home/x/.config/opencode/opencode.json", fs);
|
|
593
|
-
assert.equal(backup, null);
|
|
594
|
-
assert.deepEqual(fs.files(), {});
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
test("copies the file to a timestamped sibling and returns the path", () => {
|
|
598
|
-
const target = "/home/x/.config/opencode/opencode.json";
|
|
599
|
-
const fs = createMemoryFs({ [target]: '{"plugin":[]}' });
|
|
600
|
-
const backup = backupIfWritable(target, fs);
|
|
601
|
-
assert.ok(backup);
|
|
602
|
-
assert.ok(backup!.startsWith(`${target}.bak.`));
|
|
603
|
-
assert.equal(fs.files()[backup!], '{"plugin":[]}');
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
test("preserves the original file in addition to creating the backup", () => {
|
|
607
|
-
const target = "/home/x/.config/opencode/opencode.json";
|
|
608
|
-
const fs = createMemoryFs({ [target]: '{"plugin":[]}' });
|
|
609
|
-
backupIfWritable(target, fs);
|
|
610
|
-
assert.equal(fs.files()[target], '{"plugin":[]}');
|
|
611
|
-
});
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
describe("rotateBackups", () => {
|
|
615
|
-
const target = "/home/x/.config/opencode/opencode.json";
|
|
616
|
-
const list = (fs: ReturnType<typeof createMemoryFs>): string[] => {
|
|
617
|
-
const all = Object.keys(fs.files());
|
|
618
|
-
return all.filter((k) => k.includes(".bak.")).map((k) => k.split("/").pop()!);
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
test("keeps at most `limit` backups of the target", () => {
|
|
622
|
-
const fs = createMemoryFs({
|
|
623
|
-
[target]: "{}",
|
|
624
|
-
[`${target}.bak.20260101T000000000Z`]: "{}",
|
|
625
|
-
[`${target}.bak.20260102T000000000Z`]: "{}",
|
|
626
|
-
[`${target}.bak.20260103T000000000Z`]: "{}",
|
|
627
|
-
[`${target}.bak.20260104T000000000Z`]: "{}",
|
|
628
|
-
[`${target}.bak.20260105T000000000Z`]: "{}",
|
|
629
|
-
});
|
|
630
|
-
rotateBackups(target, BACKUP_LIMIT, fs);
|
|
631
|
-
const surviving = list(fs).sort();
|
|
632
|
-
// Only the newest BACKUP_LIMIT (3) backups survive; the two oldest are
|
|
633
|
-
// pruned from the in-memory filesystem.
|
|
634
|
-
assert.deepEqual(surviving, [
|
|
635
|
-
`${target.split("/").pop()}.bak.20260103T000000000Z`,
|
|
636
|
-
`${target.split("/").pop()}.bak.20260104T000000000Z`,
|
|
637
|
-
`${target.split("/").pop()}.bak.20260105T000000000Z`,
|
|
638
|
-
]);
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
test("does nothing when the directory holds fewer than `limit` backups", () => {
|
|
642
|
-
const fs = createMemoryFs({
|
|
643
|
-
[target]: "{}",
|
|
644
|
-
[`${target}.bak.20260101T000000000Z`]: "{}",
|
|
645
|
-
});
|
|
646
|
-
rotateBackups(target, BACKUP_LIMIT, fs);
|
|
647
|
-
assert.equal(list(fs).length, 1);
|
|
648
|
-
});
|
|
649
|
-
|
|
650
|
-
test("leaves backups of unrelated files alone", () => {
|
|
651
|
-
const fs = createMemoryFs({
|
|
652
|
-
[target]: "{}",
|
|
653
|
-
// 5 backups of `target` plus 1 backup of `other.json` — rotation
|
|
654
|
-
// only touches backups whose prefix matches `target`.
|
|
655
|
-
[`${target}.bak.20260101T000000000Z`]: "{}",
|
|
656
|
-
[`${target}.bak.20260102T000000000Z`]: "{}",
|
|
657
|
-
[`${target}.bak.20260103T000000000Z`]: "{}",
|
|
658
|
-
[`${target}.bak.20260104T000000000Z`]: "{}",
|
|
659
|
-
[`${target}.bak.20260105T000000000Z`]: "{}",
|
|
660
|
-
"/home/x/.config/opencode/other.json.bak.20260109T000000000Z": "{}",
|
|
661
|
-
});
|
|
662
|
-
rotateBackups(target, BACKUP_LIMIT, fs);
|
|
663
|
-
assert.ok(
|
|
664
|
-
fs.files()["/home/x/.config/opencode/other.json.bak.20260109T000000000Z"] !== undefined,
|
|
665
|
-
"unrelated backups must survive",
|
|
666
|
-
);
|
|
667
|
-
});
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
// ---------------------------------------------------------------------------
|
|
671
|
-
// writeAtomically
|
|
672
|
-
// ---------------------------------------------------------------------------
|
|
673
|
-
|
|
674
|
-
describe("writeAtomically", () => {
|
|
675
|
-
const target = "/home/x/.config/opencode/opencode.json";
|
|
676
|
-
|
|
677
|
-
test("writes content to the target path", () => {
|
|
678
|
-
const fs = createMemoryFs();
|
|
679
|
-
writeAtomically(target, '{"plugin":["a"]}', fs);
|
|
680
|
-
assert.equal(fs.files()[target], '{"plugin":["a"]}');
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
test("creates parent directories implicitly (first-run install)", () => {
|
|
684
|
-
const fs = createMemoryFs();
|
|
685
|
-
writeAtomically(target, "{}", fs);
|
|
686
|
-
// The in-memory adapter tracks directories via file presence — the
|
|
687
|
-
// existence check below would resolve the parent if any file under
|
|
688
|
-
// it is recorded.
|
|
689
|
-
assert.equal(fs.existsSync("/home/x/.config/opencode"), true);
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
test("cleans up the temp file when the rename fails", () => {
|
|
693
|
-
const fs = createMemoryFs();
|
|
694
|
-
fs.setFailNext("rename");
|
|
695
|
-
assert.throws(() => writeAtomically(target, '{"plugin":["a"]}', fs));
|
|
696
|
-
// tmp file must NOT linger; only `target` is what callers expect to exist.
|
|
697
|
-
const lingering = Object.keys(fs.files()).filter((k) => k.includes(".tmp-"));
|
|
698
|
-
assert.deepEqual(lingering, []);
|
|
699
|
-
});
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
// ---------------------------------------------------------------------------
|
|
703
|
-
// runInstall
|
|
704
|
-
// ---------------------------------------------------------------------------
|
|
705
|
-
|
|
706
|
-
describe("runInstall", () => {
|
|
707
|
-
const targetPath = "/home/x/.config/opencode/opencode.json";
|
|
708
|
-
|
|
709
|
-
const newFs = (initial: Record<string, string> = {}): ReturnType<typeof createMemoryFs> => {
|
|
710
|
-
const fs = createMemoryFs(initial);
|
|
711
|
-
return fs;
|
|
712
|
-
};
|
|
713
|
-
|
|
714
|
-
const env = (): NodeJS.ProcessEnv => ({ HOME: "/home/x" });
|
|
715
|
-
|
|
716
|
-
test("fresh install: appends the bare specifier to an empty plugin list", () => {
|
|
717
|
-
const fs = newFs();
|
|
718
|
-
const captured = captureConsole();
|
|
719
|
-
try {
|
|
720
|
-
const result = runInstall({}, fs as unknown as CliFs);
|
|
721
|
-
assert.equal(result.status, "wrote");
|
|
722
|
-
assert.equal(result.specifier, PLUGIN_NAME);
|
|
723
|
-
assert.equal(result.path, targetPath);
|
|
724
|
-
assert.equal(
|
|
725
|
-
fs.files()[targetPath] ?? "",
|
|
726
|
-
JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2),
|
|
727
|
-
);
|
|
728
|
-
} finally {
|
|
729
|
-
captured.restore();
|
|
730
|
-
}
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
test("fresh install: appends the versioned specifier when version supplied", () => {
|
|
734
|
-
const fs = newFs();
|
|
735
|
-
const captured = captureConsole();
|
|
736
|
-
try {
|
|
737
|
-
const result = runInstall({ version: "2.5.0" }, fs as unknown as CliFs);
|
|
738
|
-
assert.equal(result.specifier, `${PLUGIN_NAME}@2.5.0`);
|
|
739
|
-
assert.equal(
|
|
740
|
-
fs.files()[targetPath] ?? "",
|
|
741
|
-
JSON.stringify({ plugin: [`${PLUGIN_NAME}@2.5.0`] }, null, 2),
|
|
742
|
-
);
|
|
743
|
-
} finally {
|
|
744
|
-
captured.restore();
|
|
745
|
-
}
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
test("fresh install: backed up the original file when one existed", () => {
|
|
749
|
-
const original = JSON.stringify({ plugin: ["other-plugin"] }, null, 2);
|
|
750
|
-
const fs = newFs({ [targetPath]: original });
|
|
751
|
-
const captured = captureConsole();
|
|
752
|
-
try {
|
|
753
|
-
const result = runInstall({}, fs as unknown as CliFs);
|
|
754
|
-
assert.equal(result.status, "wrote");
|
|
755
|
-
assert.ok(result.backup);
|
|
756
|
-
assert.equal(fs.files()[result.backup!], original);
|
|
757
|
-
assert.equal(
|
|
758
|
-
fs.files()[targetPath] ?? "",
|
|
759
|
-
JSON.stringify({ plugin: ["other-plugin", PLUGIN_NAME] }, null, 2),
|
|
760
|
-
);
|
|
761
|
-
} finally {
|
|
762
|
-
captured.restore();
|
|
763
|
-
}
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
test("idempotent: re-running with the same specifier is a no-op", () => {
|
|
767
|
-
const fs = newFs();
|
|
768
|
-
const opts: InstallOptions = {};
|
|
769
|
-
const first = runInstall(opts, fs as unknown as CliFs);
|
|
770
|
-
assert.equal(first.status, "wrote");
|
|
771
|
-
const fileAfterFirst = fs.files()[targetPath] ?? "";
|
|
772
|
-
|
|
773
|
-
const captured = captureConsole();
|
|
774
|
-
try {
|
|
775
|
-
const second = runInstall(opts, fs as unknown as CliFs);
|
|
776
|
-
assert.equal(second.status, "noop");
|
|
777
|
-
assert.equal(second.specifier, first.specifier);
|
|
778
|
-
// No further mutation: the file content is exactly what the first
|
|
779
|
-
// call wrote.
|
|
780
|
-
assert.equal(fs.files()[targetPath] ?? "", fileAfterFirst);
|
|
781
|
-
} finally {
|
|
782
|
-
captured.restore();
|
|
783
|
-
}
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
test("dedupes legacy variants: a fresh install removes any prior target entries", () => {
|
|
787
|
-
const original = JSON.stringify(
|
|
788
|
-
{ plugin: [`${PLUGIN_NAME}@0.9.0`, "other", `${PLUGIN_NAME}@1.0.0`] },
|
|
789
|
-
null,
|
|
790
|
-
2,
|
|
791
|
-
);
|
|
792
|
-
const fs = newFs({ [targetPath]: original });
|
|
793
|
-
const captured = captureConsole();
|
|
794
|
-
try {
|
|
795
|
-
const result = runInstall({}, fs as unknown as CliFs);
|
|
796
|
-
assert.equal(result.status, "wrote");
|
|
797
|
-
// Exactly one `opencode-agent-skills-md` (no version) at the end,
|
|
798
|
-
// after the unrelated `other` plugin.
|
|
799
|
-
assert.equal(
|
|
800
|
-
fs.files()[targetPath] ?? "",
|
|
801
|
-
JSON.stringify({ plugin: ["other", PLUGIN_NAME] }, null, 2),
|
|
802
|
-
);
|
|
803
|
-
} finally {
|
|
804
|
-
captured.restore();
|
|
805
|
-
}
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
test("--dry-run: prints the planned change but writes nothing", () => {
|
|
809
|
-
const fs = newFs();
|
|
810
|
-
const captured = captureConsole();
|
|
811
|
-
try {
|
|
812
|
-
const result = runInstall({ dryRun: true }, fs as unknown as CliFs);
|
|
813
|
-
assert.equal(result.status, "planned");
|
|
814
|
-
assert.equal(result.backup, null);
|
|
815
|
-
// The file must NOT have been created.
|
|
816
|
-
assert.equal(fs.files()[targetPath], undefined);
|
|
817
|
-
assert.match(captured.output(), /\[dry-run\]/);
|
|
818
|
-
assert.match(
|
|
819
|
-
captured.output(),
|
|
820
|
-
new RegExp(PLUGIN_NAME.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")),
|
|
821
|
-
);
|
|
822
|
-
} finally {
|
|
823
|
-
captured.restore();
|
|
824
|
-
}
|
|
825
|
-
});
|
|
826
|
-
|
|
827
|
-
test("malformed config: throws instead of silently overwriting", () => {
|
|
828
|
-
const fs = newFs({ [targetPath]: "{ broken json" });
|
|
829
|
-
const captured = captureConsole();
|
|
830
|
-
try {
|
|
831
|
-
assert.throws(
|
|
832
|
-
() => runInstall({}, fs as unknown as CliFs),
|
|
833
|
-
(err: Error) => {
|
|
834
|
-
assert.match(err.message, /oas:.*malformed JSON/i);
|
|
835
|
-
assert.match(err.message, new RegExp(targetPath.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")));
|
|
836
|
-
return true;
|
|
837
|
-
},
|
|
838
|
-
);
|
|
839
|
-
// The corrupt file must remain intact — install must never overwrite it.
|
|
840
|
-
assert.equal(fs.files()[targetPath], "{ broken json");
|
|
841
|
-
} finally {
|
|
842
|
-
captured.restore();
|
|
843
|
-
}
|
|
844
|
-
});
|
|
845
|
-
|
|
846
|
-
test("malformed config: --dry-run does NOT write a replacement either", () => {
|
|
847
|
-
const fs = newFs({ [targetPath]: "{ broken json" });
|
|
848
|
-
const captured = captureConsole();
|
|
849
|
-
try {
|
|
850
|
-
assert.throws(() => runInstall({ dryRun: true }, fs as unknown as CliFs));
|
|
851
|
-
assert.equal(fs.files()[targetPath], "{ broken json");
|
|
852
|
-
} finally {
|
|
853
|
-
captured.restore();
|
|
854
|
-
}
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
test("respects $OPENCODE_CONFIG_DIR when resolving the target", () => {
|
|
858
|
-
process.env.OPENCODE_CONFIG_DIR = "/etc/opencode";
|
|
859
|
-
const fs = newFs();
|
|
860
|
-
const captured = captureConsole();
|
|
861
|
-
try {
|
|
862
|
-
const result = runInstall({}, fs as unknown as CliFs);
|
|
863
|
-
assert.equal(result.path, "/etc/opencode/opencode.json");
|
|
864
|
-
assert.equal(
|
|
865
|
-
fs.files()[result.path] ?? "",
|
|
866
|
-
JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2),
|
|
867
|
-
);
|
|
868
|
-
} finally {
|
|
869
|
-
captured.restore();
|
|
870
|
-
}
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
test("env is forwarded so tests stay hermetic across the whole suite", () => {
|
|
874
|
-
// The previous test sets OPENCODE_CONFIG_DIR; this test asserts that
|
|
875
|
-
// restoring env in afterEach() returns us to the HOME-based default.
|
|
876
|
-
assert.equal(process.env.OPENCODE_CONFIG_DIR, undefined);
|
|
877
|
-
assert.equal(process.env.HOME, "/home/x");
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
test("uses $HOME-based default after env restoration", () => {
|
|
881
|
-
const fs = newFs();
|
|
882
|
-
const captured = captureConsole();
|
|
883
|
-
try {
|
|
884
|
-
const result = runInstall({}, fs as unknown as CliFs);
|
|
885
|
-
assert.equal(result.path, targetPath);
|
|
886
|
-
} finally {
|
|
887
|
-
captured.restore();
|
|
888
|
-
}
|
|
889
|
-
});
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
// ---------------------------------------------------------------------------
|
|
893
|
-
// runUninstall
|
|
894
|
-
//
|
|
895
|
-
// Plugin-only removal. Helper tests cover:
|
|
896
|
-
// * fresh uninstall — removes the only oas entry and reports `wrote`.
|
|
897
|
-
// * idempotent no-op — when the plugin is absent, returns `noop` and
|
|
898
|
-
// leaves the config untouched.
|
|
899
|
-
// * partial removal — oas + others → oas removed, others preserved.
|
|
900
|
-
// * `--purge` + `--dry-run` — surfaces the candidate purge paths in
|
|
901
|
-
// `result.purged` without touching disk.
|
|
902
|
-
// * `--dry-run` alone — keeps the original file and emits `[dry-run]`
|
|
903
|
-
// console output so the user can review the change.
|
|
904
|
-
// * malformed config — aborts with a clear `oas:` error rather than
|
|
905
|
-
// silently overwriting a corrupted file.
|
|
906
|
-
// ---------------------------------------------------------------------------
|
|
907
|
-
|
|
908
|
-
describe("runUninstall", () => {
|
|
909
|
-
const targetPath = "/home/x/.config/opencode/opencode.json";
|
|
910
|
-
|
|
911
|
-
const newFs = (initial: Record<string, string> = {}): ReturnType<typeof createMemoryFs> => {
|
|
912
|
-
return createMemoryFs(initial);
|
|
913
|
-
};
|
|
914
|
-
|
|
915
|
-
const env = (): NodeJS.ProcessEnv => ({ HOME: "/home/x" });
|
|
916
|
-
|
|
917
|
-
test("fresh uninstall: removes the only oas entry and reports wrote", () => {
|
|
918
|
-
const original = JSON.stringify(
|
|
919
|
-
{ plugin: [PLUGIN_NAME, "other-plugin"] },
|
|
920
|
-
null,
|
|
921
|
-
2,
|
|
922
|
-
);
|
|
923
|
-
const fs = newFs({ [targetPath]: original });
|
|
924
|
-
const captured = captureConsole();
|
|
925
|
-
try {
|
|
926
|
-
const result = runUninstall({}, fs as unknown as CliFs);
|
|
927
|
-
assert.equal(result.status, "wrote");
|
|
928
|
-
assert.equal(result.path, targetPath);
|
|
929
|
-
assert.deepEqual(result.removed, [PLUGIN_NAME]);
|
|
930
|
-
assert.deepEqual(result.purged, []);
|
|
931
|
-
// Unrelated entry is preserved.
|
|
932
|
-
assert.equal(
|
|
933
|
-
fs.files()[targetPath] ?? "",
|
|
934
|
-
JSON.stringify({ plugin: ["other-plugin"] }, null, 2),
|
|
935
|
-
);
|
|
936
|
-
} finally {
|
|
937
|
-
captured.restore();
|
|
938
|
-
}
|
|
939
|
-
});
|
|
940
|
-
|
|
941
|
-
test("idempotent no-op: when the plugin is absent, returns noop without writing", () => {
|
|
942
|
-
// Empty config already — uninstall should not touch it.
|
|
943
|
-
const fs = newFs();
|
|
944
|
-
const captured = captureConsole();
|
|
945
|
-
try {
|
|
946
|
-
const result = runUninstall({}, fs as unknown as CliFs);
|
|
947
|
-
assert.equal(result.status, "noop");
|
|
948
|
-
assert.equal(result.path, targetPath);
|
|
949
|
-
assert.deepEqual(result.removed, []);
|
|
950
|
-
assert.deepEqual(result.purged, []);
|
|
951
|
-
// No file was created — `--purge` not requested and the file didn't exist.
|
|
952
|
-
assert.equal(fs.files()[targetPath], undefined);
|
|
953
|
-
} finally {
|
|
954
|
-
captured.restore();
|
|
955
|
-
}
|
|
956
|
-
});
|
|
957
|
-
|
|
958
|
-
test("partial removal: preserves unrelated entries in declaration order", () => {
|
|
959
|
-
const original = JSON.stringify(
|
|
960
|
-
{ plugin: ["alpha@1.0.0", PLUGIN_NAME, "beta@1.0.0", `${PLUGIN_NAME}@2.0.0`] },
|
|
961
|
-
null,
|
|
962
|
-
2,
|
|
963
|
-
);
|
|
964
|
-
const fs = newFs({ [targetPath]: original });
|
|
965
|
-
const captured = captureConsole();
|
|
966
|
-
try {
|
|
967
|
-
const result = runUninstall({}, fs as unknown as CliFs);
|
|
968
|
-
assert.equal(result.status, "wrote");
|
|
969
|
-
// Both oas variants are reported as removed (legacy dedup matches all).
|
|
970
|
-
assert.deepEqual(result.removed.sort(), [PLUGIN_NAME, `${PLUGIN_NAME}@2.0.0`].sort());
|
|
971
|
-
// alpha and beta survive; no oas entries remain.
|
|
972
|
-
assert.equal(
|
|
973
|
-
fs.files()[targetPath] ?? "",
|
|
974
|
-
JSON.stringify({ plugin: ["alpha@1.0.0", "beta@1.0.0"] }, null, 2),
|
|
975
|
-
);
|
|
976
|
-
} finally {
|
|
977
|
-
captured.restore();
|
|
978
|
-
}
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
test("removes the empty `plugin` key when oas was the only entry", () => {
|
|
982
|
-
const original = JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2);
|
|
983
|
-
const fs = newFs({ [targetPath]: original });
|
|
984
|
-
const captured = captureConsole();
|
|
985
|
-
try {
|
|
986
|
-
const result = runUninstall({}, fs as unknown as CliFs);
|
|
987
|
-
assert.equal(result.status, "wrote");
|
|
988
|
-
// The plugin key should be deleted entirely — leaving `{ plugin: [] }`
|
|
989
|
-
// would change the file shape without need.
|
|
990
|
-
assert.equal(fs.files()[targetPath] ?? "", "{}");
|
|
991
|
-
} finally {
|
|
992
|
-
captured.restore();
|
|
993
|
-
}
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
test("--purge --dry-run: surfaces candidate paths without writing or purging", () => {
|
|
997
|
-
const original = JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2);
|
|
998
|
-
const fs = newFs({ [targetPath]: original });
|
|
999
|
-
const captured = captureConsole();
|
|
1000
|
-
try {
|
|
1001
|
-
const result = runUninstall({ purge: true, dryRun: true }, fs as unknown as CliFs);
|
|
1002
|
-
assert.equal(result.status, "planned");
|
|
1003
|
-
// The two plugin-owned purge candidates are reported, in declaration order.
|
|
1004
|
-
assert.equal(result.purged.length, 2);
|
|
1005
|
-
assert.ok(result.purged.includes(cachePath(env())));
|
|
1006
|
-
assert.ok(result.purged.includes(pluginConfigPath(env())));
|
|
1007
|
-
// The on-disk config file is unchanged — no write happened.
|
|
1008
|
-
assert.equal(fs.files()[targetPath], original);
|
|
1009
|
-
// Console output mentions `[dry-run]` so the user sees it was a preview.
|
|
1010
|
-
assert.match(captured.output(), /\[dry-run\]/);
|
|
1011
|
-
assert.match(captured.output(), /purge/);
|
|
1012
|
-
} finally {
|
|
1013
|
-
captured.restore();
|
|
1014
|
-
}
|
|
1015
|
-
});
|
|
1016
|
-
|
|
1017
|
-
test("--dry-run alone: keeps the file and reports the planned removal", () => {
|
|
1018
|
-
const original = JSON.stringify({ plugin: [PLUGIN_NAME, "alpha"] }, null, 2);
|
|
1019
|
-
const fs = newFs({ [targetPath]: original });
|
|
1020
|
-
const captured = captureConsole();
|
|
1021
|
-
try {
|
|
1022
|
-
const result = runUninstall({ dryRun: true }, fs as unknown as CliFs);
|
|
1023
|
-
assert.equal(result.status, "planned");
|
|
1024
|
-
assert.deepEqual(result.removed, [PLUGIN_NAME]);
|
|
1025
|
-
assert.deepEqual(result.purged, []);
|
|
1026
|
-
// File is unchanged.
|
|
1027
|
-
assert.equal(fs.files()[targetPath], original);
|
|
1028
|
-
assert.match(captured.output(), /\[dry-run\]/);
|
|
1029
|
-
} finally {
|
|
1030
|
-
captured.restore();
|
|
1031
|
-
}
|
|
1032
|
-
});
|
|
1033
|
-
|
|
1034
|
-
test("malformed config: throws an `oas:` error and never writes", () => {
|
|
1035
|
-
const fs = newFs({ [targetPath]: "{ broken json" });
|
|
1036
|
-
const captured = captureConsole();
|
|
1037
|
-
try {
|
|
1038
|
-
assert.throws(
|
|
1039
|
-
() => runUninstall({}, fs as unknown as CliFs),
|
|
1040
|
-
(err: Error) => {
|
|
1041
|
-
assert.match(err.message, /oas:.*malformed JSON/i);
|
|
1042
|
-
assert.match(
|
|
1043
|
-
err.message,
|
|
1044
|
-
new RegExp(targetPath.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")),
|
|
1045
|
-
);
|
|
1046
|
-
return true;
|
|
1047
|
-
},
|
|
1048
|
-
);
|
|
1049
|
-
// The corrupt file is intact — uninstall never overwrites it.
|
|
1050
|
-
assert.equal(fs.files()[targetPath], "{ broken json");
|
|
1051
|
-
} finally {
|
|
1052
|
-
captured.restore();
|
|
1053
|
-
}
|
|
1054
|
-
});
|
|
1055
|
-
|
|
1056
|
-
test("malformed config: --dry-run also refuses to write a replacement", () => {
|
|
1057
|
-
const fs = newFs({ [targetPath]: "{ broken json" });
|
|
1058
|
-
const captured = captureConsole();
|
|
1059
|
-
try {
|
|
1060
|
-
assert.throws(() => runUninstall({ dryRun: true }, fs as unknown as CliFs));
|
|
1061
|
-
assert.equal(fs.files()[targetPath], "{ broken json");
|
|
1062
|
-
} finally {
|
|
1063
|
-
captured.restore();
|
|
1064
|
-
}
|
|
1065
|
-
});
|
|
1066
|
-
|
|
1067
|
-
test("--purge (real): config write failure leaves config file intact", () => {
|
|
1068
|
-
const original = JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2);
|
|
1069
|
-
const fs = newFs({ [targetPath]: original });
|
|
1070
|
-
fs.setFailNext("rename");
|
|
1071
|
-
try {
|
|
1072
|
-
assert.throws(() => runUninstall({ purge: true }, fs as unknown as CliFs));
|
|
1073
|
-
assert.equal(fs.files()[targetPath], original);
|
|
1074
|
-
} finally {
|
|
1075
|
-
fs.setFailNext(null);
|
|
1076
|
-
}
|
|
1077
|
-
});
|
|
1078
|
-
|
|
1079
|
-
test("--purge (real): returns `wrote` and surfaces purged paths even when targets are missing", () => {
|
|
1080
|
-
// purgeDir swallows missing-target errors so the command can complete
|
|
1081
|
-
// cleanly even if the user never installed the plugin (no cache dir).
|
|
1082
|
-
const original = JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2);
|
|
1083
|
-
const fs = newFs({ [targetPath]: original });
|
|
1084
|
-
const captured = captureConsole();
|
|
1085
|
-
try {
|
|
1086
|
-
const result = runUninstall({ purge: true }, fs as unknown as CliFs);
|
|
1087
|
-
assert.equal(result.status, "wrote");
|
|
1088
|
-
// The two candidate paths were attempted; since the home cache and
|
|
1089
|
-
// ~/.config/opencode-agent-skills-md both don't exist in the test
|
|
1090
|
-
// sandbox, `purged` will be empty (rmSync with force=true is silenced
|
|
1091
|
-
// by the catch in purgeDir).
|
|
1092
|
-
assert.ok(Array.isArray(result.purged));
|
|
1093
|
-
} finally {
|
|
1094
|
-
captured.restore();
|
|
1095
|
-
}
|
|
1096
|
-
});
|
|
1097
|
-
});
|
|
1098
|
-
|
|
1099
|
-
// ---------------------------------------------------------------------------
|
|
1100
|
-
// runStatus
|
|
1101
|
-
//
|
|
1102
|
-
// Read-only probe. Helper tests cover:
|
|
1103
|
-
// * installed state — oas entry present → `installed: true`,
|
|
1104
|
-
// `specifier` set, `extras` excludes the oas entry.
|
|
1105
|
-
// * uninstalled state — empty `plugin` → `installed: false`,
|
|
1106
|
-
// `specifier: null`.
|
|
1107
|
-
// * extras reporting — non-oas entries surface in `extras`.
|
|
1108
|
-
// * format detection — `.jsonc` config is reported as `jsonc`.
|
|
1109
|
-
// ---------------------------------------------------------------------------
|
|
1110
|
-
|
|
1111
|
-
describe("runStatus", () => {
|
|
1112
|
-
const targetPath = "/home/x/.config/opencode/opencode.json";
|
|
1113
|
-
|
|
1114
|
-
const newFs = (initial: Record<string, string> = {}): ReturnType<typeof createMemoryFs> => {
|
|
1115
|
-
return createMemoryFs(initial);
|
|
1116
|
-
};
|
|
1117
|
-
|
|
1118
|
-
test("installed state: oas entry present → installed:true and specifier set", () => {
|
|
1119
|
-
const fs = newFs({
|
|
1120
|
-
[targetPath]: JSON.stringify({ plugin: ["alpha@1.0.0", PLUGIN_NAME] }, null, 2),
|
|
1121
|
-
});
|
|
1122
|
-
const captured = captureConsoleAll();
|
|
1123
|
-
try {
|
|
1124
|
-
const result: StatusResult = runStatus(fs as unknown as CliFs);
|
|
1125
|
-
assert.equal(result.installed, true);
|
|
1126
|
-
assert.equal(result.specifier, PLUGIN_NAME);
|
|
1127
|
-
assert.equal(result.path, targetPath);
|
|
1128
|
-
assert.equal(result.format, "json");
|
|
1129
|
-
// extras excludes the oas entry.
|
|
1130
|
-
assert.deepEqual(result.extras, ["alpha@1.0.0"]);
|
|
1131
|
-
assert.match(captured.log(), new RegExp(`Installed:\\s+yes`));
|
|
1132
|
-
assert.match(captured.log(), new RegExp(`Specifier:\\s+${PLUGIN_NAME.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}`));
|
|
1133
|
-
} finally {
|
|
1134
|
-
captured.restore();
|
|
1135
|
-
}
|
|
1136
|
-
});
|
|
1137
|
-
|
|
1138
|
-
test("versioned specifier is reported verbatim", () => {
|
|
1139
|
-
const fs = newFs({
|
|
1140
|
-
[targetPath]: JSON.stringify({ plugin: [`${PLUGIN_NAME}@2.5.0`] }, null, 2),
|
|
1141
|
-
});
|
|
1142
|
-
const captured = captureConsoleAll();
|
|
1143
|
-
try {
|
|
1144
|
-
const result: StatusResult = runStatus(fs as unknown as CliFs);
|
|
1145
|
-
assert.equal(result.installed, true);
|
|
1146
|
-
assert.equal(result.specifier, `${PLUGIN_NAME}@2.5.0`);
|
|
1147
|
-
} finally {
|
|
1148
|
-
captured.restore();
|
|
1149
|
-
}
|
|
1150
|
-
});
|
|
1151
|
-
|
|
1152
|
-
test("uninstalled state: empty config → installed:false and specifier:null", () => {
|
|
1153
|
-
const fs = newFs();
|
|
1154
|
-
const captured = captureConsoleAll();
|
|
1155
|
-
try {
|
|
1156
|
-
const result: StatusResult = runStatus(fs as unknown as CliFs);
|
|
1157
|
-
assert.equal(result.installed, false);
|
|
1158
|
-
assert.equal(result.specifier, null);
|
|
1159
|
-
assert.deepEqual(result.extras, []);
|
|
1160
|
-
assert.match(captured.log(), new RegExp(`Installed:\\s+no`));
|
|
1161
|
-
} finally {
|
|
1162
|
-
captured.restore();
|
|
1163
|
-
}
|
|
1164
|
-
});
|
|
1165
|
-
|
|
1166
|
-
test("extras reporting: non-oas entries surface alongside the oas entry", () => {
|
|
1167
|
-
const fs = newFs({
|
|
1168
|
-
[targetPath]: JSON.stringify(
|
|
1169
|
-
{ plugin: ["alpha@1.0.0", PLUGIN_NAME, "beta@2.0.0"] },
|
|
1170
|
-
null,
|
|
1171
|
-
2,
|
|
1172
|
-
),
|
|
1173
|
-
});
|
|
1174
|
-
const captured = captureConsoleAll();
|
|
1175
|
-
try {
|
|
1176
|
-
const result: StatusResult = runStatus(fs as unknown as CliFs);
|
|
1177
|
-
assert.equal(result.installed, true);
|
|
1178
|
-
// Order preserved; oas itself is NOT in extras.
|
|
1179
|
-
assert.deepEqual(result.extras, ["alpha@1.0.0", "beta@2.0.0"]);
|
|
1180
|
-
assert.match(captured.log(), /Other plugins:\s+alpha@1\.0\.0, beta@2\.0\.0/);
|
|
1181
|
-
} finally {
|
|
1182
|
-
captured.restore();
|
|
1183
|
-
}
|
|
1184
|
-
});
|
|
1185
|
-
|
|
1186
|
-
test("extras only: when no oas entry exists, the `extras` field still surfaces unrelated plugins", () => {
|
|
1187
|
-
const fs = newFs({
|
|
1188
|
-
[targetPath]: JSON.stringify({ plugin: ["alpha", "beta"] }, null, 2),
|
|
1189
|
-
});
|
|
1190
|
-
const captured = captureConsoleAll();
|
|
1191
|
-
try {
|
|
1192
|
-
const result: StatusResult = runStatus(fs as unknown as CliFs);
|
|
1193
|
-
assert.equal(result.installed, false);
|
|
1194
|
-
assert.equal(result.specifier, null);
|
|
1195
|
-
// The structured `extras` field captures every non-oas plugin, even
|
|
1196
|
-
// when the plugin itself is absent — scripting callers depend on it.
|
|
1197
|
-
assert.deepEqual(result.extras, ["alpha", "beta"]);
|
|
1198
|
-
// When nothing is installed, runStatus returns early and does not
|
|
1199
|
-
// emit the "Other plugins:" console line — verified by the absence
|
|
1200
|
-
// of that marker here.
|
|
1201
|
-
assert.doesNotMatch(captured.log(), /Other plugins:/);
|
|
1202
|
-
} finally {
|
|
1203
|
-
captured.restore();
|
|
1204
|
-
}
|
|
1205
|
-
});
|
|
1206
|
-
|
|
1207
|
-
test("format detection: a .jsonc config reports format=jsonc", () => {
|
|
1208
|
-
const fs = newFs({
|
|
1209
|
-
"/home/x/.config/opencode/opencode.jsonc": JSON.stringify(
|
|
1210
|
-
{ plugin: [PLUGIN_NAME] },
|
|
1211
|
-
null,
|
|
1212
|
-
2,
|
|
1213
|
-
),
|
|
1214
|
-
});
|
|
1215
|
-
const captured = captureConsoleAll();
|
|
1216
|
-
try {
|
|
1217
|
-
const result: StatusResult = runStatus(fs as unknown as CliFs);
|
|
1218
|
-
assert.equal(result.format, "jsonc");
|
|
1219
|
-
assert.ok(result.path.endsWith(".jsonc"));
|
|
1220
|
-
assert.match(captured.log(), /Format:\s+jsonc/);
|
|
1221
|
-
} finally {
|
|
1222
|
-
captured.restore();
|
|
1223
|
-
}
|
|
1224
|
-
});
|
|
1225
|
-
});
|
|
1226
|
-
|
|
1227
|
-
// ---------------------------------------------------------------------------
|
|
1228
|
-
// runDoctor
|
|
1229
|
-
//
|
|
1230
|
-
// Health checks; read-only with respect to user config. Helper tests cover:
|
|
1231
|
-
// * Node version check — info line mentions "OK" on the test runner.
|
|
1232
|
-
// * Config shape validation — non-array/non-object `plugin` surfaces
|
|
1233
|
-
// an `issue`.
|
|
1234
|
-
// * Plugin-count warning — duplicate oas entries emit a `warning`.
|
|
1235
|
-
// * Writability probe — when the config dir does not exist, doctor
|
|
1236
|
-
// emits a "does not exist yet" warning. The "not writable" branch is
|
|
1237
|
-
// reached via the same probe but requires POSIX chmod to exercise
|
|
1238
|
-
// reliably; coverage of `statSync` failure here is the natural proof
|
|
1239
|
-
// that the probe runs.
|
|
1240
|
-
// ---------------------------------------------------------------------------
|
|
1241
|
-
|
|
1242
|
-
describe("runDoctor", () => {
|
|
1243
|
-
const targetPath = "/home/x/.config/opencode/opencode.json";
|
|
1244
|
-
|
|
1245
|
-
const newFs = (initial: Record<string, string> = {}): ReturnType<typeof createMemoryFs> => {
|
|
1246
|
-
return createMemoryFs(initial);
|
|
1247
|
-
};
|
|
1248
|
-
|
|
1249
|
-
test("happy path: empty config + writable env → ok=true and Node line is OK", () => {
|
|
1250
|
-
const fs = newFs();
|
|
1251
|
-
const captured = captureConsoleAll();
|
|
1252
|
-
try {
|
|
1253
|
-
const result: DoctorResult = runDoctor(fs as unknown as CliFs, { HOME: "/home/x" });
|
|
1254
|
-
assert.equal(result.ok, true);
|
|
1255
|
-
assert.deepEqual(result.issues, []);
|
|
1256
|
-
// Node version is recorded as informational (test runner is >= 18).
|
|
1257
|
-
assert.ok(result.info.some((line) => /Node \d+\.\d+\.\d+ OK/.test(line)));
|
|
1258
|
-
} finally {
|
|
1259
|
-
captured.restore();
|
|
1260
|
-
}
|
|
1261
|
-
});
|
|
1262
|
-
|
|
1263
|
-
test("config shape validation: plugin=42 is neither array nor object → issue reported", () => {
|
|
1264
|
-
const fs = newFs({
|
|
1265
|
-
[targetPath]: JSON.stringify({ plugin: 42 }),
|
|
1266
|
-
});
|
|
1267
|
-
const captured = captureConsoleAll();
|
|
1268
|
-
try {
|
|
1269
|
-
const result: DoctorResult = runDoctor(fs as unknown as CliFs, { HOME: "/home/x" });
|
|
1270
|
-
assert.equal(result.ok, false);
|
|
1271
|
-
assert.ok(
|
|
1272
|
-
result.issues.some((line) => /neither array nor object/.test(line)),
|
|
1273
|
-
`expected an "neither array nor object" issue, got: ${JSON.stringify(result.issues)}`,
|
|
1274
|
-
);
|
|
1275
|
-
} finally {
|
|
1276
|
-
captured.restore();
|
|
1277
|
-
}
|
|
1278
|
-
});
|
|
1279
|
-
|
|
1280
|
-
test("plugin-count warning: multiple oas entries → warning reports dedup-needed", () => {
|
|
1281
|
-
const fs = newFs({
|
|
1282
|
-
[targetPath]: JSON.stringify(
|
|
1283
|
-
{ plugin: [PLUGIN_NAME, `${PLUGIN_NAME}@1.0.0`, "other"] },
|
|
1284
|
-
null,
|
|
1285
|
-
2,
|
|
1286
|
-
),
|
|
1287
|
-
});
|
|
1288
|
-
const captured = captureConsoleAll();
|
|
1289
|
-
try {
|
|
1290
|
-
const result: DoctorResult = runDoctor(fs as unknown as CliFs, { HOME: "/home/x" });
|
|
1291
|
-
assert.ok(
|
|
1292
|
-
result.warnings.some((line) => /2 opencode-agent-skills-md entries present/.test(line)),
|
|
1293
|
-
`expected a "2 ... entries present" warning, got: ${JSON.stringify(result.warnings)}`,
|
|
1294
|
-
);
|
|
1295
|
-
// The issue list is still empty — duplicates are non-blocking.
|
|
1296
|
-
assert.deepEqual(result.issues, []);
|
|
1297
|
-
assert.equal(result.ok, true);
|
|
1298
|
-
} finally {
|
|
1299
|
-
captured.restore();
|
|
1300
|
-
}
|
|
1301
|
-
});
|
|
1302
|
-
|
|
1303
|
-
test("writability probe: config directory missing → warning, not issue", () => {
|
|
1304
|
-
// $HOME points to a path that does not exist on the test host; the
|
|
1305
|
-
// probe intentionally fails open with a warning so install can still
|
|
1306
|
-
// surface a real write error when it actually runs.
|
|
1307
|
-
const fs = newFs();
|
|
1308
|
-
const captured = captureConsoleAll();
|
|
1309
|
-
try {
|
|
1310
|
-
const result: DoctorResult = runDoctor(fs as unknown as CliFs, { HOME: "/no/such/home-xyz" });
|
|
1311
|
-
assert.ok(
|
|
1312
|
-
result.warnings.some((line) => /does not exist yet/.test(line)),
|
|
1313
|
-
`expected a "does not exist yet" warning, got: ${JSON.stringify(result.warnings)}`,
|
|
1314
|
-
);
|
|
1315
|
-
// Still no blocking issue — the warning is enough.
|
|
1316
|
-
assert.deepEqual(result.issues, []);
|
|
1317
|
-
} finally {
|
|
1318
|
-
captured.restore();
|
|
1319
|
-
}
|
|
1320
|
-
});
|
|
1321
|
-
});
|
|
1322
|
-
|
|
1323
|
-
// ---------------------------------------------------------------------------
|
|
1324
|
-
// runMain
|
|
1325
|
-
//
|
|
1326
|
-
// CLI dispatch. Helper tests cover all four branches from the spec:
|
|
1327
|
-
// * valid dispatch (exit 0) — `oas status` with no config file on disk.
|
|
1328
|
-
// * invalid usage (exit 2) — missing command.
|
|
1329
|
-
// * invalid usage (exit 2) — unknown command.
|
|
1330
|
-
// * invalid usage (exit 2) — unknown option triggers parseArgs error.
|
|
1331
|
-
// * help flag (exit 0) — both `--help` and `-h`.
|
|
1332
|
-
//
|
|
1333
|
-
// Tests pass synthetic argv like `["status"]` because `sliceProcessArgv`
|
|
1334
|
-
// only strips when `argv[0]` matches `process.argv[0]` or ends in `node`,
|
|
1335
|
-
// which `["status"]` does not. This keeps the helper hermetic.
|
|
1336
|
-
// ---------------------------------------------------------------------------
|
|
1337
|
-
|
|
1338
|
-
describe("runMain", () => {
|
|
1339
|
-
/**
|
|
1340
|
-
* Run `runMain` while capturing every console channel and preserving
|
|
1341
|
-
* `process.exitCode` from any earlier test. Returns the structured
|
|
1342
|
-
* result plus a getter for the captured output.
|
|
1343
|
-
*/
|
|
1344
|
-
const dispatch = (
|
|
1345
|
-
argv: readonly string[],
|
|
1346
|
-
): { result: MainResult; captured: ReturnType<typeof captureConsoleAll> } => {
|
|
1347
|
-
const prevExit = process.exitCode;
|
|
1348
|
-
process.exitCode = undefined;
|
|
1349
|
-
const captured = captureConsoleAll();
|
|
1350
|
-
let result: MainResult;
|
|
1351
|
-
try {
|
|
1352
|
-
result = runMain(argv);
|
|
1353
|
-
} finally {
|
|
1354
|
-
captured.restore();
|
|
1355
|
-
// Restore rather than reset, so a previous test's intent is honored.
|
|
1356
|
-
process.exitCode = prevExit;
|
|
1357
|
-
}
|
|
1358
|
-
return { result, captured };
|
|
1359
|
-
};
|
|
1360
|
-
|
|
1361
|
-
test("valid dispatch: `oas status` exits 0 with the status command resolved", () => {
|
|
1362
|
-
const { result } = dispatch(["status"]);
|
|
1363
|
-
assert.equal(result.command, "status");
|
|
1364
|
-
assert.equal(result.exitCode, 0);
|
|
1365
|
-
});
|
|
1366
|
-
|
|
1367
|
-
test("valid dispatch: `oas doctor` exits 0 when no blocking issues exist", () => {
|
|
1368
|
-
const { result } = dispatch(["doctor"]);
|
|
1369
|
-
assert.equal(result.command, "doctor");
|
|
1370
|
-
// Doctor found no blocking issues (`ok === true`) → exit 0.
|
|
1371
|
-
assert.equal(result.exitCode, 0);
|
|
1372
|
-
});
|
|
1373
|
-
|
|
1374
|
-
test("invalid usage: missing command → exit 2 and a friendly stderr hint", () => {
|
|
1375
|
-
const { result, captured } = dispatch([]);
|
|
1376
|
-
assert.equal(result.command, null);
|
|
1377
|
-
assert.equal(result.exitCode, 2);
|
|
1378
|
-
assert.match(captured.error(), /missing command/i);
|
|
1379
|
-
});
|
|
1380
|
-
|
|
1381
|
-
test("invalid usage: unknown command → exit 2", () => {
|
|
1382
|
-
const { result, captured } = dispatch(["definitely-not-real"]);
|
|
1383
|
-
assert.equal(result.command, null);
|
|
1384
|
-
assert.equal(result.exitCode, 2);
|
|
1385
|
-
assert.match(captured.error(), /unknown command/i);
|
|
1386
|
-
});
|
|
1387
|
-
|
|
1388
|
-
test("invalid usage: unknown option → exit 2 (parseArgs strict error)", () => {
|
|
1389
|
-
const { result, captured } = dispatch(["status", "--bogus-option"]);
|
|
1390
|
-
assert.equal(result.command, null);
|
|
1391
|
-
assert.equal(result.exitCode, 2);
|
|
1392
|
-
assert.match(captured.error(), /oas:|--bogus-option/);
|
|
1393
|
-
});
|
|
1394
|
-
|
|
1395
|
-
test("--help short-circuits to exit 0 before parseArgs runs", () => {
|
|
1396
|
-
const { result, captured } = dispatch(["--help"]);
|
|
1397
|
-
assert.equal(result.command, "help");
|
|
1398
|
-
assert.equal(result.exitCode, 0);
|
|
1399
|
-
assert.match(captured.log(), /Usage: oas/);
|
|
1400
|
-
});
|
|
1401
|
-
|
|
1402
|
-
test("-h short-circuits to exit 0 before parseArgs runs", () => {
|
|
1403
|
-
const { result, captured } = dispatch(["-h"]);
|
|
1404
|
-
assert.equal(result.command, "help");
|
|
1405
|
-
assert.equal(result.exitCode, 0);
|
|
1406
|
-
assert.match(captured.log(), /Usage: oas/);
|
|
1407
|
-
});
|
|
1408
|
-
|
|
1409
|
-
test("--help after a positional still wins and exits 0", () => {
|
|
1410
|
-
const { result, captured } = dispatch(["status", "--help"]);
|
|
1411
|
-
assert.equal(result.command, "help");
|
|
1412
|
-
assert.equal(result.exitCode, 0);
|
|
1413
|
-
assert.match(captured.log(), /Usage: oas/);
|
|
1414
|
-
});
|
|
1415
|
-
|
|
1416
|
-
test("default process.argv when invoked as main is not used in tests (synthetic args only)", () => {
|
|
1417
|
-
// Sanity: dispatching the bare CLI without argv shouldn't see real
|
|
1418
|
-
// process.argv positionals get parsed as commands. We call dispatch
|
|
1419
|
-
// with `[]` (not undefined) to keep the helper hermetic.
|
|
1420
|
-
const { result } = dispatch([]);
|
|
1421
|
-
assert.equal(result.exitCode, 2);
|
|
1422
|
-
});
|
|
1423
|
-
});
|