pi-cursor-sdk 0.1.27 → 0.1.29
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/CHANGELOG.md +29 -0
- package/README.md +40 -37
- package/docs/crabbox-platform-testing-lessons.md +508 -0
- package/docs/cursor-dogfood-checklist.md +4 -3
- package/docs/cursor-live-smoke-checklist.md +24 -22
- package/docs/cursor-model-ux-spec.md +12 -12
- package/docs/cursor-native-tool-replay.md +10 -10
- package/docs/cursor-native-tool-visual-audit.md +9 -7
- package/docs/cursor-testing-lessons.md +22 -17
- package/docs/cursor-tool-surfaces.md +3 -3
- package/docs/platform-smoke.md +994 -0
- package/package.json +35 -6
- package/platform-smoke.config.mjs +21 -0
- package/scripts/debug-provider-events.mjs +10 -3
- package/scripts/debug-sdk-events.mjs +10 -2
- package/scripts/isolated-cursor-smoke.sh +4 -4
- package/scripts/lib/cursor-visual-render.mjs +1 -0
- package/scripts/platform-smoke/artifacts.mjs +124 -0
- package/scripts/platform-smoke/assertions.mjs +101 -0
- package/scripts/platform-smoke/card-detect.mjs +96 -0
- package/scripts/platform-smoke/crabbox-runner.mjs +215 -0
- package/scripts/platform-smoke/doctor.mjs +446 -0
- package/scripts/platform-smoke/jsonl-text.mjs +31 -0
- package/scripts/platform-smoke/live-suite-runner.mjs +677 -0
- package/scripts/platform-smoke/platform-build-windows.ps1 +187 -0
- package/scripts/platform-smoke/pty-capture.mjs +131 -0
- package/scripts/platform-smoke/render-ansi.mjs +65 -0
- package/scripts/platform-smoke/scenarios.mjs +186 -0
- package/scripts/platform-smoke/targets.mjs +900 -0
- package/scripts/platform-smoke/visual-evidence.mjs +139 -0
- package/scripts/platform-smoke.mjs +193 -0
- package/scripts/probe-mcp-coldstart.mjs +8 -1
- package/scripts/steering-rpc-smoke.mjs +1 -1
- package/scripts/tmux-live-smoke.sh +3 -3
- package/scripts/visual-tui-smoke.mjs +1 -1
- package/src/cursor-pi-tool-bridge-abort.ts +1 -0
- package/src/cursor-pi-tool-bridge-diagnostics.ts +12 -1
- package/src/cursor-pi-tool-bridge.ts +46 -1
- package/src/cursor-provider-errors.ts +18 -2
- package/src/cursor-provider-turn-lifecycle-emitter.ts +65 -8
- package/src/cursor-provider-turn-tool-ledger.ts +2 -3
- package/src/cursor-run-final-text.ts +11 -1
- package/src/cursor-sdk-process-error-guard.ts +1 -1
- package/src/cursor-state.ts +38 -19
- package/src/cursor-tool-lifecycle.ts +1 -1
- package/src/cursor-tool-manifest.ts +1 -1
- package/src/cursor-transcript-utils.ts +7 -3
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crabbox runner — thin wrapper around the Crabbox CLI.
|
|
3
|
+
*
|
|
4
|
+
* Handles warmup, sync-aware run, stop, and artifact collection.
|
|
5
|
+
* Never prints CURSOR_API_KEY.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
const CRABBOX_BIN = process.env.PLATFORM_SMOKE_CRABBOX || "crabbox";
|
|
11
|
+
|
|
12
|
+
function env(name) { return process.env[name] ?? ""; }
|
|
13
|
+
|
|
14
|
+
function buildCrabboxEnv(opts = {}) {
|
|
15
|
+
const env = { ...process.env, CRABBOX_SYNC_GIT_SEED: "false", ...opts.env };
|
|
16
|
+
const allowed = new Set(opts.allowEnv ?? []);
|
|
17
|
+
if (!allowed.has("CURSOR_API_KEY")) delete env.CURSOR_API_KEY;
|
|
18
|
+
return env;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Run a crabbox command, returning stdout+stderr+exit+signal. */
|
|
22
|
+
export function execCrabbox(args, opts = {}) {
|
|
23
|
+
return new Promise((resolvePromise) => {
|
|
24
|
+
const timeoutMs = opts.timeout ?? 0;
|
|
25
|
+
const child = spawn(CRABBOX_BIN, args, {
|
|
26
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
27
|
+
env: buildCrabboxEnv(opts),
|
|
28
|
+
...opts.spawnOpts,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const stdoutChunks = [];
|
|
32
|
+
const stderrChunks = [];
|
|
33
|
+
let timeout;
|
|
34
|
+
let killTimeout;
|
|
35
|
+
if (timeoutMs > 0) {
|
|
36
|
+
timeout = setTimeout(() => {
|
|
37
|
+
stderrChunks.push(Buffer.from(`\n[platform-smoke] crabbox command timed out after ${timeoutMs}ms\n`));
|
|
38
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
39
|
+
killTimeout = setTimeout(() => {
|
|
40
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
41
|
+
}, 10_000);
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
child.stdout.on("data", (d) => stdoutChunks.push(d));
|
|
46
|
+
child.stderr.on("data", (d) => stderrChunks.push(d));
|
|
47
|
+
|
|
48
|
+
child.on("close", (code, signal) => {
|
|
49
|
+
if (timeout) clearTimeout(timeout);
|
|
50
|
+
if (killTimeout) clearTimeout(killTimeout);
|
|
51
|
+
resolvePromise({
|
|
52
|
+
stdout: Buffer.concat(stdoutChunks).toString(),
|
|
53
|
+
stderr: Buffer.concat(stderrChunks).toString(),
|
|
54
|
+
code: code ?? (signal ? 1 : 0),
|
|
55
|
+
signal,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
child.on("error", (err) => {
|
|
60
|
+
if (timeout) clearTimeout(timeout);
|
|
61
|
+
if (killTimeout) clearTimeout(killTimeout);
|
|
62
|
+
resolvePromise({
|
|
63
|
+
stdout: Buffer.concat(stdoutChunks).toString(),
|
|
64
|
+
stderr: (Buffer.concat(stderrChunks).toString() + "\n" + err.message).trim(),
|
|
65
|
+
code: 1,
|
|
66
|
+
signal: null,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the base crabbox args for a target (used for warmup, run, stop).
|
|
74
|
+
* These include provider, connection details, and work root.
|
|
75
|
+
* Callers should append command-specific flags/args.
|
|
76
|
+
*/
|
|
77
|
+
export function buildTargetBaseArgs(targetName, config = {}) {
|
|
78
|
+
switch (targetName) {
|
|
79
|
+
case "macos": {
|
|
80
|
+
const host = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
|
|
81
|
+
const user = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
|
|
82
|
+
const workRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || `/Users/${env("USER")}/crabbox/pi-cursor-sdk`;
|
|
83
|
+
return [
|
|
84
|
+
"--provider", "ssh",
|
|
85
|
+
"--target", "macos",
|
|
86
|
+
"--static-host", host,
|
|
87
|
+
"--static-user", user,
|
|
88
|
+
"--static-port", "22",
|
|
89
|
+
"--static-work-root", workRoot,
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
case "ubuntu": {
|
|
93
|
+
const image = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || "cimg/node:24.16";
|
|
94
|
+
return [
|
|
95
|
+
"--provider", "local-container",
|
|
96
|
+
"--target", "linux",
|
|
97
|
+
"--local-container-image", image,
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
case "windows-native": {
|
|
101
|
+
const vm = env("PLATFORM_SMOKE_WINDOWS_VM") || "pi-extension-windows-template";
|
|
102
|
+
const snap = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || "crabbox-ready";
|
|
103
|
+
const user = env("PLATFORM_SMOKE_WINDOWS_USER") || env("USER");
|
|
104
|
+
const workRoot = env("PLATFORM_SMOKE_WINDOWS_NATIVE_WORK_ROOT") || "C:\\crabbox\\pi-cursor-sdk";
|
|
105
|
+
return [
|
|
106
|
+
"--provider", "parallels",
|
|
107
|
+
"--target", "windows",
|
|
108
|
+
"--windows-mode", "normal",
|
|
109
|
+
"--parallels-source", vm,
|
|
110
|
+
"--parallels-source-snapshot", snap,
|
|
111
|
+
"--parallels-user", user,
|
|
112
|
+
"--parallels-work-root", workRoot,
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
default:
|
|
116
|
+
throw new Error(`unknown target: ${targetName}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get the internal lease ID for a target.
|
|
122
|
+
* For static SSH, this is "static_localhost".
|
|
123
|
+
* For local-container, it's the slug (pi-cursor-sdk-ubuntu).
|
|
124
|
+
* For parallels, it's the slug used during warmup.
|
|
125
|
+
*/
|
|
126
|
+
export function leaseIdFor(targetName) {
|
|
127
|
+
switch (targetName) {
|
|
128
|
+
case "macos": return "static_localhost";
|
|
129
|
+
case "ubuntu": return "pi-cursor-sdk-ubuntu";
|
|
130
|
+
default: return `pi-cursor-sdk-${targetName}`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseLeaseId(output) {
|
|
135
|
+
return output.match(/\bleased\s+(\S+)/)?.[1]
|
|
136
|
+
?? output.match(/\blease=(\S+)/)?.[1]
|
|
137
|
+
?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Warm up a Crabbox target lease.
|
|
142
|
+
* Returns { ok, stdout, stderr, leaseId }.
|
|
143
|
+
* The lease will be kept until explicitly stopped.
|
|
144
|
+
*/
|
|
145
|
+
export async function warmupLease(targetName, slug, config = {}) {
|
|
146
|
+
const fullArgs = ["warmup", ...buildTargetBaseArgs(targetName, config), "--slug", slug, "--keep"];
|
|
147
|
+
if (targetName === "macos") fullArgs.push("--reclaim");
|
|
148
|
+
console.log(` [crabbox] ${fullArgs.join(" ")}`);
|
|
149
|
+
const result = await execCrabbox(fullArgs, { timeout: 300_000 });
|
|
150
|
+
return {
|
|
151
|
+
ok: result.code === 0,
|
|
152
|
+
...result,
|
|
153
|
+
leaseId: parseLeaseId(result.stdout) ?? parseLeaseId(result.stderr) ?? leaseIdFor(targetName),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run a command on a warmed-up lease.
|
|
159
|
+
* The lease must already exist from a prior warmup. By default this performs
|
|
160
|
+
* one fresh sync so platform smoke always tests the current local checkout.
|
|
161
|
+
*/
|
|
162
|
+
export async function runOnLease(targetName, leaseId, command, opts = {}) {
|
|
163
|
+
const args = [
|
|
164
|
+
"run",
|
|
165
|
+
...buildTargetBaseArgs(targetName, opts.config),
|
|
166
|
+
"--id", leaseId,
|
|
167
|
+
];
|
|
168
|
+
for (const name of opts.allowEnv ?? []) {
|
|
169
|
+
args.push("--allow-env", name);
|
|
170
|
+
}
|
|
171
|
+
if (opts.sync === false) {
|
|
172
|
+
args.push("--no-sync");
|
|
173
|
+
} else if (opts.freshSync !== false) {
|
|
174
|
+
args.push("--fresh-sync");
|
|
175
|
+
}
|
|
176
|
+
if (opts.shell) {
|
|
177
|
+
args.push("--shell", command);
|
|
178
|
+
} else {
|
|
179
|
+
args.push("--", ...(Array.isArray(command) ? command : command.split(" ")));
|
|
180
|
+
}
|
|
181
|
+
console.log(` [crabbox] run ${args.slice(1, 6).join(" ")} ...`);
|
|
182
|
+
return execCrabbox(args, {
|
|
183
|
+
timeout: opts.timeout ?? 600_000,
|
|
184
|
+
env: opts.env,
|
|
185
|
+
allowEnv: opts.allowEnv,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Stop/release a warmed-up lease.
|
|
191
|
+
*/
|
|
192
|
+
export async function stopLease(targetName, leaseId, config = {}) {
|
|
193
|
+
const args = ["stop", ...buildTargetBaseArgs(targetName, config), "--id", leaseId];
|
|
194
|
+
console.log(` [crabbox] ${args.join(" ")}`);
|
|
195
|
+
return execCrabbox(args, { timeout: 60_000 });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* One-shot: run a command through Crabbox without warmup.
|
|
200
|
+
* Crabbox syncs the checkout, executes, and releases the lease.
|
|
201
|
+
*/
|
|
202
|
+
export async function runOneShot(targetName, command, opts = {}) {
|
|
203
|
+
const args = ["run", ...buildTargetBaseArgs(targetName, opts.config)];
|
|
204
|
+
if (opts.shell) {
|
|
205
|
+
args.push("--shell", command);
|
|
206
|
+
} else {
|
|
207
|
+
args.push("--", ...(Array.isArray(command) ? command : command.split(" ")));
|
|
208
|
+
}
|
|
209
|
+
console.log(` [crabbox] run (one-shot) ${args.slice(1, 6).join(" ")} ...`);
|
|
210
|
+
return execCrabbox(args, {
|
|
211
|
+
timeout: opts.timeout ?? 600_000,
|
|
212
|
+
env: opts.env,
|
|
213
|
+
allowEnv: opts.allowEnv,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform smoke doctor — preflight checks before any Cursor token spend.
|
|
3
|
+
*
|
|
4
|
+
* Implements doctor checks from docs/platform-smoke.md:
|
|
5
|
+
* env vars, Crabbox, providers, Docker, SSH, Parallels, Node, tools,
|
|
6
|
+
* artifacts, git status, forbidden files, Cursor auth, node-pty.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
10
|
+
import { accessSync, constants, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
|
11
|
+
import { dirname, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
let failures = 0;
|
|
14
|
+
|
|
15
|
+
function ok(label) { console.log(` \u2713 ${label}`); }
|
|
16
|
+
function warn(label) { console.log(` \u26a0 ${label}`); }
|
|
17
|
+
function fail(label) { console.error(` \u2717 ${label}`); failures++; }
|
|
18
|
+
function env(name) { return process.env[name] ?? ""; }
|
|
19
|
+
|
|
20
|
+
function versionAtLeast(actual, minimum) {
|
|
21
|
+
const actualParts = String(actual ?? "").split(".").map((part) => Number.parseInt(part, 10));
|
|
22
|
+
const minimumParts = String(minimum ?? "").split(".").map((part) => Number.parseInt(part, 10));
|
|
23
|
+
for (let index = 0; index < Math.max(actualParts.length, minimumParts.length); index++) {
|
|
24
|
+
const actualPart = Number.isFinite(actualParts[index]) ? actualParts[index] : 0;
|
|
25
|
+
const minimumPart = Number.isFinite(minimumParts[index]) ? minimumParts[index] : 0;
|
|
26
|
+
if (actualPart > minimumPart) return true;
|
|
27
|
+
if (actualPart < minimumPart) return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeChildEnv(extra = {}) {
|
|
33
|
+
const childEnv = { ...process.env, ...extra };
|
|
34
|
+
delete childEnv.CURSOR_API_KEY;
|
|
35
|
+
return childEnv;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function silent(cmd, args, opts = {}) {
|
|
39
|
+
try { return execFileSync(cmd, args, { timeout: 15_000, stdio: "pipe", ...opts, env: safeChildEnv(opts.env) }).toString().trim(); }
|
|
40
|
+
catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function shell(cmd, opts = {}) {
|
|
44
|
+
try { return execSync(cmd, { timeout: 15_000, stdio: "pipe", ...opts, env: safeChildEnv(opts.env) }).toString().trim(); }
|
|
45
|
+
catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseLeaseId(output) {
|
|
49
|
+
return output.match(/\bleased\s+(\S+)/)?.[1]
|
|
50
|
+
?? output.match(/\blease=(\S+)/)?.[1]
|
|
51
|
+
?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function windowsCrabboxBaseArgs() {
|
|
55
|
+
const vm = env("PLATFORM_SMOKE_WINDOWS_VM") || "pi-extension-windows-template";
|
|
56
|
+
const snap = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || "crabbox-ready";
|
|
57
|
+
const user = env("PLATFORM_SMOKE_WINDOWS_USER") || env("USER");
|
|
58
|
+
const workRoot = env("PLATFORM_SMOKE_WINDOWS_NATIVE_WORK_ROOT") || "C:\\crabbox\\pi-cursor-sdk";
|
|
59
|
+
return [
|
|
60
|
+
"--provider", "parallels",
|
|
61
|
+
"--target", "windows",
|
|
62
|
+
"--windows-mode", "normal",
|
|
63
|
+
"--parallels-source", vm,
|
|
64
|
+
"--parallels-source-snapshot", snap,
|
|
65
|
+
"--parallels-user", user,
|
|
66
|
+
"--parallels-work-root", workRoot,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function crabbox(cbox, args, timeout = 300_000) {
|
|
71
|
+
try {
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
stdout: execFileSync(cbox, args, {
|
|
75
|
+
timeout,
|
|
76
|
+
stdio: "pipe",
|
|
77
|
+
env: safeChildEnv({ CRABBOX_SYNC_GIT_SEED: "false" }),
|
|
78
|
+
}).toString(),
|
|
79
|
+
stderr: "",
|
|
80
|
+
};
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
stdout: error.stdout?.toString?.() ?? "",
|
|
85
|
+
stderr: error.stderr?.toString?.() ?? error.message,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function disposableWindowsSshProbe(cbox) {
|
|
91
|
+
const slug = "pi-cursor-sdk-doctor-windows";
|
|
92
|
+
const baseArgs = windowsCrabboxBaseArgs();
|
|
93
|
+
const warm = crabbox(cbox, ["warmup", ...baseArgs, "--slug", slug, "--keep", "--reclaim"], 300_000);
|
|
94
|
+
const leaseId = parseLeaseId(warm.stdout) ?? parseLeaseId(warm.stderr) ?? slug;
|
|
95
|
+
try {
|
|
96
|
+
if (!warm.ok) return { ok: false, message: `disposable Windows warmup failed: ${(warm.stderr || warm.stdout).slice(-500)}` };
|
|
97
|
+
const probeCommand = "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command 'Get-Command node,npm,git,tar -ErrorAction Stop | Out-Null; node --version; npm --version; git --version; tar --version | Select-Object -First 1; whoami'";
|
|
98
|
+
const run = crabbox(cbox, ["run", ...baseArgs, "--id", leaseId, "--no-sync", "--shell", probeCommand], 120_000);
|
|
99
|
+
if (!run.ok) return { ok: false, message: `disposable Windows probe failed: ${(run.stderr || run.stdout).slice(-500)}` };
|
|
100
|
+
const lines = run.stdout.trim().split(/\r?\n/).slice(-5);
|
|
101
|
+
if (!/^v\d+\./.test(lines[0] ?? "")) return { ok: false, message: `disposable Windows node probe missing or invalid: ${lines.join(" | ")}` };
|
|
102
|
+
if (!/^\d+\.\d+\./.test(lines[1] ?? "")) return { ok: false, message: `disposable Windows npm probe missing or invalid: ${lines.join(" | ")}` };
|
|
103
|
+
if (!/^git version/i.test(lines[2] ?? "")) return { ok: false, message: `disposable Windows git probe missing or invalid: ${lines.join(" | ")}` };
|
|
104
|
+
if (!/tar/i.test(lines[3] ?? "")) return { ok: false, message: `disposable Windows tar probe missing or invalid: ${lines.join(" | ")}` };
|
|
105
|
+
if (!(lines[4] ?? "").trim()) return { ok: false, message: `disposable Windows whoami probe missing: ${lines.join(" | ")}` };
|
|
106
|
+
return { ok: true, message: lines.join(" | ") };
|
|
107
|
+
} finally {
|
|
108
|
+
crabbox(cbox, ["stop", ...baseArgs, "--id", leaseId], 60_000);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function hasBin(name) { return silent("which", [name]) !== null; }
|
|
113
|
+
|
|
114
|
+
function findGitRoot(startPath) {
|
|
115
|
+
let dir = startPath;
|
|
116
|
+
for (let i = 0; i < 8; i++) {
|
|
117
|
+
if (existsSync(resolve(dir, ".git"))) return dir;
|
|
118
|
+
const parent = dirname(dir);
|
|
119
|
+
if (parent === dir) return null;
|
|
120
|
+
dir = parent;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function runChecks(config) {
|
|
126
|
+
// ── Phase 1: environment variables ──
|
|
127
|
+
console.log("\n── Environment variables ──");
|
|
128
|
+
const requiredVars = [
|
|
129
|
+
"PLATFORM_SMOKE_CRABBOX",
|
|
130
|
+
"CURSOR_API_KEY",
|
|
131
|
+
"PLATFORM_SMOKE_WINDOWS_VM",
|
|
132
|
+
"PLATFORM_SMOKE_WINDOWS_SNAPSHOT",
|
|
133
|
+
"PLATFORM_SMOKE_WINDOWS_USER",
|
|
134
|
+
"PLATFORM_SMOKE_WINDOWS_NATIVE_WORK_ROOT",
|
|
135
|
+
];
|
|
136
|
+
const optionalVars = [
|
|
137
|
+
"PLATFORM_SMOKE_MAC_HOST",
|
|
138
|
+
"PLATFORM_SMOKE_MAC_USER",
|
|
139
|
+
"PLATFORM_SMOKE_MAC_WORK_ROOT",
|
|
140
|
+
"PLATFORM_SMOKE_UBUNTU_IMAGE",
|
|
141
|
+
];
|
|
142
|
+
for (const name of requiredVars) {
|
|
143
|
+
const v = env(name);
|
|
144
|
+
v ? ok(`${name} = ${name === "CURSOR_API_KEY" ? "(present, redacted)" : (v.length > 50 ? v.slice(0, 50) + "..." : v)}`)
|
|
145
|
+
: fail(`${name} missing`);
|
|
146
|
+
}
|
|
147
|
+
for (const name of optionalVars) {
|
|
148
|
+
const v = env(name);
|
|
149
|
+
ok(`${name} = ${v || "(default)"}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Phase 2: Crabbox binary ──
|
|
153
|
+
console.log("\n── Crabbox binary ──");
|
|
154
|
+
const cbox = env("PLATFORM_SMOKE_CRABBOX");
|
|
155
|
+
if (!cbox) {
|
|
156
|
+
fail("PLATFORM_SMOKE_CRABBOX not set");
|
|
157
|
+
} else {
|
|
158
|
+
try { accessSync(cbox, constants.X_OK); ok(`binary: ${cbox}`); }
|
|
159
|
+
catch { fail(`${cbox} not executable`); }
|
|
160
|
+
const ver = silent(cbox, ["--version"]);
|
|
161
|
+
const actualVersion = ver?.split("\n")[0]?.trim();
|
|
162
|
+
if (actualVersion) ok(`version: ${actualVersion}`);
|
|
163
|
+
const requiredVersion = config.requiredCrabbox?.version;
|
|
164
|
+
const minimumVersion = config.requiredCrabbox?.minVersion;
|
|
165
|
+
if (requiredVersion) {
|
|
166
|
+
if (!actualVersion) fail(`could not verify Crabbox version for ${cbox}`);
|
|
167
|
+
else if (actualVersion !== requiredVersion) fail(`Crabbox version mismatch: expected ${requiredVersion}, got ${actualVersion}`);
|
|
168
|
+
else ok(`required version: ${actualVersion}`);
|
|
169
|
+
}
|
|
170
|
+
if (!requiredVersion && minimumVersion) {
|
|
171
|
+
if (!actualVersion) fail(`could not verify Crabbox version for ${cbox}`);
|
|
172
|
+
else if (!versionAtLeast(actualVersion, minimumVersion)) fail(`Crabbox version ${actualVersion} is below required minimum ${minimumVersion}`);
|
|
173
|
+
else ok(`minimum version: ${actualVersion} >= ${minimumVersion}`);
|
|
174
|
+
}
|
|
175
|
+
const requiredCommit = config.requiredCrabbox?.commit;
|
|
176
|
+
if (!requiredVersion && !minimumVersion && requiredCommit) {
|
|
177
|
+
const gitRoot = findGitRoot(dirname(cbox));
|
|
178
|
+
const actualCommit = gitRoot ? silent("git", ["-C", gitRoot, "rev-parse", "HEAD"]) : null;
|
|
179
|
+
if (!actualCommit) fail(`could not verify Crabbox source commit for ${cbox}`);
|
|
180
|
+
else if (actualCommit !== requiredCommit) fail(`Crabbox commit mismatch: expected ${requiredCommit}, got ${actualCommit}`);
|
|
181
|
+
else ok(`commit: ${actualCommit}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Phase 3: Crabbox providers ──
|
|
186
|
+
console.log("\n── Crabbox providers ──");
|
|
187
|
+
if (cbox) {
|
|
188
|
+
const providerList = silent(cbox, ["providers"]);
|
|
189
|
+
if (providerList) {
|
|
190
|
+
for (const provider of ["ssh", "local-container", "parallels"]) {
|
|
191
|
+
new RegExp(`^${provider}$`, "m").test(providerList)
|
|
192
|
+
? ok(`provider listed: ${provider}`)
|
|
193
|
+
: fail(`crabbox providers missing required provider: ${provider}`);
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
fail("crabbox providers failed");
|
|
197
|
+
}
|
|
198
|
+
const ubuntuImage = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config?.ubuntuContainerImage || "cimg/node:24.16";
|
|
199
|
+
const lcDoc = silent(cbox, ["doctor", "--provider", "local-container", "--local-container-image", ubuntuImage, "--json"]);
|
|
200
|
+
if (lcDoc) {
|
|
201
|
+
try {
|
|
202
|
+
const d = JSON.parse(lcDoc);
|
|
203
|
+
d.ok ? ok("local-container provider OK") : fail(`local-container: ${d.error ?? "not ok"}`);
|
|
204
|
+
} catch {
|
|
205
|
+
fail("could not parse crabbox doctor --json for local-container");
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
fail("crabbox doctor --provider local-container --json failed");
|
|
209
|
+
}
|
|
210
|
+
const sshHost = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
|
|
211
|
+
const sshUser = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
|
|
212
|
+
const sshRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || `/Users/${env("USER")}/crabbox/pi-cursor-sdk`;
|
|
213
|
+
const sshDoc = silent(cbox, [
|
|
214
|
+
"doctor", "--provider", "ssh", "--target", "macos",
|
|
215
|
+
"--static-host", sshHost, "--static-user", sshUser,
|
|
216
|
+
"--static-port", "22", "--static-work-root", sshRoot,
|
|
217
|
+
"--json",
|
|
218
|
+
]);
|
|
219
|
+
if (sshDoc) {
|
|
220
|
+
try {
|
|
221
|
+
const d = JSON.parse(sshDoc);
|
|
222
|
+
d.ok ? ok("ssh (static) provider OK") : fail(`ssh doctor: ${d.checks?.find(c => c.status !== "ok")?.check ?? "some checks not ok"}`);
|
|
223
|
+
} catch {
|
|
224
|
+
fail("could not parse crabbox ssh doctor JSON");
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
fail("crabbox doctor --provider ssh --target macos --json failed");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Phase 4: Docker ──
|
|
232
|
+
console.log("\n── Docker ──");
|
|
233
|
+
const dockerVer = shell("docker info --format '{{.ServerVersion}}'");
|
|
234
|
+
dockerVer ? ok(`Docker ${dockerVer}`) : fail("Docker not running or not available");
|
|
235
|
+
|
|
236
|
+
// ── Phase 5: macOS SSH ──
|
|
237
|
+
console.log("\n── macOS SSH ──");
|
|
238
|
+
const host = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
|
|
239
|
+
const user = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
|
|
240
|
+
const sshOut = shell(`ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${user}@${host} 'whoami && node --version && npm --version && git --version && rsync --version | head -1 && tar --version | head -1'`);
|
|
241
|
+
if (sshOut) {
|
|
242
|
+
const lines = sshOut.trim().split("\n");
|
|
243
|
+
ok(`SSH to ${host}: ${lines[0]}`);
|
|
244
|
+
if (lines[1]) ok(`remote Node ${lines[1]}`); else fail("remote node probe missing output");
|
|
245
|
+
if (lines[2]) ok(`remote npm ${lines[2]}`); else fail("remote npm probe missing output");
|
|
246
|
+
if (lines[3]) ok(`remote ${lines[3]}`); else fail("remote git probe missing output");
|
|
247
|
+
if (lines[4]) ok(`remote ${lines[4]}`); else fail("remote rsync probe missing output");
|
|
248
|
+
if (lines[5]) ok(`remote ${lines[5]}`); else fail("remote tar probe missing output");
|
|
249
|
+
} else {
|
|
250
|
+
fail(`SSH to ${host} failed`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Phase 6: Parallels ──
|
|
254
|
+
console.log("\n── Parallels ──");
|
|
255
|
+
if (!hasBin("prlctl")) {
|
|
256
|
+
fail("prlctl not found");
|
|
257
|
+
} else {
|
|
258
|
+
ok("prlctl found");
|
|
259
|
+
const vmName = env("PLATFORM_SMOKE_WINDOWS_VM") || "pi-extension-windows-template";
|
|
260
|
+
const list = shell("prlctl list -a --no-header 2>/dev/null");
|
|
261
|
+
if (list) {
|
|
262
|
+
const vms = list.split("\n").filter(Boolean);
|
|
263
|
+
const tpl = vms.find(l => l.includes(vmName));
|
|
264
|
+
if (tpl) {
|
|
265
|
+
ok(`template VM "${vmName}" found`);
|
|
266
|
+
const status = tpl.split(/\s+/)[1];
|
|
267
|
+
if (status === "stopped") {
|
|
268
|
+
ok(`VM "${vmName}" is stopped — ready for linked clones`);
|
|
269
|
+
} else {
|
|
270
|
+
fail(`VM "${vmName}" state: ${status} — source VM must be stopped for linked clones`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const snapName = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || "crabbox-ready";
|
|
274
|
+
const snapsJson = shell(`prlctl snapshot-list "${vmName}" -j 2>/dev/null`);
|
|
275
|
+
let snapshotFound = false;
|
|
276
|
+
let snapshotPowerOff = false;
|
|
277
|
+
if (snapsJson) {
|
|
278
|
+
try {
|
|
279
|
+
const snapshots = JSON.parse(snapsJson);
|
|
280
|
+
const matches = Object.values(snapshots).filter((item) => item?.name === snapName);
|
|
281
|
+
if (matches.length > 1) fail(`snapshot "${snapName}" is ambiguous (${matches.length} snapshots); keep exactly one named release snapshot`);
|
|
282
|
+
const snapshot = matches[0];
|
|
283
|
+
snapshotFound = Boolean(snapshot);
|
|
284
|
+
snapshotPowerOff = snapshot?.state === "poweroff";
|
|
285
|
+
} catch {
|
|
286
|
+
fail(`could not parse snapshot JSON for "${vmName}"`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (!snapshotFound) {
|
|
290
|
+
const snapsText = shell(`prlctl snapshot-list "${vmName}" 2>/dev/null`);
|
|
291
|
+
snapshotFound = Boolean(snapsText && snapsText.includes(snapName));
|
|
292
|
+
}
|
|
293
|
+
if (snapshotFound) {
|
|
294
|
+
ok(`snapshot "${snapName}" exists`);
|
|
295
|
+
if (snapshotPowerOff) ok(`snapshot "${snapName}" state is poweroff — forkable for linked clones`);
|
|
296
|
+
else fail(`snapshot "${snapName}" is not poweroff — linked clone baseline must be powered off`);
|
|
297
|
+
} else {
|
|
298
|
+
fail(`snapshot "${snapName}" not found — run: prlctl snapshot "${vmName}" --name "${snapName}"`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// SSH probe on Windows VM. Do not let a stopped template hide missing Windows prep.
|
|
302
|
+
const ipLine = shell(`prlctl list -f --no-header "${vmName}" 2>/dev/null`);
|
|
303
|
+
if (ipLine) {
|
|
304
|
+
const parts = ipLine.trim().split(/\s+/);
|
|
305
|
+
const ip = parts.length >= 3 ? parts[2] : null;
|
|
306
|
+
if (ip && ip !== "-") {
|
|
307
|
+
ok(`VM IP: ${ip}`);
|
|
308
|
+
const portCheck = shell(`nc -z -w 3 ${ip} 22 2>/dev/null && echo open || echo closed`);
|
|
309
|
+
if (portCheck?.includes("open")) {
|
|
310
|
+
ok(`SSH open on ${ip}:22`);
|
|
311
|
+
} else {
|
|
312
|
+
fail(`SSH not open on ${ip}:22 — enable OpenSSH Server in Windows template VM`);
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
ok(`template "${vmName}" has no IP; verifying Windows SSH/tools through a disposable Crabbox clone`);
|
|
316
|
+
if (cbox && snapshotFound && snapshotPowerOff) {
|
|
317
|
+
const probe = disposableWindowsSshProbe(cbox);
|
|
318
|
+
probe.ok ? ok(`disposable Windows clone SSH/tool probe OK: ${probe.message}`) : fail(probe.message);
|
|
319
|
+
} else {
|
|
320
|
+
fail(`Windows SSH probe could not run because "${vmName}" has no IP and no verified snapshot was available`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
fail(`could not inspect Windows VM IP for "${vmName}"`);
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
fail(`VM "${vmName}" not found. Available: ${vms.map(v => v.split(/\s+/).pop()).join(", ")}`);
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
fail("prlctl list returned no output");
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Phase 7: Node.js ──
|
|
335
|
+
console.log("\n── Node.js ──");
|
|
336
|
+
const nv = shell("node --version");
|
|
337
|
+
if (nv) {
|
|
338
|
+
const major = parseInt(nv.replace("v", "").split(".")[0], 10);
|
|
339
|
+
major >= (config?.nodeValidationMajor ?? 24)
|
|
340
|
+
? ok(`Node ${nv} (>= ${config?.nodeValidationMajor ?? 24})`)
|
|
341
|
+
: fail(`Node ${nv} — need ${config?.nodeValidationMajor ?? 24}+`);
|
|
342
|
+
} else {
|
|
343
|
+
fail("node not found");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Phase 8: Tools ──
|
|
347
|
+
console.log("\n── Tools ──");
|
|
348
|
+
for (const [name, command] of [
|
|
349
|
+
["npm", "npm --version"],
|
|
350
|
+
["git", "git --version"],
|
|
351
|
+
["rsync", "rsync --version"],
|
|
352
|
+
["tar", "tar --version"],
|
|
353
|
+
]) {
|
|
354
|
+
const out = shell(command);
|
|
355
|
+
out ? ok(`${name}: ${out.split("\n")[0]}`) : fail(`${name} not found`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Phase 9: Artifact root ──
|
|
359
|
+
console.log("\n── Artifact root ──");
|
|
360
|
+
const artRoot = resolve(process.cwd(), config?.artifactRoot ?? ".artifacts/platform-smoke");
|
|
361
|
+
try {
|
|
362
|
+
mkdirSync(artRoot, { recursive: true });
|
|
363
|
+
const tf = resolve(artRoot, ".doctor-write-test");
|
|
364
|
+
writeFileSync(tf, "doctor-test");
|
|
365
|
+
unlinkSync(tf);
|
|
366
|
+
ok(`writable: ${artRoot}`);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
fail(`cannot write to ${artRoot}: ${e.message}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Phase 10: Git status ──
|
|
372
|
+
console.log("\n── Git status ──");
|
|
373
|
+
const branch = shell("git branch --show-current");
|
|
374
|
+
branch ? ok(`branch: ${branch}`) : warn("could not determine branch");
|
|
375
|
+
const st = shell("git status --short");
|
|
376
|
+
if (st) {
|
|
377
|
+
const changed = st.trim().split("\n").length;
|
|
378
|
+
warn(`${changed} uncommitted change(s)`);
|
|
379
|
+
} else {
|
|
380
|
+
ok("clean worktree");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Phase 11: Forbidden files ──
|
|
384
|
+
console.log("\n── Forbidden files ──");
|
|
385
|
+
let anyForbidden = false;
|
|
386
|
+
for (const pat of [".env", "*.tgz"]) {
|
|
387
|
+
const found = shell(`find . -maxdepth 2 -name "${pat}" 2>/dev/null`);
|
|
388
|
+
if (found) {
|
|
389
|
+
fail(`found: ${found.trim()}`);
|
|
390
|
+
anyForbidden = true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (!anyForbidden) ok("no .env, .tgz");
|
|
394
|
+
|
|
395
|
+
// Check for tracked .env.*
|
|
396
|
+
for (const f of [".env.production", ".env.local"]) {
|
|
397
|
+
if (existsSync(resolve(process.cwd(), f))) {
|
|
398
|
+
fail(`tracked forbidden: ${f}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
ok("no tracked .env.*");
|
|
402
|
+
|
|
403
|
+
// ── Phase 12: Cursor auth ──
|
|
404
|
+
console.log("\n── Cursor auth ──");
|
|
405
|
+
const key = env("CURSOR_API_KEY");
|
|
406
|
+
if (key && key.length > 10) {
|
|
407
|
+
ok(`CURSOR_API_KEY present (${key.length} chars, redacted)`);
|
|
408
|
+
} else if (key) {
|
|
409
|
+
fail("CURSOR_API_KEY too short (likely invalid)");
|
|
410
|
+
} else {
|
|
411
|
+
fail("CURSOR_API_KEY missing — live Cursor suites will not run");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Phase 13: node-pty self-test ──
|
|
415
|
+
console.log("\n── node-pty self-test ──");
|
|
416
|
+
const ptyPath = resolve(process.cwd(), "node_modules", "node-pty");
|
|
417
|
+
if (existsSync(ptyPath)) {
|
|
418
|
+
try {
|
|
419
|
+
// node-pty can hang with mismatched Node ABI; use timeout
|
|
420
|
+
const ptyResult = shell("node -e \"try { require('node-pty'); console.log('node-pty ok') } catch(e) { console.error(e.message); process.exit(1) }\"", { timeout: 15_000 });
|
|
421
|
+
if (ptyResult && ptyResult.includes("node-pty ok")) {
|
|
422
|
+
ok("node-pty loads successfully");
|
|
423
|
+
} else {
|
|
424
|
+
fail(`node-pty not functional: ${ptyResult?.slice(0, 200) || 'null'}. This blocks live PTY suites but not platform-build.`);
|
|
425
|
+
}
|
|
426
|
+
} catch (e) {
|
|
427
|
+
fail(`node-pty self-test error: ${e.message}. This blocks live PTY suites but not platform-build.`);
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
warn("node-pty not installed — live PTY suites will not run. Run: npm ci");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── Phase 14: Summary ──
|
|
434
|
+
console.log(`\n=== Results: ${failures} failure(s) ===`);
|
|
435
|
+
if (failures > 0) {
|
|
436
|
+
console.log("Fix failures above before running live Cursor suites.");
|
|
437
|
+
console.log("Use `npm run smoke:platform:doctor` to re-validate.");
|
|
438
|
+
process.exitCode = 1;
|
|
439
|
+
} else {
|
|
440
|
+
console.log("All checks passed. Ready for platform smoke.");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function runDoctor(config) {
|
|
445
|
+
runChecks(config);
|
|
446
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function extractContentText(content) {
|
|
2
|
+
if (typeof content === "string") return content;
|
|
3
|
+
if (!Array.isArray(content)) return "";
|
|
4
|
+
return content.map((block) => typeof block?.text === "string" ? block.text : JSON.stringify(block)).join("\n");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function extractFinalTextContent(content) {
|
|
8
|
+
if (typeof content === "string") return content.trim().length > 0 ? content : "";
|
|
9
|
+
if (!Array.isArray(content)) return "";
|
|
10
|
+
for (let index = content.length - 1; index >= 0; index--) {
|
|
11
|
+
const block = content[index];
|
|
12
|
+
const text = typeof block?.text === "string" ? block.text : undefined;
|
|
13
|
+
if (text?.trim()) return text;
|
|
14
|
+
}
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getAssistantFinalText(message) {
|
|
19
|
+
return message?.role === "assistant" ? extractFinalTextContent(message.content) : "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function jsonlHasAssistantFinalTextMarker(jsonlRaw, finalMarker) {
|
|
23
|
+
if (!finalMarker) return true;
|
|
24
|
+
for (const line of jsonlRaw.split(/\r?\n/)) {
|
|
25
|
+
if (!line.trim()) continue;
|
|
26
|
+
let event;
|
|
27
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
28
|
+
if (getAssistantFinalText(event?.message).includes(finalMarker)) return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|