superpowers-manager 0.1.6 → 0.3.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 +184 -64
- package/bin/superpowers-manager.js +9 -237
- package/dist/adapter-result.js +204 -0
- package/dist/adapter.js +649 -0
- package/dist/atomic.js +142 -0
- package/dist/cli-arguments.js +32 -0
- package/dist/cli.js +293 -0
- package/dist/codex-json.js +89 -0
- package/dist/codex-state.js +78 -0
- package/dist/commands/context.js +1 -0
- package/dist/commands/install.js +376 -0
- package/dist/commands/pin.js +79 -0
- package/dist/commands/prepare.js +527 -0
- package/dist/commands/probe.js +392 -0
- package/dist/commands/track-latest.js +44 -0
- package/dist/commands/uninstall.js +308 -0
- package/dist/commands/unpin.js +95 -0
- package/dist/commands/update.js +138 -0
- package/dist/domain/fingerprint.js +4 -0
- package/dist/domain/refs.js +37 -0
- package/dist/effective-selection.js +123 -0
- package/dist/generated-plugin.js +748 -0
- package/dist/git.js +38 -0
- package/dist/hooks.js +290 -0
- package/dist/lifecycle.js +220 -0
- package/dist/manifest-overlay.js +125 -0
- package/dist/provenance.js +111 -0
- package/dist/python-json-format.js +83 -0
- package/dist/python-json.js +29 -0
- package/dist/python-text.js +74 -0
- package/dist/safe-path.js +137 -0
- package/dist/safety-error.js +10 -0
- package/dist/selection-state-cli.js +85 -0
- package/dist/selection-store.js +172 -0
- package/dist/selection.js +200 -0
- package/dist/status.js +24 -0
- package/dist/strict-json.js +248 -0
- package/dist/upstream-cli.js +110 -0
- package/dist/upstream-version.js +40 -0
- package/dist/upstream.js +253 -0
- package/dist/validate-generated-plugin-cli.js +118 -0
- package/dist/validator.js +317 -0
- package/dist/workspace.js +139 -0
- package/package.json +25 -2
- package/scripts/adapters/codex/adapter +0 -598
- package/scripts/adapters/codex/lib.sh +0 -391
- package/scripts/adapters/codex/validate-generated-plugin.py +0 -314
- package/scripts/core/adapter.sh +0 -92
- package/scripts/core/common.sh +0 -35
- package/scripts/core/lifecycle.sh +0 -127
- package/scripts/core/provenance.sh +0 -135
- package/scripts/core/status.sh +0 -27
- package/scripts/core/upstream.sh +0 -146
- package/scripts/core/validate-adapter-response.py +0 -245
- package/scripts/install +0 -60
- package/scripts/prepare +0 -126
- package/scripts/probe +0 -61
- package/scripts/uninstall +0 -44
- package/scripts/update +0 -31
package/dist/atomic.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { constants, lstat, open, readFile, rename, rm, unlink, } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { SafetyError } from "./safety-error.js";
|
|
5
|
+
export async function atomicWriteFile(path, bytes, options) {
|
|
6
|
+
const renamePath = options.hooks?.rename ?? rename;
|
|
7
|
+
let handle;
|
|
8
|
+
let temporary;
|
|
9
|
+
let phase = "pre-replacement";
|
|
10
|
+
try {
|
|
11
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
12
|
+
const suffix = randomBytes(8).toString("hex");
|
|
13
|
+
const candidate = join(dirname(path), `.${basename(path)}.tmp.${process.pid}.${suffix}`);
|
|
14
|
+
try {
|
|
15
|
+
handle = await open(candidate, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
16
|
+
temporary = candidate;
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
catch (cause) {
|
|
20
|
+
if (cause instanceof Error &&
|
|
21
|
+
"code" in cause &&
|
|
22
|
+
cause.code === "EEXIST") {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
throw cause;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (handle === undefined || temporary === undefined) {
|
|
29
|
+
throw new Error("cannot create unique temporary file");
|
|
30
|
+
}
|
|
31
|
+
await handle.chmod(0o600);
|
|
32
|
+
await handle.writeFile(bytes);
|
|
33
|
+
await handle.sync();
|
|
34
|
+
await handle.close();
|
|
35
|
+
handle = undefined;
|
|
36
|
+
await options.validate(temporary);
|
|
37
|
+
await renamePath(temporary, path);
|
|
38
|
+
temporary = undefined;
|
|
39
|
+
phase = "post-replacement";
|
|
40
|
+
await options.hooks?.afterReplace?.(path);
|
|
41
|
+
await fsyncDirectoryBestEffort(dirname(path));
|
|
42
|
+
}
|
|
43
|
+
catch (cause) {
|
|
44
|
+
let finalBytes;
|
|
45
|
+
if (phase === "post-replacement") {
|
|
46
|
+
finalBytes = await readFile(path).catch(() => undefined);
|
|
47
|
+
}
|
|
48
|
+
const details = finalBytes === undefined ? { phase } : { phase, finalBytes };
|
|
49
|
+
throw new SafetyError("atomic", `atomic file write failed during ${phase}`, { cause, details });
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (handle !== undefined) {
|
|
53
|
+
await handle.close().catch(() => { });
|
|
54
|
+
}
|
|
55
|
+
if (temporary !== undefined) {
|
|
56
|
+
await unlink(temporary).catch(() => { });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function fsyncDirectoryBestEffort(directory) {
|
|
61
|
+
let handle;
|
|
62
|
+
try {
|
|
63
|
+
handle = await open(directory, constants.O_RDONLY);
|
|
64
|
+
await handle.sync();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// The frozen baseline treats directory fsync as best effort.
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
await handle?.close().catch(() => { });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function exists(path) {
|
|
74
|
+
try {
|
|
75
|
+
await lstat(path);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch (cause) {
|
|
79
|
+
if (cause instanceof Error &&
|
|
80
|
+
"code" in cause &&
|
|
81
|
+
cause.code === "ENOENT") {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
throw cause;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function chooseBackup(live) {
|
|
88
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
89
|
+
const candidate = join(dirname(live), `.${basename(live)}.bak.${process.pid}.${randomBytes(8).toString("hex")}`);
|
|
90
|
+
if (!(await exists(candidate)))
|
|
91
|
+
return candidate;
|
|
92
|
+
}
|
|
93
|
+
throw new SafetyError("atomic", "cannot choose unique backup path");
|
|
94
|
+
}
|
|
95
|
+
export async function atomicReplaceDir(candidate, live, options = {}) {
|
|
96
|
+
const renamePath = options.hooks?.rename ?? rename;
|
|
97
|
+
const removePath = options.hooks?.rm ?? rm;
|
|
98
|
+
let backupCreated = false;
|
|
99
|
+
let phase = "pre-replacement";
|
|
100
|
+
try {
|
|
101
|
+
const backup = await chooseBackup(live);
|
|
102
|
+
if (await exists(live)) {
|
|
103
|
+
await renamePath(live, backup);
|
|
104
|
+
backupCreated = true;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
await renamePath(candidate, live);
|
|
108
|
+
phase = "post-replacement";
|
|
109
|
+
}
|
|
110
|
+
catch (cause) {
|
|
111
|
+
if (!backupCreated) {
|
|
112
|
+
await removePath(candidate, { recursive: true, force: true });
|
|
113
|
+
throw new SafetyError("atomic", "directory activation failed with no prior tree", { cause, details: { phase: "pre-replacement" } });
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await renamePath(backup, live);
|
|
117
|
+
}
|
|
118
|
+
catch (rollbackCause) {
|
|
119
|
+
await removePath(candidate, { recursive: true, force: true }).catch(() => { });
|
|
120
|
+
throw new SafetyError("atomic", `directory activation and rollback failed; backup preserved at ${backup}`, {
|
|
121
|
+
cause: new AggregateError([cause, rollbackCause]),
|
|
122
|
+
details: { phase: "pre-replacement" },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
await removePath(candidate, { recursive: true, force: true });
|
|
126
|
+
throw new SafetyError("atomic", "directory activation failed; previous tree restored", { cause, details: { phase: "pre-replacement" } });
|
|
127
|
+
}
|
|
128
|
+
if (backupCreated) {
|
|
129
|
+
try {
|
|
130
|
+
await removePath(backup, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
catch (cause) {
|
|
133
|
+
throw new SafetyError("atomic", `directory replacement succeeded but backup cleanup failed at ${backup}`, { cause, details: { phase: "post-replacement" } });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (cause) {
|
|
138
|
+
if (cause instanceof SafetyError)
|
|
139
|
+
throw cause;
|
|
140
|
+
throw new SafetyError("atomic", "directory replacement failed", { cause, details: { phase } });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export class UsageError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export function parseFlags(argv, names) {
|
|
4
|
+
const allowed = new Set(names);
|
|
5
|
+
const result = {};
|
|
6
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
7
|
+
const token = argv[index];
|
|
8
|
+
if (!token.startsWith("--"))
|
|
9
|
+
throw new UsageError(`unexpected argument: ${token}`);
|
|
10
|
+
const separator = token.indexOf("=");
|
|
11
|
+
const name = separator === -1 ? token.slice(2) : token.slice(2, separator);
|
|
12
|
+
if (!allowed.has(name))
|
|
13
|
+
throw new UsageError(`unknown option: --${name}`);
|
|
14
|
+
const value = separator === -1 ? argv[index + 1] : token.slice(separator + 1);
|
|
15
|
+
if (value === undefined || (separator === -1 && value.startsWith("--"))) {
|
|
16
|
+
throw new UsageError(`option --${name} requires a value`);
|
|
17
|
+
}
|
|
18
|
+
result[name] = value;
|
|
19
|
+
if (separator === -1)
|
|
20
|
+
index += 1;
|
|
21
|
+
}
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
if (!Object.hasOwn(result, name)) {
|
|
24
|
+
throw new UsageError(`required option is missing: --${name}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
export function oneLine(value) {
|
|
30
|
+
const message = value instanceof Error ? value.message : String(value);
|
|
31
|
+
return message.replace(/[\r\n]+/g, " ");
|
|
32
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { runAdapter } from "./adapter.js";
|
|
4
|
+
import { oneLine } from "./cli-arguments.js";
|
|
5
|
+
import { runInstall } from "./commands/install.js";
|
|
6
|
+
import { runPin } from "./commands/pin.js";
|
|
7
|
+
import { runPrepare } from "./commands/prepare.js";
|
|
8
|
+
import { runProbe } from "./commands/probe.js";
|
|
9
|
+
import { runTrackLatest } from "./commands/track-latest.js";
|
|
10
|
+
import { runUninstall } from "./commands/uninstall.js";
|
|
11
|
+
import { runUnpin } from "./commands/unpin.js";
|
|
12
|
+
import { runUpdate } from "./commands/update.js";
|
|
13
|
+
import { COMMIT_INPUT_RE, TAG_RE } from "./domain/refs.js";
|
|
14
|
+
import { configurationErrors } from "./validator.js";
|
|
15
|
+
const SUBCOMMANDS = [
|
|
16
|
+
"pin",
|
|
17
|
+
"track-latest",
|
|
18
|
+
"unpin",
|
|
19
|
+
"prepare",
|
|
20
|
+
"probe",
|
|
21
|
+
"install",
|
|
22
|
+
"update",
|
|
23
|
+
"uninstall",
|
|
24
|
+
];
|
|
25
|
+
// Keyed by Subcommand itself. Until slice 6 this was keyed by a mapped type
|
|
26
|
+
// that filtered Subcommand through a DISPATCH table of "spawn" | "in-process"
|
|
27
|
+
// literals; with every command in-process that filter was the identity, so the
|
|
28
|
+
// table, its mode union and the mapped type were one indirection with nothing
|
|
29
|
+
// left to discriminate. The guarantee is unchanged and is the whole protection
|
|
30
|
+
// — tests/bin/bin-dispatch.test.js records that decision: a Subcommand added
|
|
31
|
+
// without a handler registered here is a compile error, not a runtime surprise.
|
|
32
|
+
const IN_PROCESS_HANDLERS = {
|
|
33
|
+
pin: runPin,
|
|
34
|
+
"track-latest": runTrackLatest,
|
|
35
|
+
unpin: runUnpin,
|
|
36
|
+
prepare: runPrepare,
|
|
37
|
+
probe: runProbe,
|
|
38
|
+
install: runInstall,
|
|
39
|
+
update: runUpdate,
|
|
40
|
+
uninstall: runUninstall,
|
|
41
|
+
};
|
|
42
|
+
// `python3` left install, update and uninstall at slice 4b's flip. It was
|
|
43
|
+
// required because `spw_invoke_adapter` ran validate-adapter-response.py once
|
|
44
|
+
// per adapter call
|
|
45
|
+
// (`git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/adapter.sh:37-44::--response "$response_file" --result "$result_file" \`);
|
|
46
|
+
// the in-process path has no validator process. It remains CONDITIONAL for
|
|
47
|
+
// `prepare` through commandRequirements(env) below, unchanged from slice 3.4.
|
|
48
|
+
// No command requires a POSIX shell any more.
|
|
49
|
+
const COMMAND_REQUIREMENTS = {
|
|
50
|
+
pin: ["git"],
|
|
51
|
+
"track-latest": [],
|
|
52
|
+
unpin: [],
|
|
53
|
+
prepare: ["git"],
|
|
54
|
+
probe: ["git", "codex"],
|
|
55
|
+
install: ["git", "codex"],
|
|
56
|
+
update: ["git", "codex"],
|
|
57
|
+
uninstall: ["codex"],
|
|
58
|
+
};
|
|
59
|
+
// Walk upward from the bin's physical location to the directory containing
|
|
60
|
+
// package.json. realpathSync first: npm/npx expose the bin through a symlink
|
|
61
|
+
// or shim outside the package root.
|
|
62
|
+
function resolvePackageRoot(scriptPath) {
|
|
63
|
+
let dir;
|
|
64
|
+
try {
|
|
65
|
+
dir = path.dirname(fs.realpathSync(scriptPath));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
for (;;) {
|
|
71
|
+
if (fs.existsSync(path.join(dir, "package.json")))
|
|
72
|
+
return dir;
|
|
73
|
+
const parent = path.dirname(dir);
|
|
74
|
+
if (parent === dir)
|
|
75
|
+
return null;
|
|
76
|
+
dir = parent;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function isMain(moduleFilename, argvPath) {
|
|
80
|
+
if (!argvPath)
|
|
81
|
+
return false;
|
|
82
|
+
return moduleFilename === fs.realpathSync(argvPath);
|
|
83
|
+
}
|
|
84
|
+
function parseArgs(argv) {
|
|
85
|
+
const first = argv[0];
|
|
86
|
+
if (argv.length === 0)
|
|
87
|
+
return { kind: "run", cmd: "update", args: [] };
|
|
88
|
+
if (first === "--help" || first === "-h")
|
|
89
|
+
return { kind: "help" };
|
|
90
|
+
if (first === "--version")
|
|
91
|
+
return { kind: "version" };
|
|
92
|
+
if (first && SUBCOMMANDS.includes(first)) {
|
|
93
|
+
const command = first;
|
|
94
|
+
const args = argv.slice(1);
|
|
95
|
+
if (command === "pin" && args.length !== 1) {
|
|
96
|
+
return {
|
|
97
|
+
kind: "usage-error",
|
|
98
|
+
message: "usage: superpowers-manager pin REF",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (command === "pin" &&
|
|
102
|
+
!TAG_RE.test(args[0]) &&
|
|
103
|
+
!COMMIT_INPUT_RE.test(args[0])) {
|
|
104
|
+
return {
|
|
105
|
+
kind: "usage-error",
|
|
106
|
+
message: "pin REF must be an exact v-prefixed SemVer tag or full 40-hex commit",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if ((command === "track-latest" || command === "unpin") &&
|
|
110
|
+
args.length !== 0) {
|
|
111
|
+
return {
|
|
112
|
+
kind: "usage-error",
|
|
113
|
+
message: `usage: superpowers-manager ${command}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Arity lives HERE, not only in src/commands/probe.ts, so `probe` gets the
|
|
117
|
+
// same shape as every other CLI-owned usage error: `error: <msg>` plus the
|
|
118
|
+
// full usage block, exit 2, decided before preflight. Leaving it to the
|
|
119
|
+
// handler alone made `probe --porcelaine` print no usage block, and made
|
|
120
|
+
// the identical input exit 1 on a machine without `codex` because preflight
|
|
121
|
+
// ran first. `PROBE_USAGE` stays as the same unreachable-from-CLI duplicate
|
|
122
|
+
// that track-latest and unpin already carry.
|
|
123
|
+
if (command === "probe" &&
|
|
124
|
+
!(args.length === 0 || (args.length === 1 && args[0] === "--porcelain"))) {
|
|
125
|
+
return {
|
|
126
|
+
kind: "usage-error",
|
|
127
|
+
message: "usage: superpowers-manager probe [--porcelain]",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return { kind: "run", cmd: command, args };
|
|
131
|
+
}
|
|
132
|
+
return { kind: "usage-error", message: `unknown subcommand: ${first}` };
|
|
133
|
+
}
|
|
134
|
+
// Search env.PATH for an executable named `name`; on win32 also try PATHEXT
|
|
135
|
+
// extensions. Returns the full path or null.
|
|
136
|
+
function findTool(name, env, platform) {
|
|
137
|
+
const pathVar = env.PATH || env.Path || "";
|
|
138
|
+
const dirs = pathVar.split(path.delimiter).filter(Boolean);
|
|
139
|
+
const exts = platform === "win32"
|
|
140
|
+
? (env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
|
|
141
|
+
: [""];
|
|
142
|
+
for (const dir of dirs) {
|
|
143
|
+
for (const ext of exts) {
|
|
144
|
+
const candidate = path.join(dir, name + ext.toLowerCase());
|
|
145
|
+
try {
|
|
146
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
147
|
+
return candidate;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* keep looking */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
// python3 is required by `prepare` only when SUPERPOWERS_VALIDATOR names one:
|
|
157
|
+
// after the port, that optional spawn (runValidator, in src/validator.ts,
|
|
158
|
+
// called from src/commands/prepare.ts) is Python's only remaining consumer
|
|
159
|
+
// on the prepare path. The conditional lives here, in the accessor preflight reads,
|
|
160
|
+
// rather than inside preflight — an accessor that under-reports what
|
|
161
|
+
// preflight enforces is the blind spot slice 2 closed when it made
|
|
162
|
+
// CLI-PREFLIGHT-01 derive its map from production.
|
|
163
|
+
function commandRequirements(env) {
|
|
164
|
+
if (!env.SUPERPOWERS_VALIDATOR)
|
|
165
|
+
return COMMAND_REQUIREMENTS;
|
|
166
|
+
return {
|
|
167
|
+
...COMMAND_REQUIREMENTS,
|
|
168
|
+
prepare: [...COMMAND_REQUIREMENTS.prepare, "python3"],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// Preflight; never touches Codex state. It is the union of two exported
|
|
172
|
+
// accessors: configurationErrors (validator configuration) and
|
|
173
|
+
// commandRequirements (tool availability), both specific to the selected
|
|
174
|
+
// command. No command requires a POSIX shell: slice 4b flipped the last
|
|
175
|
+
// spawned command in-process, so there is no shell to discover.
|
|
176
|
+
function preflight(cmd, env, platform) {
|
|
177
|
+
const errors = [...configurationErrors(cmd, env)];
|
|
178
|
+
for (const tool of commandRequirements(env)[cmd]) {
|
|
179
|
+
if (tool === "codex") {
|
|
180
|
+
const codexBin = env.SUPERPOWERS_CODEX || "codex";
|
|
181
|
+
// An explicit override may be a path rather than a PATH-resolvable name.
|
|
182
|
+
const found = codexBin.includes(path.sep)
|
|
183
|
+
? fs.existsSync(codexBin)
|
|
184
|
+
: Boolean(findTool(codexBin, env, platform));
|
|
185
|
+
if (!found) {
|
|
186
|
+
errors.push(`required command not found: ${codexBin} — install the Codex CLI or set SUPERPOWERS_CODEX`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else if (!findTool(tool, env, platform)) {
|
|
190
|
+
errors.push(`required command not found: ${tool} — install ${tool} and re-run`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (errors.length)
|
|
194
|
+
return { ok: false, errors };
|
|
195
|
+
return { ok: true };
|
|
196
|
+
}
|
|
197
|
+
function usage() {
|
|
198
|
+
return [
|
|
199
|
+
"usage: superpowers-manager [command] [args...]",
|
|
200
|
+
"",
|
|
201
|
+
"Selection commands (save intent only; they do not prepare or install it):",
|
|
202
|
+
" pin REF save an exact upstream release tag or commit",
|
|
203
|
+
" track-latest save selection of the latest stable upstream release",
|
|
204
|
+
" unpin remove the saved selection and return to the packaged fallback",
|
|
205
|
+
"",
|
|
206
|
+
"Apply and lifecycle commands:",
|
|
207
|
+
" prepare fetch the pinned upstream ref and generate the plugin tree",
|
|
208
|
+
" probe report upstream/generated/installed status (accepts --porcelain)",
|
|
209
|
+
" install register this package root as a Codex marketplace and install the plugin",
|
|
210
|
+
" update probe, then prepare/install only if needed (default when no subcommand)",
|
|
211
|
+
" uninstall remove the manager plugin and marketplace from Codex",
|
|
212
|
+
"",
|
|
213
|
+
"Environment overrides (used by in-process commands): SUPERPOWERS_REF,",
|
|
214
|
+
"SUPERPOWERS_UPSTREAM_URL, SUPERPOWERS_CODEX, SUPERPOWERS_CACHE_DIR,",
|
|
215
|
+
"SUPERPOWERS_CONFIG_DIR, XDG_CONFIG_HOME,",
|
|
216
|
+
"SUPERPOWERS_PLUGIN_ROOT, SUPERPOWERS_MANIFEST_TEMPLATE,",
|
|
217
|
+
"SUPERPOWERS_VALIDATOR, SUPERPOWERS_VALIDATOR_EXECUTABLE,",
|
|
218
|
+
"SUPERPOWERS_INSTALLED_SEARCH_ROOT, SUPERPOWERS_INSTALL_REFRESH_MODE",
|
|
219
|
+
"",
|
|
220
|
+
"Selection state uses SUPERPOWERS_CONFIG_DIR when set; otherwise it uses",
|
|
221
|
+
"$XDG_CONFIG_HOME/superpowers-manager, then $HOME/.config/superpowers-manager.",
|
|
222
|
+
].join("\n");
|
|
223
|
+
}
|
|
224
|
+
async function main() {
|
|
225
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
226
|
+
if (parsed.kind === "help") {
|
|
227
|
+
console.log(usage());
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
230
|
+
const root = resolvePackageRoot(import.meta.filename);
|
|
231
|
+
if (!root) {
|
|
232
|
+
console.error("error: cannot resolve the superpowers-manager package root");
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
if (parsed.kind === "version") {
|
|
236
|
+
console.log(JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"))
|
|
237
|
+
.version);
|
|
238
|
+
process.exit(0);
|
|
239
|
+
}
|
|
240
|
+
if (parsed.kind === "usage-error") {
|
|
241
|
+
console.error(`error: ${parsed.message}`);
|
|
242
|
+
console.error(usage());
|
|
243
|
+
process.exit(2);
|
|
244
|
+
}
|
|
245
|
+
const pf = preflight(parsed.cmd, process.env, process.platform);
|
|
246
|
+
if (!pf.ok) {
|
|
247
|
+
for (const e of pf.errors)
|
|
248
|
+
console.error(`error: ${e}`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
// Dispatch is no longer a branch: slice 4b flipped the last spawned command,
|
|
252
|
+
// so every subcommand runs here, and slice 6 deleted the DISPATCH table whose
|
|
253
|
+
// only remaining job was narrowing this registry's key type. `parsed.cmd` is
|
|
254
|
+
// a Subcommand and the registry is keyed by Subcommand, so no cast is needed.
|
|
255
|
+
// The exhaustiveness check on IN_PROCESS_HANDLERS makes an unregistered
|
|
256
|
+
// handler a compile error; the `!handler` guard below is the runtime backstop
|
|
257
|
+
// for that guarantee. It is unreachable through production code, and no
|
|
258
|
+
// fixture reaches it either.
|
|
259
|
+
const handler = IN_PROCESS_HANDLERS[parsed.cmd];
|
|
260
|
+
if (!handler) {
|
|
261
|
+
console.error(`error: no in-process handler registered for: ${parsed.cmd}`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
const ctx = {
|
|
265
|
+
root,
|
|
266
|
+
env: process.env,
|
|
267
|
+
stdout: process.stdout,
|
|
268
|
+
stderr: process.stderr,
|
|
269
|
+
// The ONLY place runAdapter is bound to a context. Every command module
|
|
270
|
+
// reaches the adapter through this field; none imports runAdapter
|
|
271
|
+
// itself. tests/unit/ctx-adapter-provenance.test.js gates both halves.
|
|
272
|
+
adapter: runAdapter,
|
|
273
|
+
};
|
|
274
|
+
let status;
|
|
275
|
+
try {
|
|
276
|
+
status = await handler(parsed.args, ctx);
|
|
277
|
+
}
|
|
278
|
+
catch (cause) {
|
|
279
|
+
// Belt-and-suspenders: every registered handler already catches its own
|
|
280
|
+
// failures and returns a status code (see src/commands/unpin.ts). This
|
|
281
|
+
// re-emits a subordinate module's own diagnostic if one somehow escapes.
|
|
282
|
+
console.error(`error: ${oneLine(cause)}`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
process.exit(status);
|
|
286
|
+
}
|
|
287
|
+
export { resolvePackageRoot, isMain, parseArgs, findTool, commandRequirements, preflight, usage, main, };
|
|
288
|
+
if (isMain(import.meta.filename, process.argv[1])) {
|
|
289
|
+
main().catch((cause) => {
|
|
290
|
+
console.error(`error: ${oneLine(cause)}`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { SafetyError } from "./safety-error.js";
|
|
2
|
+
import { parseStrictJson, } from "./strict-json.js";
|
|
3
|
+
const ACCEPT_CONSTANTS = {
|
|
4
|
+
duplicateKeys: "last-wins",
|
|
5
|
+
nonStandardConstants: "accept",
|
|
6
|
+
};
|
|
7
|
+
const REJECT_CONSTANTS = {
|
|
8
|
+
duplicateKeys: "last-wins",
|
|
9
|
+
nonStandardConstants: "reject",
|
|
10
|
+
};
|
|
11
|
+
function fail(message, cause) {
|
|
12
|
+
throw new SafetyError("codex-json", message, { cause });
|
|
13
|
+
}
|
|
14
|
+
function object(value) {
|
|
15
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
16
|
+
? value
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
19
|
+
function parseObject(raw, profile) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = parseStrictJson(raw, profile);
|
|
23
|
+
}
|
|
24
|
+
catch (cause) {
|
|
25
|
+
fail("cannot parse Codex JSON", cause);
|
|
26
|
+
}
|
|
27
|
+
const result = object(parsed);
|
|
28
|
+
if (result === undefined)
|
|
29
|
+
fail("Codex JSON must be an object");
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
function checkedItems(raw, profile, arrayKey, field) {
|
|
33
|
+
const items = parseObject(raw, profile)[arrayKey];
|
|
34
|
+
if (!Array.isArray(items))
|
|
35
|
+
fail(`Codex JSON ${arrayKey} must be an array`);
|
|
36
|
+
return items.map((item) => {
|
|
37
|
+
const candidate = object(item);
|
|
38
|
+
if (candidate === undefined) {
|
|
39
|
+
fail(`Codex JSON ${arrayKey} item must be an object`);
|
|
40
|
+
}
|
|
41
|
+
const fieldValue = candidate[field];
|
|
42
|
+
if (typeof fieldValue !== "string" || fieldValue.length === 0) {
|
|
43
|
+
fail(`Codex JSON ${arrayKey} item needs non-empty ${field}`);
|
|
44
|
+
}
|
|
45
|
+
return candidate;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function hasTerminalControl(value) {
|
|
49
|
+
for (const character of value) {
|
|
50
|
+
const code = character.codePointAt(0);
|
|
51
|
+
if (code < 0x20 ||
|
|
52
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
53
|
+
(code >= 0xd800 && code <= 0xdfff)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
export function installedListingHas(raw, arrayKey, field, value) {
|
|
60
|
+
return checkedItems(raw, ACCEPT_CONSTANTS, arrayKey, field).some((item) => item[field] === value);
|
|
61
|
+
}
|
|
62
|
+
export function marketplaceRootFromJson(raw, marketplaceName) {
|
|
63
|
+
const items = checkedItems(raw, ACCEPT_CONSTANTS, "marketplaces", "name");
|
|
64
|
+
const match = items.find((item) => item.name === marketplaceName);
|
|
65
|
+
if (match === undefined)
|
|
66
|
+
return "";
|
|
67
|
+
if (typeof match.root !== "string" || match.root.length === 0) {
|
|
68
|
+
fail("matching marketplace needs a non-empty root");
|
|
69
|
+
}
|
|
70
|
+
return match.root;
|
|
71
|
+
}
|
|
72
|
+
export function activePluginVersionFromJson(raw, pluginId) {
|
|
73
|
+
const matches = checkedItems(raw, REJECT_CONSTANTS, "installed", "pluginId").filter((item) => item.pluginId === pluginId);
|
|
74
|
+
if (matches.length > 1)
|
|
75
|
+
fail("active plugin appears more than once");
|
|
76
|
+
if (matches.length === 0)
|
|
77
|
+
return "";
|
|
78
|
+
const version = matches[0].version;
|
|
79
|
+
if (typeof version !== "string" ||
|
|
80
|
+
version.length === 0 ||
|
|
81
|
+
version === "." ||
|
|
82
|
+
version === ".." ||
|
|
83
|
+
version.includes("/") ||
|
|
84
|
+
version.includes("\\") ||
|
|
85
|
+
hasTerminalControl(version)) {
|
|
86
|
+
fail("active plugin version is invalid");
|
|
87
|
+
}
|
|
88
|
+
return version;
|
|
89
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { COMMIT_INPUT_RE } from "./domain/refs.js";
|
|
4
|
+
import { SafetyError } from "./safety-error.js";
|
|
5
|
+
import { parseStrictJson, } from "./strict-json.js";
|
|
6
|
+
const INSTALLED_PROFILE = {
|
|
7
|
+
duplicateKeys: "last-wins",
|
|
8
|
+
nonStandardConstants: "reject",
|
|
9
|
+
maxDepth: 256,
|
|
10
|
+
};
|
|
11
|
+
function fail(message, cause) {
|
|
12
|
+
throw new SafetyError("codex-state", message, { cause });
|
|
13
|
+
}
|
|
14
|
+
function object(value) {
|
|
15
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
16
|
+
? value
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
19
|
+
async function jsonStringField(path, key) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = parseStrictJson(await readFile(path), INSTALLED_PROFILE);
|
|
23
|
+
}
|
|
24
|
+
catch (cause) {
|
|
25
|
+
fail(`cannot read installed Codex JSON ${path}`, cause);
|
|
26
|
+
}
|
|
27
|
+
const record = object(parsed);
|
|
28
|
+
if (record === undefined)
|
|
29
|
+
fail(`invalid installed Codex JSON ${path}`);
|
|
30
|
+
const value = record[key];
|
|
31
|
+
if (value === undefined || value === null)
|
|
32
|
+
return "";
|
|
33
|
+
if (typeof value !== "string")
|
|
34
|
+
fail(`invalid installed Codex JSON ${path}`);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
export function installedRootForVersion(searchRoot, marketplace, plugin, version) {
|
|
38
|
+
return join(searchRoot, "plugins", "cache", marketplace, plugin, version);
|
|
39
|
+
}
|
|
40
|
+
export async function codexMetadataCommit(path) {
|
|
41
|
+
const commit = await jsonStringField(path, "commit");
|
|
42
|
+
if (!COMMIT_INPUT_RE.test(commit) && !/^[0-9a-fA-F]{7}$/.test(commit)) {
|
|
43
|
+
fail(`invalid installed Codex commit in ${path}`);
|
|
44
|
+
}
|
|
45
|
+
return commit;
|
|
46
|
+
}
|
|
47
|
+
export async function manifestShortSha(path) {
|
|
48
|
+
const version = await jsonStringField(path, "version");
|
|
49
|
+
if (!version.includes("+manager."))
|
|
50
|
+
return "";
|
|
51
|
+
const short = version.slice(version.lastIndexOf(".") + 1);
|
|
52
|
+
return /^[0-9a-fA-F]{7}$/.test(short) ? short : "";
|
|
53
|
+
}
|
|
54
|
+
export async function installedCommitFromRoot(activeRoot) {
|
|
55
|
+
try {
|
|
56
|
+
return await codexMetadataCommit(join(activeRoot, ".superpowers-upstream.json"));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// The shell predecessor deliberately fell back to the generated manifest.
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return await manifestShortSha(join(activeRoot, ".codex-plugin", "plugin.json"));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function comparablePath(path) {
|
|
69
|
+
try {
|
|
70
|
+
return await realpath(path);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return resolve(path);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function pathsEqual(a, b) {
|
|
77
|
+
return (await comparablePath(a)) === (await comparablePath(b));
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|