superpowers-manager 0.6.0 → 0.7.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/README.md +42 -26
- package/dist/adapter-result.js +5 -9
- package/dist/atomic.js +4 -14
- package/dist/cli-arguments.js +1 -27
- package/dist/cli.js +25 -40
- package/dist/commands/adapter-call.js +28 -0
- package/dist/commands/install.js +25 -105
- package/dist/commands/pin.js +1 -1
- package/dist/commands/prepare.js +37 -81
- package/dist/commands/probe.js +35 -262
- package/dist/commands/track-latest.js +1 -1
- package/dist/commands/uninstall.js +15 -91
- package/dist/commands/update.js +5 -25
- package/dist/effective-selection.js +4 -6
- package/dist/git-tree.js +85 -0
- package/dist/harness-command-result.js +47 -0
- package/dist/harnesses/codex/adapter.js +12 -41
- package/dist/harnesses/codex/conflicts.js +2 -3
- package/dist/harnesses/codex/hooks.js +1 -1
- package/dist/harnesses/codex/json.js +1 -11
- package/dist/harnesses/codex/lifecycle.js +0 -48
- package/dist/harnesses/codex/manifest-overlay.js +1 -1
- package/dist/harnesses/codex/paths.js +14 -29
- package/dist/harnesses/codex/presentation.js +2 -23
- package/dist/harnesses/codex/publication.js +0 -6
- package/dist/harnesses/codex/recovery.js +5 -5
- package/dist/harnesses/codex/stored-state.js +1 -1
- package/dist/harnesses/opencode/config.js +256 -0
- package/dist/harnesses/opencode/discovery.js +587 -0
- package/dist/harnesses/opencode/harness.js +37 -0
- package/dist/harnesses/opencode/install.js +601 -0
- package/dist/harnesses/opencode/native.js +57 -0
- package/dist/harnesses/opencode/package.js +89 -0
- package/dist/harnesses/opencode/paths.js +38 -0
- package/dist/harnesses/opencode/prepare.js +30 -0
- package/dist/harnesses/opencode/presentation.js +28 -0
- package/dist/harnesses/opencode/state.js +174 -0
- package/dist/harnesses/pi/compatibility.js +5 -37
- package/dist/harnesses/pi/install.js +7 -5
- package/dist/harnesses/pi/native.js +3 -47
- package/dist/harnesses/pi/package.js +5 -107
- package/dist/harnesses/pi/prepare.js +13 -104
- package/dist/harnesses/pi/presentation.js +23 -66
- package/dist/harnesses/pi/shared-skills.js +28 -39
- package/dist/harnesses/pi/state.js +7 -27
- package/dist/provenance.js +2 -2
- package/dist/safe-path.js +2 -2
- package/dist/selection-store.js +2 -22
- package/dist/snapshot-package.js +78 -0
- package/dist/snapshot-prepare.js +99 -0
- package/dist/snapshot-presentation.js +44 -0
- package/dist/validator.js +4 -1
- package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
- package/node_modules/jsonc-parser/LICENSE.md +21 -0
- package/node_modules/jsonc-parser/README.md +364 -0
- package/node_modules/jsonc-parser/SECURITY.md +41 -0
- package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
- package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
- package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
- package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
- package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
- package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
- package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
- package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
- package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
- package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
- package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
- package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
- package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
- package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
- package/node_modules/jsonc-parser/package.json +37 -0
- package/package.json +8 -4
- package/dist/domain/fingerprint.js +0 -4
- package/dist/status.js +0 -24
- package/dist/validate-generated-plugin-cli.js +0 -118
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
import { tmpdir, userInfo } from "node:os";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { findNodeAtLocation, getNodeValue, } from "jsonc-parser";
|
|
8
|
+
import { readArtifactObject } from "../../artifact-tree.js";
|
|
9
|
+
import { runGit } from "../../git.js";
|
|
10
|
+
import { parseStrictJson } from "../../strict-json.js";
|
|
11
|
+
import { canonicalizeProspectivePath, classifyPathNoFollow, isContained, isErrno, } from "../../safe-path.js";
|
|
12
|
+
import { runValidator } from "../../validator.js";
|
|
13
|
+
import { withWorkspace } from "../../workspace.js";
|
|
14
|
+
import { parseOpenCodeConfig, readOpenCodeConfig, } from "./config.js";
|
|
15
|
+
// Account inspection pays a full-copy cost so it never opens the native DB or
|
|
16
|
+
// its WAL with SQLite. Keep DB+WAL to one finite 16 MiB capture, without retry.
|
|
17
|
+
const MAX_ACCOUNT_FILE_BYTES = 16 * 1024 * 1024;
|
|
18
|
+
const ACCOUNT_QUERY_POLICY = {
|
|
19
|
+
timeoutMs: 2_000,
|
|
20
|
+
graceMs: 200,
|
|
21
|
+
drainMs: 50,
|
|
22
|
+
maxBytesPerStream: 1_024,
|
|
23
|
+
};
|
|
24
|
+
const ACCOUNT_QUERY = "const{DatabaseSync}=require('node:sqlite');const db=new DatabaseSync(process.argv[1],{readOnly:true});const row=db.prepare(\"SELECT 1 AS found FROM account_state s JOIN account a ON a.id=s.active_account_id WHERE s.id=1 AND s.active_account_id<>'' AND s.active_org_id<>'' LIMIT 1\").get();db.close();process.stdout.write(row?.found===1?'active':'absent')";
|
|
25
|
+
const OPEN_CODE_UNOWNED_MANAGER_INPUT = "OpenCode Manager registration outside native global writer";
|
|
26
|
+
export const OPEN_CODE_PURE_MODE_INPUT = "OPENCODE_PURE disables OpenCode plugin activation";
|
|
27
|
+
function truthy(value) {
|
|
28
|
+
return value?.toLowerCase() === "true" || value === "1";
|
|
29
|
+
}
|
|
30
|
+
function at(root, path) {
|
|
31
|
+
const node = findNodeAtLocation(root, [...path]);
|
|
32
|
+
return node === undefined ? undefined : getNodeValue(node);
|
|
33
|
+
}
|
|
34
|
+
function containsSubstitution(value) {
|
|
35
|
+
return /\{(?:env|file):[^}]*\}/u.test(value);
|
|
36
|
+
}
|
|
37
|
+
function expandEnvironment(value, env) {
|
|
38
|
+
return value.replace(/\{env:([^}]+)\}/gu, (_token, name) => {
|
|
39
|
+
const replacement = env[name];
|
|
40
|
+
return replacement && replacement.length > 0 ? replacement : "";
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function addBlocked(state, label, registrationUncertain = true) {
|
|
44
|
+
state.blockedInputs.add(label);
|
|
45
|
+
if (registrationUncertain)
|
|
46
|
+
state.registrationUncertain = true;
|
|
47
|
+
}
|
|
48
|
+
function filesystemAncestors(cwd) {
|
|
49
|
+
const result = [];
|
|
50
|
+
let cursor = resolve(cwd);
|
|
51
|
+
for (;;) {
|
|
52
|
+
result.unshift(cursor);
|
|
53
|
+
if (cursor === parse(cursor).root)
|
|
54
|
+
break;
|
|
55
|
+
const parent = dirname(cursor);
|
|
56
|
+
cursor = parent;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
async function projectAncestors(cwd) {
|
|
61
|
+
const ancestors = filesystemAncestors(cwd);
|
|
62
|
+
let markerRoot;
|
|
63
|
+
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
64
|
+
const candidate = ancestors[index];
|
|
65
|
+
try {
|
|
66
|
+
if ((await classifyPathNoFollow(join(candidate, ".git"))) !== "missing") {
|
|
67
|
+
markerRoot = candidate;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return ancestors;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (markerRoot === undefined)
|
|
76
|
+
return ancestors;
|
|
77
|
+
try {
|
|
78
|
+
const [topLevel, gitDirectory, commonDirectory] = await Promise.all([
|
|
79
|
+
runGit(["rev-parse", "--show-toplevel"], { cwd: markerRoot }),
|
|
80
|
+
runGit(["rev-parse", "--git-dir"], { cwd: markerRoot }),
|
|
81
|
+
runGit(["rev-parse", "--git-common-dir"], { cwd: markerRoot }),
|
|
82
|
+
]);
|
|
83
|
+
if (gitDirectory.status !== 0 || commonDirectory.status !== 0)
|
|
84
|
+
return ancestors;
|
|
85
|
+
const boundary = topLevel.status === 0 && topLevel.stdout.trim().length > 0
|
|
86
|
+
? resolve(markerRoot, topLevel.stdout.trim())
|
|
87
|
+
: markerRoot;
|
|
88
|
+
const canonicalBoundary = await canonicalizeProspectivePath(boundary);
|
|
89
|
+
for (let index = 0; index < ancestors.length; index += 1) {
|
|
90
|
+
if ((await canonicalizeProspectivePath(ancestors[index])) ===
|
|
91
|
+
canonicalBoundary)
|
|
92
|
+
return ancestors.slice(index);
|
|
93
|
+
}
|
|
94
|
+
return ancestors;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return ancestors;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function isNativeWriterOrigin(observation, state) {
|
|
101
|
+
const source = resolve(observation.document.path);
|
|
102
|
+
return (source === resolve(state.paths.configRoot, "opencode.json") ||
|
|
103
|
+
source === resolve(state.paths.configRoot, "opencode.jsonc"));
|
|
104
|
+
}
|
|
105
|
+
function localPluginPath(spec, origin, state) {
|
|
106
|
+
try {
|
|
107
|
+
if (spec.startsWith("file://"))
|
|
108
|
+
return fileURLToPath(spec);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
addBlocked(state, `${origin} plugin path`);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (spec.startsWith("~/"))
|
|
115
|
+
return join(state.paths.homeDir, spec.slice(2));
|
|
116
|
+
if (isAbsolute(spec))
|
|
117
|
+
return spec;
|
|
118
|
+
if (spec.startsWith("./") || spec.startsWith("../"))
|
|
119
|
+
return resolve(dirname(origin), spec);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
function classifyUpstreamSpec(spec) {
|
|
123
|
+
const trimmed = spec.trim();
|
|
124
|
+
if (trimmed === "superpowers")
|
|
125
|
+
return "known";
|
|
126
|
+
if (/^(?:superpowers@)?github:obra\/superpowers(?:\.git)?\/?(?:#.+)?$/iu.test(trimmed))
|
|
127
|
+
return "known";
|
|
128
|
+
if (/^(?:superpowers@)?(?:git\+)?(?:https?|ssh|git):\/\/(?:git@)?github\.com\/obra\/superpowers(?:\.git)?\/?(?:[#@].+)?$/iu.test(trimmed))
|
|
129
|
+
return "known";
|
|
130
|
+
if (/^git@github\.com:obra\/superpowers(?:\.git)?\/?(?:[#@].+)?$/iu.test(trimmed))
|
|
131
|
+
return "known";
|
|
132
|
+
return /^(?:npm:)?superpowers(?:@|$)/u.test(trimmed)
|
|
133
|
+
? "unresolved"
|
|
134
|
+
: "unrelated";
|
|
135
|
+
}
|
|
136
|
+
function inspectUpstreamSpec(spec, origin, state) {
|
|
137
|
+
const classification = classifyUpstreamSpec(spec);
|
|
138
|
+
if (classification === "known") {
|
|
139
|
+
state.conflicts.add("registered OpenCode package for obra/superpowers");
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
if (classification === "unresolved") {
|
|
143
|
+
state.conflicts.add(`unresolved OpenCode package named superpowers at ${origin}`);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
function isKnownPluginFile(path) {
|
|
149
|
+
const parent = basename(dirname(path));
|
|
150
|
+
return ((parent === "plugin" || parent === "plugins") &&
|
|
151
|
+
(basename(path) === "superpowers.js" || basename(path) === "superpowers.ts"));
|
|
152
|
+
}
|
|
153
|
+
async function inspectPluginEntry(observation, entry, state, allowManaged = true, active = true) {
|
|
154
|
+
if (entry.spec.trim().length === 0) {
|
|
155
|
+
addBlocked(state, `${observation.document.path} plugin[${entry.index}]`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (containsSubstitution(entry.spec)) {
|
|
159
|
+
addBlocked(state, `${observation.document.path} plugin[${entry.index}]`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const local = localPluginPath(entry.spec, observation.document.path, state);
|
|
163
|
+
if (local !== null) {
|
|
164
|
+
const canonical = await canonicalizeProspectivePath(local);
|
|
165
|
+
if (canonical === state.installedRoot) {
|
|
166
|
+
if (allowManaged)
|
|
167
|
+
state.managedEntries.push({
|
|
168
|
+
observation,
|
|
169
|
+
entry,
|
|
170
|
+
canonicalRoot: canonical,
|
|
171
|
+
});
|
|
172
|
+
else
|
|
173
|
+
addBlocked(state, `${OPEN_CODE_UNOWNED_MANAGER_INPUT}: ${observation.document.path}`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!active)
|
|
178
|
+
return;
|
|
179
|
+
if (inspectUpstreamSpec(entry.spec, `${observation.document.path} plugin[${entry.index}]`, state))
|
|
180
|
+
return;
|
|
181
|
+
if (local === null)
|
|
182
|
+
return;
|
|
183
|
+
const kind = await classifyPathNoFollow(local);
|
|
184
|
+
if (kind === "missing") {
|
|
185
|
+
if (/^superpowers(?:\.(?:js|ts))?$/u.test(basename(local)))
|
|
186
|
+
addBlocked(state, `${observation.document.path} plugin[${entry.index}]`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (isKnownPluginFile(local)) {
|
|
190
|
+
state.conflicts.add(`registered native OpenCode plugin ${local}`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (kind !== "directory" && kind !== "symlink") {
|
|
194
|
+
addBlocked(state, `${observation.document.path} plugin[${entry.index}]`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
if ((await readArtifactObject(local, join(local, "package.json"))).name ===
|
|
199
|
+
"superpowers")
|
|
200
|
+
state.conflicts.add("registered local OpenCode package named superpowers");
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
if (basename(local) === "superpowers")
|
|
204
|
+
addBlocked(state, `${observation.document.path} plugin[${entry.index}]`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function inspectSkillFile(path, label, state, requireKnownIdentity = false) {
|
|
208
|
+
if ((await classifyPathNoFollow(path)) !== "missing") {
|
|
209
|
+
const canonical = await canonicalizeProspectivePath(path);
|
|
210
|
+
const owned = isContained(state.installedRoot, canonical);
|
|
211
|
+
if (requireKnownIdentity &&
|
|
212
|
+
!owned &&
|
|
213
|
+
!/^(?:superpowers|using-superpowers)$/u.test(basename(dirname(path))))
|
|
214
|
+
return;
|
|
215
|
+
if (owned)
|
|
216
|
+
state.ownedActivationAliases.add(canonical);
|
|
217
|
+
state.conflicts.add(`${label} ${basename(dirname(path))} at ${path}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function inspectSkillRoot(root, label, state) {
|
|
221
|
+
await inspectSkillFile(join(root, "SKILL.md"), label, state, true);
|
|
222
|
+
for (const leaf of ["superpowers/SKILL.md", "using-superpowers/SKILL.md"])
|
|
223
|
+
await inspectSkillFile(join(root, leaf), label, state);
|
|
224
|
+
}
|
|
225
|
+
async function inspectConfigFields(root, source, state) {
|
|
226
|
+
const paths = at(root, ["skills", "paths"]);
|
|
227
|
+
if (paths !== undefined) {
|
|
228
|
+
if (!Array.isArray(paths) ||
|
|
229
|
+
paths.some((item) => typeof item !== "string")) {
|
|
230
|
+
addBlocked(state, `${source} skills.paths`, false);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
for (const item of paths) {
|
|
234
|
+
if (containsSubstitution(item)) {
|
|
235
|
+
addBlocked(state, `${source} skills.paths`, false);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const raw = item;
|
|
239
|
+
const rootPath = raw.startsWith("~/")
|
|
240
|
+
? join(state.paths.homeDir, raw.slice(2))
|
|
241
|
+
: isAbsolute(raw)
|
|
242
|
+
? raw
|
|
243
|
+
: resolve(state.cwd, raw);
|
|
244
|
+
await inspectSkillRoot(rootPath, "configured OpenCode skill", state);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const urls = at(root, ["skills", "urls"]);
|
|
249
|
+
if (urls !== undefined) {
|
|
250
|
+
if (!Array.isArray(urls) || urls.length > 0)
|
|
251
|
+
addBlocked(state, "OpenCode remote skill configuration", false);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function inspectDocument(observation, state) {
|
|
255
|
+
state.documents.push(observation);
|
|
256
|
+
const source = observation.document.path;
|
|
257
|
+
if (/\{file:[^}]*\}/u.test(observation.document.text))
|
|
258
|
+
addBlocked(state, `${source} file substitution`);
|
|
259
|
+
let effective = observation.document;
|
|
260
|
+
if (/\{env:[^}]*\}/u.test(observation.document.text)) {
|
|
261
|
+
try {
|
|
262
|
+
effective = parseOpenCodeConfig(expandEnvironment(observation.document.text, state.env), source);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
addBlocked(state, `${source} environment substitution`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
await inspectConfigFields(effective.root, source, state);
|
|
270
|
+
const active = !truthy(state.env.OPENCODE_PURE);
|
|
271
|
+
for (const entry of effective.entries) {
|
|
272
|
+
const original = observation.document.entries[entry.index];
|
|
273
|
+
const stable = original !== undefined && original.spec === entry.spec;
|
|
274
|
+
await inspectPluginEntry(observation, stable ? original : entry, state, stable && isNativeWriterOrigin(observation, state), active);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function inspectConfigPath(path, state) {
|
|
278
|
+
const observation = await readOpenCodeConfig(path);
|
|
279
|
+
if (observation === null)
|
|
280
|
+
return;
|
|
281
|
+
const canonicalPath = await canonicalizeProspectivePath(path);
|
|
282
|
+
const previous = state.configObservations.get(canonicalPath);
|
|
283
|
+
if (previous !== undefined) {
|
|
284
|
+
if (previous.identity.dev !== observation.identity.dev ||
|
|
285
|
+
previous.identity.ino !== observation.identity.ino ||
|
|
286
|
+
previous.identity.mode !== observation.identity.mode ||
|
|
287
|
+
!previous.bytes.equals(observation.bytes))
|
|
288
|
+
throw new Error("OpenCode configuration changed during discovery");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
state.configObservations.set(canonicalPath, observation);
|
|
292
|
+
await inspectDocument(observation, state);
|
|
293
|
+
}
|
|
294
|
+
async function inspectNativeDirectory(dir, state) {
|
|
295
|
+
if (!truthy(state.env.OPENCODE_PURE)) {
|
|
296
|
+
for (const folder of ["plugin", "plugins"])
|
|
297
|
+
for (const file of ["superpowers.js", "superpowers.ts"])
|
|
298
|
+
await inspectSkillFile(join(dir, folder, file), "native OpenCode plugin", state);
|
|
299
|
+
}
|
|
300
|
+
for (const folder of ["skill", "skills"])
|
|
301
|
+
await inspectSkillRoot(join(dir, folder), "native OpenCode skill", state);
|
|
302
|
+
}
|
|
303
|
+
async function inspectAuth(state, dataRoot) {
|
|
304
|
+
const hasWellKnown = (value) => value !== null &&
|
|
305
|
+
typeof value === "object" &&
|
|
306
|
+
!Array.isArray(value) &&
|
|
307
|
+
Object.values(value).some((item) => item !== null &&
|
|
308
|
+
typeof item === "object" &&
|
|
309
|
+
!Array.isArray(item) &&
|
|
310
|
+
item.type === "wellknown");
|
|
311
|
+
const parseAuth = (input) => parseStrictJson(input, {
|
|
312
|
+
duplicateKeys: "reject",
|
|
313
|
+
nonStandardConstants: "reject",
|
|
314
|
+
maxDepth: 64,
|
|
315
|
+
maxBytes: 1024 * 1024,
|
|
316
|
+
});
|
|
317
|
+
const inline = state.env.OPENCODE_AUTH_CONTENT;
|
|
318
|
+
if (inline !== undefined) {
|
|
319
|
+
try {
|
|
320
|
+
if (hasWellKnown(parseAuth(inline)))
|
|
321
|
+
addBlocked(state, "OPENCODE_AUTH_CONTENT");
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
addBlocked(state, "OPENCODE_AUTH_CONTENT");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const path = join(dataRoot, "auth.json");
|
|
328
|
+
try {
|
|
329
|
+
const auth = await captureFile(path);
|
|
330
|
+
if (auth !== null && hasWellKnown(parseAuth(auth.bytes)))
|
|
331
|
+
addBlocked(state, "OpenCode auth well-known configuration");
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
addBlocked(state, `OpenCode auth configuration ${path}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async function captureFile(path) {
|
|
338
|
+
let handle;
|
|
339
|
+
try {
|
|
340
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
341
|
+
const stat = await handle.stat();
|
|
342
|
+
if (!stat.isFile() || stat.size > MAX_ACCOUNT_FILE_BYTES)
|
|
343
|
+
throw new Error("unsupported account database file");
|
|
344
|
+
const bytes = Buffer.allocUnsafe(stat.size);
|
|
345
|
+
let offset = 0;
|
|
346
|
+
while (offset < bytes.length) {
|
|
347
|
+
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
348
|
+
if (read.bytesRead === 0)
|
|
349
|
+
throw new Error("short account database read");
|
|
350
|
+
offset += read.bytesRead;
|
|
351
|
+
}
|
|
352
|
+
return { bytes, dev: stat.dev, ino: stat.ino, mode: stat.mode };
|
|
353
|
+
}
|
|
354
|
+
catch (cause) {
|
|
355
|
+
if (isErrno(cause, "ENOENT"))
|
|
356
|
+
return null;
|
|
357
|
+
throw cause;
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
await handle?.close().catch(() => { });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function sameCapture(left, right) {
|
|
364
|
+
return left === null || right === null
|
|
365
|
+
? left === right
|
|
366
|
+
: left.dev === right.dev &&
|
|
367
|
+
left.ino === right.ino &&
|
|
368
|
+
left.mode === right.mode &&
|
|
369
|
+
left.bytes.equals(right.bytes);
|
|
370
|
+
}
|
|
371
|
+
function accountDatabasePath(env, dataRoot) {
|
|
372
|
+
const configured = env.OPENCODE_DB;
|
|
373
|
+
if (configured === ":memory:")
|
|
374
|
+
return null;
|
|
375
|
+
if (configured && configured.length > 0)
|
|
376
|
+
return isAbsolute(configured) ? configured : join(dataRoot, configured);
|
|
377
|
+
return join(dataRoot, "opencode.db");
|
|
378
|
+
}
|
|
379
|
+
async function inspectAccount(state, dataRoot) {
|
|
380
|
+
const database = accountDatabasePath(state.env, dataRoot);
|
|
381
|
+
if (database === null)
|
|
382
|
+
return;
|
|
383
|
+
const wal = `${database}-wal`;
|
|
384
|
+
let initialDb;
|
|
385
|
+
let initialWal;
|
|
386
|
+
try {
|
|
387
|
+
[initialDb, initialWal] = await Promise.all([
|
|
388
|
+
captureFile(database),
|
|
389
|
+
captureFile(wal),
|
|
390
|
+
]);
|
|
391
|
+
if (initialDb === null) {
|
|
392
|
+
if (initialWal !== null)
|
|
393
|
+
throw new Error("orphan account WAL");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (initialDb.bytes.length + (initialWal?.bytes.length ?? 0) >
|
|
397
|
+
MAX_ACCOUNT_FILE_BYTES)
|
|
398
|
+
throw new Error("account database capture limit");
|
|
399
|
+
const result = await withWorkspace(tmpdir(), "spw-opencode-account-", async (workspace) => {
|
|
400
|
+
const copy = join(workspace, "opencode.db");
|
|
401
|
+
await (await open(copy, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)).close();
|
|
402
|
+
const dbOut = await open(copy, constants.O_WRONLY);
|
|
403
|
+
try {
|
|
404
|
+
await dbOut.writeFile(initialDb.bytes);
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
await dbOut.close();
|
|
408
|
+
}
|
|
409
|
+
if (initialWal !== null) {
|
|
410
|
+
const walOut = await open(`${copy}-wal`, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
411
|
+
try {
|
|
412
|
+
await walOut.writeFile(initialWal.bytes);
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
await walOut.close();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const beforeQuery = await Promise.all([
|
|
419
|
+
captureFile(database),
|
|
420
|
+
captureFile(wal),
|
|
421
|
+
]);
|
|
422
|
+
if (!sameCapture(initialDb, beforeQuery[0]) ||
|
|
423
|
+
!sameCapture(initialWal, beforeQuery[1]))
|
|
424
|
+
throw new Error("account database moved");
|
|
425
|
+
const run = await runValidator([
|
|
426
|
+
process.execPath,
|
|
427
|
+
"--disable-warning=ExperimentalWarning",
|
|
428
|
+
"-e",
|
|
429
|
+
ACCOUNT_QUERY,
|
|
430
|
+
copy,
|
|
431
|
+
], ACCOUNT_QUERY_POLICY, {}, workspace, workspace);
|
|
432
|
+
if (run.kind !== "exited" ||
|
|
433
|
+
run.code !== 0 ||
|
|
434
|
+
run.stdout.droppedBytes !== 0 ||
|
|
435
|
+
run.stderr.droppedBytes !== 0 ||
|
|
436
|
+
run.stderr.text !== "" ||
|
|
437
|
+
(run.stdout.text !== "active" && run.stdout.text !== "absent"))
|
|
438
|
+
throw new Error("account database query failed");
|
|
439
|
+
return run.stdout.text;
|
|
440
|
+
});
|
|
441
|
+
const afterQuery = await Promise.all([
|
|
442
|
+
captureFile(database),
|
|
443
|
+
captureFile(wal),
|
|
444
|
+
]);
|
|
445
|
+
if (!sameCapture(initialDb, afterQuery[0]) ||
|
|
446
|
+
!sameCapture(initialWal, afterQuery[1]))
|
|
447
|
+
throw new Error("account database moved");
|
|
448
|
+
if (result === "active")
|
|
449
|
+
addBlocked(state, "OpenCode active account remote configuration");
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
addBlocked(state, `OpenCode account database ${database}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function dataRoot(paths, env) {
|
|
456
|
+
const base = env.XDG_DATA_HOME && env.XDG_DATA_HOME.length > 0
|
|
457
|
+
? env.XDG_DATA_HOME
|
|
458
|
+
: join(paths.homeDir, ".local", "share");
|
|
459
|
+
if (!isAbsolute(base))
|
|
460
|
+
throw new Error("XDG_DATA_HOME must be absolute");
|
|
461
|
+
return join(base, "opencode");
|
|
462
|
+
}
|
|
463
|
+
async function inspectManagedPreferences(state) {
|
|
464
|
+
if (process.platform !== "darwin")
|
|
465
|
+
return;
|
|
466
|
+
let user = "user";
|
|
467
|
+
try {
|
|
468
|
+
user = userInfo().username || user;
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// Native OpenCode uses the same fallback when username lookup fails.
|
|
472
|
+
}
|
|
473
|
+
for (const path of [
|
|
474
|
+
join("/Library/Managed Preferences", user, "ai.opencode.managed.plist"),
|
|
475
|
+
"/Library/Managed Preferences/ai.opencode.managed.plist",
|
|
476
|
+
]) {
|
|
477
|
+
if ((await classifyPathNoFollow(path)) !== "missing") {
|
|
478
|
+
addBlocked(state, "OpenCode managed preferences");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
export async function inspectOpenCodeDiscovery(paths, env, cwd) {
|
|
484
|
+
const state = {
|
|
485
|
+
documents: [],
|
|
486
|
+
configObservations: new Map(),
|
|
487
|
+
conflicts: new Set(),
|
|
488
|
+
blockedInputs: new Set(),
|
|
489
|
+
registrationUncertain: false,
|
|
490
|
+
ownedActivationAliases: new Set(),
|
|
491
|
+
managedEntries: [],
|
|
492
|
+
installedRoot: await canonicalizeProspectivePath(paths.installedRoot),
|
|
493
|
+
paths,
|
|
494
|
+
env,
|
|
495
|
+
cwd: resolve(cwd),
|
|
496
|
+
};
|
|
497
|
+
if (truthy(env.OPENCODE_PURE))
|
|
498
|
+
addBlocked(state, OPEN_CODE_PURE_MODE_INPUT, false);
|
|
499
|
+
for (const name of ["config.json", "opencode.json", "opencode.jsonc"])
|
|
500
|
+
await inspectConfigPath(join(paths.configRoot, name), state);
|
|
501
|
+
if (env.OPENCODE_CONFIG) {
|
|
502
|
+
const path = isAbsolute(env.OPENCODE_CONFIG)
|
|
503
|
+
? env.OPENCODE_CONFIG
|
|
504
|
+
: resolve(cwd, env.OPENCODE_CONFIG);
|
|
505
|
+
await inspectConfigPath(path, state);
|
|
506
|
+
}
|
|
507
|
+
const ancestors = await projectAncestors(cwd);
|
|
508
|
+
if (!truthy(env.OPENCODE_DISABLE_PROJECT_CONFIG)) {
|
|
509
|
+
for (const dir of ancestors)
|
|
510
|
+
for (const name of ["opencode.jsonc", "opencode.json"])
|
|
511
|
+
await inspectConfigPath(join(dir, name), state);
|
|
512
|
+
}
|
|
513
|
+
const configDirs = new Set([
|
|
514
|
+
paths.configRoot,
|
|
515
|
+
...(!truthy(env.OPENCODE_DISABLE_PROJECT_CONFIG)
|
|
516
|
+
? ancestors.map((dir) => join(dir, ".opencode"))
|
|
517
|
+
: []),
|
|
518
|
+
join(paths.homeDir, ".opencode"),
|
|
519
|
+
...(env.OPENCODE_CONFIG_DIR ? [resolve(cwd, env.OPENCODE_CONFIG_DIR)] : []),
|
|
520
|
+
]);
|
|
521
|
+
for (const dir of configDirs) {
|
|
522
|
+
if (dir.endsWith(".opencode") ||
|
|
523
|
+
dir === resolve(cwd, env.OPENCODE_CONFIG_DIR ?? ""))
|
|
524
|
+
for (const name of ["opencode.json", "opencode.jsonc"])
|
|
525
|
+
await inspectConfigPath(join(dir, name), state);
|
|
526
|
+
await inspectNativeDirectory(dir, state);
|
|
527
|
+
}
|
|
528
|
+
if (env.OPENCODE_CONFIG_CONTENT !== undefined) {
|
|
529
|
+
try {
|
|
530
|
+
if (/\{file:[^}]*\}/u.test(env.OPENCODE_CONFIG_CONTENT))
|
|
531
|
+
addBlocked(state, "OPENCODE_CONFIG_CONTENT file substitution");
|
|
532
|
+
const inline = parseOpenCodeConfig(expandEnvironment(env.OPENCODE_CONFIG_CONTENT, env), "OPENCODE_CONFIG_CONTENT");
|
|
533
|
+
await inspectConfigFields(inline.root, "OPENCODE_CONFIG_CONTENT", state);
|
|
534
|
+
if (!truthy(env.OPENCODE_PURE)) {
|
|
535
|
+
for (const entry of inline.entries) {
|
|
536
|
+
if (entry.spec.trim().length === 0 ||
|
|
537
|
+
containsSubstitution(entry.spec))
|
|
538
|
+
addBlocked(state, `OPENCODE_CONFIG_CONTENT plugin[${entry.index}]`);
|
|
539
|
+
else if (inspectUpstreamSpec(entry.spec, `OPENCODE_CONFIG_CONTENT plugin[${entry.index}]`, state)) {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
const local = localPluginPath(entry.spec, join(cwd, "opencode.json"), state);
|
|
544
|
+
if (local !== null) {
|
|
545
|
+
const canonical = await canonicalizeProspectivePath(local);
|
|
546
|
+
addBlocked(state, canonical === state.installedRoot
|
|
547
|
+
? `${OPEN_CODE_UNOWNED_MANAGER_INPUT}: OPENCODE_CONFIG_CONTENT`
|
|
548
|
+
: `OPENCODE_CONFIG_CONTENT plugin[${entry.index}]`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
addBlocked(state, "OPENCODE_CONFIG_CONTENT");
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (!truthy(env.OPENCODE_DISABLE_EXTERNAL_SKILLS)) {
|
|
559
|
+
const skillAncestors = [...ancestors, paths.homeDir];
|
|
560
|
+
for (const dir of skillAncestors) {
|
|
561
|
+
await inspectSkillRoot(join(dir, ".agents", "skills"), "shared OpenCode skill", state);
|
|
562
|
+
if (!truthy(env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS))
|
|
563
|
+
await inspectSkillRoot(join(dir, ".claude", "skills"), "shared OpenCode skill", state);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const managedDir = env.OPENCODE_TEST_MANAGED_CONFIG_DIR ??
|
|
567
|
+
(process.platform === "darwin"
|
|
568
|
+
? "/Library/Application Support/opencode"
|
|
569
|
+
: process.platform === "win32"
|
|
570
|
+
? join(env.ProgramData || "C:\\ProgramData", "opencode")
|
|
571
|
+
: "/etc/opencode");
|
|
572
|
+
for (const name of ["opencode.json", "opencode.jsonc"])
|
|
573
|
+
await inspectConfigPath(join(managedDir, name), state);
|
|
574
|
+
await inspectNativeDirectory(managedDir, state);
|
|
575
|
+
await inspectManagedPreferences(state);
|
|
576
|
+
const nativeData = dataRoot(paths, env);
|
|
577
|
+
await inspectAuth(state, nativeData);
|
|
578
|
+
await inspectAccount(state, nativeData);
|
|
579
|
+
return {
|
|
580
|
+
documents: state.documents,
|
|
581
|
+
conflicts: [...state.conflicts],
|
|
582
|
+
blockedInputs: [...state.blockedInputs].sort(),
|
|
583
|
+
registrationUncertain: state.registrationUncertain,
|
|
584
|
+
ownedActivationAliases: [...state.ownedActivationAliases].sort(),
|
|
585
|
+
managedEntries: state.managedEntries,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { installOpenCode, removeOpenCode } from "./install.js";
|
|
2
|
+
import { assertOpenCodePreparationSeparate, openCodePaths } from "./paths.js";
|
|
3
|
+
import { inspectOpenCodePrepared, openCodePreparationLocation, prepareOpenCodeCandidate, readOpenCodePrepared, validateOpenCodePreparationBeforeFetch, } from "./prepare.js";
|
|
4
|
+
import { openCodePresentation } from "./presentation.js";
|
|
5
|
+
import { inspectOpenCodeControl, inspectOpenCodeInstalled, inspectOpenCodeOwnership, } from "./state.js";
|
|
6
|
+
function requirements(command, env) {
|
|
7
|
+
if (command !== "install" && command !== "update")
|
|
8
|
+
return [];
|
|
9
|
+
const executable = env.SUPERPOWERS_OPENCODE || "opencode";
|
|
10
|
+
return [
|
|
11
|
+
{
|
|
12
|
+
name: "opencode",
|
|
13
|
+
executable,
|
|
14
|
+
lookup: "explicit-path-or-path",
|
|
15
|
+
missingMessage: `required command not found: ${executable} — install the OpenCode CLI or set SUPERPOWERS_OPENCODE`,
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
export const openCodeHarness = {
|
|
20
|
+
preparationLocation: openCodePreparationLocation,
|
|
21
|
+
mutationRoots: async (ctx) => {
|
|
22
|
+
const paths = openCodePaths(ctx.env ?? {}, process.cwd());
|
|
23
|
+
await assertOpenCodePreparationSeparate(paths);
|
|
24
|
+
return [paths.configRoot];
|
|
25
|
+
},
|
|
26
|
+
validatePreparationBeforeFetch: validateOpenCodePreparationBeforeFetch,
|
|
27
|
+
prepareCandidate: prepareOpenCodeCandidate,
|
|
28
|
+
inspectPrepared: inspectOpenCodePrepared,
|
|
29
|
+
readPrepared: readOpenCodePrepared,
|
|
30
|
+
inspectOwnership: inspectOpenCodeOwnership,
|
|
31
|
+
inspectUpdateControl: inspectOpenCodeControl,
|
|
32
|
+
inspectInstalled: inspectOpenCodeInstalled,
|
|
33
|
+
install: installOpenCode,
|
|
34
|
+
remove: removeOpenCode,
|
|
35
|
+
requirements,
|
|
36
|
+
presentation: openCodePresentation,
|
|
37
|
+
};
|