waitspin 0.1.2 → 0.1.4
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 +84 -18
- package/assets/waitspin-mimocode/README.md +169 -0
- package/assets/waitspin-mimocode/mimocode-install.sh +44 -0
- package/assets/waitspin-mimocode/mimocode-runtime.sh +19 -0
- package/assets/waitspin-mimocode/mimocode-status.sh +44 -0
- package/assets/waitspin-mimocode/mimocode-uninstall.sh +44 -0
- package/assets/waitspin-opencode/opencode-statusline.mjs +280 -0
- package/assets/waitspin-opencode/waitspin-opencode.plugin.tsx +267 -0
- package/assets/waitspin-vscode/out/extension.js +31 -9
- package/assets/waitspin-vscode/out/extension.js.map +1 -1
- package/assets/waitspin-vscode/package.json +1 -1
- package/dist/cli.js +2402 -78
- package/dist/cli.js.map +1 -1
- package/package.json +8 -2
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { realpathSync } from "node:fs";
|
|
4
5
|
import { constants as fsConstants } from "node:fs";
|
|
5
|
-
import { access, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { access, chmod, cp, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
6
7
|
import os from "node:os";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
const DEFAULT_BASE_URL = process.env.WAITSPIN_BASE_URL?.trim() || "https://api.waitspin.com";
|
|
10
|
+
const PRODUCTION_API_ORIGIN = "https://api.waitspin.com";
|
|
9
11
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
12
|
+
const DEV_API_BASE_OPT_IN_ENV = "WAITSPIN_ALLOW_DEV_API_BASE";
|
|
13
|
+
const DEV_EXTENSION_ASSETS_OPT_IN_ENV = "WAITSPIN_ALLOW_DEV_EXTENSION_ASSETS";
|
|
14
|
+
const CLAUDE_CODE_BIN_ENV = "WAITSPIN_CLAUDE_CODE_BIN";
|
|
15
|
+
const CLAUDE_CODE_MIN_VERSION = "2.1.97";
|
|
16
|
+
const CLAUDE_CODE_PUBLISHER_TARGET = "claude-code";
|
|
17
|
+
const CLAUDE_CODE_REFRESH_INTERVAL_SECONDS = 5;
|
|
18
|
+
const MIMOCODE_BIN_ENV = "WAITSPIN_MIMOCODE_BIN";
|
|
19
|
+
const MIMOCODE_DEFAULT_BIN = "mimo";
|
|
20
|
+
const OPENCODE_PUBLISHER_TARGET = "opencode";
|
|
21
|
+
const OPENCODE_BIN_ENV = "WAITSPIN_OPENCODE_BIN";
|
|
22
|
+
const OPENCODE_DEFAULT_BIN = "opencode";
|
|
10
23
|
const extensionTargets = {
|
|
11
24
|
vscode: "vscode",
|
|
12
25
|
};
|
|
@@ -26,11 +39,22 @@ export function usageText() {
|
|
|
26
39
|
" waitspin extension install [--target vscode] [--base-url URL] [--api-key KEY] [--dry-run]",
|
|
27
40
|
" waitspin extension status [--target vscode]",
|
|
28
41
|
" waitspin extension uninstall [--target vscode] [--dry-run]",
|
|
42
|
+
" waitspin install --all [--api-key KEY] [--compose-existing] [--dry-run]",
|
|
43
|
+
" waitspin status --all",
|
|
44
|
+
" waitspin claude-code install [--api-key KEY] [--compose-existing] [--dry-run]",
|
|
45
|
+
" waitspin claude-code status",
|
|
46
|
+
" waitspin claude-code uninstall [--dry-run]",
|
|
47
|
+
" waitspin mimocode install [--api-key KEY] [--dry-run]",
|
|
48
|
+
" waitspin mimocode status",
|
|
49
|
+
" waitspin mimocode uninstall [--dry-run]",
|
|
50
|
+
" waitspin opencode install [--api-key KEY] [--dry-run]",
|
|
51
|
+
" waitspin opencode status",
|
|
52
|
+
" waitspin opencode uninstall [--dry-run]",
|
|
29
53
|
"",
|
|
30
54
|
"Defaults:",
|
|
31
55
|
" API base: https://api.waitspin.com",
|
|
32
56
|
" API key: WAITSPIN_API_KEY env var",
|
|
33
|
-
" Public
|
|
57
|
+
" Public publisher targets: status-bar-fallback, claude-code, mimocode, opencode",
|
|
34
58
|
].join("\n") + "\n");
|
|
35
59
|
}
|
|
36
60
|
function usage(exitCode = 1) {
|
|
@@ -67,7 +91,11 @@ function parseArgs(argv) {
|
|
|
67
91
|
}
|
|
68
92
|
if (key === "dry-run" ||
|
|
69
93
|
key === "allow-debug-auto-verify" ||
|
|
70
|
-
key === "
|
|
94
|
+
key === "allow-dev-api-base" ||
|
|
95
|
+
key === "allow-dev-extension-assets" ||
|
|
96
|
+
key === "confirm-test-transfer" ||
|
|
97
|
+
key === "compose-existing" ||
|
|
98
|
+
key === "all") {
|
|
71
99
|
flags.set(key, ["true"]);
|
|
72
100
|
continue;
|
|
73
101
|
}
|
|
@@ -99,6 +127,51 @@ function booleanFlag(flags, name) {
|
|
|
99
127
|
function resolveBaseUrl(flags) {
|
|
100
128
|
return optionalFlag(flags, "base-url") || DEFAULT_BASE_URL;
|
|
101
129
|
}
|
|
130
|
+
function allowDevApiBase(flags) {
|
|
131
|
+
return (booleanFlag(flags, "allow-dev-api-base") ||
|
|
132
|
+
process.env[DEV_API_BASE_OPT_IN_ENV] === "1");
|
|
133
|
+
}
|
|
134
|
+
function allowDevExtensionAssets(flags) {
|
|
135
|
+
return (booleanFlag(flags, "allow-dev-extension-assets") ||
|
|
136
|
+
process.env[DEV_EXTENSION_ASSETS_OPT_IN_ENV] === "1");
|
|
137
|
+
}
|
|
138
|
+
function isLoopbackApiHostname(hostname) {
|
|
139
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, "");
|
|
140
|
+
return (normalized === "localhost" ||
|
|
141
|
+
normalized === "::1" ||
|
|
142
|
+
normalized === "[::1]" ||
|
|
143
|
+
/^127(?:\.\d{1,3}){3}$/.test(normalized));
|
|
144
|
+
}
|
|
145
|
+
function normalizeOriginUrl(value) {
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = new URL(value);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new Error("Invalid WaitSpin API base URL. Use an http(s) origin without credentials, path, query, or fragment.");
|
|
152
|
+
}
|
|
153
|
+
if ((parsed.protocol !== "https:" && parsed.protocol !== "http:") ||
|
|
154
|
+
parsed.username ||
|
|
155
|
+
parsed.password ||
|
|
156
|
+
parsed.search ||
|
|
157
|
+
parsed.hash ||
|
|
158
|
+
(parsed.pathname !== "" && parsed.pathname !== "/")) {
|
|
159
|
+
throw new Error("Invalid WaitSpin API base URL. Use an http(s) origin without credentials, path, query, or fragment.");
|
|
160
|
+
}
|
|
161
|
+
return parsed;
|
|
162
|
+
}
|
|
163
|
+
function resolveCredentialedBaseUrl(flags) {
|
|
164
|
+
const parsed = normalizeOriginUrl(resolveBaseUrl(flags));
|
|
165
|
+
if (parsed.protocol === "https:" && parsed.origin === PRODUCTION_API_ORIGIN) {
|
|
166
|
+
return parsed.origin;
|
|
167
|
+
}
|
|
168
|
+
if (allowDevApiBase(flags) &&
|
|
169
|
+
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
|
170
|
+
isLoopbackApiHostname(parsed.hostname)) {
|
|
171
|
+
return parsed.origin;
|
|
172
|
+
}
|
|
173
|
+
throw new Error(`Refusing to send WaitSpin credentials to a non-production API origin. Use ${PRODUCTION_API_ORIGIN} or set ${DEV_API_BASE_OPT_IN_ENV}=1 / --allow-dev-api-base with a loopback http(s) origin for local development.`);
|
|
174
|
+
}
|
|
102
175
|
function resolveApiKey(flags) {
|
|
103
176
|
return optionalFlag(flags, "api-key") || process.env.WAITSPIN_API_KEY?.trim();
|
|
104
177
|
}
|
|
@@ -118,7 +191,11 @@ function resolveKeyIntendedUse(flags) {
|
|
|
118
191
|
function requireApiKey(flags) {
|
|
119
192
|
const apiKey = resolveApiKey(flags);
|
|
120
193
|
if (!apiKey) {
|
|
121
|
-
throw new Error(
|
|
194
|
+
throw new Error([
|
|
195
|
+
"Missing API key. Set WAITSPIN_API_KEY or pass --api-key.",
|
|
196
|
+
"Next command:",
|
|
197
|
+
" export WAITSPIN_API_KEY='PASTE_KEY_HERE'",
|
|
198
|
+
].join("\n"));
|
|
122
199
|
}
|
|
123
200
|
return apiKey;
|
|
124
201
|
}
|
|
@@ -153,8 +230,76 @@ async function requestJson(input, init) {
|
|
|
153
230
|
}
|
|
154
231
|
return { status: response.status, body: payload };
|
|
155
232
|
}
|
|
156
|
-
|
|
233
|
+
let jsonPrinter = (value) => {
|
|
157
234
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
235
|
+
};
|
|
236
|
+
function printJson(value) {
|
|
237
|
+
jsonPrinter(value);
|
|
238
|
+
}
|
|
239
|
+
async function capturePrintedJson(fn) {
|
|
240
|
+
const previousPrinter = jsonPrinter;
|
|
241
|
+
let captured;
|
|
242
|
+
jsonPrinter = (value) => {
|
|
243
|
+
captured = value;
|
|
244
|
+
};
|
|
245
|
+
try {
|
|
246
|
+
await fn();
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
jsonPrinter = previousPrinter;
|
|
250
|
+
}
|
|
251
|
+
return captured;
|
|
252
|
+
}
|
|
253
|
+
function keyProfileFromIntendedUse(intendedUse) {
|
|
254
|
+
if (intendedUse === "key_profile:publisher_extension") {
|
|
255
|
+
return "publisher_extension";
|
|
256
|
+
}
|
|
257
|
+
if (intendedUse === "key_profile:control") {
|
|
258
|
+
return "control";
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
function keyProfileFlagFromIntendedUse(intendedUse) {
|
|
263
|
+
const profile = keyProfileFromIntendedUse(intendedUse);
|
|
264
|
+
if (profile === "publisher_extension") {
|
|
265
|
+
return " --key-profile publisher-extension";
|
|
266
|
+
}
|
|
267
|
+
if (profile === "control") {
|
|
268
|
+
return " --key-profile control";
|
|
269
|
+
}
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
function waitspinInitVerifyCommand(input) {
|
|
273
|
+
return `waitspin init --email ${input.email} --code CODE_FROM_EMAIL${keyProfileFlagFromIntendedUse(input.intendedUse)}`;
|
|
274
|
+
}
|
|
275
|
+
function nextCommandsForVerifiedKey(input) {
|
|
276
|
+
const scopes = input.scopes ?? [];
|
|
277
|
+
const publisherKey = keyProfileFromIntendedUse(input.intendedUse) === "publisher_extension" ||
|
|
278
|
+
(scopes.includes("publishers:write") &&
|
|
279
|
+
scopes.includes("serve:read") &&
|
|
280
|
+
scopes.includes("events:write") &&
|
|
281
|
+
!scopes.includes("campaigns:write"));
|
|
282
|
+
if (publisherKey) {
|
|
283
|
+
return {
|
|
284
|
+
next: "install_publisher_target",
|
|
285
|
+
next_commands: [
|
|
286
|
+
"export WAITSPIN_API_KEY='PASTE_KEY_HERE'",
|
|
287
|
+
"waitspin install --all --dry-run --compose-existing",
|
|
288
|
+
"waitspin install --all --compose-existing",
|
|
289
|
+
"waitspin status --all",
|
|
290
|
+
],
|
|
291
|
+
human_message: "Use this publisher-extension key only for publisher install, serve polling, and impressions. Rotate it if it appears in logs.",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
next: "create_campaign_or_inspect_market",
|
|
296
|
+
next_commands: [
|
|
297
|
+
"export WAITSPIN_API_KEY='PASTE_KEY_HERE'",
|
|
298
|
+
"waitspin market",
|
|
299
|
+
'waitspin bid create --line "Your ad" --url https://example.com --price-per-block 500 --blocks 1',
|
|
300
|
+
],
|
|
301
|
+
human_message: "Use this control key for advertiser and wallet commands. Keep it out of shell history and logs.",
|
|
302
|
+
};
|
|
158
303
|
}
|
|
159
304
|
async function runInit(flags) {
|
|
160
305
|
const baseUrl = resolveBaseUrl(flags);
|
|
@@ -172,7 +317,15 @@ async function runInit(flags) {
|
|
|
172
317
|
...(intendedUse ? { intended_use: intendedUse } : {}),
|
|
173
318
|
}),
|
|
174
319
|
});
|
|
175
|
-
printJson({
|
|
320
|
+
printJson({
|
|
321
|
+
ok: true,
|
|
322
|
+
base_url: baseUrl,
|
|
323
|
+
...body,
|
|
324
|
+
...nextCommandsForVerifiedKey({
|
|
325
|
+
intendedUse,
|
|
326
|
+
scopes: body?.scopes,
|
|
327
|
+
}),
|
|
328
|
+
});
|
|
176
329
|
return;
|
|
177
330
|
}
|
|
178
331
|
const { body: requestResult } = await requestJson(`${baseUrl}/v1/keys/request`, {
|
|
@@ -189,10 +342,13 @@ async function runInit(flags) {
|
|
|
189
342
|
if (!code || !allowDebugAutoVerify) {
|
|
190
343
|
printJson({
|
|
191
344
|
ok: true,
|
|
192
|
-
next: "
|
|
345
|
+
next: "enter_email_code",
|
|
193
346
|
delivery: requestResult?.delivery,
|
|
194
347
|
email,
|
|
195
|
-
|
|
348
|
+
expires_in_seconds: requestResult?.expires_in_seconds ?? 900,
|
|
349
|
+
next_command: waitspinInitVerifyCommand({ email, intendedUse }),
|
|
350
|
+
human_message: "Check your email for the 6-digit WaitSpin code. Paste it as CODE_FROM_EMAIL.",
|
|
351
|
+
...(code ? { debug_code_available: true } : {}),
|
|
196
352
|
});
|
|
197
353
|
return;
|
|
198
354
|
}
|
|
@@ -205,10 +361,18 @@ async function runInit(flags) {
|
|
|
205
361
|
...(intendedUse ? { intended_use: intendedUse } : {}),
|
|
206
362
|
}),
|
|
207
363
|
});
|
|
208
|
-
printJson({
|
|
364
|
+
printJson({
|
|
365
|
+
ok: true,
|
|
366
|
+
base_url: baseUrl,
|
|
367
|
+
...verifyResult,
|
|
368
|
+
...nextCommandsForVerifiedKey({
|
|
369
|
+
intendedUse,
|
|
370
|
+
scopes: verifyResult?.scopes,
|
|
371
|
+
}),
|
|
372
|
+
});
|
|
209
373
|
}
|
|
210
374
|
async function runBidCreate(flags) {
|
|
211
|
-
const baseUrl =
|
|
375
|
+
const baseUrl = resolveCredentialedBaseUrl(flags);
|
|
212
376
|
const apiKey = requireApiKey(flags);
|
|
213
377
|
const adLine = requireFlag(flags, "line");
|
|
214
378
|
const destinationUrl = requireFlag(flags, "url");
|
|
@@ -237,7 +401,7 @@ async function runBidCreate(flags) {
|
|
|
237
401
|
printJson({ ok: true, ...body });
|
|
238
402
|
}
|
|
239
403
|
async function runBidsList(flags) {
|
|
240
|
-
const baseUrl =
|
|
404
|
+
const baseUrl = resolveCredentialedBaseUrl(flags);
|
|
241
405
|
const apiKey = requireApiKey(flags);
|
|
242
406
|
const { body } = await requestJson(`${baseUrl}/v1/campaigns`, {
|
|
243
407
|
method: "GET",
|
|
@@ -248,9 +412,9 @@ async function runBidsList(flags) {
|
|
|
248
412
|
async function runBidCheckout(flags, positionals) {
|
|
249
413
|
const campaignId = positionals[0]?.trim();
|
|
250
414
|
if (!campaignId) {
|
|
251
|
-
throw new Error("Usage: waitspin bid checkout
|
|
415
|
+
throw new Error("Usage: waitspin bid checkout CAMPAIGN_ID");
|
|
252
416
|
}
|
|
253
|
-
const baseUrl =
|
|
417
|
+
const baseUrl = resolveCredentialedBaseUrl(flags);
|
|
254
418
|
const apiKey = requireApiKey(flags);
|
|
255
419
|
const { body } = await requestJson(`${baseUrl}/v1/blocks/checkout`, {
|
|
256
420
|
method: "POST",
|
|
@@ -266,7 +430,7 @@ async function runBidCheckout(flags, positionals) {
|
|
|
266
430
|
checkout_disclosure: {
|
|
267
431
|
terms_url: "https://waitspin.com/waitspin/terms",
|
|
268
432
|
privacy_url: "https://waitspin.com/waitspin/privacy",
|
|
269
|
-
refund_policy: "Unused prepaid
|
|
433
|
+
refund_policy: "Unused prepaid block handling is support-reviewed. No automated account-credit balance, redemption flow, or self-serve cash refund request flow is shipped.",
|
|
270
434
|
},
|
|
271
435
|
});
|
|
272
436
|
}
|
|
@@ -276,7 +440,7 @@ async function runMarket(flags) {
|
|
|
276
440
|
printJson({ ok: true, ...body });
|
|
277
441
|
}
|
|
278
442
|
async function runWalletStatus(flags) {
|
|
279
|
-
const baseUrl =
|
|
443
|
+
const baseUrl = resolveCredentialedBaseUrl(flags);
|
|
280
444
|
const apiKey = requireApiKey(flags);
|
|
281
445
|
const { body } = await requestJson(`${baseUrl}/v1/wallet/status`, {
|
|
282
446
|
method: "GET",
|
|
@@ -285,7 +449,7 @@ async function runWalletStatus(flags) {
|
|
|
285
449
|
printJson({ ok: true, ...body });
|
|
286
450
|
}
|
|
287
451
|
async function runWalletConnect(flags) {
|
|
288
|
-
const baseUrl =
|
|
452
|
+
const baseUrl = resolveCredentialedBaseUrl(flags);
|
|
289
453
|
const apiKey = requireApiKey(flags);
|
|
290
454
|
const { body } = await requestJson(`${baseUrl}/v1/wallet/connect`, {
|
|
291
455
|
method: "POST",
|
|
@@ -294,7 +458,7 @@ async function runWalletConnect(flags) {
|
|
|
294
458
|
printJson({ ok: true, ...body });
|
|
295
459
|
}
|
|
296
460
|
async function runWalletLedger(flags) {
|
|
297
|
-
const baseUrl =
|
|
461
|
+
const baseUrl = resolveCredentialedBaseUrl(flags);
|
|
298
462
|
const apiKey = requireApiKey(flags);
|
|
299
463
|
const limit = optionalFlag(flags, "limit");
|
|
300
464
|
const url = new URL(`${baseUrl}/v1/wallet/ledger`);
|
|
@@ -308,7 +472,7 @@ async function runWalletLedger(flags) {
|
|
|
308
472
|
printJson({ ok: true, ...body });
|
|
309
473
|
}
|
|
310
474
|
async function runWalletPayout(flags) {
|
|
311
|
-
const baseUrl =
|
|
475
|
+
const baseUrl = resolveCredentialedBaseUrl(flags);
|
|
312
476
|
const apiKey = requireApiKey(flags);
|
|
313
477
|
const dryRun = booleanFlag(flags, "dry-run");
|
|
314
478
|
const confirmTestTransfer = booleanFlag(flags, "confirm-test-transfer");
|
|
@@ -342,7 +506,16 @@ function extensionInstallDir(_target) {
|
|
|
342
506
|
return path.join(os.homedir(), ".vscode", "extensions");
|
|
343
507
|
}
|
|
344
508
|
function vscodeExtensionInstallDir(manifest) {
|
|
345
|
-
|
|
509
|
+
const extensionRoot = path.resolve(extensionInstallDir("vscode"));
|
|
510
|
+
const installName = `${manifest.publisher}.${manifest.name}-${manifest.version}`;
|
|
511
|
+
if (!/^[a-z0-9][a-z0-9.-]*$/.test(installName)) {
|
|
512
|
+
throw new Error("Refusing to install extension with unsafe manifest name.");
|
|
513
|
+
}
|
|
514
|
+
const installPath = path.resolve(extensionRoot, installName);
|
|
515
|
+
if (!installPath.startsWith(`${extensionRoot}${path.sep}`)) {
|
|
516
|
+
throw new Error("Refusing to install extension outside the VS Code root.");
|
|
517
|
+
}
|
|
518
|
+
return installPath;
|
|
346
519
|
}
|
|
347
520
|
function installStatePath(target) {
|
|
348
521
|
return path.join(os.homedir(), ".waitspin", `${target}-install.json`);
|
|
@@ -389,40 +562,92 @@ function assertManagedVscodeExtensionPath(input) {
|
|
|
389
562
|
return resolved;
|
|
390
563
|
}
|
|
391
564
|
async function registerPublisherInstall(input) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
565
|
+
let body = null;
|
|
566
|
+
try {
|
|
567
|
+
({ body } = await requestJson(`${input.baseUrl}/v1/publishers/register`, {
|
|
568
|
+
method: "POST",
|
|
569
|
+
headers: {
|
|
570
|
+
"Content-Type": "application/json",
|
|
571
|
+
Authorization: `Bearer ${input.apiKey}`,
|
|
572
|
+
},
|
|
573
|
+
body: JSON.stringify({
|
|
574
|
+
install_id: input.installId,
|
|
575
|
+
target: input.target,
|
|
576
|
+
}),
|
|
577
|
+
}));
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
if (input.target === CLAUDE_CODE_PUBLISHER_TARGET &&
|
|
581
|
+
error instanceof WaitSpinCliHttpError &&
|
|
582
|
+
error.status === 400 &&
|
|
583
|
+
/status-bar-fallback|Invalid input|Validation error/.test(error.message)) {
|
|
584
|
+
throw new Error([
|
|
585
|
+
'WaitSpin API rejected target "claude-code".',
|
|
586
|
+
"Your local CLI supports Claude Code, but the selected API base does not.",
|
|
587
|
+
"",
|
|
588
|
+
"For production:",
|
|
589
|
+
" deploy the backend target allowlist, then rerun:",
|
|
590
|
+
" waitspin claude-code install --compose-existing",
|
|
591
|
+
"",
|
|
592
|
+
"For developer-local acceptance only, use an explicit loopback API with --allow-dev-api-base.",
|
|
593
|
+
].join("\n"));
|
|
594
|
+
}
|
|
595
|
+
if (input.target === OPENCODE_PUBLISHER_TARGET &&
|
|
596
|
+
error instanceof WaitSpinCliHttpError &&
|
|
597
|
+
error.status === 400 &&
|
|
598
|
+
/Invalid input|Validation error/.test(error.message)) {
|
|
599
|
+
throw new Error([
|
|
600
|
+
`WaitSpin API rejected target "${OPENCODE_PUBLISHER_TARGET}".`,
|
|
601
|
+
"Your local CLI supports OpenCode, but the selected API base does not.",
|
|
602
|
+
"",
|
|
603
|
+
"For production:",
|
|
604
|
+
" deploy the backend target allowlist, then rerun:",
|
|
605
|
+
` waitspin ${OPENCODE_PUBLISHER_TARGET} install`,
|
|
606
|
+
"",
|
|
607
|
+
"For developer-local acceptance only, use an explicit loopback API with --allow-dev-api-base.",
|
|
608
|
+
].join("\n"));
|
|
609
|
+
}
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
403
612
|
if (!body) {
|
|
404
613
|
throw new Error("Publisher registration returned empty body");
|
|
405
614
|
}
|
|
406
615
|
return body;
|
|
407
616
|
}
|
|
408
|
-
async function resolveExtensionDir() {
|
|
617
|
+
async function resolveExtensionDir(flags) {
|
|
409
618
|
const packageRoot = resolveCliPackageRoot();
|
|
410
619
|
const candidates = [
|
|
411
|
-
path.join(process.cwd(), "packages/waitspin/assets/waitspin-vscode"),
|
|
412
|
-
path.join(process.cwd(), "extensions/waitspin-vscode"),
|
|
413
620
|
path.join(packageRoot, "assets", "waitspin-vscode"),
|
|
414
|
-
path.join(packageRoot, "../../extensions/waitspin-vscode"),
|
|
415
621
|
];
|
|
622
|
+
if (allowDevExtensionAssets(flags)) {
|
|
623
|
+
candidates.push(path.join(packageRoot, "../../extensions/waitspin-vscode"));
|
|
624
|
+
}
|
|
416
625
|
for (const candidate of candidates) {
|
|
626
|
+
const resolved = path.resolve(candidate);
|
|
417
627
|
try {
|
|
418
|
-
await access(path.join(
|
|
419
|
-
return
|
|
628
|
+
await access(path.join(resolved, "package.json"), fsConstants.F_OK);
|
|
629
|
+
return resolved;
|
|
420
630
|
}
|
|
421
631
|
catch {
|
|
422
632
|
continue;
|
|
423
633
|
}
|
|
424
634
|
}
|
|
425
|
-
throw new Error(
|
|
635
|
+
throw new Error(`WaitSpin extension package not found. Use a build that ships assets/waitspin-vscode or set ${DEV_EXTENSION_ASSETS_OPT_IN_ENV}=1 / --allow-dev-extension-assets from a trusted checkout.`);
|
|
636
|
+
}
|
|
637
|
+
function parseVscodeExtensionManifest(value) {
|
|
638
|
+
if (!value || typeof value !== "object") {
|
|
639
|
+
throw new Error("Extension manifest must be a JSON object.");
|
|
640
|
+
}
|
|
641
|
+
const manifest = value;
|
|
642
|
+
const name = typeof manifest.name === "string" ? manifest.name.trim() : "";
|
|
643
|
+
const publisher = typeof manifest.publisher === "string" ? manifest.publisher.trim() : "";
|
|
644
|
+
const version = typeof manifest.version === "string" ? manifest.version.trim() : "";
|
|
645
|
+
if (name !== "waitspin-vscode" ||
|
|
646
|
+
publisher !== "waitspin" ||
|
|
647
|
+
!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
|
|
648
|
+
throw new Error("Unexpected WaitSpin VS Code extension manifest. Refusing to construct install path.");
|
|
649
|
+
}
|
|
650
|
+
return { name, publisher, version };
|
|
426
651
|
}
|
|
427
652
|
function resolveCliPackageRoot() {
|
|
428
653
|
if (!process.argv[1])
|
|
@@ -451,17 +676,18 @@ async function installVscodeExtensionRuntime(input) {
|
|
|
451
676
|
}
|
|
452
677
|
export async function runExtensionInstall(flags) {
|
|
453
678
|
const target = resolveExtensionTarget(flags);
|
|
454
|
-
const baseUrl =
|
|
679
|
+
const baseUrl = resolveCredentialedBaseUrl(flags);
|
|
455
680
|
const publisherTarget = publisherTargetForExtension(target);
|
|
456
|
-
const extensionDir = await resolveExtensionDir();
|
|
681
|
+
const extensionDir = await resolveExtensionDir(flags);
|
|
457
682
|
const manifestPath = path.join(extensionDir, "package.json");
|
|
458
|
-
let
|
|
683
|
+
let manifestJson;
|
|
459
684
|
try {
|
|
460
|
-
|
|
685
|
+
manifestJson = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
461
686
|
}
|
|
462
687
|
catch {
|
|
463
688
|
throw new Error(`Extension package not found at ${extensionDir}.`);
|
|
464
689
|
}
|
|
690
|
+
const manifest = parseVscodeExtensionManifest(manifestJson);
|
|
465
691
|
const installDir = extensionInstallDir(target);
|
|
466
692
|
const statePath = installStatePath(target);
|
|
467
693
|
const existingState = await loadInstallState(statePath);
|
|
@@ -482,10 +708,9 @@ export async function runExtensionInstall(flags) {
|
|
|
482
708
|
set_vscode_settings: {
|
|
483
709
|
"waitspin.installId": installId,
|
|
484
710
|
},
|
|
485
|
-
credential_storage: "The VS Code extension migrates waitspin.apiKey user
|
|
711
|
+
credential_storage: "The VS Code extension migrates a one-time waitspin.apiKey user setting into SecretStorage; runtime polling reads SecretStorage only.",
|
|
486
712
|
optional_bootstrap_env: {
|
|
487
713
|
WAITSPIN_INSTALL_ID: installId,
|
|
488
|
-
WAITSPIN_API_KEY: "<temporary publisher-extension key for first activation>",
|
|
489
714
|
},
|
|
490
715
|
},
|
|
491
716
|
};
|
|
@@ -612,54 +837,2153 @@ export async function runExtensionUninstall(flags) {
|
|
|
612
837
|
install_marker_error: installedPathStatus.error,
|
|
613
838
|
});
|
|
614
839
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
840
|
+
function claudeCodeInstallDir() {
|
|
841
|
+
return path.join(os.homedir(), ".waitspin");
|
|
842
|
+
}
|
|
843
|
+
function claudeCodeStatePath() {
|
|
844
|
+
return path.join(claudeCodeInstallDir(), "claude-code-install.json");
|
|
845
|
+
}
|
|
846
|
+
function claudeCodeRuntimePath() {
|
|
847
|
+
return path.join(claudeCodeInstallDir(), "claude-code-statusline.mjs");
|
|
848
|
+
}
|
|
849
|
+
function claudeCodeCachePath() {
|
|
850
|
+
return path.join(claudeCodeInstallDir(), "claude-code-statusline-cache.json");
|
|
851
|
+
}
|
|
852
|
+
function claudeCodeSettingsPath() {
|
|
853
|
+
return path.join(os.homedir(), ".claude", "settings.json");
|
|
854
|
+
}
|
|
855
|
+
function claudeCodeProjectSettingsCandidates(startDir = process.cwd()) {
|
|
856
|
+
const candidates = [];
|
|
857
|
+
const homeDir = path.resolve(os.homedir());
|
|
858
|
+
let currentDir = path.resolve(startDir);
|
|
859
|
+
while (currentDir !== homeDir) {
|
|
860
|
+
candidates.push({
|
|
861
|
+
scope: "local",
|
|
862
|
+
path: path.join(currentDir, ".claude", "settings.local.json"),
|
|
863
|
+
});
|
|
864
|
+
candidates.push({
|
|
865
|
+
scope: "project",
|
|
866
|
+
path: path.join(currentDir, ".claude", "settings.json"),
|
|
867
|
+
});
|
|
868
|
+
const parentDir = path.dirname(currentDir);
|
|
869
|
+
if (parentDir === currentDir)
|
|
870
|
+
break;
|
|
871
|
+
currentDir = parentDir;
|
|
620
872
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
873
|
+
return candidates;
|
|
874
|
+
}
|
|
875
|
+
function claudeCodeBinary() {
|
|
876
|
+
return process.env[CLAUDE_CODE_BIN_ENV]?.trim() || "claude";
|
|
877
|
+
}
|
|
878
|
+
function execFileText(file, args, options) {
|
|
879
|
+
return new Promise((resolve, reject) => {
|
|
880
|
+
execFile(file, args, options, (error, stdout, stderr) => {
|
|
881
|
+
if (error) {
|
|
882
|
+
reject(error);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
resolve({
|
|
886
|
+
stdout: String(stdout || ""),
|
|
887
|
+
stderr: String(stderr || ""),
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
function shellQuote(value) {
|
|
893
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
894
|
+
}
|
|
895
|
+
function windowsPath(value) {
|
|
896
|
+
return value.replace(/\\/g, "/");
|
|
897
|
+
}
|
|
898
|
+
function powerShellQuote(value) {
|
|
899
|
+
return `'${windowsPath(value).replace(/'/g, "''")}'`;
|
|
900
|
+
}
|
|
901
|
+
function powerShellCommandArgument(value) {
|
|
902
|
+
return `"${value.replace(/[`"$]/g, "`$&")}"`;
|
|
903
|
+
}
|
|
904
|
+
function claudeCodeStatusLineCommand(input) {
|
|
905
|
+
if (process.platform === "win32") {
|
|
906
|
+
const command = [
|
|
907
|
+
"&",
|
|
908
|
+
powerShellQuote(process.execPath),
|
|
909
|
+
powerShellQuote(input.runtimePath),
|
|
910
|
+
"--state",
|
|
911
|
+
powerShellQuote(input.statePath),
|
|
912
|
+
].join(" ");
|
|
913
|
+
return [
|
|
914
|
+
"powershell",
|
|
915
|
+
"-NoProfile",
|
|
916
|
+
"-NonInteractive",
|
|
917
|
+
"-ExecutionPolicy",
|
|
918
|
+
"Bypass",
|
|
919
|
+
"-Command",
|
|
920
|
+
powerShellCommandArgument(command),
|
|
921
|
+
].join(" ");
|
|
624
922
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
923
|
+
return `${shellQuote(process.execPath)} ${shellQuote(input.runtimePath)} --state ${shellQuote(input.statePath)}`;
|
|
924
|
+
}
|
|
925
|
+
function managedClaudeCodeStatusLine(input) {
|
|
926
|
+
return {
|
|
927
|
+
type: "command",
|
|
928
|
+
command: claudeCodeStatusLineCommand(input),
|
|
929
|
+
padding: 2,
|
|
930
|
+
refreshInterval: CLAUDE_CODE_REFRESH_INTERVAL_SECONDS,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
function parseVersionParts(value) {
|
|
934
|
+
const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
935
|
+
if (!match)
|
|
936
|
+
return null;
|
|
937
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
938
|
+
}
|
|
939
|
+
function isVersionAtLeast(value, minimum) {
|
|
940
|
+
const current = parseVersionParts(value);
|
|
941
|
+
const target = parseVersionParts(minimum);
|
|
942
|
+
if (!current || !target)
|
|
943
|
+
return false;
|
|
944
|
+
for (let index = 0; index < target.length; index += 1) {
|
|
945
|
+
if (current[index] > target[index])
|
|
946
|
+
return true;
|
|
947
|
+
if (current[index] < target[index])
|
|
948
|
+
return false;
|
|
628
949
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
async function readClaudeCodeVersion() {
|
|
953
|
+
try {
|
|
954
|
+
const result = await execFileText(claudeCodeBinary(), ["--version"], {
|
|
955
|
+
encoding: "utf8",
|
|
956
|
+
timeout: 5_000,
|
|
957
|
+
});
|
|
958
|
+
return `${result.stdout || result.stderr || ""}`.trim();
|
|
632
959
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
return;
|
|
960
|
+
catch (error) {
|
|
961
|
+
throw new Error(`Unable to run Claude Code. Install Claude Code or set ${CLAUDE_CODE_BIN_ENV} to its executable path before installing WaitSpin Claude Code support.`, { cause: error });
|
|
636
962
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
963
|
+
}
|
|
964
|
+
async function assertSupportedClaudeCodeVersion() {
|
|
965
|
+
const version = await readClaudeCodeVersion();
|
|
966
|
+
if (!isVersionAtLeast(version, CLAUDE_CODE_MIN_VERSION)) {
|
|
967
|
+
throw new Error(`Unsupported Claude Code version: ${version || "unknown"}. WaitSpin Claude Code statusline support requires Claude Code ${CLAUDE_CODE_MIN_VERSION} or newer.`);
|
|
640
968
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
969
|
+
return version;
|
|
970
|
+
}
|
|
971
|
+
function isErrno(error, code) {
|
|
972
|
+
return (typeof error === "object" &&
|
|
973
|
+
error !== null &&
|
|
974
|
+
"code" in error &&
|
|
975
|
+
error.code === code);
|
|
976
|
+
}
|
|
977
|
+
async function readJsonObjectFile(filePath) {
|
|
978
|
+
try {
|
|
979
|
+
const raw = await readFile(filePath, "utf8");
|
|
980
|
+
if (!raw.trim())
|
|
981
|
+
return {};
|
|
982
|
+
const parsed = JSON.parse(raw);
|
|
983
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
984
|
+
throw new Error(`${filePath} must contain a JSON object.`);
|
|
985
|
+
}
|
|
986
|
+
return parsed;
|
|
644
987
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
988
|
+
catch (error) {
|
|
989
|
+
if (isErrno(error, "ENOENT")) {
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
throw error;
|
|
648
993
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
994
|
+
}
|
|
995
|
+
async function writeJsonObjectFile(filePath, value, mode) {
|
|
996
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
997
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
998
|
+
encoding: "utf8",
|
|
999
|
+
mode,
|
|
1000
|
+
});
|
|
1001
|
+
if (mode) {
|
|
1002
|
+
await chmod(filePath, mode);
|
|
652
1003
|
}
|
|
653
|
-
|
|
654
|
-
|
|
1004
|
+
}
|
|
1005
|
+
async function loadClaudeCodeSettings() {
|
|
1006
|
+
const parsed = await readJsonObjectFile(claudeCodeSettingsPath());
|
|
1007
|
+
return (parsed ?? {});
|
|
1008
|
+
}
|
|
1009
|
+
async function findClaudeCodeScopedStatusLine() {
|
|
1010
|
+
for (const candidate of claudeCodeProjectSettingsCandidates()) {
|
|
1011
|
+
const parsed = await readJsonObjectFile(candidate.path);
|
|
1012
|
+
if (parsed &&
|
|
1013
|
+
Object.prototype.hasOwnProperty.call(parsed, "statusLine")) {
|
|
1014
|
+
return {
|
|
1015
|
+
scope: candidate.scope,
|
|
1016
|
+
path: candidate.path,
|
|
1017
|
+
statusLine: parsed.statusLine,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
async function loadClaudeCodeInstallState() {
|
|
1024
|
+
const parsed = await readJsonObjectFile(claudeCodeStatePath());
|
|
1025
|
+
if (!parsed?.install_id || parsed.target !== CLAUDE_CODE_PUBLISHER_TARGET) {
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
return parsed;
|
|
1029
|
+
}
|
|
1030
|
+
function isCommandStatusLine(value) {
|
|
1031
|
+
return (Boolean(value) &&
|
|
1032
|
+
typeof value === "object" &&
|
|
1033
|
+
!Array.isArray(value) &&
|
|
1034
|
+
value.type === "command" &&
|
|
1035
|
+
typeof value.command === "string" &&
|
|
1036
|
+
value.command.trim().length > 0);
|
|
1037
|
+
}
|
|
1038
|
+
function statusLineEquals(left, right) {
|
|
1039
|
+
if (isCommandStatusLine(left) && isCommandStatusLine(right)) {
|
|
1040
|
+
return (left.type === right.type &&
|
|
1041
|
+
left.command === right.command &&
|
|
1042
|
+
left.padding === right.padding &&
|
|
1043
|
+
left.refreshInterval === right.refreshInterval);
|
|
1044
|
+
}
|
|
1045
|
+
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
|
|
1046
|
+
}
|
|
1047
|
+
function claudeCodeScopedStatusLineBlocker(scopedStatusLine, managedStatusLine) {
|
|
1048
|
+
if (!scopedStatusLine)
|
|
1049
|
+
return null;
|
|
1050
|
+
if (statusLineEquals(scopedStatusLine.statusLine, managedStatusLine)) {
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
return `Claude Code ${scopedStatusLine.scope} settings at ${scopedStatusLine.path} define a higher-priority statusLine. User-level WaitSpin install would not be used in this project. Remove or compose that statusLine first, then retry.`;
|
|
1054
|
+
}
|
|
1055
|
+
function resolveClaudeCodeSettingsUpdate(input) {
|
|
1056
|
+
const current = input.settings.statusLine;
|
|
1057
|
+
const isAlreadyManaged = statusLineEquals(current, input.managedStatusLine) ||
|
|
1058
|
+
(input.existingState?.managed_status_line &&
|
|
1059
|
+
statusLineEquals(current, input.existingState.managed_status_line));
|
|
1060
|
+
if (!current || isAlreadyManaged) {
|
|
1061
|
+
return {
|
|
1062
|
+
nextSettings: { ...input.settings, statusLine: input.managedStatusLine },
|
|
1063
|
+
previousStatusLine: input.existingState?.previous_status_line,
|
|
1064
|
+
composedExistingStatusLine: Boolean(input.existingState?.composed_existing_status_line),
|
|
1065
|
+
action: isAlreadyManaged ? "refresh-managed" : "install",
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
if (!input.composeExisting) {
|
|
1069
|
+
throw new Error("Claude Code already has a statusLine configured. Re-run with --compose-existing to preserve it and append the WaitSpin sponsor line, or remove it first.");
|
|
1070
|
+
}
|
|
1071
|
+
if (!isCommandStatusLine(current)) {
|
|
1072
|
+
throw new Error("Claude Code statusLine exists but is not a command status line; refusing to compose because restore would be ambiguous.");
|
|
1073
|
+
}
|
|
1074
|
+
if (current.command === input.managedStatusLine.command) {
|
|
1075
|
+
return {
|
|
1076
|
+
nextSettings: { ...input.settings, statusLine: input.managedStatusLine },
|
|
1077
|
+
composedExistingStatusLine: false,
|
|
1078
|
+
action: "refresh-managed",
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
return {
|
|
1082
|
+
nextSettings: { ...input.settings, statusLine: input.managedStatusLine },
|
|
1083
|
+
previousStatusLine: current,
|
|
1084
|
+
composedExistingStatusLine: true,
|
|
1085
|
+
action: "compose-existing",
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
function redactedClaudeCodeState(state) {
|
|
1089
|
+
if (!state)
|
|
1090
|
+
return null;
|
|
1091
|
+
return {
|
|
1092
|
+
target: state.target,
|
|
1093
|
+
install_id: state.install_id,
|
|
1094
|
+
publisher_id: state.publisher_id,
|
|
1095
|
+
publisher_target: state.publisher_target,
|
|
1096
|
+
registered_at: state.registered_at,
|
|
1097
|
+
base_url: state.base_url,
|
|
1098
|
+
runtime_path: state.runtime_path,
|
|
1099
|
+
cache_path: state.cache_path,
|
|
1100
|
+
settings_path: state.settings_path,
|
|
1101
|
+
composed_existing_status_line: Boolean(state.composed_existing_status_line),
|
|
1102
|
+
has_previous_status_line: state.previous_status_line !== undefined,
|
|
1103
|
+
claude_version: state.claude_version,
|
|
1104
|
+
installed_at: state.installed_at,
|
|
1105
|
+
api_key_present: Boolean(state.api_key),
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
function claudeCodeStatuslineRuntimeSource() {
|
|
1109
|
+
return String.raw `#!/usr/bin/env node
|
|
1110
|
+
import { spawn } from "node:child_process";
|
|
1111
|
+
import {
|
|
1112
|
+
mkdir,
|
|
1113
|
+
readFile,
|
|
1114
|
+
rename,
|
|
1115
|
+
rm,
|
|
1116
|
+
stat,
|
|
1117
|
+
writeFile,
|
|
1118
|
+
} from "node:fs/promises";
|
|
1119
|
+
|
|
1120
|
+
const FETCH_INTERVAL_MS = 15_000;
|
|
1121
|
+
const FETCH_TIMEOUT_MS = 2_500;
|
|
1122
|
+
const PREVIOUS_TIMEOUT_MS = 1_000;
|
|
1123
|
+
const MAX_ACTIVE_AGE_MS = 60_000;
|
|
1124
|
+
const LOCK_RETRY_MS = 40;
|
|
1125
|
+
const LOCK_TIMEOUT_MS = 2_000;
|
|
1126
|
+
const LOCK_STALE_MS = 10_000;
|
|
1127
|
+
|
|
1128
|
+
function argValue(name) {
|
|
1129
|
+
const index = process.argv.indexOf(name);
|
|
1130
|
+
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
async function readStdin() {
|
|
1134
|
+
const chunks = [];
|
|
1135
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
1136
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function readJson(filePath, fallback) {
|
|
1140
|
+
try {
|
|
1141
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
1142
|
+
} catch {
|
|
1143
|
+
return fallback;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async function writeJson(filePath, value) {
|
|
1148
|
+
const tmp = filePath + "." + process.pid + ".tmp";
|
|
1149
|
+
await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", {
|
|
1150
|
+
encoding: "utf8",
|
|
1151
|
+
mode: 0o600,
|
|
1152
|
+
});
|
|
1153
|
+
await rename(tmp, filePath);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function sleep(ms) {
|
|
1157
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async function acquireCacheLock(cachePath) {
|
|
1161
|
+
const lockPath = cachePath + ".lock";
|
|
1162
|
+
const startedAt = Date.now();
|
|
1163
|
+
|
|
1164
|
+
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
1165
|
+
try {
|
|
1166
|
+
await mkdir(lockPath);
|
|
1167
|
+
return async () => {
|
|
1168
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
1169
|
+
};
|
|
1170
|
+
} catch {
|
|
1171
|
+
try {
|
|
1172
|
+
const lockStat = await stat(lockPath);
|
|
1173
|
+
if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
|
|
1174
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
} catch {
|
|
1178
|
+
// Another process may have released the lock between mkdir/stat.
|
|
1179
|
+
}
|
|
1180
|
+
await sleep(LOCK_RETRY_MS);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
throw new Error("Timed out waiting for WaitSpin statusline cache lock.");
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function withCacheLock(cachePath, callback) {
|
|
1188
|
+
const release = await acquireCacheLock(cachePath);
|
|
1189
|
+
try {
|
|
1190
|
+
return await callback();
|
|
1191
|
+
} finally {
|
|
1192
|
+
await release();
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function cleanLine(value) {
|
|
1197
|
+
return String(value || "")
|
|
1198
|
+
.replace(
|
|
1199
|
+
/(?:\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\)|\u001B[P^_][\s\S]*?\u001B\\|\u001B[@-Z\\-_]|\u009B[0-?]*[ -/]*[@-~])/g,
|
|
1200
|
+
" ",
|
|
1201
|
+
)
|
|
1202
|
+
.replace(/[\r\n\u0000-\u001F\u007F-\u009F]/g, " ")
|
|
1203
|
+
.replace(/\s+/g, " ")
|
|
1204
|
+
.trim()
|
|
1205
|
+
.slice(0, 120);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
async function runPreviousStatusLine(command, input) {
|
|
1209
|
+
if (!command) return "";
|
|
1210
|
+
return await new Promise((resolve) => {
|
|
1211
|
+
const child = spawn(command, {
|
|
1212
|
+
shell: true,
|
|
1213
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
1214
|
+
env: process.env,
|
|
1215
|
+
});
|
|
1216
|
+
let stdout = "";
|
|
1217
|
+
let settled = false;
|
|
1218
|
+
let killTimer = null;
|
|
1219
|
+
function finish(value) {
|
|
1220
|
+
if (settled) return;
|
|
1221
|
+
settled = true;
|
|
1222
|
+
clearTimeout(timer);
|
|
1223
|
+
if (killTimer) clearTimeout(killTimer);
|
|
1224
|
+
resolve(value);
|
|
1225
|
+
}
|
|
1226
|
+
const timer = setTimeout(() => {
|
|
1227
|
+
child.stdout.destroy();
|
|
1228
|
+
child.stdin.destroy();
|
|
1229
|
+
child.kill("SIGTERM");
|
|
1230
|
+
killTimer = setTimeout(() => {
|
|
1231
|
+
child.kill("SIGKILL");
|
|
1232
|
+
}, 100);
|
|
1233
|
+
killTimer.unref?.();
|
|
1234
|
+
child.unref?.();
|
|
1235
|
+
finish("");
|
|
1236
|
+
}, PREVIOUS_TIMEOUT_MS);
|
|
1237
|
+
timer.unref?.();
|
|
1238
|
+
child.stdout.on("data", (chunk) => {
|
|
1239
|
+
stdout += chunk.toString("utf8");
|
|
1240
|
+
if (stdout.length > 4000) stdout = stdout.slice(0, 4000);
|
|
1241
|
+
});
|
|
1242
|
+
child.on("error", () => {
|
|
1243
|
+
finish("");
|
|
1244
|
+
});
|
|
1245
|
+
child.on("close", () => {
|
|
1246
|
+
finish(stdout.trimEnd());
|
|
1247
|
+
});
|
|
1248
|
+
child.stdin.end(input);
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
async function waitspinFetch(url, init) {
|
|
1253
|
+
const controller = new AbortController();
|
|
1254
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
1255
|
+
try {
|
|
1256
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
1257
|
+
} finally {
|
|
1258
|
+
clearTimeout(timeout);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function parseServe(payload) {
|
|
1263
|
+
if (!payload || typeof payload !== "object") return null;
|
|
1264
|
+
const creative = payload.creative;
|
|
1265
|
+
if (!creative || typeof creative !== "object") return null;
|
|
1266
|
+
const line = cleanLine(creative.line);
|
|
1267
|
+
if (!line) return null;
|
|
1268
|
+
if (
|
|
1269
|
+
typeof payload.serve_id !== "string" ||
|
|
1270
|
+
typeof payload.serve_receipt !== "string"
|
|
1271
|
+
) {
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
1274
|
+
const parsedExpiresAt = Date.parse(payload.expires_at || "");
|
|
1275
|
+
return {
|
|
1276
|
+
serveId: payload.serve_id,
|
|
1277
|
+
serveReceipt: payload.serve_receipt,
|
|
1278
|
+
line,
|
|
1279
|
+
shownAt: Date.now(),
|
|
1280
|
+
expiresAtMs: Number.isFinite(parsedExpiresAt)
|
|
1281
|
+
? parsedExpiresAt
|
|
1282
|
+
: Date.now() + MAX_ACTIVE_AGE_MS,
|
|
1283
|
+
minVisibleMs:
|
|
1284
|
+
typeof payload.min_visible_ms === "number" && payload.min_visible_ms >= 5000
|
|
1285
|
+
? payload.min_visible_ms
|
|
1286
|
+
: 5000,
|
|
1287
|
+
impressionRecorded: false,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function serveIsExpired(serve) {
|
|
1292
|
+
return (
|
|
1293
|
+
Date.now() >= (serve.expiresAtMs || 0) ||
|
|
1294
|
+
Date.now() - serve.shownAt > MAX_ACTIVE_AGE_MS
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
async function fetchNextServe(state, session) {
|
|
1299
|
+
const response = await waitspinFetch(state.base_url + "/v1/serve/next", {
|
|
1300
|
+
method: "POST",
|
|
1301
|
+
headers: {
|
|
1302
|
+
Authorization: "Bearer " + state.api_key,
|
|
1303
|
+
"Content-Type": "application/json",
|
|
1304
|
+
},
|
|
1305
|
+
body: JSON.stringify({ install_id: state.install_id }),
|
|
1306
|
+
});
|
|
1307
|
+
session.lastFetchAt = Date.now();
|
|
1308
|
+
if (response.status === 204) {
|
|
1309
|
+
session.activeServe = null;
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (!response.ok) return;
|
|
1313
|
+
const parsed = parseServe(await response.json());
|
|
1314
|
+
if (parsed) session.activeServe = parsed;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
async function recordImpression(state, session) {
|
|
1318
|
+
const serve = session.activeServe;
|
|
1319
|
+
if (!serve || serve.impressionRecorded) return;
|
|
1320
|
+
const visibleMs = Date.now() - serve.shownAt;
|
|
1321
|
+
if (visibleMs < serve.minVisibleMs) return;
|
|
1322
|
+
const response = await waitspinFetch(state.base_url + "/v1/events/impression", {
|
|
1323
|
+
method: "POST",
|
|
1324
|
+
headers: {
|
|
1325
|
+
Authorization: "Bearer " + state.api_key,
|
|
1326
|
+
"Content-Type": "application/json",
|
|
1327
|
+
},
|
|
1328
|
+
body: JSON.stringify({
|
|
1329
|
+
serve_id: serve.serveId,
|
|
1330
|
+
serve_receipt: serve.serveReceipt,
|
|
1331
|
+
install_id: state.install_id,
|
|
1332
|
+
visible_ms: Math.max(visibleMs, serve.minVisibleMs),
|
|
1333
|
+
}),
|
|
1334
|
+
});
|
|
1335
|
+
if (response.ok) serve.impressionRecorded = true;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function sessionKey(inputJson) {
|
|
1339
|
+
return (
|
|
1340
|
+
inputJson.session_id ||
|
|
1341
|
+
inputJson.transcript_path ||
|
|
1342
|
+
inputJson.cwd ||
|
|
1343
|
+
"claude-code"
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function pruneSessions(cache) {
|
|
1348
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
1349
|
+
for (const [key, value] of Object.entries(cache.sessions || {})) {
|
|
1350
|
+
if ((value.lastSeenAt || 0) < cutoff) delete cache.sessions[key];
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
async function main() {
|
|
1355
|
+
const statePath = argValue("--state");
|
|
1356
|
+
if (!statePath) return;
|
|
1357
|
+
const stdin = await readStdin();
|
|
1358
|
+
let inputJson = {};
|
|
1359
|
+
try {
|
|
1360
|
+
inputJson = stdin.trim() ? JSON.parse(stdin) : {};
|
|
1361
|
+
} catch {
|
|
1362
|
+
inputJson = {};
|
|
1363
|
+
}
|
|
1364
|
+
const state = await readJson(statePath, null);
|
|
1365
|
+
if (!state?.api_key || !state.install_id || !state.base_url || !state.cache_path) {
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const previous = await runPreviousStatusLine(
|
|
1370
|
+
state.previous_status_line?.type === "command"
|
|
1371
|
+
? state.previous_status_line.command
|
|
1372
|
+
: "",
|
|
1373
|
+
stdin,
|
|
1374
|
+
);
|
|
1375
|
+
let renderedSession = null;
|
|
1376
|
+
|
|
1377
|
+
try {
|
|
1378
|
+
renderedSession = await withCacheLock(state.cache_path, async () => {
|
|
1379
|
+
const cache = await readJson(state.cache_path, { sessions: {} });
|
|
1380
|
+
if (!cache.sessions || typeof cache.sessions !== "object") cache.sessions = {};
|
|
1381
|
+
const key = sessionKey(inputJson);
|
|
1382
|
+
const session = cache.sessions[key] || {};
|
|
1383
|
+
session.lastSeenAt = Date.now();
|
|
1384
|
+
cache.sessions[key] = session;
|
|
1385
|
+
|
|
1386
|
+
if (session.activeServe && serveIsExpired(session.activeServe)) {
|
|
1387
|
+
session.activeServe = null;
|
|
1388
|
+
}
|
|
1389
|
+
await recordImpression(state, session);
|
|
1390
|
+
const shouldFetchNext = !session.activeServe
|
|
1391
|
+
? Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS
|
|
1392
|
+
: session.activeServe.impressionRecorded &&
|
|
1393
|
+
Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS;
|
|
1394
|
+
if (shouldFetchNext) {
|
|
1395
|
+
await fetchNextServe(state, session);
|
|
1396
|
+
}
|
|
1397
|
+
pruneSessions(cache);
|
|
1398
|
+
await writeJson(state.cache_path, cache);
|
|
1399
|
+
return session;
|
|
1400
|
+
});
|
|
1401
|
+
} catch {
|
|
1402
|
+
// Statusline rendering must never interrupt Claude Code.
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const sponsor = renderedSession?.activeServe?.line
|
|
1406
|
+
? "Sponsored: " + renderedSession.activeServe.line
|
|
1407
|
+
: "";
|
|
1408
|
+
const lines = [previous, sponsor].filter(Boolean);
|
|
1409
|
+
if (lines.length > 0) process.stdout.write(lines.join("\n"));
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
main().catch(() => {});
|
|
1413
|
+
`;
|
|
1414
|
+
}
|
|
1415
|
+
async function writeClaudeCodeRuntime(runtimePath) {
|
|
1416
|
+
await mkdir(path.dirname(runtimePath), { recursive: true });
|
|
1417
|
+
await writeFile(runtimePath, claudeCodeStatuslineRuntimeSource(), {
|
|
1418
|
+
encoding: "utf8",
|
|
1419
|
+
mode: 0o755,
|
|
1420
|
+
});
|
|
1421
|
+
await chmod(runtimePath, 0o755);
|
|
1422
|
+
}
|
|
1423
|
+
function assertSafeClaudeCodeManagedPath(filePath) {
|
|
1424
|
+
const installRoot = path.resolve(claudeCodeInstallDir());
|
|
1425
|
+
const resolved = path.resolve(filePath);
|
|
1426
|
+
if (!resolved.startsWith(`${installRoot}${path.sep}`) ||
|
|
1427
|
+
!path.basename(resolved).startsWith("claude-code-")) {
|
|
1428
|
+
throw new Error("Refusing to manage a Claude Code WaitSpin file outside ~/.waitspin.");
|
|
1429
|
+
}
|
|
1430
|
+
return resolved;
|
|
1431
|
+
}
|
|
1432
|
+
export async function runClaudeCodeInstall(flags) {
|
|
1433
|
+
const dryRun = booleanFlag(flags, "dry-run");
|
|
1434
|
+
const baseUrl = resolveCredentialedBaseUrl(flags);
|
|
1435
|
+
const statePath = claudeCodeStatePath();
|
|
1436
|
+
const runtimePath = claudeCodeRuntimePath();
|
|
1437
|
+
const cachePath = claudeCodeCachePath();
|
|
1438
|
+
const settingsPath = claudeCodeSettingsPath();
|
|
1439
|
+
const existingState = await loadClaudeCodeInstallState();
|
|
1440
|
+
const installId = existingState?.install_id || generateInstallId();
|
|
1441
|
+
const managedStatusLine = managedClaudeCodeStatusLine({
|
|
1442
|
+
runtimePath,
|
|
1443
|
+
statePath,
|
|
1444
|
+
});
|
|
1445
|
+
const settings = await loadClaudeCodeSettings();
|
|
1446
|
+
const scopedStatusLine = await findClaudeCodeScopedStatusLine();
|
|
1447
|
+
const scopedStatusLineBlockedReason = claudeCodeScopedStatusLineBlocker(scopedStatusLine, managedStatusLine);
|
|
1448
|
+
let settingsUpdate = null;
|
|
1449
|
+
let settingsBlockedReason = null;
|
|
1450
|
+
try {
|
|
1451
|
+
settingsUpdate = resolveClaudeCodeSettingsUpdate({
|
|
1452
|
+
settings,
|
|
1453
|
+
managedStatusLine,
|
|
1454
|
+
existingState,
|
|
1455
|
+
composeExisting: booleanFlag(flags, "compose-existing"),
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
catch (error) {
|
|
1459
|
+
if (!dryRun)
|
|
1460
|
+
throw error;
|
|
1461
|
+
settingsBlockedReason =
|
|
1462
|
+
error instanceof Error ? error.message : String(error);
|
|
1463
|
+
}
|
|
1464
|
+
if (scopedStatusLineBlockedReason && !dryRun) {
|
|
1465
|
+
throw new Error(scopedStatusLineBlockedReason);
|
|
1466
|
+
}
|
|
1467
|
+
const summary = {
|
|
1468
|
+
ok: true,
|
|
1469
|
+
target: CLAUDE_CODE_PUBLISHER_TARGET,
|
|
1470
|
+
mode: "statusline-command",
|
|
1471
|
+
install_id: installId,
|
|
1472
|
+
publisher_target: CLAUDE_CODE_PUBLISHER_TARGET,
|
|
1473
|
+
state_path: statePath,
|
|
1474
|
+
runtime_path: runtimePath,
|
|
1475
|
+
cache_path: cachePath,
|
|
1476
|
+
settings_path: settingsPath,
|
|
1477
|
+
settings_action: settingsUpdate?.action ?? "blocked",
|
|
1478
|
+
composed_existing_status_line: settingsUpdate?.composedExistingStatusLine ?? false,
|
|
1479
|
+
note: "Installs WaitSpin through Claude Code's official statusLine.command surface.",
|
|
1480
|
+
next: "check_status",
|
|
1481
|
+
next_command: "waitspin claude-code status",
|
|
1482
|
+
};
|
|
1483
|
+
if (dryRun) {
|
|
1484
|
+
const blockedReason = settingsBlockedReason ?? scopedStatusLineBlockedReason;
|
|
1485
|
+
printJson({
|
|
1486
|
+
...summary,
|
|
1487
|
+
dry_run: true,
|
|
1488
|
+
publisher_registered: false,
|
|
1489
|
+
has_existing_status_line: Boolean(settings.statusLine),
|
|
1490
|
+
...(scopedStatusLine
|
|
1491
|
+
? {
|
|
1492
|
+
scoped_status_line: {
|
|
1493
|
+
scope: scopedStatusLine.scope,
|
|
1494
|
+
path: scopedStatusLine.path,
|
|
1495
|
+
overrides_user_settings: Boolean(scopedStatusLineBlockedReason),
|
|
1496
|
+
},
|
|
1497
|
+
}
|
|
1498
|
+
: {}),
|
|
1499
|
+
...(blockedReason
|
|
1500
|
+
? {
|
|
1501
|
+
would_fail: true,
|
|
1502
|
+
settings_blocked_reason: blockedReason,
|
|
1503
|
+
next: settingsBlockedReason
|
|
1504
|
+
? "resolve_status_line_conflict"
|
|
1505
|
+
: "resolve_project_status_line_override",
|
|
1506
|
+
...(settingsBlockedReason
|
|
1507
|
+
? {
|
|
1508
|
+
next_command: "waitspin claude-code install --compose-existing",
|
|
1509
|
+
}
|
|
1510
|
+
: {
|
|
1511
|
+
human_message: "Current project/local Claude Code settings override user-level statusLine. Remove or compose that statusLine before installing WaitSpin for this project.",
|
|
1512
|
+
}),
|
|
1513
|
+
}
|
|
1514
|
+
: {}),
|
|
1515
|
+
});
|
|
655
1516
|
return;
|
|
656
1517
|
}
|
|
657
|
-
if (
|
|
658
|
-
|
|
1518
|
+
if (!settingsUpdate) {
|
|
1519
|
+
throw new Error("Unable to resolve Claude Code settings update.");
|
|
1520
|
+
}
|
|
1521
|
+
const claudeVersion = await assertSupportedClaudeCodeVersion();
|
|
1522
|
+
const apiKey = requireApiKey(flags);
|
|
1523
|
+
const registration = await registerPublisherInstall({
|
|
1524
|
+
baseUrl,
|
|
1525
|
+
apiKey,
|
|
1526
|
+
installId,
|
|
1527
|
+
target: CLAUDE_CODE_PUBLISHER_TARGET,
|
|
1528
|
+
});
|
|
1529
|
+
const installedAt = new Date().toISOString();
|
|
1530
|
+
const installState = {
|
|
1531
|
+
target: CLAUDE_CODE_PUBLISHER_TARGET,
|
|
1532
|
+
install_id: registration.install_id,
|
|
1533
|
+
publisher_id: registration.publisher_id,
|
|
1534
|
+
publisher_target: registration.target,
|
|
1535
|
+
registered_at: installedAt,
|
|
1536
|
+
base_url: baseUrl,
|
|
1537
|
+
api_key: apiKey,
|
|
1538
|
+
runtime_path: runtimePath,
|
|
1539
|
+
cache_path: cachePath,
|
|
1540
|
+
settings_path: settingsPath,
|
|
1541
|
+
managed_status_line: managedStatusLine,
|
|
1542
|
+
previous_status_line: settingsUpdate.previousStatusLine,
|
|
1543
|
+
composed_existing_status_line: settingsUpdate.composedExistingStatusLine,
|
|
1544
|
+
claude_version: claudeVersion,
|
|
1545
|
+
installed_at: installedAt,
|
|
1546
|
+
};
|
|
1547
|
+
try {
|
|
1548
|
+
await writeClaudeCodeRuntime(runtimePath);
|
|
1549
|
+
await writeJsonObjectFile(statePath, installState, 0o600);
|
|
1550
|
+
await writeJsonObjectFile(settingsPath, settingsUpdate.nextSettings);
|
|
1551
|
+
}
|
|
1552
|
+
catch (error) {
|
|
1553
|
+
if (existingState) {
|
|
1554
|
+
await writeJsonObjectFile(statePath, existingState, 0o600).catch(() => { });
|
|
1555
|
+
}
|
|
1556
|
+
else {
|
|
1557
|
+
await Promise.resolve(rm(statePath, { force: true, recursive: true })).catch(() => { });
|
|
1558
|
+
await Promise.resolve(rm(runtimePath, { force: true, recursive: true })).catch(() => { });
|
|
1559
|
+
}
|
|
1560
|
+
throw error;
|
|
1561
|
+
}
|
|
1562
|
+
printJson({
|
|
1563
|
+
...summary,
|
|
1564
|
+
...redactedClaudeCodeState(installState),
|
|
1565
|
+
publisher_registered: true,
|
|
1566
|
+
claude_version: claudeVersion,
|
|
1567
|
+
next: "launch_claude_code",
|
|
1568
|
+
next_command: "claude",
|
|
1569
|
+
acceptance_hint: "Keep the sponsored line visible for at least 5 seconds, then verify an impression.",
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
export async function runClaudeCodeStatus() {
|
|
1573
|
+
const state = await loadClaudeCodeInstallState();
|
|
1574
|
+
const settings = await loadClaudeCodeSettings();
|
|
1575
|
+
const managedStatusLine = state?.managed_status_line ?? null;
|
|
1576
|
+
const currentStatusLine = settings.statusLine;
|
|
1577
|
+
const statusLineConfigured = Boolean(managedStatusLine && statusLineEquals(currentStatusLine, managedStatusLine));
|
|
1578
|
+
const scopedStatusLine = await findClaudeCodeScopedStatusLine();
|
|
1579
|
+
const scopedStatusLineOverridden = Boolean(managedStatusLine &&
|
|
1580
|
+
scopedStatusLine &&
|
|
1581
|
+
!statusLineEquals(scopedStatusLine.statusLine, managedStatusLine));
|
|
1582
|
+
const effectiveStatusLineConfigured = statusLineConfigured && !scopedStatusLineOverridden;
|
|
1583
|
+
const runtimeInstalled = state
|
|
1584
|
+
? await pathExists(assertSafeClaudeCodeManagedPath(state.runtime_path))
|
|
1585
|
+
: false;
|
|
1586
|
+
const installed = Boolean(state && runtimeInstalled && effectiveStatusLineConfigured);
|
|
1587
|
+
printJson({
|
|
1588
|
+
ok: true,
|
|
1589
|
+
target: CLAUDE_CODE_PUBLISHER_TARGET,
|
|
1590
|
+
mode: "statusline-command",
|
|
1591
|
+
installed,
|
|
1592
|
+
publisher_registered: Boolean(state?.publisher_id),
|
|
1593
|
+
install_id: state?.install_id ?? null,
|
|
1594
|
+
publisher_id: state?.publisher_id ?? null,
|
|
1595
|
+
publisher_target: state?.publisher_target ?? CLAUDE_CODE_PUBLISHER_TARGET,
|
|
1596
|
+
state_path: claudeCodeStatePath(),
|
|
1597
|
+
runtime_path: state?.runtime_path ?? claudeCodeRuntimePath(),
|
|
1598
|
+
cache_path: state?.cache_path ?? claudeCodeCachePath(),
|
|
1599
|
+
settings_path: claudeCodeSettingsPath(),
|
|
1600
|
+
runtime_installed: runtimeInstalled,
|
|
1601
|
+
status_line_configured: statusLineConfigured,
|
|
1602
|
+
effective_status_line_configured: effectiveStatusLineConfigured,
|
|
1603
|
+
status_line_overridden: scopedStatusLineOverridden,
|
|
1604
|
+
...(scopedStatusLineOverridden && scopedStatusLine
|
|
1605
|
+
? {
|
|
1606
|
+
status_line_override_scope: scopedStatusLine.scope,
|
|
1607
|
+
status_line_override_path: scopedStatusLine.path,
|
|
1608
|
+
}
|
|
1609
|
+
: {}),
|
|
1610
|
+
composed_existing_status_line: Boolean(state?.composed_existing_status_line),
|
|
1611
|
+
has_previous_status_line: Boolean(state?.previous_status_line),
|
|
1612
|
+
claude_version: state?.claude_version ?? null,
|
|
1613
|
+
...(installed
|
|
1614
|
+
? {
|
|
1615
|
+
next: "launch_claude_code",
|
|
1616
|
+
next_command: "claude",
|
|
1617
|
+
acceptance_hint: "Keep the sponsored line visible for at least 5 seconds, then verify an impression.",
|
|
1618
|
+
}
|
|
1619
|
+
: {
|
|
1620
|
+
next: "install_claude_code",
|
|
1621
|
+
next_command: "waitspin claude-code install --compose-existing",
|
|
1622
|
+
human_message: scopedStatusLineOverridden
|
|
1623
|
+
? "Claude Code WaitSpin support is installed in user settings, but a higher-priority project/local statusLine overrides it in this directory."
|
|
1624
|
+
: "Claude Code WaitSpin statusline support is not installed for this user.",
|
|
1625
|
+
}),
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
export async function runClaudeCodeUninstall(flags) {
|
|
1629
|
+
const dryRun = booleanFlag(flags, "dry-run");
|
|
1630
|
+
const state = await loadClaudeCodeInstallState();
|
|
1631
|
+
const settings = await loadClaudeCodeSettings();
|
|
1632
|
+
const declaredRemovePaths = state
|
|
1633
|
+
? [
|
|
1634
|
+
state.runtime_path,
|
|
1635
|
+
state.cache_path,
|
|
1636
|
+
claudeCodeStatePath(),
|
|
1637
|
+
]
|
|
1638
|
+
: [claudeCodeStatePath()];
|
|
1639
|
+
let nextSettings = null;
|
|
1640
|
+
let settingsAction = "not-managed";
|
|
1641
|
+
let settingsWarning = null;
|
|
1642
|
+
if (state?.managed_status_line) {
|
|
1643
|
+
if (!statusLineEquals(settings.statusLine, state.managed_status_line)) {
|
|
1644
|
+
settingsAction = "skip-user-settings";
|
|
1645
|
+
settingsWarning =
|
|
1646
|
+
"Claude Code statusLine is no longer the WaitSpin managed command; leaving user settings unchanged while removing WaitSpin-managed files.";
|
|
1647
|
+
}
|
|
1648
|
+
else {
|
|
1649
|
+
nextSettings = { ...settings };
|
|
1650
|
+
if (state.previous_status_line !== undefined) {
|
|
1651
|
+
nextSettings.statusLine = state.previous_status_line;
|
|
1652
|
+
settingsAction = "restore-previous";
|
|
1653
|
+
}
|
|
1654
|
+
else {
|
|
1655
|
+
delete nextSettings.statusLine;
|
|
1656
|
+
settingsAction = "remove-managed";
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
if (dryRun) {
|
|
1661
|
+
printJson({
|
|
1662
|
+
ok: true,
|
|
1663
|
+
target: CLAUDE_CODE_PUBLISHER_TARGET,
|
|
1664
|
+
dry_run: true,
|
|
1665
|
+
installed: Boolean(state),
|
|
1666
|
+
settings_action: settingsAction,
|
|
1667
|
+
would_remove: declaredRemovePaths,
|
|
1668
|
+
path_validation: state ? "deferred_until_apply" : "not_needed",
|
|
1669
|
+
...(settingsWarning
|
|
1670
|
+
? {
|
|
1671
|
+
settings_warning: settingsWarning,
|
|
1672
|
+
}
|
|
1673
|
+
: {}),
|
|
1674
|
+
});
|
|
659
1675
|
return;
|
|
660
1676
|
}
|
|
661
|
-
|
|
662
|
-
|
|
1677
|
+
const removePaths = state
|
|
1678
|
+
? [
|
|
1679
|
+
assertSafeClaudeCodeManagedPath(state.runtime_path),
|
|
1680
|
+
assertSafeClaudeCodeManagedPath(state.cache_path),
|
|
1681
|
+
claudeCodeStatePath(),
|
|
1682
|
+
]
|
|
1683
|
+
: [claudeCodeStatePath()];
|
|
1684
|
+
if (nextSettings) {
|
|
1685
|
+
await writeJsonObjectFile(claudeCodeSettingsPath(), nextSettings);
|
|
1686
|
+
}
|
|
1687
|
+
await Promise.all(removePaths.map((filePath) => rm(filePath, { force: true, recursive: true })));
|
|
1688
|
+
printJson({
|
|
1689
|
+
ok: true,
|
|
1690
|
+
target: CLAUDE_CODE_PUBLISHER_TARGET,
|
|
1691
|
+
uninstalled: true,
|
|
1692
|
+
settings_action: settingsAction,
|
|
1693
|
+
removed: removePaths,
|
|
1694
|
+
...(settingsWarning ? { settings_warning: settingsWarning } : {}),
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
// --- MiMo Code Publisher Support ---
|
|
1698
|
+
const MIMOCODE_PUBLISHER_TARGET = "mimocode";
|
|
1699
|
+
function miMoCodeInstallDir() {
|
|
1700
|
+
return path.join(os.homedir(), ".waitspin");
|
|
1701
|
+
}
|
|
1702
|
+
function miMoCodeStatePath() {
|
|
1703
|
+
return path.join(miMoCodeInstallDir(), "mimocode-statusline.json");
|
|
1704
|
+
}
|
|
1705
|
+
function miMoCodeCachePath() {
|
|
1706
|
+
return path.join(miMoCodeInstallDir(), "mimocode-statusline-cache.json");
|
|
1707
|
+
}
|
|
1708
|
+
function miMoCodeRuntimePath() {
|
|
1709
|
+
return path.join(os.homedir(), ".local", "bin", "waitspin-mimocode-runtime");
|
|
1710
|
+
}
|
|
1711
|
+
function miMoCodeBashrcPath() {
|
|
1712
|
+
return path.join(os.homedir(), ".bashrc");
|
|
1713
|
+
}
|
|
1714
|
+
const MIMOCODE_BASHRC_MARKER = "# WaitSpin MiMo Code statusline hook";
|
|
1715
|
+
const MIMOCODE_BASHRC_END_MARKER = "# End WaitSpin MiMo Code statusline hook";
|
|
1716
|
+
function miMoCodeStatuslineRuntimeSource() {
|
|
1717
|
+
return String.raw `#!/usr/bin/env node
|
|
1718
|
+
import {
|
|
1719
|
+
mkdir,
|
|
1720
|
+
readFile,
|
|
1721
|
+
rename,
|
|
1722
|
+
rm,
|
|
1723
|
+
stat,
|
|
1724
|
+
writeFile,
|
|
1725
|
+
} from "node:fs/promises";
|
|
1726
|
+
import os from "node:os";
|
|
1727
|
+
import path from "node:path";
|
|
1728
|
+
|
|
1729
|
+
const FETCH_INTERVAL_MS = 15_000;
|
|
1730
|
+
const FETCH_TIMEOUT_MS = 2_500;
|
|
1731
|
+
const MAX_ACTIVE_AGE_MS = 60_000;
|
|
1732
|
+
const LOCK_RETRY_MS = 40;
|
|
1733
|
+
const LOCK_TIMEOUT_MS = 2_000;
|
|
1734
|
+
const LOCK_STALE_MS = 10_000;
|
|
1735
|
+
|
|
1736
|
+
const STATE_DIR = path.join(os.homedir(), ".waitspin");
|
|
1737
|
+
const STATE_FILE = path.join(STATE_DIR, "mimocode-statusline.json");
|
|
1738
|
+
const DEFAULT_CACHE_FILE = path.join(STATE_DIR, "mimocode-statusline-cache.json");
|
|
1739
|
+
|
|
1740
|
+
async function readJson(filePath, fallback) {
|
|
1741
|
+
try {
|
|
1742
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
1743
|
+
} catch {
|
|
1744
|
+
return fallback;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
async function writeJson(filePath, value) {
|
|
1749
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
1750
|
+
const tmp = filePath + "." + process.pid + ".tmp";
|
|
1751
|
+
await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", {
|
|
1752
|
+
encoding: "utf8",
|
|
1753
|
+
mode: 0o600,
|
|
1754
|
+
});
|
|
1755
|
+
await rename(tmp, filePath);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function sleep(ms) {
|
|
1759
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
async function acquireCacheLock(cachePath) {
|
|
1763
|
+
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
1764
|
+
const lockPath = cachePath + ".lock";
|
|
1765
|
+
const startedAt = Date.now();
|
|
1766
|
+
|
|
1767
|
+
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
1768
|
+
try {
|
|
1769
|
+
await mkdir(lockPath);
|
|
1770
|
+
return async () => {
|
|
1771
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
1772
|
+
};
|
|
1773
|
+
} catch {
|
|
1774
|
+
try {
|
|
1775
|
+
const lockStat = await stat(lockPath);
|
|
1776
|
+
if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
|
|
1777
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
} catch {
|
|
1781
|
+
// Another process may have released the lock between mkdir/stat.
|
|
1782
|
+
}
|
|
1783
|
+
await sleep(LOCK_RETRY_MS);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
throw new Error("Timed out waiting for WaitSpin MiMo cache lock.");
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
async function withCacheLock(cachePath, callback) {
|
|
1791
|
+
const release = await acquireCacheLock(cachePath);
|
|
1792
|
+
try {
|
|
1793
|
+
return await callback();
|
|
1794
|
+
} finally {
|
|
1795
|
+
await release();
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function cleanLine(value) {
|
|
1800
|
+
return String(value || "")
|
|
1801
|
+
.replace(
|
|
1802
|
+
/(?:\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\)|\u001B[P^_][\s\S]*?\u001B\\|\u001B[@-Z\\-_]|\u009B[0-?]*[ -/]*[@-~])/g,
|
|
1803
|
+
" ",
|
|
1804
|
+
)
|
|
1805
|
+
.replace(/[\r\n\u0000-\u001F\u007F-\u009F]/g, " ")
|
|
1806
|
+
.replace(/\s+/g, " ")
|
|
1807
|
+
.trim()
|
|
1808
|
+
.slice(0, 120);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
async function waitspinFetch(url, init) {
|
|
1812
|
+
const controller = new AbortController();
|
|
1813
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
1814
|
+
try {
|
|
1815
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
1816
|
+
} finally {
|
|
1817
|
+
clearTimeout(timeout);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function parseServe(payload) {
|
|
1822
|
+
if (!payload || typeof payload !== "object") return null;
|
|
1823
|
+
const creative = payload.creative;
|
|
1824
|
+
if (!creative || typeof creative !== "object") return null;
|
|
1825
|
+
const line = cleanLine(creative.line);
|
|
1826
|
+
if (!line) return null;
|
|
1827
|
+
if (
|
|
1828
|
+
typeof payload.serve_id !== "string" ||
|
|
1829
|
+
typeof payload.serve_receipt !== "string"
|
|
1830
|
+
) {
|
|
1831
|
+
return null;
|
|
1832
|
+
}
|
|
1833
|
+
const parsedExpiresAt = Date.parse(payload.expires_at || "");
|
|
1834
|
+
return {
|
|
1835
|
+
serveId: payload.serve_id,
|
|
1836
|
+
serveReceipt: payload.serve_receipt,
|
|
1837
|
+
line,
|
|
1838
|
+
shownAt: Date.now(),
|
|
1839
|
+
expiresAtMs: Number.isFinite(parsedExpiresAt)
|
|
1840
|
+
? parsedExpiresAt
|
|
1841
|
+
: Date.now() + MAX_ACTIVE_AGE_MS,
|
|
1842
|
+
minVisibleMs:
|
|
1843
|
+
typeof payload.min_visible_ms === "number" && payload.min_visible_ms >= 5000
|
|
1844
|
+
? payload.min_visible_ms
|
|
1845
|
+
: 5000,
|
|
1846
|
+
impressionRecorded: false,
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function serveIsExpired(serve) {
|
|
1851
|
+
return (
|
|
1852
|
+
Date.now() >= (serve.expiresAtMs || 0) ||
|
|
1853
|
+
Date.now() - serve.shownAt > MAX_ACTIVE_AGE_MS
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
async function fetchNextServe(state, cache) {
|
|
1858
|
+
const response = await waitspinFetch(state.base_url + "/v1/serve/next", {
|
|
1859
|
+
method: "POST",
|
|
1860
|
+
headers: {
|
|
1861
|
+
Authorization: "Bearer " + state.api_key,
|
|
1862
|
+
"Content-Type": "application/json",
|
|
1863
|
+
},
|
|
1864
|
+
body: JSON.stringify({ install_id: state.install_id }),
|
|
1865
|
+
});
|
|
1866
|
+
cache.lastFetchAt = Date.now();
|
|
1867
|
+
if (response.status === 204) {
|
|
1868
|
+
cache.activeServe = null;
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
if (!response.ok) return;
|
|
1872
|
+
const parsed = parseServe(await response.json());
|
|
1873
|
+
if (parsed) cache.activeServe = parsed;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
async function recordImpression(state, cache) {
|
|
1877
|
+
const serve = cache.activeServe;
|
|
1878
|
+
if (!serve || serve.impressionRecorded) return;
|
|
1879
|
+
const visibleMs = Date.now() - serve.shownAt;
|
|
1880
|
+
if (visibleMs < serve.minVisibleMs) return;
|
|
1881
|
+
const response = await waitspinFetch(state.base_url + "/v1/events/impression", {
|
|
1882
|
+
method: "POST",
|
|
1883
|
+
headers: {
|
|
1884
|
+
Authorization: "Bearer " + state.api_key,
|
|
1885
|
+
"Content-Type": "application/json",
|
|
1886
|
+
},
|
|
1887
|
+
body: JSON.stringify({
|
|
1888
|
+
serve_id: serve.serveId,
|
|
1889
|
+
serve_receipt: serve.serveReceipt,
|
|
1890
|
+
install_id: state.install_id,
|
|
1891
|
+
visible_ms: Math.max(visibleMs, serve.minVisibleMs),
|
|
1892
|
+
}),
|
|
1893
|
+
});
|
|
1894
|
+
if (response.ok) {
|
|
1895
|
+
serve.impressionRecorded = true;
|
|
1896
|
+
} else if ([400, 404, 409, 410].includes(response.status)) {
|
|
1897
|
+
cache.activeServe = null;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
async function renderSponsorLine(state) {
|
|
1902
|
+
const cachePath = state.cache_path || DEFAULT_CACHE_FILE;
|
|
1903
|
+
return await withCacheLock(cachePath, async () => {
|
|
1904
|
+
const cache = await readJson(cachePath, {});
|
|
1905
|
+
if (cache.activeServe && serveIsExpired(cache.activeServe)) {
|
|
1906
|
+
cache.activeServe = null;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
await recordImpression(state, cache);
|
|
1910
|
+
|
|
1911
|
+
const shouldFetchNext = !cache.activeServe
|
|
1912
|
+
? Date.now() - (cache.lastFetchAt || 0) >= FETCH_INTERVAL_MS
|
|
1913
|
+
: cache.activeServe.impressionRecorded &&
|
|
1914
|
+
Date.now() - (cache.lastFetchAt || 0) >= FETCH_INTERVAL_MS;
|
|
1915
|
+
if (shouldFetchNext) {
|
|
1916
|
+
await fetchNextServe(state, cache);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
await writeJson(cachePath, cache);
|
|
1920
|
+
return cache.activeServe?.line || "";
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
async function main() {
|
|
1925
|
+
const state = await readJson(STATE_FILE, null);
|
|
1926
|
+
if (!state?.api_key || !state.install_id || !state.base_url) return;
|
|
1927
|
+
|
|
1928
|
+
try {
|
|
1929
|
+
const line = await renderSponsorLine(state);
|
|
1930
|
+
if (line) process.stdout.write(line);
|
|
1931
|
+
} catch {
|
|
1932
|
+
// Prompt hooks must never interrupt the user's shell.
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
main().catch(() => {});
|
|
1937
|
+
`;
|
|
1938
|
+
}
|
|
1939
|
+
function miMoCodeBashHook() {
|
|
1940
|
+
const D = "$";
|
|
1941
|
+
return `${MIMOCODE_BASHRC_MARKER}
|
|
1942
|
+
__waitspin_statusline() {
|
|
1943
|
+
local line
|
|
1944
|
+
line=$("$HOME/.local/bin/waitspin-mimocode-runtime" 2>/dev/null)
|
|
1945
|
+
if [ -n "$line" ]; then
|
|
1946
|
+
printf "\\x1b[38;5;245m──\\x1b[0m \\x1b[38;5;220m%s\\x1b[0m \\x1b[38;5;245m──\\x1b[0m" "$line"
|
|
1947
|
+
fi
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
__waitspin_prompt_command() {
|
|
1951
|
+
local waitspin_previous_status=$?
|
|
1952
|
+
if [ -n "${D}{__WAITSPIN_PREVIOUS_PROMPT_COMMAND:-}" ]; then
|
|
1953
|
+
eval "$__WAITSPIN_PREVIOUS_PROMPT_COMMAND"
|
|
1954
|
+
fi
|
|
1955
|
+
__waitspin_statusline
|
|
1956
|
+
return ${D}waitspin_previous_status
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
if [ -z "${D}{__WAITSPIN_PROMPT_INSTALLED:-}" ]; then
|
|
1960
|
+
__WAITSPIN_PREVIOUS_PROMPT_COMMAND="${D}{PROMPT_COMMAND:-}"
|
|
1961
|
+
PROMPT_COMMAND="__waitspin_prompt_command"
|
|
1962
|
+
__WAITSPIN_PROMPT_INSTALLED=1
|
|
1963
|
+
fi
|
|
1964
|
+
${MIMOCODE_BASHRC_END_MARKER}`;
|
|
1965
|
+
}
|
|
1966
|
+
function readJsonObjectFileSafe(filePath) {
|
|
1967
|
+
return readJsonObjectFile(filePath);
|
|
1968
|
+
}
|
|
1969
|
+
async function loadMiMoCodeInstallState() {
|
|
1970
|
+
const parsed = await readJsonObjectFileSafe(miMoCodeStatePath());
|
|
1971
|
+
if (!parsed?.install_id || parsed.target !== MIMOCODE_PUBLISHER_TARGET) {
|
|
1972
|
+
return null;
|
|
1973
|
+
}
|
|
1974
|
+
return parsed;
|
|
1975
|
+
}
|
|
1976
|
+
function redactedMiMoCodeState(state) {
|
|
1977
|
+
if (!state)
|
|
1978
|
+
return null;
|
|
1979
|
+
return {
|
|
1980
|
+
target: state.target,
|
|
1981
|
+
install_id: state.install_id,
|
|
1982
|
+
publisher_id: state.publisher_id,
|
|
1983
|
+
publisher_target: state.publisher_target,
|
|
1984
|
+
registered_at: state.registered_at,
|
|
1985
|
+
base_url: state.base_url,
|
|
1986
|
+
runtime_path: state.runtime_path,
|
|
1987
|
+
cache_path: state.cache_path,
|
|
1988
|
+
state_path: state.state_path,
|
|
1989
|
+
bashrc_path: state.bashrc_path,
|
|
1990
|
+
installed_at: state.installed_at,
|
|
1991
|
+
api_key_present: Boolean(state.api_key),
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
async function writeMiMoCodeRuntime(runtimePath) {
|
|
1995
|
+
await mkdir(path.dirname(runtimePath), { recursive: true });
|
|
1996
|
+
await writeFile(runtimePath, miMoCodeStatuslineRuntimeSource(), {
|
|
1997
|
+
encoding: "utf8",
|
|
1998
|
+
mode: 0o755,
|
|
1999
|
+
});
|
|
2000
|
+
await chmod(runtimePath, 0o755);
|
|
2001
|
+
}
|
|
2002
|
+
async function writeTextFileAtomic(filePath, value) {
|
|
2003
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
2004
|
+
const tmpPath = `${filePath}.waitspin-${process.pid}.tmp`;
|
|
2005
|
+
await writeFile(tmpPath, value, { encoding: "utf8" });
|
|
2006
|
+
await rename(tmpPath, filePath);
|
|
2007
|
+
}
|
|
2008
|
+
async function addMiMoCodeBashHook(bashrcPath) {
|
|
2009
|
+
try {
|
|
2010
|
+
const content = await readFile(bashrcPath, "utf8").catch(() => "");
|
|
2011
|
+
if (content.includes(MIMOCODE_BASHRC_MARKER)) {
|
|
2012
|
+
return false;
|
|
2013
|
+
}
|
|
2014
|
+
await writeTextFileAtomic(bashrcPath, `${content}${content.endsWith("\n") || !content ? "" : "\n"}${miMoCodeBashHook()}\n`);
|
|
2015
|
+
return true;
|
|
2016
|
+
}
|
|
2017
|
+
catch {
|
|
2018
|
+
return false;
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
async function removeMiMoCodeBashHook(bashrcPath) {
|
|
2022
|
+
try {
|
|
2023
|
+
const content = await readFile(bashrcPath, "utf8");
|
|
2024
|
+
if (!content.includes(MIMOCODE_BASHRC_MARKER)) {
|
|
2025
|
+
return false;
|
|
2026
|
+
}
|
|
2027
|
+
const markerIndex = content.indexOf(MIMOCODE_BASHRC_MARKER);
|
|
2028
|
+
const afterMarker = content.slice(markerIndex);
|
|
2029
|
+
const markerEnd = afterMarker.indexOf(MIMOCODE_BASHRC_END_MARKER);
|
|
2030
|
+
const before = content.slice(0, markerIndex);
|
|
2031
|
+
let after;
|
|
2032
|
+
if (markerEnd >= 0) {
|
|
2033
|
+
after = afterMarker.slice(markerEnd + MIMOCODE_BASHRC_END_MARKER.length);
|
|
2034
|
+
}
|
|
2035
|
+
else {
|
|
2036
|
+
const hookEnd = afterMarker.indexOf("\n}\n");
|
|
2037
|
+
if (hookEnd === -1) {
|
|
2038
|
+
return false;
|
|
2039
|
+
}
|
|
2040
|
+
after = afterMarker.slice(hookEnd + 3);
|
|
2041
|
+
}
|
|
2042
|
+
await writeTextFileAtomic(bashrcPath, before + after);
|
|
2043
|
+
return true;
|
|
2044
|
+
}
|
|
2045
|
+
catch {
|
|
2046
|
+
return false;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
export async function runMiMoCodeInstall(flags) {
|
|
2050
|
+
const dryRun = booleanFlag(flags, "dry-run");
|
|
2051
|
+
const baseUrl = resolveCredentialedBaseUrl(flags);
|
|
2052
|
+
const statePath = miMoCodeStatePath();
|
|
2053
|
+
const runtimePath = miMoCodeRuntimePath();
|
|
2054
|
+
const cachePath = miMoCodeCachePath();
|
|
2055
|
+
const bashrcPath = miMoCodeBashrcPath();
|
|
2056
|
+
const existingState = await loadMiMoCodeInstallState();
|
|
2057
|
+
const installId = existingState?.install_id || generateInstallId();
|
|
2058
|
+
const summary = {
|
|
2059
|
+
ok: true,
|
|
2060
|
+
target: MIMOCODE_PUBLISHER_TARGET,
|
|
2061
|
+
mode: "shell-hook",
|
|
2062
|
+
install_id: installId,
|
|
2063
|
+
publisher_target: MIMOCODE_PUBLISHER_TARGET,
|
|
2064
|
+
state_path: statePath,
|
|
2065
|
+
runtime_path: runtimePath,
|
|
2066
|
+
cache_path: cachePath,
|
|
2067
|
+
bashrc_path: bashrcPath,
|
|
2068
|
+
note: "Installs WaitSpin through a bash hook that polls the API for sponsored messages.",
|
|
2069
|
+
next: "check_status",
|
|
2070
|
+
next_command: "waitspin mimocode status",
|
|
2071
|
+
};
|
|
2072
|
+
if (dryRun) {
|
|
2073
|
+
const hookExists = await readFile(bashrcPath, "utf8")
|
|
2074
|
+
.then((c) => c.includes(MIMOCODE_BASHRC_MARKER))
|
|
2075
|
+
.catch(() => false);
|
|
2076
|
+
printJson({
|
|
2077
|
+
...summary,
|
|
2078
|
+
dry_run: true,
|
|
2079
|
+
publisher_registered: false,
|
|
2080
|
+
bashrc_hook_exists: hookExists,
|
|
2081
|
+
would_write: [statePath, runtimePath],
|
|
2082
|
+
});
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
const apiKey = requireApiKey(flags);
|
|
2086
|
+
const registration = await registerPublisherInstall({
|
|
2087
|
+
baseUrl,
|
|
2088
|
+
apiKey,
|
|
2089
|
+
installId,
|
|
2090
|
+
target: MIMOCODE_PUBLISHER_TARGET,
|
|
2091
|
+
});
|
|
2092
|
+
const installedAt = new Date().toISOString();
|
|
2093
|
+
const installState = {
|
|
2094
|
+
target: MIMOCODE_PUBLISHER_TARGET,
|
|
2095
|
+
install_id: registration.install_id,
|
|
2096
|
+
publisher_id: registration.publisher_id,
|
|
2097
|
+
publisher_target: registration.target,
|
|
2098
|
+
registered_at: installedAt,
|
|
2099
|
+
base_url: baseUrl,
|
|
2100
|
+
api_key: apiKey,
|
|
2101
|
+
runtime_path: runtimePath,
|
|
2102
|
+
cache_path: cachePath,
|
|
2103
|
+
state_path: statePath,
|
|
2104
|
+
bashrc_path: bashrcPath,
|
|
2105
|
+
installed_at: installedAt,
|
|
2106
|
+
};
|
|
2107
|
+
try {
|
|
2108
|
+
await writeMiMoCodeRuntime(runtimePath);
|
|
2109
|
+
await writeJsonObjectFile(statePath, installState, 0o600);
|
|
2110
|
+
await addMiMoCodeBashHook(bashrcPath);
|
|
2111
|
+
}
|
|
2112
|
+
catch (error) {
|
|
2113
|
+
if (existingState) {
|
|
2114
|
+
await writeJsonObjectFile(statePath, existingState, 0o600).catch(() => { });
|
|
2115
|
+
}
|
|
2116
|
+
else {
|
|
2117
|
+
await Promise.resolve(rm(statePath, { force: true, recursive: true })).catch(() => { });
|
|
2118
|
+
await Promise.resolve(rm(runtimePath, { force: true, recursive: true })).catch(() => { });
|
|
2119
|
+
}
|
|
2120
|
+
throw error;
|
|
2121
|
+
}
|
|
2122
|
+
printJson({
|
|
2123
|
+
...summary,
|
|
2124
|
+
...redactedMiMoCodeState(installState),
|
|
2125
|
+
publisher_registered: true,
|
|
2126
|
+
next: "test_runtime",
|
|
2127
|
+
next_command: "waitspin-mimocode-runtime",
|
|
2128
|
+
acceptance_hint: "Run 'source ~/.bashrc' or restart your shell, then keep the sponsored line visible for at least 5 seconds.",
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
export async function runMiMoCodeStatus() {
|
|
2132
|
+
const state = await loadMiMoCodeInstallState();
|
|
2133
|
+
const runtimePath = state?.runtime_path ?? miMoCodeRuntimePath();
|
|
2134
|
+
const cachePath = state?.cache_path ?? miMoCodeCachePath();
|
|
2135
|
+
const bashrcPath = state?.bashrc_path ?? miMoCodeBashrcPath();
|
|
2136
|
+
const runtimeInstalled = await pathExists(runtimePath);
|
|
2137
|
+
const hookExists = await readFile(bashrcPath, "utf8")
|
|
2138
|
+
.then((c) => c.includes(MIMOCODE_BASHRC_MARKER))
|
|
2139
|
+
.catch(() => false);
|
|
2140
|
+
const installed = Boolean(state && runtimeInstalled);
|
|
2141
|
+
printJson({
|
|
2142
|
+
ok: true,
|
|
2143
|
+
target: MIMOCODE_PUBLISHER_TARGET,
|
|
2144
|
+
mode: "shell-hook",
|
|
2145
|
+
installed,
|
|
2146
|
+
publisher_registered: Boolean(state?.publisher_id),
|
|
2147
|
+
install_id: state?.install_id ?? null,
|
|
2148
|
+
publisher_id: state?.publisher_id ?? null,
|
|
2149
|
+
publisher_target: state?.publisher_target ?? MIMOCODE_PUBLISHER_TARGET,
|
|
2150
|
+
state_path: miMoCodeStatePath(),
|
|
2151
|
+
runtime_path: runtimePath,
|
|
2152
|
+
cache_path: cachePath,
|
|
2153
|
+
bashrc_path: bashrcPath,
|
|
2154
|
+
runtime_installed: runtimeInstalled,
|
|
2155
|
+
bashrc_hook: hookExists,
|
|
2156
|
+
installed_at: state?.installed_at ?? null,
|
|
2157
|
+
...(installed
|
|
2158
|
+
? {
|
|
2159
|
+
next: "test_runtime",
|
|
2160
|
+
next_command: "waitspin-mimocode-runtime",
|
|
2161
|
+
acceptance_hint: "Run 'source ~/.bashrc' or restart your shell, then keep the sponsored line visible for at least 5 seconds.",
|
|
2162
|
+
}
|
|
2163
|
+
: {
|
|
2164
|
+
next: "install_mimocode",
|
|
2165
|
+
next_command: "waitspin mimocode install",
|
|
2166
|
+
}),
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
export async function runMiMoCodeUninstall(flags) {
|
|
2170
|
+
const dryRun = booleanFlag(flags, "dry-run");
|
|
2171
|
+
const state = await loadMiMoCodeInstallState();
|
|
2172
|
+
const bashrcPath = state?.bashrc_path ?? miMoCodeBashrcPath();
|
|
2173
|
+
const runtimePath = state?.runtime_path ?? miMoCodeRuntimePath();
|
|
2174
|
+
const cachePath = state?.cache_path ?? miMoCodeCachePath();
|
|
2175
|
+
const declaredRemovePaths = [
|
|
2176
|
+
runtimePath,
|
|
2177
|
+
miMoCodeStatePath(),
|
|
2178
|
+
cachePath,
|
|
2179
|
+
];
|
|
2180
|
+
if (dryRun) {
|
|
2181
|
+
printJson({
|
|
2182
|
+
ok: true,
|
|
2183
|
+
target: MIMOCODE_PUBLISHER_TARGET,
|
|
2184
|
+
dry_run: true,
|
|
2185
|
+
installed: Boolean(state),
|
|
2186
|
+
would_remove: declaredRemovePaths,
|
|
2187
|
+
bashrc_hook: await readFile(bashrcPath, "utf8")
|
|
2188
|
+
.then((c) => c.includes(MIMOCODE_BASHRC_MARKER))
|
|
2189
|
+
.catch(() => false),
|
|
2190
|
+
});
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
const removePaths = [miMoCodeStatePath()];
|
|
2194
|
+
const skippedUnsafePaths = [];
|
|
2195
|
+
if (state) {
|
|
2196
|
+
try {
|
|
2197
|
+
removePaths.unshift(assertSafeMiMoCodeRuntimePath(state.runtime_path));
|
|
2198
|
+
}
|
|
2199
|
+
catch {
|
|
2200
|
+
skippedUnsafePaths.push(state.runtime_path);
|
|
2201
|
+
}
|
|
2202
|
+
try {
|
|
2203
|
+
removePaths.push(assertSafeMiMoCodeCachePath(cachePath));
|
|
2204
|
+
}
|
|
2205
|
+
catch {
|
|
2206
|
+
skippedUnsafePaths.push(cachePath);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
else {
|
|
2210
|
+
removePaths.push(assertSafeMiMoCodeCachePath(cachePath));
|
|
2211
|
+
}
|
|
2212
|
+
await removeMiMoCodeBashHook(bashrcPath);
|
|
2213
|
+
await Promise.all(removePaths.map((filePath) => rm(filePath, { force: true, recursive: true })));
|
|
2214
|
+
printJson({
|
|
2215
|
+
ok: true,
|
|
2216
|
+
target: MIMOCODE_PUBLISHER_TARGET,
|
|
2217
|
+
uninstalled: true,
|
|
2218
|
+
removed: removePaths,
|
|
2219
|
+
bashrc_hook_removed: true,
|
|
2220
|
+
...(skippedUnsafePaths.length > 0
|
|
2221
|
+
? { skipped_unsafe_paths: skippedUnsafePaths }
|
|
2222
|
+
: {}),
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
function assertSafeMiMoCodeRuntimePath(filePath) {
|
|
2226
|
+
const expected = path.resolve(miMoCodeRuntimePath());
|
|
2227
|
+
const resolved = path.resolve(filePath);
|
|
2228
|
+
if (resolved !== expected) {
|
|
2229
|
+
throw new Error("Refusing to manage a MiMo Code runtime path that is not the WaitSpin-managed executable.");
|
|
2230
|
+
}
|
|
2231
|
+
return expected;
|
|
2232
|
+
}
|
|
2233
|
+
function assertSafeMiMoCodeCachePath(filePath) {
|
|
2234
|
+
const expected = path.resolve(miMoCodeCachePath());
|
|
2235
|
+
const resolved = path.resolve(filePath);
|
|
2236
|
+
if (resolved !== expected) {
|
|
2237
|
+
throw new Error("Refusing to manage a MiMo Code cache path that is not the WaitSpin-managed cache.");
|
|
2238
|
+
}
|
|
2239
|
+
return expected;
|
|
2240
|
+
}
|
|
2241
|
+
const OPENCODE_TUI_PLUGIN_ENTRY = "./plugins/waitspin-opencode.plugin.tsx";
|
|
2242
|
+
function opencodeInstallDir() {
|
|
2243
|
+
return path.join(os.homedir(), ".waitspin");
|
|
2244
|
+
}
|
|
2245
|
+
function opencodeConfigDir() {
|
|
2246
|
+
return path.join(os.homedir(), ".config", "opencode");
|
|
2247
|
+
}
|
|
2248
|
+
function opencodeStatePath() {
|
|
2249
|
+
return path.join(opencodeInstallDir(), "opencode-install.json");
|
|
2250
|
+
}
|
|
2251
|
+
function opencodeRuntimePath() {
|
|
2252
|
+
return path.join(opencodeInstallDir(), "opencode-statusline.mjs");
|
|
2253
|
+
}
|
|
2254
|
+
function opencodeCachePath() {
|
|
2255
|
+
return path.join(opencodeInstallDir(), "opencode-statusline-cache.json");
|
|
2256
|
+
}
|
|
2257
|
+
function opencodePluginInstallDir() {
|
|
2258
|
+
return path.join(opencodeConfigDir(), "plugins");
|
|
2259
|
+
}
|
|
2260
|
+
function opencodePluginDestPath() {
|
|
2261
|
+
return path.join(opencodePluginInstallDir(), "waitspin-opencode.plugin.tsx");
|
|
2262
|
+
}
|
|
2263
|
+
function opencodeTuiConfigPath() {
|
|
2264
|
+
return path.join(opencodeConfigDir(), "tui.json");
|
|
2265
|
+
}
|
|
2266
|
+
function opencodePluginAssetName() {
|
|
2267
|
+
return "waitspin-opencode.plugin.tsx";
|
|
2268
|
+
}
|
|
2269
|
+
async function resolveOpencodeAssetsDir(flags) {
|
|
2270
|
+
const packageRoot = resolveCliPackageRoot();
|
|
2271
|
+
const candidates = [
|
|
2272
|
+
path.join(packageRoot, "assets", "waitspin-opencode"),
|
|
2273
|
+
];
|
|
2274
|
+
if (allowDevExtensionAssets(flags)) {
|
|
2275
|
+
candidates.push(path.join(packageRoot, "..", "..", "extensions", "waitspin-opencode"));
|
|
2276
|
+
}
|
|
2277
|
+
for (const candidate of candidates) {
|
|
2278
|
+
const resolved = path.resolve(candidate);
|
|
2279
|
+
try {
|
|
2280
|
+
await access(path.join(resolved, "opencode-statusline.mjs"), fsConstants.F_OK);
|
|
2281
|
+
await access(path.join(resolved, opencodePluginAssetName()), fsConstants.F_OK);
|
|
2282
|
+
return resolved;
|
|
2283
|
+
}
|
|
2284
|
+
catch {
|
|
2285
|
+
continue;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
throw new Error(`WaitSpin OpenCode assets not found. Use a build that ships assets/waitspin-opencode or set ${DEV_EXTENSION_ASSETS_OPT_IN_ENV}=1 / --allow-dev-extension-assets from a trusted checkout.`);
|
|
2289
|
+
}
|
|
2290
|
+
function assertSafeOpencodeManagedPath(filePath) {
|
|
2291
|
+
const expected = path.resolve(opencodeCachePath());
|
|
2292
|
+
const resolved = path.resolve(filePath);
|
|
2293
|
+
if (resolved !== expected) {
|
|
2294
|
+
throw new Error("Refusing to manage an OpenCode WaitSpin cache path that is not the managed cache.");
|
|
2295
|
+
}
|
|
2296
|
+
return expected;
|
|
2297
|
+
}
|
|
2298
|
+
function assertSafeOpencodePluginPath(filePath) {
|
|
2299
|
+
const expected = path.resolve(opencodePluginDestPath());
|
|
2300
|
+
const resolved = path.resolve(filePath);
|
|
2301
|
+
if (resolved !== expected) {
|
|
2302
|
+
throw new Error("Refusing to manage an OpenCode plugin path that is not the WaitSpin-managed plugin.");
|
|
2303
|
+
}
|
|
2304
|
+
return expected;
|
|
2305
|
+
}
|
|
2306
|
+
function assertSafeOpencodeTuiConfigPath(filePath) {
|
|
2307
|
+
const expected = path.resolve(opencodeTuiConfigPath());
|
|
2308
|
+
const resolved = path.resolve(filePath);
|
|
2309
|
+
if (resolved !== expected) {
|
|
2310
|
+
throw new Error("Refusing to manage an OpenCode TUI config path that is not the WaitSpin-managed config.");
|
|
2311
|
+
}
|
|
2312
|
+
return expected;
|
|
2313
|
+
}
|
|
2314
|
+
function assertSafeOpencodeManagedRuntimePath(filePath) {
|
|
2315
|
+
const expected = path.resolve(opencodeRuntimePath());
|
|
2316
|
+
const resolved = path.resolve(filePath);
|
|
2317
|
+
if (resolved !== expected) {
|
|
2318
|
+
throw new Error("Refusing to manage an OpenCode runtime path that is not the WaitSpin-managed runtime.");
|
|
2319
|
+
}
|
|
2320
|
+
return expected;
|
|
2321
|
+
}
|
|
2322
|
+
function opencodeTuiPluginEntrySpec(value) {
|
|
2323
|
+
if (typeof value === "string")
|
|
2324
|
+
return value;
|
|
2325
|
+
if (Array.isArray(value) && typeof value[0] === "string")
|
|
2326
|
+
return value[0];
|
|
2327
|
+
return null;
|
|
2328
|
+
}
|
|
2329
|
+
function isWaitSpinOpencodeTuiPluginEntry(value, tuiConfigPath = opencodeTuiConfigPath()) {
|
|
2330
|
+
const spec = opencodeTuiPluginEntrySpec(value);
|
|
2331
|
+
if (!spec)
|
|
2332
|
+
return false;
|
|
2333
|
+
if (spec === OPENCODE_TUI_PLUGIN_ENTRY)
|
|
2334
|
+
return true;
|
|
2335
|
+
if (path.isAbsolute(spec)) {
|
|
2336
|
+
return path.resolve(spec) === path.resolve(opencodePluginDestPath());
|
|
2337
|
+
}
|
|
2338
|
+
if (spec.startsWith(".")) {
|
|
2339
|
+
return (path.resolve(path.dirname(tuiConfigPath), spec) ===
|
|
2340
|
+
path.resolve(opencodePluginDestPath()));
|
|
2341
|
+
}
|
|
2342
|
+
return false;
|
|
2343
|
+
}
|
|
2344
|
+
function opencodeConfigWithTuiPluginEntry(input) {
|
|
2345
|
+
const plugin = input.config.plugin;
|
|
2346
|
+
if (plugin !== undefined && !Array.isArray(plugin)) {
|
|
2347
|
+
throw new Error("OpenCode tui.json plugin field must be an array before WaitSpin can manage it.");
|
|
2348
|
+
}
|
|
2349
|
+
const entries = Array.isArray(plugin) ? plugin : [];
|
|
2350
|
+
if (entries.some((entry) => isWaitSpinOpencodeTuiPluginEntry(entry, input.tuiConfigPath))) {
|
|
2351
|
+
return input.config;
|
|
2352
|
+
}
|
|
2353
|
+
return {
|
|
2354
|
+
...input.config,
|
|
2355
|
+
plugin: [...entries, OPENCODE_TUI_PLUGIN_ENTRY],
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
async function planOpencodeTuiConfigInstall(tuiConfigPath) {
|
|
2359
|
+
const previousConfig = await readJsonObjectFile(tuiConfigPath);
|
|
2360
|
+
const config = previousConfig ?? {};
|
|
2361
|
+
const nextConfig = opencodeConfigWithTuiPluginEntry({
|
|
2362
|
+
config,
|
|
2363
|
+
tuiConfigPath,
|
|
2364
|
+
});
|
|
2365
|
+
return {
|
|
2366
|
+
configPath: tuiConfigPath,
|
|
2367
|
+
previousConfig,
|
|
2368
|
+
nextConfig,
|
|
2369
|
+
changed: JSON.stringify(config) !== JSON.stringify(nextConfig),
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
async function writeOpencodeTuiConfigPlan(plan) {
|
|
2373
|
+
if (!plan.changed)
|
|
2374
|
+
return;
|
|
2375
|
+
await writeJsonObjectFile(plan.configPath, plan.nextConfig);
|
|
2376
|
+
}
|
|
2377
|
+
async function restoreOpencodeTuiConfigPlan(plan) {
|
|
2378
|
+
if (!plan.changed)
|
|
2379
|
+
return;
|
|
2380
|
+
if (plan.previousConfig) {
|
|
2381
|
+
await writeJsonObjectFile(plan.configPath, plan.previousConfig).catch(() => { });
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
await Promise.resolve(rm(plan.configPath, { force: true })).catch(() => { });
|
|
2385
|
+
}
|
|
2386
|
+
async function opencodeTuiPluginConfigured(tuiConfigPath) {
|
|
2387
|
+
const config = (await readJsonObjectFile(tuiConfigPath)) ?? {};
|
|
2388
|
+
const plugin = config.plugin;
|
|
2389
|
+
return (Array.isArray(plugin) &&
|
|
2390
|
+
plugin.some((entry) => isWaitSpinOpencodeTuiPluginEntry(entry, tuiConfigPath)));
|
|
2391
|
+
}
|
|
2392
|
+
async function removeOpencodeTuiPluginEntry(tuiConfigPath) {
|
|
2393
|
+
try {
|
|
2394
|
+
const config = (await readJsonObjectFile(tuiConfigPath)) ?? {};
|
|
2395
|
+
const plugin = config.plugin;
|
|
2396
|
+
if (plugin === undefined) {
|
|
2397
|
+
return { updated: false, configured_before: false, error: null };
|
|
2398
|
+
}
|
|
2399
|
+
if (!Array.isArray(plugin)) {
|
|
2400
|
+
return {
|
|
2401
|
+
updated: false,
|
|
2402
|
+
configured_before: false,
|
|
2403
|
+
error: "invalid_plugin_field",
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
const filtered = plugin.filter((entry) => !isWaitSpinOpencodeTuiPluginEntry(entry, tuiConfigPath));
|
|
2407
|
+
if (filtered.length === plugin.length) {
|
|
2408
|
+
return { updated: false, configured_before: false, error: null };
|
|
2409
|
+
}
|
|
2410
|
+
const nextConfig = { ...config };
|
|
2411
|
+
if (filtered.length > 0) {
|
|
2412
|
+
nextConfig.plugin = filtered;
|
|
2413
|
+
}
|
|
2414
|
+
else {
|
|
2415
|
+
delete nextConfig.plugin;
|
|
2416
|
+
}
|
|
2417
|
+
await writeJsonObjectFile(tuiConfigPath, nextConfig);
|
|
2418
|
+
return { updated: true, configured_before: true, error: null };
|
|
2419
|
+
}
|
|
2420
|
+
catch (error) {
|
|
2421
|
+
if (isErrno(error, "ENOENT")) {
|
|
2422
|
+
return { updated: false, configured_before: false, error: null };
|
|
2423
|
+
}
|
|
2424
|
+
return { updated: false, configured_before: false, error: "read_failed" };
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
async function loadOpencodeInstallState() {
|
|
2428
|
+
const parsed = await readJsonObjectFile(opencodeStatePath());
|
|
2429
|
+
if (!parsed?.install_id || parsed.target !== OPENCODE_PUBLISHER_TARGET) {
|
|
2430
|
+
return null;
|
|
2431
|
+
}
|
|
2432
|
+
return parsed;
|
|
2433
|
+
}
|
|
2434
|
+
function redactedOpencodeState(state) {
|
|
2435
|
+
if (!state)
|
|
2436
|
+
return null;
|
|
2437
|
+
return {
|
|
2438
|
+
target: state.target,
|
|
2439
|
+
install_id: state.install_id,
|
|
2440
|
+
publisher_id: state.publisher_id,
|
|
2441
|
+
publisher_target: state.publisher_target,
|
|
2442
|
+
registered_at: state.registered_at,
|
|
2443
|
+
base_url: state.base_url,
|
|
2444
|
+
runtime_path: state.runtime_path,
|
|
2445
|
+
cache_path: state.cache_path,
|
|
2446
|
+
plugin_path: state.plugin_path,
|
|
2447
|
+
tui_config_path: state.tui_config_path,
|
|
2448
|
+
tui_plugin_entry: state.tui_plugin_entry,
|
|
2449
|
+
installed_at: state.installed_at,
|
|
2450
|
+
api_key_present: Boolean(state.api_key),
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
async function writeOpencodeRuntimeAndPlugin(input) {
|
|
2454
|
+
await mkdir(path.dirname(input.runtimePath), { recursive: true });
|
|
2455
|
+
await mkdir(path.dirname(input.pluginDest), { recursive: true });
|
|
2456
|
+
await cp(path.join(input.sourceDir, "opencode-statusline.mjs"), input.runtimePath, { force: true });
|
|
2457
|
+
const pluginTemplate = await readFile(path.join(input.sourceDir, opencodePluginAssetName()), "utf8");
|
|
2458
|
+
const pluginSource = pluginTemplate
|
|
2459
|
+
.replaceAll('"__WAITSPIN_STATE_PATH__"', JSON.stringify(input.statePath));
|
|
2460
|
+
await writeFile(input.pluginDest, pluginSource, {
|
|
2461
|
+
encoding: "utf8",
|
|
2462
|
+
mode: 0o600,
|
|
2463
|
+
});
|
|
2464
|
+
await chmod(input.runtimePath, 0o755);
|
|
2465
|
+
await chmod(input.pluginDest, 0o600);
|
|
2466
|
+
}
|
|
2467
|
+
export async function runOpencodeInstall(flags) {
|
|
2468
|
+
const dryRun = booleanFlag(flags, "dry-run");
|
|
2469
|
+
const baseUrl = resolveCredentialedBaseUrl(flags);
|
|
2470
|
+
const statePath = opencodeStatePath();
|
|
2471
|
+
const runtimePath = opencodeRuntimePath();
|
|
2472
|
+
const cachePath = opencodeCachePath();
|
|
2473
|
+
const pluginDest = opencodePluginDestPath();
|
|
2474
|
+
const tuiConfigPath = opencodeTuiConfigPath();
|
|
2475
|
+
const existingState = await loadOpencodeInstallState();
|
|
2476
|
+
const installId = existingState?.install_id || generateInstallId();
|
|
2477
|
+
const summary = {
|
|
2478
|
+
ok: true,
|
|
2479
|
+
target: OPENCODE_PUBLISHER_TARGET,
|
|
2480
|
+
mode: "tui-plugin-slot",
|
|
2481
|
+
install_id: installId,
|
|
2482
|
+
publisher_target: OPENCODE_PUBLISHER_TARGET,
|
|
2483
|
+
state_path: statePath,
|
|
2484
|
+
runtime_path: runtimePath,
|
|
2485
|
+
cache_path: cachePath,
|
|
2486
|
+
plugin_path: pluginDest,
|
|
2487
|
+
tui_config_path: tuiConfigPath,
|
|
2488
|
+
tui_plugin_entry: OPENCODE_TUI_PLUGIN_ENTRY,
|
|
2489
|
+
note: "Installs WaitSpin through the OpenCode TUI plugin slot (app_bottom).",
|
|
2490
|
+
next: "check_status",
|
|
2491
|
+
next_command: "waitspin opencode status",
|
|
2492
|
+
};
|
|
2493
|
+
if (dryRun) {
|
|
2494
|
+
printJson({
|
|
2495
|
+
...summary,
|
|
2496
|
+
dry_run: true,
|
|
2497
|
+
publisher_registered: false,
|
|
2498
|
+
});
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
const assetsDir = await resolveOpencodeAssetsDir(flags);
|
|
2502
|
+
const apiKey = requireApiKey(flags);
|
|
2503
|
+
const tuiConfigPlan = await planOpencodeTuiConfigInstall(tuiConfigPath);
|
|
2504
|
+
const registration = await registerPublisherInstall({
|
|
2505
|
+
baseUrl,
|
|
2506
|
+
apiKey,
|
|
2507
|
+
installId,
|
|
2508
|
+
target: OPENCODE_PUBLISHER_TARGET,
|
|
2509
|
+
});
|
|
2510
|
+
const installedAt = new Date().toISOString();
|
|
2511
|
+
const installState = {
|
|
2512
|
+
target: OPENCODE_PUBLISHER_TARGET,
|
|
2513
|
+
install_id: registration.install_id,
|
|
2514
|
+
publisher_id: registration.publisher_id,
|
|
2515
|
+
publisher_target: registration.target,
|
|
2516
|
+
registered_at: installedAt,
|
|
2517
|
+
base_url: baseUrl,
|
|
2518
|
+
api_key: apiKey,
|
|
2519
|
+
runtime_path: runtimePath,
|
|
2520
|
+
cache_path: cachePath,
|
|
2521
|
+
plugin_path: pluginDest,
|
|
2522
|
+
tui_config_path: tuiConfigPath,
|
|
2523
|
+
tui_plugin_entry: OPENCODE_TUI_PLUGIN_ENTRY,
|
|
2524
|
+
installed_at: installedAt,
|
|
2525
|
+
};
|
|
2526
|
+
try {
|
|
2527
|
+
await writeOpencodeRuntimeAndPlugin({
|
|
2528
|
+
sourceDir: assetsDir,
|
|
2529
|
+
runtimePath,
|
|
2530
|
+
pluginDest,
|
|
2531
|
+
statePath,
|
|
2532
|
+
});
|
|
2533
|
+
await writeOpencodeTuiConfigPlan(tuiConfigPlan);
|
|
2534
|
+
await writeJsonObjectFile(statePath, installState, 0o600);
|
|
2535
|
+
}
|
|
2536
|
+
catch (error) {
|
|
2537
|
+
await restoreOpencodeTuiConfigPlan(tuiConfigPlan);
|
|
2538
|
+
if (existingState) {
|
|
2539
|
+
await writeJsonObjectFile(statePath, existingState, 0o600).catch(() => { });
|
|
2540
|
+
}
|
|
2541
|
+
else {
|
|
2542
|
+
await Promise.resolve(rm(statePath, { force: true, recursive: true })).catch(() => { });
|
|
2543
|
+
await Promise.resolve(rm(runtimePath, { force: true, recursive: true })).catch(() => { });
|
|
2544
|
+
await Promise.resolve(rm(pluginDest, { force: true, recursive: true })).catch(() => { });
|
|
2545
|
+
}
|
|
2546
|
+
throw error;
|
|
2547
|
+
}
|
|
2548
|
+
printJson({
|
|
2549
|
+
...summary,
|
|
2550
|
+
...redactedOpencodeState(installState),
|
|
2551
|
+
publisher_registered: true,
|
|
2552
|
+
next: "restart_opencode",
|
|
2553
|
+
next_command: "opencode",
|
|
2554
|
+
acceptance_hint: "Restart OpenCode to load the TUI plugin. The sponsored line appears in the app_bottom slot.",
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
export async function runOpencodeStatus() {
|
|
2558
|
+
const state = await loadOpencodeInstallState();
|
|
2559
|
+
const runtimePath = state?.runtime_path ?? opencodeRuntimePath();
|
|
2560
|
+
const cachePath = state?.cache_path ?? opencodeCachePath();
|
|
2561
|
+
const pluginDest = state?.plugin_path ?? opencodePluginDestPath();
|
|
2562
|
+
const tuiConfigPath = state?.tui_config_path ?? opencodeTuiConfigPath();
|
|
2563
|
+
let runtimeInstalled = false;
|
|
2564
|
+
let pluginInstalled = false;
|
|
2565
|
+
let tuiPluginConfigured = false;
|
|
2566
|
+
let tuiConfigError = null;
|
|
2567
|
+
if (state) {
|
|
2568
|
+
runtimeInstalled = await pathExists(assertSafeOpencodeManagedRuntimePath(state.runtime_path));
|
|
2569
|
+
pluginInstalled = await pathExists(assertSafeOpencodePluginPath(state.plugin_path));
|
|
2570
|
+
try {
|
|
2571
|
+
tuiPluginConfigured = await opencodeTuiPluginConfigured(assertSafeOpencodeTuiConfigPath(tuiConfigPath));
|
|
2572
|
+
}
|
|
2573
|
+
catch {
|
|
2574
|
+
tuiConfigError = "invalid_tui_config";
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
else {
|
|
2578
|
+
const safeRuntime = assertSafeOpencodeManagedRuntimePath(runtimePath);
|
|
2579
|
+
runtimeInstalled = await pathExists(safeRuntime);
|
|
2580
|
+
const safePlugin = assertSafeOpencodePluginPath(pluginDest);
|
|
2581
|
+
pluginInstalled = await pathExists(safePlugin);
|
|
2582
|
+
try {
|
|
2583
|
+
tuiPluginConfigured = await opencodeTuiPluginConfigured(assertSafeOpencodeTuiConfigPath(tuiConfigPath));
|
|
2584
|
+
}
|
|
2585
|
+
catch {
|
|
2586
|
+
tuiPluginConfigured = false;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
const installed = Boolean(state && runtimeInstalled && pluginInstalled && tuiPluginConfigured);
|
|
2590
|
+
printJson({
|
|
2591
|
+
ok: true,
|
|
2592
|
+
target: OPENCODE_PUBLISHER_TARGET,
|
|
2593
|
+
mode: "tui-plugin-slot",
|
|
2594
|
+
installed,
|
|
2595
|
+
publisher_registered: Boolean(state?.publisher_id),
|
|
2596
|
+
install_id: state?.install_id ?? null,
|
|
2597
|
+
publisher_id: state?.publisher_id ?? null,
|
|
2598
|
+
publisher_target: state?.publisher_target ?? OPENCODE_PUBLISHER_TARGET,
|
|
2599
|
+
state_path: opencodeStatePath(),
|
|
2600
|
+
runtime_path: runtimePath,
|
|
2601
|
+
cache_path: cachePath,
|
|
2602
|
+
plugin_path: pluginDest,
|
|
2603
|
+
tui_config_path: tuiConfigPath,
|
|
2604
|
+
tui_plugin_entry: state?.tui_plugin_entry ?? OPENCODE_TUI_PLUGIN_ENTRY,
|
|
2605
|
+
runtime_installed: runtimeInstalled,
|
|
2606
|
+
plugin_installed: pluginInstalled,
|
|
2607
|
+
tui_plugin_configured: tuiPluginConfigured,
|
|
2608
|
+
tui_config_error: tuiConfigError,
|
|
2609
|
+
installed_at: state?.installed_at ?? null,
|
|
2610
|
+
...(installed
|
|
2611
|
+
? {
|
|
2612
|
+
next: "restart_opencode",
|
|
2613
|
+
next_command: "opencode",
|
|
2614
|
+
acceptance_hint: "Restart OpenCode to load the TUI plugin. The sponsored line appears in the app_bottom slot.",
|
|
2615
|
+
}
|
|
2616
|
+
: {
|
|
2617
|
+
next: "install_opencode",
|
|
2618
|
+
next_command: "waitspin opencode install",
|
|
2619
|
+
}),
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
export async function runOpencodeUninstall(flags) {
|
|
2623
|
+
const dryRun = booleanFlag(flags, "dry-run");
|
|
2624
|
+
const state = await loadOpencodeInstallState();
|
|
2625
|
+
const tuiConfigPath = state?.tui_config_path ?? opencodeTuiConfigPath();
|
|
2626
|
+
const declaredRemovePaths = state
|
|
2627
|
+
? [
|
|
2628
|
+
state.runtime_path,
|
|
2629
|
+
state.cache_path,
|
|
2630
|
+
opencodeStatePath(),
|
|
2631
|
+
state.plugin_path,
|
|
2632
|
+
]
|
|
2633
|
+
: [opencodeStatePath()];
|
|
2634
|
+
if (dryRun) {
|
|
2635
|
+
printJson({
|
|
2636
|
+
ok: true,
|
|
2637
|
+
target: OPENCODE_PUBLISHER_TARGET,
|
|
2638
|
+
dry_run: true,
|
|
2639
|
+
installed: Boolean(state),
|
|
2640
|
+
would_remove: declaredRemovePaths,
|
|
2641
|
+
would_update: [tuiConfigPath],
|
|
2642
|
+
});
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
const removePaths = [];
|
|
2646
|
+
const skippedUnsafePaths = [];
|
|
2647
|
+
let tuiConfigUpdate = null;
|
|
2648
|
+
if (state) {
|
|
2649
|
+
try {
|
|
2650
|
+
removePaths.push(assertSafeOpencodeManagedRuntimePath(state.runtime_path));
|
|
2651
|
+
}
|
|
2652
|
+
catch {
|
|
2653
|
+
skippedUnsafePaths.push(state.runtime_path);
|
|
2654
|
+
}
|
|
2655
|
+
try {
|
|
2656
|
+
removePaths.push(assertSafeOpencodeManagedPath(state.cache_path));
|
|
2657
|
+
}
|
|
2658
|
+
catch {
|
|
2659
|
+
skippedUnsafePaths.push(state.cache_path);
|
|
2660
|
+
}
|
|
2661
|
+
try {
|
|
2662
|
+
removePaths.push(assertSafeOpencodePluginPath(state.plugin_path));
|
|
2663
|
+
}
|
|
2664
|
+
catch {
|
|
2665
|
+
skippedUnsafePaths.push(state.plugin_path);
|
|
2666
|
+
}
|
|
2667
|
+
try {
|
|
2668
|
+
tuiConfigUpdate = await removeOpencodeTuiPluginEntry(assertSafeOpencodeTuiConfigPath(tuiConfigPath));
|
|
2669
|
+
}
|
|
2670
|
+
catch {
|
|
2671
|
+
skippedUnsafePaths.push(tuiConfigPath);
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
removePaths.splice(state ? Math.min(removePaths.length, 2) : 0, 0, opencodeStatePath());
|
|
2675
|
+
await Promise.all(removePaths.map((filePath) => rm(filePath, { force: true, recursive: true })));
|
|
2676
|
+
printJson({
|
|
2677
|
+
ok: true,
|
|
2678
|
+
target: OPENCODE_PUBLISHER_TARGET,
|
|
2679
|
+
uninstalled: true,
|
|
2680
|
+
removed: removePaths,
|
|
2681
|
+
tui_config_updated: tuiConfigUpdate?.updated ?? false,
|
|
2682
|
+
tui_plugin_configured_before: tuiConfigUpdate?.configured_before ?? false,
|
|
2683
|
+
tui_config_error: tuiConfigUpdate?.error ?? null,
|
|
2684
|
+
...(skippedUnsafePaths.length > 0
|
|
2685
|
+
? { skipped_unsafe_paths: skippedUnsafePaths }
|
|
2686
|
+
: {}),
|
|
2687
|
+
});
|
|
2688
|
+
}
|
|
2689
|
+
function cloneFlags(flags) {
|
|
2690
|
+
return new Map(Array.from(flags.entries()).map(([key, values]) => [key, [...values]]));
|
|
2691
|
+
}
|
|
2692
|
+
function extensionAllFlags(flags) {
|
|
2693
|
+
const next = cloneFlags(flags);
|
|
2694
|
+
next.set("target", ["vscode"]);
|
|
2695
|
+
return next;
|
|
2696
|
+
}
|
|
2697
|
+
function executableFromEnv(envName, defaultBin) {
|
|
2698
|
+
return process.env[envName]?.trim() || defaultBin;
|
|
2699
|
+
}
|
|
2700
|
+
async function executableVersion(envName, defaultBin, label) {
|
|
2701
|
+
const binary = executableFromEnv(envName, defaultBin);
|
|
2702
|
+
try {
|
|
2703
|
+
const result = await execFileText(binary, ["--version"], {
|
|
2704
|
+
encoding: "utf8",
|
|
2705
|
+
timeout: 5_000,
|
|
2706
|
+
});
|
|
2707
|
+
return `${result.stdout || result.stderr || label}`.trim();
|
|
2708
|
+
}
|
|
2709
|
+
catch (error) {
|
|
2710
|
+
throw new Error(`${label} was not detected. Install ${label} or set ${envName} to its executable path.`, { cause: error });
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
async function preflightVscode(flags) {
|
|
2714
|
+
return resolveExtensionDir(flags);
|
|
2715
|
+
}
|
|
2716
|
+
async function preflightClaudeCode() {
|
|
2717
|
+
return assertSupportedClaudeCodeVersion();
|
|
2718
|
+
}
|
|
2719
|
+
async function preflightMiMoCode() {
|
|
2720
|
+
return executableVersion(MIMOCODE_BIN_ENV, MIMOCODE_DEFAULT_BIN, "MiMo Code");
|
|
2721
|
+
}
|
|
2722
|
+
async function preflightOpenCode() {
|
|
2723
|
+
return executableVersion(OPENCODE_BIN_ENV, OPENCODE_DEFAULT_BIN, "OpenCode");
|
|
2724
|
+
}
|
|
2725
|
+
function allInstallTargets() {
|
|
2726
|
+
return [
|
|
2727
|
+
{
|
|
2728
|
+
target: "vscode",
|
|
2729
|
+
command: "waitspin extension install --target vscode",
|
|
2730
|
+
statusCommand: "waitspin extension status --target vscode",
|
|
2731
|
+
preflight: preflightVscode,
|
|
2732
|
+
install: (flags) => runExtensionInstall(extensionAllFlags(flags)),
|
|
2733
|
+
status: (flags) => runExtensionStatus(extensionAllFlags(flags)),
|
|
2734
|
+
},
|
|
2735
|
+
{
|
|
2736
|
+
target: CLAUDE_CODE_PUBLISHER_TARGET,
|
|
2737
|
+
command: "waitspin claude-code install --compose-existing",
|
|
2738
|
+
statusCommand: "waitspin claude-code status",
|
|
2739
|
+
preflight: preflightClaudeCode,
|
|
2740
|
+
install: runClaudeCodeInstall,
|
|
2741
|
+
status: () => runClaudeCodeStatus(),
|
|
2742
|
+
},
|
|
2743
|
+
{
|
|
2744
|
+
target: MIMOCODE_PUBLISHER_TARGET,
|
|
2745
|
+
command: "waitspin mimocode install",
|
|
2746
|
+
statusCommand: "waitspin mimocode status",
|
|
2747
|
+
preflight: preflightMiMoCode,
|
|
2748
|
+
install: runMiMoCodeInstall,
|
|
2749
|
+
status: () => runMiMoCodeStatus(),
|
|
2750
|
+
},
|
|
2751
|
+
{
|
|
2752
|
+
target: OPENCODE_PUBLISHER_TARGET,
|
|
2753
|
+
command: "waitspin opencode install",
|
|
2754
|
+
statusCommand: "waitspin opencode status",
|
|
2755
|
+
preflight: preflightOpenCode,
|
|
2756
|
+
install: runOpencodeInstall,
|
|
2757
|
+
status: () => runOpencodeStatus(),
|
|
2758
|
+
},
|
|
2759
|
+
];
|
|
2760
|
+
}
|
|
2761
|
+
function safeErrorMessage(error) {
|
|
2762
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2763
|
+
return message
|
|
2764
|
+
.replace(/wts_(?:live|test)__[A-Za-z0-9_-]+/g, "[REDACTED_WAITSPIN_KEY]")
|
|
2765
|
+
.replace(/npm_[A-Za-z0-9_-]+/g, "[REDACTED_NPM_TOKEN]");
|
|
2766
|
+
}
|
|
2767
|
+
function isNotDetectedError(message) {
|
|
2768
|
+
return /not detected|not found|Unable to run Claude Code|Unsupported Claude Code version|ENOENT|spawn .*ENOENT|package not found/i.test(message);
|
|
2769
|
+
}
|
|
2770
|
+
function isConflictError(message) {
|
|
2771
|
+
return /statusLine|status line|conflict|override|already has|blocked/i.test(message);
|
|
2772
|
+
}
|
|
2773
|
+
function objectRecord(value) {
|
|
2774
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
2775
|
+
? value
|
|
2776
|
+
: {};
|
|
2777
|
+
}
|
|
2778
|
+
function dryRunConflictReason(result) {
|
|
2779
|
+
const record = objectRecord(result);
|
|
2780
|
+
if (!record.would_fail)
|
|
2781
|
+
return null;
|
|
2782
|
+
const reason = record.settings_blocked_reason ?? record.human_message;
|
|
2783
|
+
return typeof reason === "string" ? reason : "target reported a conflict";
|
|
2784
|
+
}
|
|
2785
|
+
export async function runInstallAll(flags) {
|
|
2786
|
+
const dryRun = booleanFlag(flags, "dry-run");
|
|
2787
|
+
const installed = [];
|
|
2788
|
+
const wouldInstall = [];
|
|
2789
|
+
const skippedNotDetected = [];
|
|
2790
|
+
const skippedConflict = [];
|
|
2791
|
+
const failedRollback = [];
|
|
2792
|
+
for (const target of allInstallTargets()) {
|
|
2793
|
+
let detail = null;
|
|
2794
|
+
try {
|
|
2795
|
+
detail = await target.preflight(flags);
|
|
2796
|
+
}
|
|
2797
|
+
catch (error) {
|
|
2798
|
+
const reason = safeErrorMessage(error);
|
|
2799
|
+
skippedNotDetected.push({
|
|
2800
|
+
target: target.target,
|
|
2801
|
+
command: target.command,
|
|
2802
|
+
reason,
|
|
2803
|
+
});
|
|
2804
|
+
continue;
|
|
2805
|
+
}
|
|
2806
|
+
try {
|
|
2807
|
+
const result = await capturePrintedJson(() => target.install(flags));
|
|
2808
|
+
const conflictReason = dryRunConflictReason(result);
|
|
2809
|
+
if (conflictReason) {
|
|
2810
|
+
skippedConflict.push({
|
|
2811
|
+
target: target.target,
|
|
2812
|
+
command: target.command,
|
|
2813
|
+
reason: conflictReason,
|
|
2814
|
+
detail,
|
|
2815
|
+
result,
|
|
2816
|
+
});
|
|
2817
|
+
continue;
|
|
2818
|
+
}
|
|
2819
|
+
const summary = {
|
|
2820
|
+
target: target.target,
|
|
2821
|
+
command: target.command,
|
|
2822
|
+
detail,
|
|
2823
|
+
result,
|
|
2824
|
+
};
|
|
2825
|
+
if (dryRun) {
|
|
2826
|
+
wouldInstall.push(summary);
|
|
2827
|
+
}
|
|
2828
|
+
else {
|
|
2829
|
+
installed.push(summary);
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
catch (error) {
|
|
2833
|
+
const reason = safeErrorMessage(error);
|
|
2834
|
+
const bucket = isConflictError(reason)
|
|
2835
|
+
? skippedConflict
|
|
2836
|
+
: isNotDetectedError(reason)
|
|
2837
|
+
? skippedNotDetected
|
|
2838
|
+
: failedRollback;
|
|
2839
|
+
bucket.push({
|
|
2840
|
+
target: target.target,
|
|
2841
|
+
command: target.command,
|
|
2842
|
+
reason,
|
|
2843
|
+
detail,
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
printJson({
|
|
2848
|
+
ok: failedRollback.length === 0,
|
|
2849
|
+
command: "install --all",
|
|
2850
|
+
dry_run: dryRun,
|
|
2851
|
+
mode: "detected-targets",
|
|
2852
|
+
installed,
|
|
2853
|
+
would_install: wouldInstall,
|
|
2854
|
+
skipped_not_detected: skippedNotDetected,
|
|
2855
|
+
skipped_conflict: skippedConflict,
|
|
2856
|
+
failed_rollback: failedRollback,
|
|
2857
|
+
next: "check_all_status",
|
|
2858
|
+
next_command: "waitspin status --all",
|
|
2859
|
+
human_message: "Install-all is an advanced agent command. Explicit target commands remain the canonical debug path.",
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2862
|
+
export async function runStatusAll(flags) {
|
|
2863
|
+
const statuses = [];
|
|
2864
|
+
const installed = [];
|
|
2865
|
+
const failedStatus = [];
|
|
2866
|
+
for (const target of allInstallTargets()) {
|
|
2867
|
+
try {
|
|
2868
|
+
const result = await capturePrintedJson(() => target.status(flags));
|
|
2869
|
+
const summary = {
|
|
2870
|
+
target: target.target,
|
|
2871
|
+
command: target.statusCommand,
|
|
2872
|
+
result,
|
|
2873
|
+
};
|
|
2874
|
+
statuses.push(summary);
|
|
2875
|
+
if (objectRecord(result).installed === true) {
|
|
2876
|
+
installed.push(summary);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
catch (error) {
|
|
2880
|
+
failedStatus.push({
|
|
2881
|
+
target: target.target,
|
|
2882
|
+
command: target.statusCommand,
|
|
2883
|
+
reason: safeErrorMessage(error),
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
printJson({
|
|
2888
|
+
ok: failedStatus.length === 0,
|
|
2889
|
+
command: "status --all",
|
|
2890
|
+
installed,
|
|
2891
|
+
statuses,
|
|
2892
|
+
failed_status: failedStatus,
|
|
2893
|
+
});
|
|
2894
|
+
}
|
|
2895
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
2896
|
+
const { command, flags, positionals } = parseArgs(argv);
|
|
2897
|
+
if (command === "init") {
|
|
2898
|
+
await runInit(flags);
|
|
2899
|
+
return;
|
|
2900
|
+
}
|
|
2901
|
+
if (command === "bid" && positionals[0] === "create") {
|
|
2902
|
+
await runBidCreate(flags);
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
if (command === "bids" && positionals[0] === "list") {
|
|
2906
|
+
await runBidsList(flags);
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
if (command === "bid" && positionals[0] === "checkout") {
|
|
2910
|
+
await runBidCheckout(flags, positionals.slice(1));
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2913
|
+
if (command === "market") {
|
|
2914
|
+
await runMarket(flags);
|
|
2915
|
+
return;
|
|
2916
|
+
}
|
|
2917
|
+
if (command === "wallet" && positionals[0] === "status") {
|
|
2918
|
+
await runWalletStatus(flags);
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
if (command === "wallet" && positionals[0] === "connect") {
|
|
2922
|
+
await runWalletConnect(flags);
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
if (command === "wallet" && positionals[0] === "ledger") {
|
|
2926
|
+
await runWalletLedger(flags);
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
if (command === "wallet" && positionals[0] === "payout") {
|
|
2930
|
+
await runWalletPayout(flags);
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
if (command === "extension" && positionals[0] === "install") {
|
|
2934
|
+
await runExtensionInstall(flags);
|
|
2935
|
+
return;
|
|
2936
|
+
}
|
|
2937
|
+
if (command === "extension" && positionals[0] === "status") {
|
|
2938
|
+
await runExtensionStatus(flags);
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
if (command === "extension" && positionals[0] === "uninstall") {
|
|
2942
|
+
await runExtensionUninstall(flags);
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
if (command === "install" && booleanFlag(flags, "all")) {
|
|
2946
|
+
await runInstallAll(flags);
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
if (command === "status" && booleanFlag(flags, "all")) {
|
|
2950
|
+
await runStatusAll(flags);
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
if (command === "claude-code" && positionals[0] === "install") {
|
|
2954
|
+
await runClaudeCodeInstall(flags);
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
if (command === "claude-code" && positionals[0] === "status") {
|
|
2958
|
+
await runClaudeCodeStatus();
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
if (command === "claude-code" && positionals[0] === "uninstall") {
|
|
2962
|
+
await runClaudeCodeUninstall(flags);
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
if (command === "mimocode" && positionals[0] === "install") {
|
|
2966
|
+
await runMiMoCodeInstall(flags);
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
if (command === "mimocode" && positionals[0] === "status") {
|
|
2970
|
+
await runMiMoCodeStatus();
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
if (command === "mimocode" && positionals[0] === "uninstall") {
|
|
2974
|
+
await runMiMoCodeUninstall(flags);
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
if (command === "opencode" && positionals[0] === "install") {
|
|
2978
|
+
await runOpencodeInstall(flags);
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
if (command === "opencode" && positionals[0] === "status") {
|
|
2982
|
+
await runOpencodeStatus();
|
|
2983
|
+
return;
|
|
2984
|
+
}
|
|
2985
|
+
if (command === "opencode" && positionals[0] === "uninstall") {
|
|
2986
|
+
await runOpencodeUninstall(flags);
|
|
663
2987
|
return;
|
|
664
2988
|
}
|
|
665
2989
|
usage();
|