superpowers-manager 0.4.0 → 0.5.1
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 +76 -517
- package/dist/artifact-tree.js +111 -0
- package/dist/atomic.js +103 -7
- package/dist/cli-arguments.js +37 -0
- package/dist/cli.js +49 -12
- package/dist/commands/install.js +116 -17
- package/dist/commands/mutation.js +71 -0
- package/dist/commands/prepare.js +23 -17
- package/dist/commands/probe.js +164 -14
- package/dist/commands/uninstall.js +9 -5
- package/dist/commands/update.js +6 -2
- package/dist/domain/refs.js +4 -0
- package/dist/harness-compatibility.js +13 -0
- package/dist/{adapter.js → harnesses/codex/adapter.js} +17 -13
- package/dist/harnesses/codex/compatibility.js +126 -0
- package/dist/harnesses/codex/conflicts.js +99 -0
- package/dist/{generated-plugin.js → harnesses/codex/generated-plugin.js} +7 -73
- package/dist/{codex-harness.js → harnesses/codex/harness.js} +55 -5
- package/dist/{hooks.js → harnesses/codex/hooks.js} +6 -6
- package/dist/{codex-json.js → harnesses/codex/json.js} +9 -2
- package/dist/{lifecycle.js → harnesses/codex/lifecycle.js} +2 -2
- package/dist/{manifest-overlay.js → harnesses/codex/manifest-overlay.js} +5 -5
- package/dist/{codex-prepare.js → harnesses/codex/prepare.js} +72 -16
- package/dist/{codex-presentation.js → harnesses/codex/presentation.js} +27 -4
- package/dist/{codex-state.js → harnesses/codex/state.js} +3 -3
- package/dist/harnesses/pi/compatibility.js +73 -0
- package/dist/harnesses/pi/harness.js +63 -0
- package/dist/harnesses/pi/install.js +495 -0
- package/dist/harnesses/pi/native.js +81 -0
- package/dist/harnesses/pi/package.js +153 -0
- package/dist/harnesses/pi/paths.js +30 -0
- package/dist/harnesses/pi/prepare.js +111 -0
- package/dist/harnesses/pi/presentation.js +67 -0
- package/dist/harnesses/pi/settings.js +171 -0
- package/dist/harnesses/pi/shared-skills.js +413 -0
- package/dist/harnesses/pi/state.js +311 -0
- package/dist/python-text.js +16 -0
- package/dist/resource-lock.js +265 -0
- package/dist/safe-path.js +21 -5
- package/dist/skill-validation.js +52 -0
- package/dist/upstream-workspace.js +8 -0
- package/dist/validate-generated-plugin-cli.js +1 -1
- package/dist/validator.js +2 -1
- package/package.json +12 -8
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { sharedPiSkillsActivity } from "./shared-skills.js";
|
|
3
|
+
import { failureResult, successResult, } from "../../adapter-result.js";
|
|
4
|
+
import { readArtifactObject } from "../../artifact-tree.js";
|
|
5
|
+
import { samePiSource } from "./compatibility.js";
|
|
6
|
+
import { digestPiTree, readPiPackageAssessment, readPiReceipt, } from "./package.js";
|
|
7
|
+
import { piPaths } from "./paths.js";
|
|
8
|
+
import { readPiSettings, resolveCanonicalPiLocalSource, resolvePiLocalSource, } from "./settings.js";
|
|
9
|
+
import { classifyPathNoFollow } from "../../safe-path.js";
|
|
10
|
+
// Verified remote spellings only: Pi 0.85.1 accepts http:// as Git. Unprefixed
|
|
11
|
+
// github: and git+ forms stay local; git:-wrapped aliases and explicit transports
|
|
12
|
+
// normalize only an exact GitHub authority and repository path (native source
|
|
13
|
+
// citations are in pi-settings.ts).
|
|
14
|
+
const KNOWN_UPSTREAM_PI_SOURCE_BASES = [
|
|
15
|
+
"git:github.com/obra/superpowers",
|
|
16
|
+
"git:github.com/obra/superpowers.git",
|
|
17
|
+
"git:git@github.com:obra/superpowers",
|
|
18
|
+
"git:git@github.com:obra/superpowers.git",
|
|
19
|
+
"git:git@github.com/obra/superpowers",
|
|
20
|
+
"git:git@github.com/obra/superpowers.git",
|
|
21
|
+
"git:github:obra/superpowers",
|
|
22
|
+
"git:github:obra/superpowers.git",
|
|
23
|
+
"git:obra/superpowers",
|
|
24
|
+
"git:obra/superpowers.git",
|
|
25
|
+
"http://github.com/obra/superpowers",
|
|
26
|
+
"http://github.com/obra/superpowers.git",
|
|
27
|
+
"https://github.com/obra/superpowers",
|
|
28
|
+
"https://github.com/obra/superpowers.git",
|
|
29
|
+
"ssh://github.com/obra/superpowers",
|
|
30
|
+
"ssh://github.com/obra/superpowers.git",
|
|
31
|
+
"ssh://git@github.com/obra/superpowers",
|
|
32
|
+
"ssh://git@github.com/obra/superpowers.git",
|
|
33
|
+
"git://github.com/obra/superpowers",
|
|
34
|
+
"git://github.com/obra/superpowers.git",
|
|
35
|
+
];
|
|
36
|
+
const SHARED_PI_SKILLS_CONFLICT = "native Pi skills route ~/.agents/skills/superpowers";
|
|
37
|
+
const SHARED_PI_SKILLS_INDETERMINATE = `${SHARED_PI_SKILLS_CONFLICT} has indeterminate activity`;
|
|
38
|
+
function blocked(...stderr) {
|
|
39
|
+
return { kind: "blocked", output: { stdout: [], stderr } };
|
|
40
|
+
}
|
|
41
|
+
function pathsFor(ctx) {
|
|
42
|
+
return piPaths(ctx.env ?? {}, process.cwd());
|
|
43
|
+
}
|
|
44
|
+
function isNonemptyControlFree(value) {
|
|
45
|
+
if (value.length === 0)
|
|
46
|
+
return false;
|
|
47
|
+
for (const character of value) {
|
|
48
|
+
const code = character.codePointAt(0);
|
|
49
|
+
if (code <= 0x20 || code === 0x7f)
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
function normalizeVerifiedGithubSource(candidate) {
|
|
55
|
+
const match = /^(https?|ssh|git):\/\/([^/?#]+)(\/obra\/superpowers(?:\.git)?\/?(?:[@#].*)?)$/u.exec(candidate);
|
|
56
|
+
if (match === null)
|
|
57
|
+
return candidate;
|
|
58
|
+
const [, transport, authority, path] = match;
|
|
59
|
+
const at = authority.lastIndexOf("@");
|
|
60
|
+
const userinfo = at === -1 ? null : authority.slice(0, at);
|
|
61
|
+
if ((userinfo !== null &&
|
|
62
|
+
(authority.indexOf("@") !== at || !isNonemptyControlFree(userinfo))) ||
|
|
63
|
+
authority.slice(at + 1) === "")
|
|
64
|
+
return candidate;
|
|
65
|
+
const host = authority.slice(at + 1);
|
|
66
|
+
const normalizedHost = host.startsWith("www.") ? host.slice(4) : host;
|
|
67
|
+
if (normalizedHost !== "github.com")
|
|
68
|
+
return candidate;
|
|
69
|
+
return `${transport}://github.com${path}`;
|
|
70
|
+
}
|
|
71
|
+
function isKnownUpstreamPiSource(raw) {
|
|
72
|
+
const source = raw.trim();
|
|
73
|
+
const outerBody = source.startsWith("git:") ? source.slice(4).trim() : null;
|
|
74
|
+
const candidates = [
|
|
75
|
+
source,
|
|
76
|
+
...(outerBody === null
|
|
77
|
+
? []
|
|
78
|
+
: [
|
|
79
|
+
outerBody.startsWith("git+https://") ||
|
|
80
|
+
outerBody.startsWith("git+ssh://")
|
|
81
|
+
? outerBody.slice(4)
|
|
82
|
+
: outerBody,
|
|
83
|
+
]),
|
|
84
|
+
];
|
|
85
|
+
return candidates.some((candidate) => {
|
|
86
|
+
const normalized = normalizeVerifiedGithubSource(candidate);
|
|
87
|
+
return KNOWN_UPSTREAM_PI_SOURCE_BASES.some((base) => {
|
|
88
|
+
if (normalized === base)
|
|
89
|
+
return true;
|
|
90
|
+
if (!normalized.startsWith(base))
|
|
91
|
+
return false;
|
|
92
|
+
let suffix = normalized.slice(base.length);
|
|
93
|
+
if (suffix.startsWith("/"))
|
|
94
|
+
suffix = suffix.slice(1);
|
|
95
|
+
if (suffix === "")
|
|
96
|
+
return true;
|
|
97
|
+
if (suffix.at(0) !== "@" && suffix.at(0) !== "#")
|
|
98
|
+
return false;
|
|
99
|
+
return isNonemptyControlFree(suffix.slice(1));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function observeSettings(paths) {
|
|
104
|
+
const settings = await readPiSettings(paths.settingsFile, paths.homeDir);
|
|
105
|
+
const installedRoot = await resolveCanonicalPiLocalSource(paths.installedRoot, paths.agentDir, paths.homeDir);
|
|
106
|
+
let registration = null;
|
|
107
|
+
for (const entry of settings.packages) {
|
|
108
|
+
if ((await resolveCanonicalPiLocalSource(entry.source, paths.agentDir, paths.homeDir)) === installedRoot) {
|
|
109
|
+
registration = entry;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { settings, registration };
|
|
113
|
+
}
|
|
114
|
+
async function observeSnapshot(paths) {
|
|
115
|
+
const kind = await classifyPathNoFollow(paths.installedRoot);
|
|
116
|
+
if (kind === "missing")
|
|
117
|
+
return { kind: "absent" };
|
|
118
|
+
if (kind !== "directory")
|
|
119
|
+
return { kind: "unverified" };
|
|
120
|
+
try {
|
|
121
|
+
const receipt = await readPiReceipt(paths.installedRoot);
|
|
122
|
+
const digest = await digestPiTree(paths.installedRoot);
|
|
123
|
+
return receipt.digest === digest
|
|
124
|
+
? { kind: "owned", receipt, digest }
|
|
125
|
+
: { kind: "unverified" };
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { kind: "unverified" };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function isNamedLocalSuperpowers(entry, paths, canonicalInstalledRoot) {
|
|
132
|
+
const root = resolvePiLocalSource(entry.source, paths.agentDir, paths.homeDir);
|
|
133
|
+
if (root === null ||
|
|
134
|
+
(await resolveCanonicalPiLocalSource(entry.source, paths.agentDir, paths.homeDir)) === canonicalInstalledRoot)
|
|
135
|
+
return false;
|
|
136
|
+
const rootKind = await classifyPathNoFollow(root);
|
|
137
|
+
if (rootKind !== "directory" && rootKind !== "symlink")
|
|
138
|
+
return false;
|
|
139
|
+
const metadata = join(root, "package.json");
|
|
140
|
+
const metadataKind = await classifyPathNoFollow(metadata);
|
|
141
|
+
if (metadataKind === "missing")
|
|
142
|
+
return false;
|
|
143
|
+
if (metadataKind !== "regular-file") {
|
|
144
|
+
throw new Error("registered local Pi package metadata is not a regular file");
|
|
145
|
+
}
|
|
146
|
+
const pkg = await readArtifactObject(root, metadata);
|
|
147
|
+
return pkg.name === "superpowers";
|
|
148
|
+
}
|
|
149
|
+
async function unmanagedConflicts(settings, paths) {
|
|
150
|
+
const conflicts = new Set();
|
|
151
|
+
const canonicalInstalledRoot = await resolveCanonicalPiLocalSource(paths.installedRoot, paths.agentDir, paths.homeDir);
|
|
152
|
+
if (canonicalInstalledRoot === null)
|
|
153
|
+
throw new Error("Pi installed root is not local");
|
|
154
|
+
for (const entry of settings.packages) {
|
|
155
|
+
if (entry.resourceState === "disabled")
|
|
156
|
+
continue;
|
|
157
|
+
if (isKnownUpstreamPiSource(entry.source)) {
|
|
158
|
+
conflicts.add("registered Pi package for obra/superpowers");
|
|
159
|
+
}
|
|
160
|
+
else if (await isNamedLocalSuperpowers(entry, paths, canonicalInstalledRoot)) {
|
|
161
|
+
conflicts.add("registered local Pi package named superpowers");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if ((await classifyPathNoFollow(join(paths.agentDir, "extensions", "superpowers.ts"))) !== "missing") {
|
|
165
|
+
conflicts.add("native Pi extension superpowers.ts");
|
|
166
|
+
}
|
|
167
|
+
if ((await classifyPathNoFollow(join(paths.agentDir, "skills", "using-superpowers", "SKILL.md"))) !== "missing") {
|
|
168
|
+
conflicts.add("native Pi skill using-superpowers");
|
|
169
|
+
}
|
|
170
|
+
const sharedSkills = await sharedPiSkillsActivity(settings, paths);
|
|
171
|
+
if (sharedSkills === "active") {
|
|
172
|
+
conflicts.add(SHARED_PI_SKILLS_CONFLICT);
|
|
173
|
+
}
|
|
174
|
+
else if (sharedSkills === "indeterminate") {
|
|
175
|
+
conflicts.add(SHARED_PI_SKILLS_INDETERMINATE);
|
|
176
|
+
}
|
|
177
|
+
return [...conflicts];
|
|
178
|
+
}
|
|
179
|
+
function inspectionFailure(operation, subject) {
|
|
180
|
+
return failureResult(operation, "invalid-state", `cannot inspect ${subject}`, [], []);
|
|
181
|
+
}
|
|
182
|
+
export async function inspectPiOwnership(ctx) {
|
|
183
|
+
const operation = "inspect-pi-ownership";
|
|
184
|
+
const paths = pathsFor(ctx);
|
|
185
|
+
try {
|
|
186
|
+
const [{ settings, registration }, snapshot] = await Promise.all([
|
|
187
|
+
observeSettings(paths),
|
|
188
|
+
observeSnapshot(paths),
|
|
189
|
+
]);
|
|
190
|
+
if (snapshot.kind === "unverified") {
|
|
191
|
+
return inspectionFailure(operation, `Pi ownership at ${paths.installedRoot}`);
|
|
192
|
+
}
|
|
193
|
+
const conflicts = await unmanagedConflicts(settings, paths);
|
|
194
|
+
let installEligibility = { kind: "allowed" };
|
|
195
|
+
if (registration?.resourceState === "disabled") {
|
|
196
|
+
installEligibility = blocked("error: the Manager Pi registration disables required resources", "Enable both Manager-provided Pi resources manually, then retry.");
|
|
197
|
+
}
|
|
198
|
+
else if (registration?.resourceState === "indeterminate") {
|
|
199
|
+
installEligibility = blocked("error: the Manager Pi registration uses unsupported resource filters", "Remove the filters or enable both Manager-provided Pi resources manually, then retry.");
|
|
200
|
+
}
|
|
201
|
+
else if (conflicts.length > 0) {
|
|
202
|
+
installEligibility = blocked("Conflicting unmanaged Superpowers Pi resources require manual resolution:", ...conflicts.map((conflict) => `- ${conflict}`), "Remove or disable each resource manually, then retry.");
|
|
203
|
+
}
|
|
204
|
+
let removalVerification = { kind: "allowed" };
|
|
205
|
+
if (registration !== null) {
|
|
206
|
+
removalVerification = blocked("error: the Manager Pi package registration is still present after removal");
|
|
207
|
+
}
|
|
208
|
+
else if (snapshot.kind === "owned") {
|
|
209
|
+
removalVerification = blocked("error: the Manager-owned Pi snapshot is still present after removal");
|
|
210
|
+
}
|
|
211
|
+
const presentationValue = snapshot.kind === "owned"
|
|
212
|
+
? registration === null
|
|
213
|
+
? `managed snapshot ${snapshot.digest}`
|
|
214
|
+
: `managed ${snapshot.digest}`
|
|
215
|
+
: registration === null
|
|
216
|
+
? "absent"
|
|
217
|
+
: "registered without an installed snapshot";
|
|
218
|
+
return successResult(operation, {
|
|
219
|
+
installEligibility,
|
|
220
|
+
removalInput: {
|
|
221
|
+
installedRoot: paths.installedRoot,
|
|
222
|
+
registrationIdentity: registration?.source ?? null,
|
|
223
|
+
receiptDigest: snapshot.kind === "owned" ? snapshot.receipt.digest : null,
|
|
224
|
+
},
|
|
225
|
+
removalVerification,
|
|
226
|
+
postRemovalOutput: { stdout: [], stderr: [] },
|
|
227
|
+
presentationValue,
|
|
228
|
+
presentationConflicts: conflicts,
|
|
229
|
+
}, []);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return inspectionFailure(operation, `Pi ownership at ${paths.installedRoot}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export async function inspectPiInstalled(selection, ctx) {
|
|
236
|
+
const operation = "inspect-pi-installed";
|
|
237
|
+
const paths = pathsFor(ctx);
|
|
238
|
+
let settings;
|
|
239
|
+
let snapshot;
|
|
240
|
+
try {
|
|
241
|
+
[settings, snapshot] = await Promise.all([
|
|
242
|
+
observeSettings(paths),
|
|
243
|
+
observeSnapshot(paths),
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return inspectionFailure(operation, `Pi installed state at ${paths.installedRoot}`);
|
|
248
|
+
}
|
|
249
|
+
if (snapshot.kind === "absent") {
|
|
250
|
+
return successResult(operation, settings.registration === null
|
|
251
|
+
? { kind: "absent", observedIdentity: "" }
|
|
252
|
+
: {
|
|
253
|
+
kind: "mismatch",
|
|
254
|
+
observedIdentity: "registered without an installed snapshot",
|
|
255
|
+
}, []);
|
|
256
|
+
}
|
|
257
|
+
if (snapshot.kind === "unverified") {
|
|
258
|
+
return successResult(operation, { kind: "mismatch", observedIdentity: "unverified installed snapshot" }, []);
|
|
259
|
+
}
|
|
260
|
+
const mismatch = successResult(operation, { kind: "mismatch", observedIdentity: snapshot.digest }, []);
|
|
261
|
+
if (settings.registration === null ||
|
|
262
|
+
settings.registration.resourceState !== "enabled") {
|
|
263
|
+
return mismatch;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const intended = await readPiPackageAssessment(paths.preparedRoot);
|
|
267
|
+
if ((intended.compatibility.kind !== "supported" &&
|
|
268
|
+
intended.compatibility.kind !== "experimental") ||
|
|
269
|
+
intended.receipt.commit !== selection.desiredCommit ||
|
|
270
|
+
!samePiSource(intended.receipt.source, selection.effectiveSource) ||
|
|
271
|
+
snapshot.receipt.commit !== intended.receipt.commit ||
|
|
272
|
+
!samePiSource(snapshot.receipt.source, intended.receipt.source) ||
|
|
273
|
+
snapshot.digest !== intended.receipt.digest) {
|
|
274
|
+
return mismatch;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return mismatch;
|
|
279
|
+
}
|
|
280
|
+
return successResult(operation, { kind: "current", observedIdentity: snapshot.digest }, []);
|
|
281
|
+
}
|
|
282
|
+
export async function inspectPiControl(ctx) {
|
|
283
|
+
const operation = "inspect-pi-control";
|
|
284
|
+
const paths = pathsFor(ctx);
|
|
285
|
+
try {
|
|
286
|
+
const { settings, registration } = await observeSettings(paths);
|
|
287
|
+
const conflicts = await unmanagedConflicts(settings, paths);
|
|
288
|
+
const probeEligibility = { kind: "allowed" };
|
|
289
|
+
const mutationEligibility = registration?.resourceState === "disabled"
|
|
290
|
+
? blocked("error: the Manager Pi registration disables required resources")
|
|
291
|
+
: registration?.resourceState === "indeterminate"
|
|
292
|
+
? blocked("error: the Manager Pi registration uses unsupported resource filters")
|
|
293
|
+
: conflicts.length > 0
|
|
294
|
+
? blocked("error: unmanaged Superpowers Pi resources prevent managed update control")
|
|
295
|
+
: { kind: "allowed" };
|
|
296
|
+
return successResult(operation, {
|
|
297
|
+
probeEligibility,
|
|
298
|
+
mutationEligibility,
|
|
299
|
+
presentationValue: registration === null
|
|
300
|
+
? conflicts.length > 0
|
|
301
|
+
? "unmanaged conflicts"
|
|
302
|
+
: "unregistered"
|
|
303
|
+
: registration.resourceState === "enabled"
|
|
304
|
+
? "managed local registration"
|
|
305
|
+
: "manual resource configuration",
|
|
306
|
+
}, []);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return inspectionFailure(operation, `Pi update control at ${paths.settingsFile}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
package/dist/python-text.js
CHANGED
|
@@ -72,3 +72,19 @@ export function pythonSplitlines(value) {
|
|
|
72
72
|
lines.push(value.slice(start));
|
|
73
73
|
return lines;
|
|
74
74
|
}
|
|
75
|
+
/** Python `sorted()` orders by code point; JavaScript's default sort does not. */
|
|
76
|
+
export function compareByCodePoint(left, right) {
|
|
77
|
+
// `Array.from` splits by code point, exactly as spreading would; oxlint's
|
|
78
|
+
// `no-misused-spread` rejects the spread form, and grapheme segmentation is
|
|
79
|
+
// the wrong unit here — Python compares code points.
|
|
80
|
+
const leftPoints = Array.from(left);
|
|
81
|
+
const rightPoints = Array.from(right);
|
|
82
|
+
const shared = Math.min(leftPoints.length, rightPoints.length);
|
|
83
|
+
for (let index = 0; index < shared; index += 1) {
|
|
84
|
+
const a = leftPoints[index].codePointAt(0);
|
|
85
|
+
const b = rightPoints[index].codePointAt(0);
|
|
86
|
+
if (a !== b)
|
|
87
|
+
return a - b;
|
|
88
|
+
}
|
|
89
|
+
return leftPoints.length - rightPoints.length;
|
|
90
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, readFile, realpath, rmdir, unlink, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { canonicalizeProspectivePath } from "./safe-path.js";
|
|
6
|
+
import { SafetyError } from "./safety-error.js";
|
|
7
|
+
function errnoIs(cause, code) {
|
|
8
|
+
return (cause instanceof Error &&
|
|
9
|
+
"code" in cause &&
|
|
10
|
+
cause.code === code);
|
|
11
|
+
}
|
|
12
|
+
async function nearestExistingDirectory(path) {
|
|
13
|
+
let cursor = path;
|
|
14
|
+
for (;;) {
|
|
15
|
+
try {
|
|
16
|
+
return await realpath(cursor);
|
|
17
|
+
}
|
|
18
|
+
catch (cause) {
|
|
19
|
+
if (!errnoIs(cause, "ENOENT"))
|
|
20
|
+
throw cause;
|
|
21
|
+
const parent = dirname(cursor);
|
|
22
|
+
if (parent === cursor)
|
|
23
|
+
throw cause;
|
|
24
|
+
cursor = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function resourceLockPaths(resource) {
|
|
29
|
+
let lockParent;
|
|
30
|
+
try {
|
|
31
|
+
lockParent = await nearestExistingDirectory(dirname(resource));
|
|
32
|
+
}
|
|
33
|
+
catch (cause) {
|
|
34
|
+
throw new SafetyError("resource-lock", `cannot locate resource lock parent: ${resource}`, { cause });
|
|
35
|
+
}
|
|
36
|
+
const digest = createHash("sha256").update(resource).digest("hex");
|
|
37
|
+
const lockName = `.superpowers-manager.${digest}.resource-lock`;
|
|
38
|
+
const paths = [];
|
|
39
|
+
let cursor = lockParent;
|
|
40
|
+
for (;;) {
|
|
41
|
+
paths.push(join(cursor, lockName));
|
|
42
|
+
const parent = dirname(cursor);
|
|
43
|
+
if (parent === cursor)
|
|
44
|
+
break;
|
|
45
|
+
cursor = parent;
|
|
46
|
+
}
|
|
47
|
+
return paths;
|
|
48
|
+
}
|
|
49
|
+
async function resourceLock(resource) {
|
|
50
|
+
const canonical = await canonicalizeProspectivePath(resource);
|
|
51
|
+
const paths = await resourceLockPaths(canonical);
|
|
52
|
+
for (const candidate of paths) {
|
|
53
|
+
try {
|
|
54
|
+
await lstat(candidate);
|
|
55
|
+
return { resource: canonical, lockPath: candidate };
|
|
56
|
+
}
|
|
57
|
+
catch (cause) {
|
|
58
|
+
if (!errnoIs(cause, "ENOENT")) {
|
|
59
|
+
throw new SafetyError("resource-lock", `cannot inspect resource lock: ${canonical}`, { cause });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
resource: canonical,
|
|
65
|
+
lockPath: paths[0],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async function assertNoCompetingResourceLock(lock, held) {
|
|
69
|
+
for (const candidate of await resourceLockPaths(lock.resource)) {
|
|
70
|
+
try {
|
|
71
|
+
await lstat(candidate);
|
|
72
|
+
}
|
|
73
|
+
catch (cause) {
|
|
74
|
+
if (errnoIs(cause, "ENOENT"))
|
|
75
|
+
continue;
|
|
76
|
+
throw new SafetyError("resource-lock", `cannot inspect resource lock: ${lock.resource}`, { cause });
|
|
77
|
+
}
|
|
78
|
+
if (held.get(candidate)?.resource === lock.resource)
|
|
79
|
+
continue;
|
|
80
|
+
throw new SafetyError("resource-lock", `resource is busy: ${lock.resource}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function ownerRecord(value) {
|
|
84
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
if (!("token" in value) ||
|
|
88
|
+
typeof value.token !== "string" ||
|
|
89
|
+
!("resource" in value) ||
|
|
90
|
+
typeof value.resource !== "string") {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return { token: value.token, resource: value.resource };
|
|
94
|
+
}
|
|
95
|
+
function releaseFailure(resource, cause) {
|
|
96
|
+
return new SafetyError("resource-lock", `cannot release resource lock: ${resource}`, { cause });
|
|
97
|
+
}
|
|
98
|
+
async function releaseOwned(held) {
|
|
99
|
+
if (held.count > 1) {
|
|
100
|
+
held.count -= 1;
|
|
101
|
+
return { keepHeld: true, failure: null };
|
|
102
|
+
}
|
|
103
|
+
const metadataPath = join(held.lockPath, "owner.json");
|
|
104
|
+
let owner;
|
|
105
|
+
try {
|
|
106
|
+
owner = ownerRecord(JSON.parse(await readFile(metadataPath, "utf8")));
|
|
107
|
+
}
|
|
108
|
+
catch (cause) {
|
|
109
|
+
return {
|
|
110
|
+
keepHeld: false,
|
|
111
|
+
failure: releaseFailure(held.resource, cause),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (owner === undefined ||
|
|
115
|
+
owner.token !== held.token ||
|
|
116
|
+
owner.resource !== held.resource) {
|
|
117
|
+
return {
|
|
118
|
+
keepHeld: false,
|
|
119
|
+
failure: releaseFailure(held.resource),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await unlink(metadataPath);
|
|
124
|
+
await rmdir(held.lockPath);
|
|
125
|
+
}
|
|
126
|
+
catch (cause) {
|
|
127
|
+
// A lock whose contents changed or cannot be inspected is not ours to
|
|
128
|
+
// clear. Leaving it busy is safer than guessing at stale ownership.
|
|
129
|
+
return {
|
|
130
|
+
keepHeld: false,
|
|
131
|
+
failure: releaseFailure(held.resource, cause),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { keepHeld: false, failure: null };
|
|
135
|
+
}
|
|
136
|
+
class FilesystemResourceCoordinator {
|
|
137
|
+
#held = new Map();
|
|
138
|
+
async observeResources(paths) {
|
|
139
|
+
return await Promise.all(paths.map(async (path) => {
|
|
140
|
+
const lock = await resourceLock(path);
|
|
141
|
+
const observation = (state, markerIdentity = "") => ({
|
|
142
|
+
resource: lock.resource,
|
|
143
|
+
state,
|
|
144
|
+
markerIdentity,
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
const before = await lstat(lock.lockPath);
|
|
148
|
+
if (!before.isDirectory())
|
|
149
|
+
return observation("uninspectable");
|
|
150
|
+
const metadataPath = join(lock.lockPath, "owner.json");
|
|
151
|
+
const file = await open(metadataPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
152
|
+
try {
|
|
153
|
+
const metadata = await file.stat();
|
|
154
|
+
if (!metadata.isFile() || metadata.size > 8192)
|
|
155
|
+
return observation("uninspectable");
|
|
156
|
+
const owner = ownerRecord(JSON.parse(await file.readFile("utf8")));
|
|
157
|
+
const after = await lstat(lock.lockPath);
|
|
158
|
+
const metadataAfter = await lstat(metadataPath);
|
|
159
|
+
if (!owner ||
|
|
160
|
+
!owner.token ||
|
|
161
|
+
owner.resource !== lock.resource ||
|
|
162
|
+
before.dev !== after.dev ||
|
|
163
|
+
before.ino !== after.ino ||
|
|
164
|
+
!after.isDirectory() ||
|
|
165
|
+
!metadataAfter.isFile() ||
|
|
166
|
+
metadata.dev !== metadataAfter.dev ||
|
|
167
|
+
metadata.ino !== metadataAfter.ino ||
|
|
168
|
+
metadata.mtimeMs !== metadataAfter.mtimeMs ||
|
|
169
|
+
metadata.size !== metadataAfter.size)
|
|
170
|
+
return observation("uninspectable");
|
|
171
|
+
const held = this.#held.get(lock.lockPath);
|
|
172
|
+
const owned = held?.token === owner.token && held.resource === owner.resource;
|
|
173
|
+
return observation(owned ? "owned" : "busy", `${before.dev}:${before.ino}:${metadata.dev}:${metadata.ino}:${owner.token}`);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await file.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (cause) {
|
|
180
|
+
if (errnoIs(cause, "ENOENT")) {
|
|
181
|
+
try {
|
|
182
|
+
await lstat(lock.lockPath);
|
|
183
|
+
}
|
|
184
|
+
catch (again) {
|
|
185
|
+
if (errnoIs(again, "ENOENT"))
|
|
186
|
+
return observation(this.#held.has(lock.lockPath) ? "uninspectable" : "idle");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return observation("uninspectable");
|
|
190
|
+
}
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
async withResources(paths, action) {
|
|
194
|
+
const resolved = await Promise.all(paths.map(resourceLock));
|
|
195
|
+
const locks = [
|
|
196
|
+
...new Map(resolved.map((entry) => [entry.lockPath, entry])).values(),
|
|
197
|
+
].sort((left, right) => left.lockPath < right.lockPath
|
|
198
|
+
? -1
|
|
199
|
+
: left.lockPath > right.lockPath
|
|
200
|
+
? 1
|
|
201
|
+
: 0);
|
|
202
|
+
const acquired = [];
|
|
203
|
+
let completion;
|
|
204
|
+
try {
|
|
205
|
+
for (const lock of locks) {
|
|
206
|
+
const existing = this.#held.get(lock.lockPath);
|
|
207
|
+
if (existing !== undefined) {
|
|
208
|
+
existing.count += 1;
|
|
209
|
+
acquired.push({ held: existing });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const token = randomUUID();
|
|
213
|
+
try {
|
|
214
|
+
await mkdir(lock.lockPath);
|
|
215
|
+
}
|
|
216
|
+
catch (cause) {
|
|
217
|
+
if (errnoIs(cause, "EEXIST")) {
|
|
218
|
+
throw new SafetyError("resource-lock", `resource is busy: ${lock.resource}`, { cause });
|
|
219
|
+
}
|
|
220
|
+
throw new SafetyError("resource-lock", `cannot acquire resource lock: ${lock.resource}`, { cause });
|
|
221
|
+
}
|
|
222
|
+
const held = { ...lock, token, count: 1 };
|
|
223
|
+
try {
|
|
224
|
+
await writeFile(join(lock.lockPath, "owner.json"), JSON.stringify({
|
|
225
|
+
token,
|
|
226
|
+
pid: process.pid,
|
|
227
|
+
resource: lock.resource,
|
|
228
|
+
}), { flag: "wx" });
|
|
229
|
+
}
|
|
230
|
+
catch (cause) {
|
|
231
|
+
throw new SafetyError("resource-lock", `cannot record resource lock ownership: ${lock.resource}`, { cause });
|
|
232
|
+
}
|
|
233
|
+
this.#held.set(lock.lockPath, held);
|
|
234
|
+
acquired.push({ held });
|
|
235
|
+
}
|
|
236
|
+
// A missing ancestor can appear between placement discovery and mkdir,
|
|
237
|
+
// leaving concurrently acquired markers at different depths. Checking
|
|
238
|
+
// every placement also prevents our descendant marker from hiding a
|
|
239
|
+
// competing ancestor marker.
|
|
240
|
+
for (const lock of locks) {
|
|
241
|
+
await assertNoCompetingResourceLock(lock, this.#held);
|
|
242
|
+
}
|
|
243
|
+
completion = { kind: "returned", value: await action() };
|
|
244
|
+
}
|
|
245
|
+
catch (cause) {
|
|
246
|
+
completion = { kind: "threw", cause };
|
|
247
|
+
}
|
|
248
|
+
let firstReleaseFailure = null;
|
|
249
|
+
for (const entry of acquired.reverse()) {
|
|
250
|
+
const released = await releaseOwned(entry.held);
|
|
251
|
+
if (!released.keepHeld) {
|
|
252
|
+
this.#held.delete(entry.held.lockPath);
|
|
253
|
+
}
|
|
254
|
+
firstReleaseFailure ??= released.failure;
|
|
255
|
+
}
|
|
256
|
+
if (completion.kind === "threw")
|
|
257
|
+
throw completion.cause;
|
|
258
|
+
if (firstReleaseFailure !== null)
|
|
259
|
+
throw firstReleaseFailure;
|
|
260
|
+
return completion.value;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
export function createResourceCoordinator() {
|
|
264
|
+
return new FilesystemResourceCoordinator();
|
|
265
|
+
}
|
package/dist/safe-path.js
CHANGED
|
@@ -73,9 +73,27 @@ export async function assertProspectiveContained(root, candidate) {
|
|
|
73
73
|
requireContained(lexicalRoot, lexicalCandidate);
|
|
74
74
|
try {
|
|
75
75
|
const resolvedRoot = await realpath(lexicalRoot);
|
|
76
|
+
const resolvedCandidate = await canonicalizeProspectivePath(lexicalCandidate);
|
|
77
|
+
requireContained(resolvedRoot, resolvedCandidate);
|
|
78
|
+
return lexicalCandidate;
|
|
79
|
+
}
|
|
80
|
+
catch (cause) {
|
|
81
|
+
if (cause instanceof SafetyError)
|
|
82
|
+
throw cause;
|
|
83
|
+
throw new SafetyError("safe-path", `cannot resolve prospective path: ${lexicalCandidate}`, { cause });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Resolve every existing component and retain a normalized suffix for
|
|
87
|
+
// components that do not exist yet. Unlike assertProspectiveContained this is
|
|
88
|
+
// not a containment assertion: resource coordination needs one canonical
|
|
89
|
+
// identity even when two package roots reach the target through different
|
|
90
|
+
// symlink aliases.
|
|
91
|
+
export async function canonicalizeProspectivePath(path) {
|
|
92
|
+
const lexicalPath = resolve(path);
|
|
93
|
+
try {
|
|
76
94
|
const missing = [];
|
|
77
95
|
const visitedCursors = new Set();
|
|
78
|
-
let cursor =
|
|
96
|
+
let cursor = lexicalPath;
|
|
79
97
|
let resolvedAncestor;
|
|
80
98
|
for (;;) {
|
|
81
99
|
if (visitedCursors.has(cursor)) {
|
|
@@ -110,14 +128,12 @@ export async function assertProspectiveContained(root, candidate) {
|
|
|
110
128
|
cursor = parent;
|
|
111
129
|
}
|
|
112
130
|
}
|
|
113
|
-
|
|
114
|
-
requireContained(resolvedRoot, resolvedCandidate);
|
|
115
|
-
return lexicalCandidate;
|
|
131
|
+
return resolve(resolvedAncestor, ...missing);
|
|
116
132
|
}
|
|
117
133
|
catch (cause) {
|
|
118
134
|
if (cause instanceof SafetyError)
|
|
119
135
|
throw cause;
|
|
120
|
-
throw new SafetyError("safe-path", `cannot resolve prospective path: ${
|
|
136
|
+
throw new SafetyError("safe-path", `cannot resolve prospective path: ${lexicalPath}`, { cause });
|
|
121
137
|
}
|
|
122
138
|
}
|
|
123
139
|
export async function assertSymlinkTargetContained(root, candidate) {
|