supipowers 2.2.1 → 2.2.3
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 +3 -0
- package/package.json +10 -9
- package/skills/context-mode/SKILL.md +1 -1
- package/src/bootstrap.ts +2 -1
- package/src/ci.ts +47 -0
- package/src/commands/doctor.ts +4 -2
- package/src/commands/memory.ts +138 -5
- package/src/commands/plan.ts +2 -1
- package/src/commands/update.ts +7 -5
- package/src/config/defaults.ts +2 -0
- package/src/config/schema.ts +2 -0
- package/src/context-mode/sandbox/executor.ts +30 -1
- package/src/fix-pr/scripts/exec.ts +32 -1
- package/src/harness/anti_slop/fallow-adapter.ts +4 -3
- package/src/harness/command.ts +12 -7
- package/src/harness/pipeline.ts +2 -8
- package/src/harness/stage-runner.ts +3 -0
- package/src/harness/stages/docs.ts +82 -0
- package/src/harness/stages/implement-apply.ts +1 -0
- package/src/harness/storage.ts +2 -1
- package/src/mempalace/contract.ts +32 -0
- package/src/mempalace/git-hook.ts +443 -0
- package/src/mempalace/hooks.ts +10 -15
- package/src/mempalace/tool.ts +2 -2
- package/src/mempalace/uv.ts +51 -8
- package/src/planning/approval-flow.ts +15 -17
- package/src/planning/planning-ask-tool.ts +4 -10
- package/src/release/executor.ts +13 -3
- package/src/storage/plans.ts +2 -2
- package/src/text.ts +7 -2
- package/src/types.ts +12 -0
- package/src/utils/editor.ts +31 -5
- package/src/utils/exec-cli.ts +106 -0
- package/src/visual/scripts/npm-shrinkwrap.json +878 -0
- package/src/visual/scripts/package-lock.json +878 -0
- package/src/visual/scripts/package.json +1 -1
package/src/mempalace/hooks.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { resolveInstalledBridgeScriptPath } from "./runtime.js";
|
|
|
7
7
|
import { getEventStore as getContextEventStore, getSessionId as getContextSessionId } from "../context-mode/hooks.js";
|
|
8
8
|
import { buildCompactionCheckpoint, buildShutdownDiary } from "./session-summary.js";
|
|
9
9
|
import { snapshotMempalaceInstall } from "./installer-helper.js";
|
|
10
|
+
import { buildMempalaceGuidance } from "./contract.js";
|
|
10
11
|
|
|
11
12
|
export interface MempalaceHooksDeps {
|
|
12
13
|
createBridge?: (config: ResolvedMempalaceConfig, cwd: string) => MempalaceBridgeFacade;
|
|
@@ -162,12 +163,9 @@ function wakeUpBlock(resolved: ResolvedMempalaceConfig, wing: string, text: stri
|
|
|
162
163
|
"# MemPalace memory",
|
|
163
164
|
`- palace: ${resolved.palacePath}`,
|
|
164
165
|
`- default wing: ${wing}`,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
"- You **MUST** call `mempalace(action=\"search\", query=...)` before answering questions about prior decisions, people, projects, or past events. Skip only when the answer is fully derivable from the current turn or the active codebase.",
|
|
169
|
-
);
|
|
170
|
-
}
|
|
166
|
+
"",
|
|
167
|
+
...buildMempalaceGuidance(resolved.hooks, "full"),
|
|
168
|
+
].filter((line) => line.length > 0);
|
|
171
169
|
if (excerpt.trim()) {
|
|
172
170
|
lines.push("", "## Wake-up excerpt", excerpt.trim());
|
|
173
171
|
}
|
|
@@ -175,19 +173,15 @@ function wakeUpBlock(resolved: ResolvedMempalaceConfig, wing: string, text: stri
|
|
|
175
173
|
}
|
|
176
174
|
|
|
177
175
|
/**
|
|
178
|
-
* Compact
|
|
179
|
-
*
|
|
180
|
-
*
|
|
176
|
+
* Compact refresher injected on turns where we skip the full wake-up dump.
|
|
177
|
+
* Keeps the model oriented (palace/wing) and re-asserts the MemPalace
|
|
178
|
+
* read/write contract without paying for the wake-up excerpt.
|
|
181
179
|
*/
|
|
182
180
|
function wakeUpRefresher(resolved: ResolvedMempalaceConfig, wing: string): string {
|
|
183
181
|
const lines = [
|
|
184
182
|
`# MemPalace memory: wing=${wing}`,
|
|
183
|
+
...buildMempalaceGuidance(resolved.hooks, "refresher"),
|
|
185
184
|
];
|
|
186
|
-
if (resolved.hooks.searchGuidance) {
|
|
187
|
-
lines.push(
|
|
188
|
-
"- You **MUST** call `mempalace(action=\"search\", query=...)` before answering past-fact questions; per-turn search results appear below when relevant.",
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
185
|
return lines.join("\n");
|
|
192
186
|
}
|
|
193
187
|
|
|
@@ -326,7 +320,8 @@ export function registerMempalaceHooks(
|
|
|
326
320
|
const wakeUpEnabled = config.mempalace.hooks.wakeUp;
|
|
327
321
|
const guidanceEnabled = config.mempalace.hooks.searchGuidance;
|
|
328
322
|
const autoSearchEnabled = config.mempalace.hooks.autoSearchOnPrompt;
|
|
329
|
-
|
|
323
|
+
const writeGuidanceEnabled = config.mempalace.hooks.writeGuidance;
|
|
324
|
+
if (!wakeUpEnabled && !guidanceEnabled && !writeGuidanceEnabled && !autoSearchEnabled) return undefined;
|
|
330
325
|
|
|
331
326
|
const cwd = contextCwd(ctx);
|
|
332
327
|
const resolved = resolveMempalaceConfig(config, cwd, platform.paths);
|
package/src/mempalace/tool.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
snapshotMempalaceInstall,
|
|
16
16
|
type MempalaceInstallSnapshot,
|
|
17
17
|
} from "./installer-helper.js";
|
|
18
|
+
import { MEMPALACE_TOOL_DESCRIPTION } from "./contract.js";
|
|
18
19
|
|
|
19
20
|
export interface MempalaceToolDeps {
|
|
20
21
|
createBridge?: (config: ResolvedMempalaceConfig, cwd: string) => MempalaceBridgeFacade;
|
|
@@ -130,8 +131,7 @@ export function registerMempalaceTool(
|
|
|
130
131
|
platform.registerTool({
|
|
131
132
|
name: "mempalace",
|
|
132
133
|
label: "MemPalace",
|
|
133
|
-
description:
|
|
134
|
-
"MemPalace memory dispatcher. **MUST** call `search` before answering past-fact questions; write only on explicit user request.",
|
|
134
|
+
description: MEMPALACE_TOOL_DESCRIPTION,
|
|
135
135
|
parameters: mempalaceToolParameters,
|
|
136
136
|
async execute(_toolCallId: string, rawParams: unknown, _signal: AbortSignal, onUpdate: unknown, toolCtx: unknown) {
|
|
137
137
|
try {
|
package/src/mempalace/uv.ts
CHANGED
|
@@ -12,6 +12,10 @@ export const PINNED_UV_VERSION = "0.5.30";
|
|
|
12
12
|
|
|
13
13
|
const UV_BASE_URL = "https://github.com/astral-sh/uv/releases/download";
|
|
14
14
|
|
|
15
|
+
const TAR_PREFLIGHT_TIMEOUT_MS = 3_000;
|
|
16
|
+
const TAR_REMEDIATION =
|
|
17
|
+
"Install GNU tar / BSD tar (built-in on macOS, Linux, and Windows 10+). On older Windows, install Git for Windows or run `/supi:memory setup` after adding tar to PATH.";
|
|
18
|
+
|
|
15
19
|
export type UvPlatform =
|
|
16
20
|
| "darwin-arm64"
|
|
17
21
|
| "darwin-x64"
|
|
@@ -49,17 +53,22 @@ export function uvTargetFor(uvPlatform: UvPlatform): UvTarget {
|
|
|
49
53
|
case "linux-arm64":
|
|
50
54
|
return target("aarch64-unknown-linux-gnu", ".tar.gz", "uv");
|
|
51
55
|
case "win32-x64":
|
|
52
|
-
return target("x86_64-pc-windows-msvc", ".zip", "uv.exe");
|
|
56
|
+
return target("x86_64-pc-windows-msvc", ".zip", "uv.exe", "uv.exe");
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
function target(
|
|
60
|
+
function target(
|
|
61
|
+
triple: string,
|
|
62
|
+
archiveSuffix: string,
|
|
63
|
+
binary: string,
|
|
64
|
+
archiveBinaryRelativePath = path.join(`uv-${triple}`, binary),
|
|
65
|
+
): UvTarget {
|
|
57
66
|
const archive = `uv-${triple}${archiveSuffix}`;
|
|
58
67
|
return {
|
|
59
68
|
triple,
|
|
60
69
|
archive,
|
|
61
70
|
binary,
|
|
62
|
-
archiveBinaryRelativePath
|
|
71
|
+
archiveBinaryRelativePath,
|
|
63
72
|
};
|
|
64
73
|
}
|
|
65
74
|
|
|
@@ -113,6 +122,34 @@ function writeVersionStamp(managedBinDir: string, version: string): void {
|
|
|
113
122
|
fs.writeFileSync(versionStampPath(managedBinDir), `${version}\n`, "utf8");
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
function tarUnavailable(message: string): EnsureUvResult {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: {
|
|
129
|
+
code: "uv_extract_failed",
|
|
130
|
+
message,
|
|
131
|
+
remediation: TAR_REMEDIATION,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function verifyTarAvailable(runner: ProcessRunner): Promise<EnsureUvResult | null> {
|
|
137
|
+
let result;
|
|
138
|
+
try {
|
|
139
|
+
result = await runner("tar", ["--version"], { timeoutMs: TAR_PREFLIGHT_TIMEOUT_MS });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return tarUnavailable(
|
|
142
|
+
`tar is required to extract the uv archive but could not be launched: ${error instanceof Error ? error.message : String(error)}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (result.code !== 0) {
|
|
147
|
+
return tarUnavailable("tar is required to extract the uv archive but is not on PATH.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
116
153
|
export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult> {
|
|
117
154
|
const version = options.version ?? PINNED_UV_VERSION;
|
|
118
155
|
const uvPlatform = options.platform === undefined ? detectUvPlatform() : options.platform;
|
|
@@ -134,6 +171,9 @@ export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult
|
|
|
134
171
|
return { ok: true, uvPath: managedPath, version, source: "cached" };
|
|
135
172
|
}
|
|
136
173
|
|
|
174
|
+
const tarPreflight = await verifyTarAvailable(options.runner);
|
|
175
|
+
if (tarPreflight) return tarPreflight;
|
|
176
|
+
|
|
137
177
|
const fetcher = options.fetcher ?? defaultFetcher;
|
|
138
178
|
options.onProgress?.(`Downloading uv ${version} for ${targetSpec.triple}`);
|
|
139
179
|
|
|
@@ -210,7 +250,7 @@ export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult
|
|
|
210
250
|
message: `tar failed to extract uv archive (code ${extract.code}): ${
|
|
211
251
|
extract.stderr.trim() || extract.stdout.trim() || "no output"
|
|
212
252
|
}`,
|
|
213
|
-
remediation:
|
|
253
|
+
remediation: TAR_REMEDIATION,
|
|
214
254
|
},
|
|
215
255
|
};
|
|
216
256
|
}
|
|
@@ -226,11 +266,14 @@ export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult
|
|
|
226
266
|
};
|
|
227
267
|
}
|
|
228
268
|
|
|
229
|
-
// Replace any pre-existing managed binary atomically-ish.
|
|
230
|
-
|
|
231
|
-
|
|
269
|
+
// Replace any pre-existing managed binary atomically-ish. Windows uv zips place
|
|
270
|
+
// uv.exe at the archive root, which is already `managedPath` after extraction.
|
|
271
|
+
if (path.resolve(extractedBinary) !== path.resolve(managedPath)) {
|
|
272
|
+
if (fs.existsSync(managedPath)) {
|
|
273
|
+
try { fs.unlinkSync(managedPath); } catch { /* best effort */ }
|
|
274
|
+
}
|
|
275
|
+
fs.renameSync(extractedBinary, managedPath);
|
|
232
276
|
}
|
|
233
|
-
fs.renameSync(extractedBinary, managedPath);
|
|
234
277
|
if (process.platform !== "win32") {
|
|
235
278
|
fs.chmodSync(managedPath, 0o755);
|
|
236
279
|
}
|
|
@@ -278,7 +278,7 @@ async function executeApproveFlow(
|
|
|
278
278
|
debugLogger?.log("execution_handoff_new_session_cancelled", {
|
|
279
279
|
planPath,
|
|
280
280
|
});
|
|
281
|
-
ctx.ui
|
|
281
|
+
ctx.ui?.notify?.("Session start cancelled. Plan saved; run /supi:plan again to execute.");
|
|
282
282
|
return;
|
|
283
283
|
}
|
|
284
284
|
platform.sendUserMessage(prompt);
|
|
@@ -298,7 +298,7 @@ async function executeApproveFlow(
|
|
|
298
298
|
debugLogger?.log("execution_handoff_same_session_steer_sent", {
|
|
299
299
|
planPath,
|
|
300
300
|
});
|
|
301
|
-
ctx.ui
|
|
301
|
+
ctx.ui?.notify?.("Plan approved — starting execution");
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
|
|
@@ -408,25 +408,23 @@ export function registerPlanApprovalHook(platform: Platform): void {
|
|
|
408
408
|
});
|
|
409
409
|
} catch {}
|
|
410
410
|
if (!ctx?.hasUI) {
|
|
411
|
-
|
|
412
|
-
`Plan saved to \`${planPath}\`.`,
|
|
413
|
-
"Interactive approval is unavailable in this runtime, so no execution was started.",
|
|
414
|
-
`To continue manually, explicitly send: \`Execute the saved plan at ${planPath} step by step; verify each step before proceeding.\``,
|
|
415
|
-
].join("\n");
|
|
416
|
-
debugLogger?.log("approval_flow_no_ui", {
|
|
411
|
+
debugLogger?.log("approval_flow_no_ui_auto_execute", {
|
|
417
412
|
planName,
|
|
418
413
|
planPath,
|
|
419
414
|
});
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
{
|
|
423
|
-
customType: "supi-plan-awaiting-interactive-approval",
|
|
424
|
-
content: [{ type: "text", text: message }],
|
|
425
|
-
display: true,
|
|
426
|
-
},
|
|
427
|
-
{ deliverAs: "steer", triggerTurn: false },
|
|
428
|
-
);
|
|
415
|
+
const executionNewSession = capturedNewSession;
|
|
416
|
+
const executionModel = capturedResolvedModel;
|
|
429
417
|
cancelPlanTracking();
|
|
418
|
+
await executeApproveFlow(
|
|
419
|
+
platform,
|
|
420
|
+
ctx,
|
|
421
|
+
canonicalContent,
|
|
422
|
+
planPath,
|
|
423
|
+
executionNewSession,
|
|
424
|
+
executionModel,
|
|
425
|
+
debugLogger,
|
|
426
|
+
parsedPlan,
|
|
427
|
+
);
|
|
430
428
|
return;
|
|
431
429
|
}
|
|
432
430
|
|
|
@@ -129,11 +129,11 @@ function getAskRedirectReason(): string | null {
|
|
|
129
129
|
*/
|
|
130
130
|
export function registerPlanningAskToolGuard(platform: Platform): void {
|
|
131
131
|
platform.on("tool_call", (event) => {
|
|
132
|
-
if (event.toolName === "resolve" &&
|
|
132
|
+
if (event.toolName === "resolve" && isResolveApplyInput(event.input) && isPlanningActive()) {
|
|
133
133
|
return {
|
|
134
134
|
block: true,
|
|
135
135
|
reason:
|
|
136
|
-
"Planning mode: /supi:plan uses a file-based approval hook.
|
|
136
|
+
"Planning mode: /supi:plan uses a file-based approval hook. Native OMP plan approval is blocked because it bypasses supipowers plan tracking.",
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
|
|
@@ -149,13 +149,7 @@ export function registerPlanningAskToolGuard(platform: Platform): void {
|
|
|
149
149
|
});
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
function
|
|
152
|
+
function isResolveApplyInput(input: unknown): boolean {
|
|
153
153
|
if (input === null || typeof input !== "object" || Array.isArray(input)) return false;
|
|
154
|
-
|
|
155
|
-
if (candidate.action !== "apply") return false;
|
|
156
|
-
const extra = candidate.extra;
|
|
157
|
-
return extra !== null
|
|
158
|
-
&& typeof extra === "object"
|
|
159
|
-
&& !Array.isArray(extra)
|
|
160
|
-
&& typeof (extra as { title?: unknown }).title === "string";
|
|
154
|
+
return (input as { action?: unknown }).action === "apply";
|
|
161
155
|
}
|
package/src/release/executor.ts
CHANGED
|
@@ -116,7 +116,8 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
|
|
|
116
116
|
} else {
|
|
117
117
|
console.log(`[dry-run] Would git tag -a ${tagName}`);
|
|
118
118
|
}
|
|
119
|
-
console.log(`[dry-run] Would git push origin HEAD
|
|
119
|
+
console.log(`[dry-run] Would git push origin HEAD`);
|
|
120
|
+
console.log(`[dry-run] Would git push origin ${tagName}`);
|
|
120
121
|
for (const channel of channels) {
|
|
121
122
|
console.log(`[dry-run] Would publish to channel: ${channel}`);
|
|
122
123
|
}
|
|
@@ -199,8 +200,8 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
|
|
|
199
200
|
|
|
200
201
|
let pushAttempt = 0;
|
|
201
202
|
while (true) {
|
|
202
|
-
progress("git-push", "active", pushAttempt === 0 ? "Pushing to origin" : "Retrying push after rebase");
|
|
203
|
-
const gitPush = await exec("git", ["push", "origin", "HEAD"
|
|
203
|
+
progress("git-push", "active", pushAttempt === 0 ? "Pushing commit to origin" : "Retrying commit push after rebase");
|
|
204
|
+
const gitPush = await exec("git", ["push", "origin", "HEAD"], { cwd });
|
|
204
205
|
if (gitPush.code === 0) {
|
|
205
206
|
progress("git-push", "done");
|
|
206
207
|
break;
|
|
@@ -228,6 +229,15 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
|
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
progress("git-push-tags", "active", `Pushing ${tagName} to origin`);
|
|
233
|
+
const gitPushTag = await exec("git", ["push", "origin", tagName], { cwd });
|
|
234
|
+
if (gitPushTag.code !== 0) {
|
|
235
|
+
const detail = gitPushTag.stderr || gitPushTag.stdout || `exit code ${gitPushTag.code}`;
|
|
236
|
+
progress("git-push-tags", "error", detail);
|
|
237
|
+
return { version, tagCreated: true, pushed: false, channels: [], error: `git push tag: ${detail}` };
|
|
238
|
+
}
|
|
239
|
+
progress("git-push-tags", "done");
|
|
240
|
+
|
|
231
241
|
const channelResults: ReleaseResult["channels"] = [];
|
|
232
242
|
for (const channel of channels) {
|
|
233
243
|
progress(`publish-${channel}`, "active", `Publishing to ${channel}`);
|
package/src/storage/plans.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { normalizeLineEndings } from "../text.js";
|
|
3
|
+
import { ensureTrailingNewline, normalizeLineEndings } from "../text.js";
|
|
4
4
|
import type { Plan, PlanTask, TaskComplexity, WorkspaceTarget } from "../types.js";
|
|
5
5
|
import type { PlatformPaths } from "../platform/types.js";
|
|
6
6
|
import { getProjectStatePath, getProjectTargetStatePath } from "../workspace/state-paths.js";
|
|
@@ -40,7 +40,7 @@ export function savePlan(paths: PlatformPaths, cwd: string, filename: string, co
|
|
|
40
40
|
const dir = getPlansDir(paths, cwd);
|
|
41
41
|
fs.mkdirSync(dir, { recursive: true });
|
|
42
42
|
const filePath = path.join(dir, filename);
|
|
43
|
-
fs.writeFileSync(filePath, content);
|
|
43
|
+
fs.writeFileSync(filePath, ensureTrailingNewline(normalizeLineEndings(content)));
|
|
44
44
|
return filePath;
|
|
45
45
|
}
|
|
46
46
|
|
package/src/text.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
/** Normalizes
|
|
1
|
+
/** Normalizes CRLF and lone CR line endings to LF for deterministic artifacts. */
|
|
2
2
|
export function normalizeLineEndings(text: string): string {
|
|
3
|
-
return text.replace(/\r\n
|
|
3
|
+
return text.replace(/\r\n?/g, "\n");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Ensures text files have a trailing LF without changing already-terminated content. */
|
|
7
|
+
export function ensureTrailingNewline(text: string): string {
|
|
8
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
4
9
|
}
|
|
5
10
|
|
|
6
11
|
/** Removes a single outer markdown code fence wrapper when present. */
|
package/src/types.ts
CHANGED
|
@@ -572,9 +572,11 @@ export interface MempalaceConfig {
|
|
|
572
572
|
hooks: {
|
|
573
573
|
wakeUp: boolean;
|
|
574
574
|
searchGuidance: boolean;
|
|
575
|
+
writeGuidance: boolean;
|
|
575
576
|
autoSearchOnPrompt: boolean;
|
|
576
577
|
compactionCheckpoint: boolean;
|
|
577
578
|
shutdownDiary: boolean;
|
|
579
|
+
postCommitReindex: boolean;
|
|
578
580
|
};
|
|
579
581
|
budgets: {
|
|
580
582
|
wakeUpTokens: number;
|
|
@@ -1550,6 +1552,16 @@ export type HarnessStage =
|
|
|
1550
1552
|
| "docs"
|
|
1551
1553
|
| "validate";
|
|
1552
1554
|
|
|
1555
|
+
/** Progress event emitted by harness pipeline drivers and long-running stages. */
|
|
1556
|
+
export type HarnessPipelineProgressEvent =
|
|
1557
|
+
| { type: "stage-started"; stage: HarnessStage }
|
|
1558
|
+
| { type: "stage-progress"; stage: HarnessStage; detail: string }
|
|
1559
|
+
| { type: "stage-skipped"; stage: HarnessStage }
|
|
1560
|
+
| { type: "stage-completed"; stage: HarnessStage; detail?: string }
|
|
1561
|
+
| { type: "stage-blocked"; stage: HarnessStage; detail: string }
|
|
1562
|
+
| { type: "stage-failed"; stage: HarnessStage; detail: string }
|
|
1563
|
+
| { type: "awaiting-user"; stage: HarnessStage; detail?: string };
|
|
1564
|
+
|
|
1553
1565
|
/** Operational status of a harness stage. Mirrors UltraPlanAuthoringStageStatus. */
|
|
1554
1566
|
export type HarnessStageStatus =
|
|
1555
1567
|
| "pending"
|
package/src/utils/editor.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { Platform } from "../platform/types.js";
|
|
|
6
6
|
* Resolution order:
|
|
7
7
|
* 1. `$VISUAL`
|
|
8
8
|
* 2. `$EDITOR`
|
|
9
|
-
* 3. OS default opener (`open` on darwin, `start` on win32, `xdg-open` elsewhere)
|
|
9
|
+
* 3. OS default opener (`open` on darwin, `cmd /d /s /c start` on win32, `xdg-open` elsewhere)
|
|
10
10
|
*
|
|
11
11
|
* `platform.exec` blocks until the spawned editor process exits, which is what
|
|
12
12
|
* the synthesize stage needs for its `$EDITOR` round-trip. For OS-default openers
|
|
@@ -18,7 +18,34 @@ import type { Platform } from "../platform/types.js";
|
|
|
18
18
|
* without throwing. Callers that need to verify the user actually edited the file
|
|
19
19
|
* should detect changes by comparing mtime / contents before and after.
|
|
20
20
|
*/
|
|
21
|
-
export
|
|
21
|
+
export interface EditorInvocation {
|
|
22
|
+
command: string;
|
|
23
|
+
args: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
function quoteCmdArgument(arg: string): string {
|
|
28
|
+
return `"${arg.replace(/"/g, '""')}"`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveDefaultEditorInvocation(
|
|
32
|
+
hostPlatform: NodeJS.Platform,
|
|
33
|
+
filePath: string,
|
|
34
|
+
): EditorInvocation {
|
|
35
|
+
if (hostPlatform === "win32") {
|
|
36
|
+
return { command: "cmd", args: ["/d", "/s", "/c", `start "" ${quoteCmdArgument(filePath)}`] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return hostPlatform === "darwin"
|
|
40
|
+
? { command: "open", args: [filePath] }
|
|
41
|
+
: { command: "xdg-open", args: [filePath] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function openInEditor(
|
|
45
|
+
platform: Platform,
|
|
46
|
+
filePath: string,
|
|
47
|
+
hostPlatform: NodeJS.Platform = process.platform,
|
|
48
|
+
): Promise<void> {
|
|
22
49
|
const editor = process.env.VISUAL || process.env.EDITOR;
|
|
23
50
|
try {
|
|
24
51
|
if (editor) {
|
|
@@ -28,9 +55,8 @@ export async function openInEditor(platform: Platform, filePath: string): Promis
|
|
|
28
55
|
const args = [...tokens.slice(1), filePath];
|
|
29
56
|
await platform.exec(cmd, args);
|
|
30
57
|
} else {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
await platform.exec(cmd, [filePath]);
|
|
58
|
+
const invocation = resolveDefaultEditorInvocation(hostPlatform, filePath);
|
|
59
|
+
await platform.exec(invocation.command, invocation.args);
|
|
34
60
|
}
|
|
35
61
|
} catch {
|
|
36
62
|
// Editor open failed — non-fatal, file was still written
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import type { ExecOptions, ExecResult } from "../platform/types.js";
|
|
5
|
+
import { findExecutable } from "./executable.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cross-platform invocation for npm/npx that survives Windows `.cmd` shims.
|
|
9
|
+
*
|
|
10
|
+
* OMP's `platform.exec` is a thin wrapper over libuv's `uv_spawn`. On Windows
|
|
11
|
+
* that exposes two distinct bugs when the target is an npm-shipped CLI:
|
|
12
|
+
*
|
|
13
|
+
* 1. libuv does not consult `PATHEXT`, so spawning `"npm"` fails with
|
|
14
|
+
* `ENOENT: uv_spawn 'npm'` because the on-disk file is `npm.cmd`.
|
|
15
|
+
* 2. Even when callers resolve the absolute path, Node ≥18.20.2 hard-rejects
|
|
16
|
+
* spawning `.cmd`/`.bat` shims without `shell: true` (CVE-2024-27980).
|
|
17
|
+
* `ExecOptions` does not expose `shell`.
|
|
18
|
+
*
|
|
19
|
+
* Wrapping in `cmd.exe /d /s /c` is the canonical workaround, but only safe
|
|
20
|
+
* when the spawner sets `windowsVerbatimArguments: true` — Node's default
|
|
21
|
+
* CRT escaping double-quotes the command line and cmd's `/s` only strips one
|
|
22
|
+
* pair. We don't control the spawner, so we sidestep the whole problem by
|
|
23
|
+
* resolving the shim to the real `node <cli.js>` invocation, which is exactly
|
|
24
|
+
* what `npm.cmd` does internally. `node.exe` is a plain binary that libuv
|
|
25
|
+
* spawns without ceremony.
|
|
26
|
+
*
|
|
27
|
+
* Non-shim binaries (`bun`, `git`, `node`, `gh`, `rustup`, `go`, `pip`, …
|
|
28
|
+
* all ship as `.exe` on Windows) pass through untouched; POSIX always passes
|
|
29
|
+
* through.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type ExecFn = (
|
|
33
|
+
cmd: string,
|
|
34
|
+
args: string[],
|
|
35
|
+
opts?: ExecOptions,
|
|
36
|
+
) => Promise<ExecResult>;
|
|
37
|
+
|
|
38
|
+
interface ResolvedInvocation {
|
|
39
|
+
cmd: string;
|
|
40
|
+
prefixArgs: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const NODE_SHIMS = new Set<string>(["npm", "npx"]);
|
|
44
|
+
const resolutionCache = new Map<string, ResolvedInvocation>();
|
|
45
|
+
|
|
46
|
+
function resolveNodeShim(command: string): ResolvedInvocation | null {
|
|
47
|
+
if (!NODE_SHIMS.has(command)) return null;
|
|
48
|
+
|
|
49
|
+
// node.exe is the executor; npm-cli.js / npx-cli.js sits next to it under
|
|
50
|
+
// node_modules/npm/bin/. We deliberately key off node's location (not the
|
|
51
|
+
// shim's) because `npm.cmd` can live in a user-global dir (e.g. nvm,
|
|
52
|
+
// %AppData%\npm) while the actual CLI bundle stays alongside node.
|
|
53
|
+
const nodeBin = findExecutable("node");
|
|
54
|
+
if (!nodeBin) return null;
|
|
55
|
+
|
|
56
|
+
const cliJs = join(
|
|
57
|
+
dirname(nodeBin),
|
|
58
|
+
"node_modules",
|
|
59
|
+
"npm",
|
|
60
|
+
"bin",
|
|
61
|
+
`${command}-cli.js`,
|
|
62
|
+
);
|
|
63
|
+
if (!existsSync(cliJs)) return null;
|
|
64
|
+
|
|
65
|
+
return { cmd: nodeBin, prefixArgs: [cliJs] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveInvocation(command: string): ResolvedInvocation {
|
|
69
|
+
if (process.platform !== "win32") {
|
|
70
|
+
return { cmd: command, prefixArgs: [] };
|
|
71
|
+
}
|
|
72
|
+
const cached = resolutionCache.get(command);
|
|
73
|
+
if (cached) return cached;
|
|
74
|
+
|
|
75
|
+
const resolved = resolveNodeShim(command) ?? { cmd: command, prefixArgs: [] };
|
|
76
|
+
resolutionCache.set(command, resolved);
|
|
77
|
+
return resolved;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Drop-in replacement for `platform.exec` callers that invoke npm/npx by name.
|
|
82
|
+
* Other commands pass through unchanged.
|
|
83
|
+
*/
|
|
84
|
+
export function execCli(
|
|
85
|
+
exec: ExecFn,
|
|
86
|
+
command: string,
|
|
87
|
+
args: string[],
|
|
88
|
+
opts?: ExecOptions,
|
|
89
|
+
): Promise<ExecResult> {
|
|
90
|
+
const resolved = resolveInvocation(command);
|
|
91
|
+
return exec(resolved.cmd, [...resolved.prefixArgs, ...args], opts);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Wrap an `ExecFn` so every call routes through `execCli`. Use when threading
|
|
96
|
+
* an exec callback into helpers that dispatch arbitrary tools by string name
|
|
97
|
+
* (e.g. the deps installer which splits install-command strings).
|
|
98
|
+
*/
|
|
99
|
+
export function wrapExecForCli(exec: ExecFn): ExecFn {
|
|
100
|
+
return (cmd, args, opts) => execCli(exec, cmd, args, opts);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Clears cached CLI resolutions after PATH or tooling changes. */
|
|
104
|
+
export function clearExecCliResolutionCache(): void {
|
|
105
|
+
resolutionCache.clear();
|
|
106
|
+
}
|