impel-cli 0.14.3 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -24
- package/package.json +1 -1
- package/src/apps.js +16 -1
- package/src/cli.js +3 -1
- package/src/commands/apps.js +126 -40
- package/src/commands/setup.js +173 -22
- package/src/commands/update.js +40 -2
- package/src/installRecovery/actions.js +440 -0
- package/src/installRecovery/checkpoint.js +59 -0
- package/src/installRecovery/client.js +84 -0
- package/src/installRecovery/loop.js +258 -0
- package/src/installRecovery/redact.js +83 -0
- package/src/nativeProcess.js +21 -3
- package/src/windowsSetup.js +115 -28
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
import { promptText } from "../prompt.js";
|
|
5
|
+
import {
|
|
6
|
+
deterministicInstallRecoveryAction,
|
|
7
|
+
executeInstallRecoveryAction,
|
|
8
|
+
installRecoveryActionMutates,
|
|
9
|
+
validateInstallRecoveryAction,
|
|
10
|
+
} from "./actions.js";
|
|
11
|
+
import {
|
|
12
|
+
clearInstallRecoveryCheckpoint,
|
|
13
|
+
loadInstallRecoveryCheckpoint,
|
|
14
|
+
saveInstallRecoveryCheckpoint,
|
|
15
|
+
} from "./checkpoint.js";
|
|
16
|
+
import { createInstallRecoveryClient } from "./client.js";
|
|
17
|
+
import {
|
|
18
|
+
installRecoveryFingerprint,
|
|
19
|
+
installRecoveryStateFingerprint,
|
|
20
|
+
sanitizeInstallFailureEnvelope,
|
|
21
|
+
} from "./redact.js";
|
|
22
|
+
|
|
23
|
+
const CLI_VERSION = JSON.parse(
|
|
24
|
+
fs.readFileSync(
|
|
25
|
+
fileURLToPath(new URL("../../package.json", import.meta.url)),
|
|
26
|
+
"utf8"
|
|
27
|
+
)
|
|
28
|
+
).version;
|
|
29
|
+
const TERMINAL_STATUSES = new Set(["fixed", "blocked", "aborted"]);
|
|
30
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
31
|
+
|
|
32
|
+
function recoveryDisabled(environment) {
|
|
33
|
+
return ["1", "true", "yes"].includes(
|
|
34
|
+
String(environment.IMPEL_DISABLE_INSTALL_RECOVERY || "").toLowerCase()
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function yes(answer) {
|
|
39
|
+
return /^(?:y|yes)$/iu.test(String(answer || "").trim());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function confirmMutation(action, io) {
|
|
43
|
+
if (!installRecoveryActionMutates(action.id)) return true;
|
|
44
|
+
if (!io.isTTY) return false;
|
|
45
|
+
const answer = await io.promptText(
|
|
46
|
+
`Install recovery proposes ${action.id}: ${action.reason}\nRun this allowlisted local action? [y/N] `
|
|
47
|
+
);
|
|
48
|
+
return yes(answer);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function checkpointFrom(state, fingerprint) {
|
|
52
|
+
return {
|
|
53
|
+
protocolVersion: 1,
|
|
54
|
+
sessionId: state.sessionId,
|
|
55
|
+
recoveryToken: state.recoveryToken,
|
|
56
|
+
fingerprint,
|
|
57
|
+
status: state.status,
|
|
58
|
+
turn: state.turn,
|
|
59
|
+
expiresAt: state.expiresAt,
|
|
60
|
+
updatedAt: Date.now(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function terminalOutcome(status) {
|
|
65
|
+
if (status === "fixed") return "fixed";
|
|
66
|
+
if (status === "aborted") return "aborted";
|
|
67
|
+
return "blocked";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function uploadConsent(io, explicit, failure) {
|
|
71
|
+
io.log(
|
|
72
|
+
"Install recovery can send a sanitized failure envelope to Impel. Credentials, home paths, email addresses, ANSI controls, and raw tokens are removed."
|
|
73
|
+
);
|
|
74
|
+
io.log(
|
|
75
|
+
"The hosted agent selects typed action IDs; the Impel CLI runs the corresponding reviewed commands on this machine after local policy and confirmation checks."
|
|
76
|
+
);
|
|
77
|
+
io.log("Sanitized payload preview:");
|
|
78
|
+
io.log(JSON.stringify(failure, null, 2));
|
|
79
|
+
if (explicit) return true;
|
|
80
|
+
if (!io.isTTY) return false;
|
|
81
|
+
return yes(await io.promptText("Send the sanitized failure and start assisted recovery? [y/N] "));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function runInstallRecovery(options, overrides = {}) {
|
|
85
|
+
const environment = options.environment || process.env;
|
|
86
|
+
const io = {
|
|
87
|
+
promptText,
|
|
88
|
+
isTTY: process.stdin.isTTY,
|
|
89
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
90
|
+
loadCheckpoint: loadInstallRecoveryCheckpoint,
|
|
91
|
+
saveCheckpoint: saveInstallRecoveryCheckpoint,
|
|
92
|
+
clearCheckpoint: clearInstallRecoveryCheckpoint,
|
|
93
|
+
client:
|
|
94
|
+
options.client ||
|
|
95
|
+
createInstallRecoveryClient({
|
|
96
|
+
appUrl: options.config.appUrl,
|
|
97
|
+
fetchImpl: options.fetchImpl || fetch,
|
|
98
|
+
}),
|
|
99
|
+
executeAction: executeInstallRecoveryAction,
|
|
100
|
+
now: () => Date.now(),
|
|
101
|
+
log: console.log,
|
|
102
|
+
warn: console.warn,
|
|
103
|
+
...overrides,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (options.noRecovery || recoveryDisabled(environment)) {
|
|
107
|
+
return { status: "disabled", fixed: false, uploaded: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const failure = sanitizeInstallFailureEnvelope(options.failure);
|
|
111
|
+
const fingerprint = installRecoveryFingerprint(failure);
|
|
112
|
+
const encounteredErrors = [failure.message];
|
|
113
|
+
const actionContext = {
|
|
114
|
+
...(options.actionContext || {}),
|
|
115
|
+
platform: failure.platform,
|
|
116
|
+
environment,
|
|
117
|
+
isTTY: io.isTTY,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Always attempt one deterministic, allowlisted local diagnostic first. A
|
|
121
|
+
// mutation still needs the same explicit confirmation as a hosted proposal.
|
|
122
|
+
const deterministicAction = deterministicInstallRecoveryAction(failure);
|
|
123
|
+
const deterministicConfirmed = await confirmMutation(deterministicAction, io);
|
|
124
|
+
const deterministicResult = await io.executeAction(deterministicAction, {
|
|
125
|
+
...actionContext,
|
|
126
|
+
confirmed: deterministicConfirmed,
|
|
127
|
+
});
|
|
128
|
+
io.log(`Install recovery local check: ${deterministicResult.summary}`);
|
|
129
|
+
if (deterministicResult.outcome === "failed") {
|
|
130
|
+
encounteredErrors.push(deterministicResult.summary);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const consented = await uploadConsent(io, options.explicit === true, failure);
|
|
134
|
+
if (!consented) {
|
|
135
|
+
io.log("Install recovery stayed local; no diagnostic data was uploaded.");
|
|
136
|
+
return {
|
|
137
|
+
status: "local_only",
|
|
138
|
+
fixed: false,
|
|
139
|
+
uploaded: false,
|
|
140
|
+
localResult: deterministicResult,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let state;
|
|
145
|
+
const existing = io.loadCheckpoint();
|
|
146
|
+
if (existing?.fingerprint === fingerprint) {
|
|
147
|
+
state = existing;
|
|
148
|
+
io.log(`Resuming install recovery session ${existing.sessionId}.`);
|
|
149
|
+
} else {
|
|
150
|
+
state = await io.client.create(options.config.pat, {
|
|
151
|
+
protocolVersion: 1,
|
|
152
|
+
orgId: options.config.tenantId,
|
|
153
|
+
cliVersion: CLI_VERSION,
|
|
154
|
+
consent: true,
|
|
155
|
+
fingerprint,
|
|
156
|
+
stateFingerprint: installRecoveryStateFingerprint({
|
|
157
|
+
failure,
|
|
158
|
+
deterministicResult,
|
|
159
|
+
}),
|
|
160
|
+
failure: {
|
|
161
|
+
...failure,
|
|
162
|
+
diagnostics: {
|
|
163
|
+
...(failure.diagnostics || {}),
|
|
164
|
+
localCheck: deterministicResult.summary,
|
|
165
|
+
localCheckOutcome: deterministicResult.outcome,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
io.saveCheckpoint(checkpointFrom(state, fingerprint));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const deadline = Math.min(
|
|
173
|
+
state.expiresAt || io.now() + 15 * 60 * 1_000,
|
|
174
|
+
io.now() + 15 * 60 * 1_000
|
|
175
|
+
);
|
|
176
|
+
let terminal = false;
|
|
177
|
+
try {
|
|
178
|
+
while (io.now() < deadline) {
|
|
179
|
+
if (!TERMINAL_STATUSES.has(state.status)) {
|
|
180
|
+
state = await io.client.read(state.sessionId, state.recoveryToken);
|
|
181
|
+
io.saveCheckpoint(checkpointFrom(state, fingerprint));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (state.status === "awaiting_action") {
|
|
185
|
+
const action = validateInstallRecoveryAction(state.proposal?.action);
|
|
186
|
+
if (!action || !state.actionNonce) {
|
|
187
|
+
encounteredErrors.push("Hosted recovery returned an invalid typed action.");
|
|
188
|
+
state.status = "blocked";
|
|
189
|
+
state.terminalSummary = "Hosted recovery returned an invalid typed action.";
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
io.log(`Install recovery: ${state.proposal.userMessage || state.proposal.summary}`);
|
|
193
|
+
const confirmed = await confirmMutation(action, io);
|
|
194
|
+
const result = await io.executeAction(action, {
|
|
195
|
+
...actionContext,
|
|
196
|
+
confirmed,
|
|
197
|
+
});
|
|
198
|
+
if (result.outcome === "failed") encounteredErrors.push(result.summary);
|
|
199
|
+
state = await io.client.submitResult(
|
|
200
|
+
state.sessionId,
|
|
201
|
+
state.recoveryToken,
|
|
202
|
+
{
|
|
203
|
+
actionNonce: state.actionNonce,
|
|
204
|
+
actionId: action.id,
|
|
205
|
+
outcome: result.outcome,
|
|
206
|
+
stateFingerprint: installRecoveryStateFingerprint({
|
|
207
|
+
failure,
|
|
208
|
+
actionId: action.id,
|
|
209
|
+
result,
|
|
210
|
+
}),
|
|
211
|
+
summary: result.summary,
|
|
212
|
+
exitCode: result.exitCode,
|
|
213
|
+
...(result.stdout ? { stdout: result.stdout } : {}),
|
|
214
|
+
...(result.stderr ? { stderr: result.stderr } : {}),
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
io.saveCheckpoint(checkpointFrom(state, fingerprint));
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (TERMINAL_STATUSES.has(state.status)) {
|
|
222
|
+
terminal = true;
|
|
223
|
+
const summary =
|
|
224
|
+
state.terminalSummary || state.proposal?.summary || "Install recovery finished.";
|
|
225
|
+
await io.client.complete(state.sessionId, state.recoveryToken, {
|
|
226
|
+
outcome: terminalOutcome(state.status),
|
|
227
|
+
summary,
|
|
228
|
+
encounteredErrors: [...new Set(encounteredErrors)].slice(0, 20),
|
|
229
|
+
});
|
|
230
|
+
io.log(`Install recovery ${state.status}: ${summary}`);
|
|
231
|
+
return {
|
|
232
|
+
status: state.status,
|
|
233
|
+
fixed: state.status === "fixed",
|
|
234
|
+
uploaded: true,
|
|
235
|
+
summary,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await io.sleep(POLL_INTERVAL_MS);
|
|
240
|
+
}
|
|
241
|
+
const summary = terminal
|
|
242
|
+
? state.terminalSummary
|
|
243
|
+
: "Install recovery reached its 15-minute local safety limit.";
|
|
244
|
+
io.warn(summary);
|
|
245
|
+
return { status: "blocked", fixed: false, uploaded: true, summary };
|
|
246
|
+
} catch (error) {
|
|
247
|
+
io.warn(`Install recovery paused: ${error?.message || error}`);
|
|
248
|
+
io.warn("Re-run `impel setup --repair` to resume the short-lived session.");
|
|
249
|
+
return {
|
|
250
|
+
status: "paused",
|
|
251
|
+
fixed: false,
|
|
252
|
+
uploaded: true,
|
|
253
|
+
summary: String(error?.message || error),
|
|
254
|
+
};
|
|
255
|
+
} finally {
|
|
256
|
+
if (terminal) io.clearCheckpoint();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
|
|
4
|
+
import { redactSecretText } from "../config.js";
|
|
5
|
+
|
|
6
|
+
const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu;
|
|
7
|
+
const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+\/-]{8,}/giu;
|
|
8
|
+
const SECRET_ASSIGNMENT_RE = /\b(api[_-]?key|authorization|password|secret|token)\s*[:=]\s*([^\s,;]+)/giu;
|
|
9
|
+
const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu;
|
|
10
|
+
const WINDOWS_USER_PATH_RE = /\b[A-Za-z]:\\Users\\[^\\\s]+/giu;
|
|
11
|
+
|
|
12
|
+
function escapeRegex(value) {
|
|
13
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function redactInstallRecoveryText(value, { homeDir = os.homedir() } = {}) {
|
|
17
|
+
let text = redactSecretText(value);
|
|
18
|
+
if (homeDir) {
|
|
19
|
+
text = text.replace(new RegExp(escapeRegex(homeDir), "giu"), "~");
|
|
20
|
+
}
|
|
21
|
+
return text
|
|
22
|
+
.replace(WINDOWS_USER_PATH_RE, "%USERPROFILE%")
|
|
23
|
+
.replace(JWT_RE, "[REDACTED TOKEN]")
|
|
24
|
+
.replace(BEARER_RE, "Bearer [REDACTED]")
|
|
25
|
+
.replace(SECRET_ASSIGNMENT_RE, (_match, name) => `${name}=[REDACTED]`)
|
|
26
|
+
.replace(EMAIL_RE, "[REDACTED EMAIL]")
|
|
27
|
+
.slice(0, 12_000);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function boundedText(value, max, options) {
|
|
31
|
+
return redactInstallRecoveryText(value, options).trim().slice(0, max);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sanitizeInstallFailureEnvelope(input, options = {}) {
|
|
35
|
+
const diagnostics = Object.fromEntries(
|
|
36
|
+
Object.entries(input?.diagnostics || {})
|
|
37
|
+
.slice(0, 24)
|
|
38
|
+
.map(([key, value]) => [
|
|
39
|
+
boundedText(key, 64, options) || "diagnostic",
|
|
40
|
+
boundedText(value, 2_000, options),
|
|
41
|
+
])
|
|
42
|
+
);
|
|
43
|
+
return {
|
|
44
|
+
platform: ["win32", "darwin", "linux"].includes(input?.platform)
|
|
45
|
+
? input.platform
|
|
46
|
+
: "unknown",
|
|
47
|
+
architecture: boundedText(input?.architecture || process.arch, 32, options),
|
|
48
|
+
step: boundedText(input?.step || "unknown", 128, options) || "unknown",
|
|
49
|
+
...(input?.command ? { command: boundedText(input.command, 256, options) } : {}),
|
|
50
|
+
exitCode: Number.isInteger(input?.exitCode) ? input.exitCode : null,
|
|
51
|
+
signal: input?.signal ? boundedText(input.signal, 32, options) : null,
|
|
52
|
+
errorCode: input?.errorCode ? boundedText(input.errorCode, 96, options) : null,
|
|
53
|
+
message: boundedText(input?.message || "Installation failed.", 4_000, options)
|
|
54
|
+
|| "Installation failed.",
|
|
55
|
+
...(input?.stdout ? { stdout: boundedText(input.stdout, 12_000, options) } : {}),
|
|
56
|
+
...(input?.stderr ? { stderr: boundedText(input.stderr, 12_000, options) } : {}),
|
|
57
|
+
...(Object.keys(diagnostics).length ? { diagnostics } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function canonical(value) {
|
|
62
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
63
|
+
if (value && typeof value === "object") {
|
|
64
|
+
return Object.fromEntries(
|
|
65
|
+
Object.entries(value)
|
|
66
|
+
.filter(([, child]) => child !== undefined)
|
|
67
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
68
|
+
.map(([key, child]) => [key, canonical(child)])
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function installRecoveryFingerprint(value) {
|
|
75
|
+
return crypto
|
|
76
|
+
.createHash("sha256")
|
|
77
|
+
.update(JSON.stringify(canonical(value)))
|
|
78
|
+
.digest("hex");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function installRecoveryStateFingerprint(value) {
|
|
82
|
+
return installRecoveryFingerprint(value);
|
|
83
|
+
}
|
package/src/nativeProcess.js
CHANGED
|
@@ -55,6 +55,7 @@ function overrideName(tool, environmentPrefix = "IMPEL") {
|
|
|
55
55
|
if (tool === "claude") return `${environmentPrefix}_CLAUDE_BIN`;
|
|
56
56
|
if (tool === "codex") return `${environmentPrefix}_CODEX_BIN`;
|
|
57
57
|
if (tool === "npm") return `${environmentPrefix}_NPM_BIN`;
|
|
58
|
+
if (tool === "powershell") return `${environmentPrefix}_POWERSHELL_BIN`;
|
|
58
59
|
return null;
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -86,6 +87,7 @@ function commonCandidates(tool, environment, platform) {
|
|
|
86
87
|
const localAppData = environmentValue(environment, "LOCALAPPDATA");
|
|
87
88
|
const nvmSymlink = environmentValue(environment, "NVM_SYMLINK");
|
|
88
89
|
const programFiles = environmentValue(environment, "ProgramFiles");
|
|
90
|
+
const systemRoot = environmentValue(environment, "SystemRoot");
|
|
89
91
|
const nodeDirectory = path.win32.isAbsolute(process.execPath)
|
|
90
92
|
? path.win32.dirname(process.execPath)
|
|
91
93
|
: null;
|
|
@@ -110,6 +112,12 @@ function commonCandidates(tool, environment, platform) {
|
|
|
110
112
|
if (nvmSymlink) locations.push([nvmSymlink, "npm"]);
|
|
111
113
|
if (programFiles) locations.push([paths.join(programFiles, "nodejs"), "npm"]);
|
|
112
114
|
}
|
|
115
|
+
|
|
116
|
+
// Windows PowerShell 5.1 is present on supported Windows releases even
|
|
117
|
+
// when a customized PATH omits its standard directory.
|
|
118
|
+
if (tool === "powershell" && systemRoot) {
|
|
119
|
+
locations.push([paths.join(systemRoot, "System32", "WindowsPowerShell", "v1.0"), "powershell"]);
|
|
120
|
+
}
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
return locations.flatMap(([directory, name]) => binaryCandidates(directory, name, environment, platform));
|
|
@@ -121,10 +129,20 @@ export function findNativeBinary(
|
|
|
121
129
|
environment = process.env,
|
|
122
130
|
platform = process.platform,
|
|
123
131
|
environmentPrefix = "IMPEL",
|
|
132
|
+
accept = null,
|
|
124
133
|
) {
|
|
134
|
+
const usable = (candidate) => {
|
|
135
|
+
if (!isExecutable(candidate, platform)) return false;
|
|
136
|
+
if (!accept) return true;
|
|
137
|
+
try {
|
|
138
|
+
return accept(candidate) === true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
125
143
|
const override = overrideName(tool, environmentPrefix);
|
|
126
144
|
const overriddenBinary = override ? environmentValue(environment, override) : null;
|
|
127
|
-
if (overriddenBinary) return
|
|
145
|
+
if (overriddenBinary) return usable(overriddenBinary) ? overriddenBinary : null;
|
|
128
146
|
|
|
129
147
|
for (const rawDirectory of String(environmentValue(environment, "PATH") || "").split(pathDelimiter(platform))) {
|
|
130
148
|
const expandedDirectory = platform === "win32"
|
|
@@ -133,11 +151,11 @@ export function findNativeBinary(
|
|
|
133
151
|
const directory = stripPathEntryQuotes(expandedDirectory);
|
|
134
152
|
if (!directory) continue;
|
|
135
153
|
for (const candidate of binaryCandidates(directory, tool, environment, platform)) {
|
|
136
|
-
if (
|
|
154
|
+
if (usable(candidate)) return candidate;
|
|
137
155
|
}
|
|
138
156
|
}
|
|
139
157
|
|
|
140
|
-
return commonCandidates(tool, environment, platform).find(
|
|
158
|
+
return commonCandidates(tool, environment, platform).find(usable) || null;
|
|
141
159
|
}
|
|
142
160
|
|
|
143
161
|
/** Resolve an executable for launch, preserving explicit overrides and useful ENOENT errors. */
|
package/src/windowsSetup.js
CHANGED
|
@@ -1,29 +1,84 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
|
|
3
3
|
import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
findNativeBinary,
|
|
6
|
+
nativeCommandInvocation,
|
|
7
|
+
nativeSpawnInvocation,
|
|
8
|
+
} from "./nativeProcess.js";
|
|
5
9
|
import { syncSkillsSafe } from "./skills.js";
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
const POWERSHELL_ARGS = [
|
|
12
|
+
"-NoLogo",
|
|
13
|
+
"-NoProfile",
|
|
14
|
+
"-NonInteractive",
|
|
15
|
+
"-ExecutionPolicy",
|
|
16
|
+
"Bypass",
|
|
17
|
+
"-Command",
|
|
18
|
+
];
|
|
11
19
|
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Official, checksum-verifying native installers recommended by each vendor.
|
|
22
|
+
* Keep the copyable command separate from the script passed to PowerShell so
|
|
23
|
+
* setup errors can offer an exact manual recovery without exposing cmd.exe's
|
|
24
|
+
* internal escaping.
|
|
25
|
+
*/
|
|
26
|
+
export const WINDOWS_CLI_INSTALLERS = Object.freeze({
|
|
27
|
+
claude: Object.freeze({
|
|
28
|
+
command: "powershell -ExecutionPolicy Bypass -NoProfile -Command \"irm https://claude.ai/install.ps1 | iex\"",
|
|
29
|
+
script: "Invoke-RestMethod -Uri 'https://claude.ai/install.ps1' | Invoke-Expression",
|
|
30
|
+
}),
|
|
31
|
+
codex: Object.freeze({
|
|
32
|
+
command: "powershell -ExecutionPolicy Bypass -NoProfile -Command \"$env:CODEX_NON_INTERACTIVE='1'; irm https://chatgpt.com/codex/install.ps1 | iex\"",
|
|
33
|
+
script: "$env:CODEX_NON_INTERACTIVE = '1'; Invoke-RestMethod -Uri 'https://chatgpt.com/codex/install.ps1' | Invoke-Expression",
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export function windowsCliInstallCommands(tools = Object.keys(WINDOWS_CLI_INSTALLERS)) {
|
|
38
|
+
return Object.fromEntries(tools.map((tool) => {
|
|
39
|
+
const installer = WINDOWS_CLI_INSTALLERS[tool];
|
|
40
|
+
if (!installer) throw new Error(`unsupported Windows CLI installer: ${tool}`);
|
|
41
|
+
return [tool, installer.command];
|
|
42
|
+
}));
|
|
14
43
|
}
|
|
15
44
|
|
|
16
|
-
function
|
|
45
|
+
function verifyWindowsCli(_tool, binary, environment) {
|
|
46
|
+
try {
|
|
47
|
+
const invocation = nativeSpawnInvocation(binary, ["--version"], environment, "win32");
|
|
48
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
env: environment,
|
|
51
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
52
|
+
timeout: 15_000,
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
55
|
+
});
|
|
56
|
+
return result?.status === 0 && !result?.error;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function detectWindowsClis(find, environment, verify) {
|
|
63
|
+
const detect = (tool) => find(
|
|
64
|
+
tool,
|
|
65
|
+
environment,
|
|
66
|
+
"win32",
|
|
67
|
+
"IMPEL",
|
|
68
|
+
(binary) => verify(tool, binary, environment),
|
|
69
|
+
);
|
|
17
70
|
return {
|
|
18
|
-
claude:
|
|
19
|
-
codex:
|
|
71
|
+
claude: detect("claude"),
|
|
72
|
+
codex: detect("codex"),
|
|
20
73
|
};
|
|
21
74
|
}
|
|
22
75
|
|
|
23
|
-
function
|
|
76
|
+
function installCli(tool, { environment, run }) {
|
|
77
|
+
const installer = WINDOWS_CLI_INSTALLERS[tool];
|
|
78
|
+
if (!installer) throw new Error(`unsupported Windows CLI installer: ${tool}`);
|
|
24
79
|
const invocation = nativeCommandInvocation(
|
|
25
|
-
"
|
|
26
|
-
[
|
|
80
|
+
"powershell",
|
|
81
|
+
[...POWERSHELL_ARGS, installer.script],
|
|
27
82
|
environment,
|
|
28
83
|
"win32",
|
|
29
84
|
);
|
|
@@ -57,10 +112,16 @@ function installationFailure(result, thrown = null) {
|
|
|
57
112
|
* vendor CLIs, create the same tenant-isolated profiles used at launch time,
|
|
58
113
|
* and sync the shared Impel skill bundle into each available client.
|
|
59
114
|
*/
|
|
60
|
-
export async function prepareWindowsClis({
|
|
115
|
+
export async function prepareWindowsClis({
|
|
116
|
+
gatewayUrl,
|
|
117
|
+
tenantId,
|
|
118
|
+
skipInstall = false,
|
|
119
|
+
installTools = ["claude", "codex"],
|
|
120
|
+
} = {}, dependencies = {}) {
|
|
61
121
|
const io = {
|
|
62
122
|
environment: process.env,
|
|
63
123
|
find: findNativeBinary,
|
|
124
|
+
verify: verifyWindowsCli,
|
|
64
125
|
run: spawnSync,
|
|
65
126
|
ensureClaudeProfile: ensureImpelClaudeProfile,
|
|
66
127
|
ensureCodexProfile: ensureImpelCodexProfile,
|
|
@@ -68,27 +129,52 @@ export async function prepareWindowsClis({ gatewayUrl, tenantId, skipInstall = f
|
|
|
68
129
|
...dependencies,
|
|
69
130
|
};
|
|
70
131
|
|
|
71
|
-
const before = detectWindowsClis(io.find, io.environment);
|
|
132
|
+
const before = detectWindowsClis(io.find, io.environment, io.verify);
|
|
72
133
|
const missingBefore = Object.entries(before)
|
|
73
134
|
.filter(([, binary]) => !binary)
|
|
74
135
|
.map(([tool]) => tool);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
136
|
+
const installations = {};
|
|
137
|
+
const requestedInstallTools = new Set(installTools);
|
|
138
|
+
if (
|
|
139
|
+
requestedInstallTools.size !== installTools.length
|
|
140
|
+
|| [...requestedInstallTools].some((tool) => !WINDOWS_CLI_INSTALLERS[tool])
|
|
141
|
+
) {
|
|
142
|
+
throw new Error("installTools must contain unique supported Windows CLI names");
|
|
143
|
+
}
|
|
78
144
|
|
|
79
145
|
if (missingBefore.length > 0 && !skipInstall) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
146
|
+
// Install independently: one broken vendor endpoint or local conflict must
|
|
147
|
+
// not prevent the other client from reaching a usable state.
|
|
148
|
+
for (const tool of missingBefore.filter((candidate) => requestedInstallTools.has(candidate))) {
|
|
149
|
+
try {
|
|
150
|
+
const result = installCli(tool, io);
|
|
151
|
+
const succeeded = result?.status === 0 && !result?.error;
|
|
152
|
+
installations[tool] = {
|
|
153
|
+
attempted: true,
|
|
154
|
+
succeeded,
|
|
155
|
+
failure: succeeded ? null : installationFailure(result),
|
|
156
|
+
command: WINDOWS_CLI_INSTALLERS[tool].command,
|
|
157
|
+
};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
installations[tool] = {
|
|
160
|
+
attempted: true,
|
|
161
|
+
succeeded: false,
|
|
162
|
+
failure: installationFailure(null, error),
|
|
163
|
+
command: WINDOWS_CLI_INSTALLERS[tool].command,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
88
166
|
}
|
|
89
167
|
}
|
|
90
168
|
|
|
91
|
-
const
|
|
169
|
+
const attemptedInstallations = Object.values(installations);
|
|
170
|
+
const installAttempted = attemptedInstallations.length > 0;
|
|
171
|
+
const installSucceeded = installAttempted
|
|
172
|
+
? attemptedInstallations.every((installation) => installation.succeeded)
|
|
173
|
+
: null;
|
|
174
|
+
const installFailure = attemptedInstallations.find((installation) => installation.failure)?.failure || null;
|
|
175
|
+
const binaries = installAttempted
|
|
176
|
+
? detectWindowsClis(io.find, io.environment, io.verify)
|
|
177
|
+
: before;
|
|
92
178
|
const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId);
|
|
93
179
|
const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId);
|
|
94
180
|
|
|
@@ -116,7 +202,8 @@ export async function prepareWindowsClis({ gatewayUrl, tenantId, skipInstall = f
|
|
|
116
202
|
installAttempted,
|
|
117
203
|
installSucceeded,
|
|
118
204
|
installFailure,
|
|
119
|
-
|
|
205
|
+
installations,
|
|
206
|
+
installCommands: windowsCliInstallCommands(missingBefore),
|
|
120
207
|
profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
|
|
121
208
|
};
|
|
122
209
|
}
|