superpowers-manager 0.2.0 → 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 +112 -47
- package/bin/superpowers-manager.js +9 -283
- 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 -603
- package/scripts/adapters/codex/lib.sh +0 -449
- package/scripts/adapters/codex/validate-generated-plugin.py +0 -314
- package/scripts/core/adapter.sh +0 -103
- package/scripts/core/common.sh +0 -59
- package/scripts/core/lifecycle.sh +0 -141
- package/scripts/core/provenance.sh +0 -135
- package/scripts/core/selection-state.py +0 -414
- package/scripts/core/selection.sh +0 -168
- package/scripts/core/status.sh +0 -27
- package/scripts/core/upstream.sh +0 -280
- package/scripts/core/validate-adapter-response.py +0 -284
- package/scripts/install +0 -59
- package/scripts/pin +0 -51
- package/scripts/prepare +0 -117
- package/scripts/probe +0 -82
- package/scripts/track-latest +0 -30
- package/scripts/uninstall +0 -35
- package/scripts/unpin +0 -41
- package/scripts/update +0 -34
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { COMMIT_INPUT_RE } from "./domain/refs.js";
|
|
4
|
+
import { escapePythonJsonString } from "./python-json.js";
|
|
5
|
+
import { SafetyError } from "./safety-error.js";
|
|
6
|
+
import { parseStrictJson, } from "./strict-json.js";
|
|
7
|
+
export const PROVENANCE_STRICT_PROFILE = {
|
|
8
|
+
duplicateKeys: "last-wins",
|
|
9
|
+
nonStandardConstants: "reject",
|
|
10
|
+
maxDepth: 256,
|
|
11
|
+
};
|
|
12
|
+
export const PROVENANCE_LENIENT_PROFILE = {
|
|
13
|
+
duplicateKeys: "last-wins",
|
|
14
|
+
nonStandardConstants: "reject",
|
|
15
|
+
};
|
|
16
|
+
export const PROVENANCE_CODEX_SOURCE_PROFILE = {
|
|
17
|
+
duplicateKeys: "last-wins",
|
|
18
|
+
nonStandardConstants: "accept",
|
|
19
|
+
};
|
|
20
|
+
function asObject(value) {
|
|
21
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
22
|
+
? value
|
|
23
|
+
: undefined;
|
|
24
|
+
}
|
|
25
|
+
export async function readStrictProvenanceField(path, dottedKey) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = parseStrictJson(await readFile(path), PROVENANCE_STRICT_PROFILE);
|
|
28
|
+
let current = parsed;
|
|
29
|
+
if (asObject(current) === undefined) {
|
|
30
|
+
throw new SafetyError("provenance", `provenance value must be an object: ${path}`);
|
|
31
|
+
}
|
|
32
|
+
for (const part of dottedKey.split(".")) {
|
|
33
|
+
const object = current === undefined ? undefined : asObject(current);
|
|
34
|
+
if (object === undefined || !Object.hasOwn(object, part))
|
|
35
|
+
return undefined;
|
|
36
|
+
current = object[part];
|
|
37
|
+
}
|
|
38
|
+
return current === null ? undefined : current;
|
|
39
|
+
}
|
|
40
|
+
catch (cause) {
|
|
41
|
+
if (cause instanceof SafetyError && cause.module === "provenance") {
|
|
42
|
+
throw cause;
|
|
43
|
+
}
|
|
44
|
+
throw new SafetyError("provenance", `cannot read strict provenance field from ${path}`, { cause });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function readGeneratedCommitLenient(path) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = parseStrictJson(await readFile(path), PROVENANCE_LENIENT_PROFILE);
|
|
50
|
+
const object = asObject(parsed);
|
|
51
|
+
const commit = object?.commit;
|
|
52
|
+
return typeof commit === "string" && COMMIT_INPUT_RE.test(commit)
|
|
53
|
+
? commit
|
|
54
|
+
: "";
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Ported from
|
|
61
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:28-31::spw_generated_metadata_path(`.
|
|
62
|
+
// The path the generated tree's provenance lives at, relative to a package
|
|
63
|
+
// root.
|
|
64
|
+
export function generatedMetadataPath(root) {
|
|
65
|
+
return join(root, "plugins", "superpowers", ".superpowers-upstream.json");
|
|
66
|
+
}
|
|
67
|
+
// Ported from
|
|
68
|
+
// `git show ad56569a4c161e7b122967442e2b026eeb6395f6:scripts/core/lifecycle.sh:33-37::spw_generated_commit_or_empty`.
|
|
69
|
+
// Lenient by design: a missing or malformed generated provenance file yields
|
|
70
|
+
// "", which `statusForCommits` reads as "needs prepare". Aborting here would
|
|
71
|
+
// deny the operator the remediation path.
|
|
72
|
+
export async function generatedCommitOrEmpty(root) {
|
|
73
|
+
return readGeneratedCommitLenient(generatedMetadataPath(root));
|
|
74
|
+
}
|
|
75
|
+
export async function readCodexBuildSource(path) {
|
|
76
|
+
try {
|
|
77
|
+
const parsed = parseStrictJson(await readFile(path), PROVENANCE_CODEX_SOURCE_PROFILE);
|
|
78
|
+
const source = asObject(parsed)?.source;
|
|
79
|
+
if (typeof source !== "string" || source.length === 0) {
|
|
80
|
+
throw new SafetyError("provenance", `invalid Codex source in ${path}`);
|
|
81
|
+
}
|
|
82
|
+
return source;
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
if (cause instanceof SafetyError && cause.module === "provenance") {
|
|
86
|
+
throw cause;
|
|
87
|
+
}
|
|
88
|
+
throw new SafetyError("provenance", `cannot read Codex source from ${path}`, { cause });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const PROVENANCE_KEYS = [
|
|
92
|
+
"source",
|
|
93
|
+
"requested_ref",
|
|
94
|
+
"resolved_ref",
|
|
95
|
+
"commit",
|
|
96
|
+
"upstream_manifest_version",
|
|
97
|
+
];
|
|
98
|
+
export function serializeProvenance(record) {
|
|
99
|
+
const lines = PROVENANCE_KEYS.map((key) => ` "${key}": "${escapePythonJsonString(record[key])}"`);
|
|
100
|
+
return `{\n${lines.join(",\n")}\n}\n`;
|
|
101
|
+
}
|
|
102
|
+
export async function writeProvenance(path, record) {
|
|
103
|
+
try {
|
|
104
|
+
await writeFile(path, serializeProvenance(record), "utf8");
|
|
105
|
+
}
|
|
106
|
+
catch (cause) {
|
|
107
|
+
throw new SafetyError("provenance", `cannot write provenance ${path}`, {
|
|
108
|
+
cause,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { SafetyError } from "./safety-error.js";
|
|
2
|
+
/**
|
|
3
|
+
* Render a JSON number token the way CPython's `json.dump` does.
|
|
4
|
+
*
|
|
5
|
+
* Classification is by source text, matching CPython's own: a token containing
|
|
6
|
+
* `.` or `e`/`E` parses as a float, anything else as an arbitrary-width int.
|
|
7
|
+
* Integers are emitted verbatim so widths above 2^53 survive; `-0` is the only
|
|
8
|
+
* normalization JSON's grammar leaves room for, since leading zeros and a
|
|
9
|
+
* leading `+` are already forbidden.
|
|
10
|
+
*/
|
|
11
|
+
export function formatPythonNumber(raw) {
|
|
12
|
+
if (!/[.eE]/.test(raw)) {
|
|
13
|
+
// NaN/Infinity/-Infinity contain no `.` or `e`/`E`, so they would
|
|
14
|
+
// otherwise fall into the "emit verbatim" integer branch and echo back
|
|
15
|
+
// as invalid JSON. A reader running a non-standard-constants accept
|
|
16
|
+
// profile (see src/strict-json.ts) can hand this function exactly these
|
|
17
|
+
// three tokens as a raw number source, so this function must reject them
|
|
18
|
+
// itself rather than assume its caller already did. CPython's
|
|
19
|
+
// `json.dump(..., allow_nan=False)` raises ValueError for all three.
|
|
20
|
+
if (raw === "NaN" || raw === "Infinity" || raw === "-Infinity") {
|
|
21
|
+
throw new SafetyError("manifest-overlay", `JSON number out of range: ${raw}`);
|
|
22
|
+
}
|
|
23
|
+
return raw === "-0" ? "0" : raw;
|
|
24
|
+
}
|
|
25
|
+
const value = Number(raw);
|
|
26
|
+
if (!Number.isFinite(value)) {
|
|
27
|
+
throw new SafetyError("manifest-overlay", `JSON number out of range: ${raw}`);
|
|
28
|
+
}
|
|
29
|
+
return formatPythonFloat(value);
|
|
30
|
+
}
|
|
31
|
+
function formatPythonFloat(value) {
|
|
32
|
+
// toExponential() drops the sign of negative zero; CPython's repr keeps it.
|
|
33
|
+
// Note this differs from the integer -0, which normalizes to 0 — consistent,
|
|
34
|
+
// because -0 has no `.` or `e` and so classifies as an integer.
|
|
35
|
+
if (Object.is(value, -0))
|
|
36
|
+
return "-0.0";
|
|
37
|
+
// Called with no argument, toExponential() yields the shortest digit string
|
|
38
|
+
// that uniquely identifies the double — the same guarantee CPython's repr
|
|
39
|
+
// relies on. Taking digits and exponent separately is what lets us apply
|
|
40
|
+
// CPython's notation threshold instead of JavaScript's.
|
|
41
|
+
const [mantissa, exponentText] = value.toExponential().split("e");
|
|
42
|
+
const exponent = Number(exponentText);
|
|
43
|
+
const negative = mantissa.startsWith("-");
|
|
44
|
+
const digits = mantissa.replace("-", "").replace(".", "");
|
|
45
|
+
let body;
|
|
46
|
+
if (exponent >= -4 && exponent < 16) {
|
|
47
|
+
// Fixed notation, built by MOVING the decimal point through the digit
|
|
48
|
+
// string above. Do NOT route this through value.toFixed(): toExponential()
|
|
49
|
+
// has already produced the shortest digits that identify this double, and
|
|
50
|
+
// toFixed performs a second, independent rounding of the full decimal
|
|
51
|
+
// expansion. The two disagree — 1888570120608320.2 formats as
|
|
52
|
+
// ...320.3 via toFixed and ...320.2 in CPython. Measured 2026-07-31.
|
|
53
|
+
if (exponent >= digits.length - 1) {
|
|
54
|
+
body = `${digits}${"0".repeat(exponent - (digits.length - 1))}.0`;
|
|
55
|
+
}
|
|
56
|
+
else if (exponent >= 0) {
|
|
57
|
+
body = `${digits.slice(0, exponent + 1)}.${digits.slice(exponent + 1)}`;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
body = `0.${"0".repeat(-exponent - 1)}${digits}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const sign = exponent < 0 ? "-" : "+";
|
|
65
|
+
const magnitude = String(Math.abs(exponent)).padStart(2, "0");
|
|
66
|
+
body = `${mantissa.replace("-", "")}e${sign}${magnitude}`;
|
|
67
|
+
}
|
|
68
|
+
return negative ? `-${body}` : body;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Apply CPython's `ensure_ascii=True` to an already-JSON-encoded string. Input
|
|
72
|
+
* must be the output of JSON.stringify, which has already escaped quotes,
|
|
73
|
+
* backslashes, and control characters; this only replaces the remainder.
|
|
74
|
+
* Astral characters are already surrogate pairs in a JavaScript string, so the
|
|
75
|
+
* per-code-unit replacement emits the pair CPython emits.
|
|
76
|
+
*
|
|
77
|
+
* The range starts at U+007F, not U+0080: CPython escapes DEL while
|
|
78
|
+
* JSON.stringify leaves it literal, and U+007E is "~" in both. Below U+0020
|
|
79
|
+
* the two already agree. Measured 2026-07-31.
|
|
80
|
+
*/
|
|
81
|
+
export function escapeNonAscii(text) {
|
|
82
|
+
return text.replace(/[\u007f-\uffff]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
83
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function escapePythonJsonString(value) {
|
|
2
|
+
let escaped = "";
|
|
3
|
+
const shortEscapes = {
|
|
4
|
+
0x08: "\\b",
|
|
5
|
+
0x09: "\\t",
|
|
6
|
+
0x0a: "\\n",
|
|
7
|
+
0x0c: "\\f",
|
|
8
|
+
0x0d: "\\r",
|
|
9
|
+
};
|
|
10
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
11
|
+
const code = value.charCodeAt(index);
|
|
12
|
+
if (code === 0x22) {
|
|
13
|
+
escaped += '\\"';
|
|
14
|
+
}
|
|
15
|
+
else if (code === 0x5c) {
|
|
16
|
+
escaped += "\\\\";
|
|
17
|
+
}
|
|
18
|
+
else if (Object.hasOwn(shortEscapes, code)) {
|
|
19
|
+
escaped += shortEscapes[code];
|
|
20
|
+
}
|
|
21
|
+
else if (code < 0x20 || code >= 0x7f) {
|
|
22
|
+
escaped += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
escaped += value[index];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return escaped;
|
|
29
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// CPython `str.strip()` whitespace, from Unicode's Bidi/White_Space tables as
|
|
2
|
+
// CPython applies them. Deliberately excludes U+FEFF, which JavaScript's
|
|
3
|
+
// `trim()` removes and Python's `strip()` does not.
|
|
4
|
+
const PYTHON_WHITESPACE = new Set([
|
|
5
|
+
"\t",
|
|
6
|
+
"\n",
|
|
7
|
+
"\v",
|
|
8
|
+
"\f",
|
|
9
|
+
"\r",
|
|
10
|
+
" ",
|
|
11
|
+
"\x1c",
|
|
12
|
+
"\x1d",
|
|
13
|
+
"\x1e",
|
|
14
|
+
"\x1f",
|
|
15
|
+
"\x85",
|
|
16
|
+
" ",
|
|
17
|
+
" ",
|
|
18
|
+
" ",
|
|
19
|
+
" ",
|
|
20
|
+
" ",
|
|
21
|
+
" ",
|
|
22
|
+
" ",
|
|
23
|
+
" ",
|
|
24
|
+
" ",
|
|
25
|
+
" ",
|
|
26
|
+
" ",
|
|
27
|
+
" ",
|
|
28
|
+
" ",
|
|
29
|
+
"
",
|
|
30
|
+
"
",
|
|
31
|
+
" ",
|
|
32
|
+
" ",
|
|
33
|
+
" ",
|
|
34
|
+
]);
|
|
35
|
+
// CPython `str.splitlines()` boundaries. `\r\n` is handled as a pair below.
|
|
36
|
+
const PYTHON_LINE_BOUNDARIES = new Set([
|
|
37
|
+
"\n",
|
|
38
|
+
"\r",
|
|
39
|
+
"\v",
|
|
40
|
+
"\f",
|
|
41
|
+
"\x1c",
|
|
42
|
+
"\x1d",
|
|
43
|
+
"\x1e",
|
|
44
|
+
"\x85",
|
|
45
|
+
"
",
|
|
46
|
+
"
",
|
|
47
|
+
]);
|
|
48
|
+
export function pythonStrip(value) {
|
|
49
|
+
let start = 0;
|
|
50
|
+
let end = value.length;
|
|
51
|
+
while (start < end && PYTHON_WHITESPACE.has(value[start]))
|
|
52
|
+
start += 1;
|
|
53
|
+
while (end > start && PYTHON_WHITESPACE.has(value[end - 1]))
|
|
54
|
+
end -= 1;
|
|
55
|
+
return value.slice(start, end);
|
|
56
|
+
}
|
|
57
|
+
export function pythonSplitlines(value) {
|
|
58
|
+
const lines = [];
|
|
59
|
+
let start = 0;
|
|
60
|
+
let index = 0;
|
|
61
|
+
while (index < value.length) {
|
|
62
|
+
const character = value[index];
|
|
63
|
+
if (!PYTHON_LINE_BOUNDARIES.has(character)) {
|
|
64
|
+
index += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
lines.push(value.slice(start, index));
|
|
68
|
+
index += character === "\r" && value[index + 1] === "\n" ? 2 : 1;
|
|
69
|
+
start = index;
|
|
70
|
+
}
|
|
71
|
+
if (start < value.length)
|
|
72
|
+
lines.push(value.slice(start));
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { lstat, readlink, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep, } from "node:path";
|
|
3
|
+
import { SafetyError } from "./safety-error.js";
|
|
4
|
+
function isErrno(value, code) {
|
|
5
|
+
return (value instanceof Error &&
|
|
6
|
+
"code" in value &&
|
|
7
|
+
value.code === code);
|
|
8
|
+
}
|
|
9
|
+
function isContained(root, candidate) {
|
|
10
|
+
const suffix = relative(root, candidate);
|
|
11
|
+
return (suffix === "" ||
|
|
12
|
+
(!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)));
|
|
13
|
+
}
|
|
14
|
+
function requireContained(root, candidate) {
|
|
15
|
+
if (!isContained(root, candidate)) {
|
|
16
|
+
throw new SafetyError("safe-path", `path escapes containment root: ${candidate}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function classifyPathNoFollow(path) {
|
|
20
|
+
try {
|
|
21
|
+
const info = await lstat(path);
|
|
22
|
+
if (info.isSymbolicLink())
|
|
23
|
+
return "symlink";
|
|
24
|
+
if (info.isFile())
|
|
25
|
+
return "regular-file";
|
|
26
|
+
if (info.isDirectory())
|
|
27
|
+
return "directory";
|
|
28
|
+
return "other";
|
|
29
|
+
}
|
|
30
|
+
catch (cause) {
|
|
31
|
+
if (isErrno(cause, "ENOENT"))
|
|
32
|
+
return "missing";
|
|
33
|
+
throw new SafetyError("safe-path", `cannot inspect path: ${path}`, {
|
|
34
|
+
cause,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function assertNoFollowType(path, allowed) {
|
|
39
|
+
const actual = await classifyPathNoFollow(path);
|
|
40
|
+
if (!allowed.includes(actual)) {
|
|
41
|
+
throw new SafetyError("safe-path", `path has disallowed type ${actual}: ${path}`);
|
|
42
|
+
}
|
|
43
|
+
return actual;
|
|
44
|
+
}
|
|
45
|
+
export async function assertDesignatedParentDirectory(target) {
|
|
46
|
+
const parent = dirname(resolve(target));
|
|
47
|
+
const actual = await classifyPathNoFollow(parent);
|
|
48
|
+
if (actual !== "missing" && actual !== "directory") {
|
|
49
|
+
throw new SafetyError("safe-path", `designated parent must be a directory without following symlinks: ${parent}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function assertExistingContained(root, candidate) {
|
|
53
|
+
const lexicalRoot = resolve(root);
|
|
54
|
+
const lexicalCandidate = resolve(candidate);
|
|
55
|
+
requireContained(lexicalRoot, lexicalCandidate);
|
|
56
|
+
try {
|
|
57
|
+
const [resolvedRoot, resolvedCandidate] = await Promise.all([
|
|
58
|
+
realpath(lexicalRoot),
|
|
59
|
+
realpath(lexicalCandidate),
|
|
60
|
+
]);
|
|
61
|
+
requireContained(resolvedRoot, resolvedCandidate);
|
|
62
|
+
return lexicalCandidate;
|
|
63
|
+
}
|
|
64
|
+
catch (cause) {
|
|
65
|
+
if (cause instanceof SafetyError)
|
|
66
|
+
throw cause;
|
|
67
|
+
throw new SafetyError("safe-path", `cannot resolve existing path: ${lexicalCandidate}`, { cause });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function assertProspectiveContained(root, candidate) {
|
|
71
|
+
const lexicalRoot = resolve(root);
|
|
72
|
+
const lexicalCandidate = resolve(candidate);
|
|
73
|
+
requireContained(lexicalRoot, lexicalCandidate);
|
|
74
|
+
try {
|
|
75
|
+
const resolvedRoot = await realpath(lexicalRoot);
|
|
76
|
+
const missing = [];
|
|
77
|
+
const visitedCursors = new Set();
|
|
78
|
+
let cursor = lexicalCandidate;
|
|
79
|
+
let resolvedAncestor;
|
|
80
|
+
for (;;) {
|
|
81
|
+
if (visitedCursors.has(cursor)) {
|
|
82
|
+
throw new SafetyError("safe-path", `prospective path resolution repeats cursor: ${cursor}`);
|
|
83
|
+
}
|
|
84
|
+
visitedCursors.add(cursor);
|
|
85
|
+
try {
|
|
86
|
+
resolvedAncestor = await realpath(cursor);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
catch (cause) {
|
|
90
|
+
if (!isErrno(cause, "ENOENT"))
|
|
91
|
+
throw cause;
|
|
92
|
+
let symlinkTarget;
|
|
93
|
+
try {
|
|
94
|
+
if ((await lstat(cursor)).isSymbolicLink()) {
|
|
95
|
+
symlinkTarget = await readlink(cursor);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (inspectionCause) {
|
|
99
|
+
if (!isErrno(inspectionCause, "ENOENT"))
|
|
100
|
+
throw inspectionCause;
|
|
101
|
+
}
|
|
102
|
+
if (symlinkTarget !== undefined) {
|
|
103
|
+
cursor = resolve(await realpath(dirname(cursor)), symlinkTarget);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const parent = dirname(cursor);
|
|
107
|
+
if (parent === cursor)
|
|
108
|
+
throw cause;
|
|
109
|
+
missing.unshift(basename(cursor));
|
|
110
|
+
cursor = parent;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const resolvedCandidate = resolve(resolvedAncestor, ...missing);
|
|
114
|
+
requireContained(resolvedRoot, resolvedCandidate);
|
|
115
|
+
return lexicalCandidate;
|
|
116
|
+
}
|
|
117
|
+
catch (cause) {
|
|
118
|
+
if (cause instanceof SafetyError)
|
|
119
|
+
throw cause;
|
|
120
|
+
throw new SafetyError("safe-path", `cannot resolve prospective path: ${lexicalCandidate}`, { cause });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function assertSymlinkTargetContained(root, candidate) {
|
|
124
|
+
await assertNoFollowType(candidate, ["symlink"]);
|
|
125
|
+
try {
|
|
126
|
+
const [resolvedRoot, resolvedTarget] = await Promise.all([
|
|
127
|
+
realpath(resolve(root)),
|
|
128
|
+
realpath(resolve(candidate)),
|
|
129
|
+
]);
|
|
130
|
+
requireContained(resolvedRoot, resolvedTarget);
|
|
131
|
+
}
|
|
132
|
+
catch (cause) {
|
|
133
|
+
if (cause instanceof SafetyError)
|
|
134
|
+
throw cause;
|
|
135
|
+
throw new SafetyError("safe-path", `cannot resolve symlink target: ${candidate}`, { cause });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { displaySource, normalizePinnedArguments, normalizeSaved, validateSource, } from "./selection.js";
|
|
4
|
+
import { readSelectionState, writeSelectionState } from "./selection-store.js";
|
|
5
|
+
import { oneLine, parseFlags, UsageError } from "./cli-arguments.js";
|
|
6
|
+
const COMMAND_FLAGS = {
|
|
7
|
+
read: ["path", "output"],
|
|
8
|
+
"write-pinned": ["path", "source", "requested-ref", "resolved-ref", "commit"],
|
|
9
|
+
"write-track-latest": ["path", "source"],
|
|
10
|
+
"validate-source": ["source"],
|
|
11
|
+
"display-source": ["source"],
|
|
12
|
+
};
|
|
13
|
+
function parseCommand(argv) {
|
|
14
|
+
const [name, ...rest] = argv;
|
|
15
|
+
if (name === undefined)
|
|
16
|
+
throw new UsageError("a subcommand is required");
|
|
17
|
+
if (!Object.hasOwn(COMMAND_FLAGS, name)) {
|
|
18
|
+
throw new UsageError(`unknown subcommand: ${name}`);
|
|
19
|
+
}
|
|
20
|
+
const command = name;
|
|
21
|
+
return { name: command, flags: parseFlags(rest, COMMAND_FLAGS[command]) };
|
|
22
|
+
}
|
|
23
|
+
async function dispatch({ name, flags }) {
|
|
24
|
+
if (name === "read") {
|
|
25
|
+
const normalized = normalizeSaved(await readSelectionState(flags.path));
|
|
26
|
+
try {
|
|
27
|
+
await writeFile(flags.output, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch (cause) {
|
|
30
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
31
|
+
throw new Error(`cannot write normalized selection output ${flags.output}: ${message}`, { cause });
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (name === "write-pinned") {
|
|
36
|
+
await writeSelectionState(flags.path, normalizePinnedArguments({
|
|
37
|
+
source: flags.source,
|
|
38
|
+
requestedRef: flags["requested-ref"],
|
|
39
|
+
resolvedRef: flags["resolved-ref"],
|
|
40
|
+
commit: flags.commit,
|
|
41
|
+
}));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (name === "write-track-latest") {
|
|
45
|
+
await writeSelectionState(flags.path, {
|
|
46
|
+
schema_version: 1,
|
|
47
|
+
mode: "track-latest",
|
|
48
|
+
source: flags.source,
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (name === "validate-source") {
|
|
53
|
+
validateSource(flags.source);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (name === "display-source") {
|
|
57
|
+
process.stdout.write(`${displaySource(flags.source)}\n`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const unreachable = name;
|
|
61
|
+
throw new Error(`unhandled subcommand: ${String(unreachable)}`);
|
|
62
|
+
}
|
|
63
|
+
export async function runSelectionStateCli(argv) {
|
|
64
|
+
let command;
|
|
65
|
+
try {
|
|
66
|
+
command = parseCommand(argv);
|
|
67
|
+
}
|
|
68
|
+
catch (cause) {
|
|
69
|
+
process.stderr.write(`usage: selection-state-cli <subcommand> [options]\n`);
|
|
70
|
+
process.stderr.write(`error: ${oneLine(cause)}\n`);
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await dispatch(command);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
catch (cause) {
|
|
78
|
+
process.stderr.write(`error: ${oneLine(cause)}\n`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const entry = process.argv[1];
|
|
83
|
+
if (entry !== undefined && import.meta.filename === realpathSync(entry)) {
|
|
84
|
+
process.exitCode = await runSelectionStateCli(process.argv.slice(2));
|
|
85
|
+
}
|