impel-cli 0.17.1 → 0.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/package.json +1 -1
- package/src/apps.js +60 -16
- package/src/cli.js +2 -0
- package/src/codesign.js +98 -24
- package/src/commands/apps.js +30 -14
- package/src/commands/converge.js +95 -2
- package/src/commands/setup.js +29 -30
- package/src/commands/update.js +5 -0
- package/src/installRecovery/engine.js +4 -1
- package/src/installRecovery/inference.js +3 -1
- package/src/installRecovery/tools.js +23 -5
- package/src/macSetup.js +260 -0
- package/src/nativeProcess.js +3 -3
- package/src/platformSetup.js +49 -0
- package/src/privileges.js +32 -0
- package/src/provisioning.js +10 -8
package/src/commands/setup.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
reconcileAllTenants,
|
|
18
18
|
selectDefaultTenant,
|
|
19
19
|
} from "../provisioning.js";
|
|
20
|
-
import {
|
|
20
|
+
import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
|
|
21
21
|
import { runInstallRecovery } from "../installRecovery/engine.js";
|
|
22
22
|
import { restoreNativeProfiles } from "./use.js";
|
|
23
23
|
|
|
@@ -33,7 +33,7 @@ Usage:
|
|
|
33
33
|
impel setup --pat <pat> Non-interactive authentication
|
|
34
34
|
impel setup --tenant <org> Choose the default tenant for CLI launches
|
|
35
35
|
impel setup --skip-apps Skip desktop apps
|
|
36
|
-
impel setup --skip-clis
|
|
36
|
+
impel setup --skip-clis Do not install missing vendor CLIs
|
|
37
37
|
impel setup --no-recovery Disable automatic install recovery
|
|
38
38
|
`;
|
|
39
39
|
|
|
@@ -53,16 +53,19 @@ async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
|
|
|
53
53
|
const controller = new AbortController();
|
|
54
54
|
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
55
55
|
try {
|
|
56
|
-
const response = await fetchImpl(`${gatewayUrl}/
|
|
57
|
-
method: "POST",
|
|
56
|
+
const response = await fetchImpl(`${gatewayUrl}/v1/models`, {
|
|
58
57
|
headers: {
|
|
59
|
-
|
|
58
|
+
accept: "application/json",
|
|
60
59
|
authorization: `Bearer ${tenantCredential(pat, tenantId)}`,
|
|
61
60
|
},
|
|
62
|
-
body: "{}",
|
|
63
61
|
signal: controller.signal,
|
|
64
62
|
});
|
|
65
|
-
return {
|
|
63
|
+
return {
|
|
64
|
+
reachable: true,
|
|
65
|
+
healthy: response.ok,
|
|
66
|
+
status: response.status,
|
|
67
|
+
rejected: [401, 403].includes(response.status),
|
|
68
|
+
};
|
|
66
69
|
} catch (error) {
|
|
67
70
|
return {
|
|
68
71
|
reachable: false,
|
|
@@ -75,7 +78,7 @@ async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
|
|
|
75
78
|
|
|
76
79
|
function markProbeFailures(report, probes) {
|
|
77
80
|
probes.forEach((probe, index) => {
|
|
78
|
-
if (probe.reachable && !probe.rejected) return;
|
|
81
|
+
if (probe.reachable && !probe.rejected && probe.healthy !== false) return;
|
|
79
82
|
const tenant = report.tenants[index];
|
|
80
83
|
tenant.cli = "failed";
|
|
81
84
|
tenant.status = "failed";
|
|
@@ -84,7 +87,9 @@ function markProbeFailures(report, probes) {
|
|
|
84
87
|
}
|
|
85
88
|
tenant.errors.push(probe.rejected
|
|
86
89
|
? `credential rejected (HTTP ${probe.status})`
|
|
87
|
-
:
|
|
90
|
+
: probe.reachable
|
|
91
|
+
? `gateway readiness failed (HTTP ${probe.status})`
|
|
92
|
+
: `gateway unreachable (${probe.error || "unknown error"})`);
|
|
88
93
|
});
|
|
89
94
|
report.passed = report.tenants.every((tenant) => (
|
|
90
95
|
["ready", "unavailable"].includes(tenant.cli)
|
|
@@ -109,20 +114,6 @@ function mergeTenantReport(report, replacement) {
|
|
|
109
114
|
return recomputeReport(report);
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
function describeWindowsCliFailure(prepared) {
|
|
113
|
-
const labels = { claude: "Claude Code", codex: "Codex" };
|
|
114
|
-
const details = [];
|
|
115
|
-
for (const tool of prepared.missingAfter || []) {
|
|
116
|
-
const installation = prepared.installations?.[tool];
|
|
117
|
-
const failure = installation?.failure;
|
|
118
|
-
const reason = failure?.message || failure?.code
|
|
119
|
-
|| (Number.isInteger(failure?.status) ? `exit ${failure.status}` : "not discoverable after installation");
|
|
120
|
-
details.push(`${labels[tool] || tool}'s native installer failed: ${redactSecretText(reason)}`);
|
|
121
|
-
if (installation?.command) details.push(`manual ${tool} installer: ${redactSecretText(installation.command)}`);
|
|
122
|
-
}
|
|
123
|
-
return details.join("; ") || `missing vendor CLIs: ${(prepared.missingAfter || []).join(", ")}`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
117
|
function confirmed(answer) {
|
|
127
118
|
return /^(?:y|yes)$/iu.test(String(answer || "").trim());
|
|
128
119
|
}
|
|
@@ -134,7 +125,7 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
134
125
|
promptSecret,
|
|
135
126
|
promptText,
|
|
136
127
|
fetchTenants,
|
|
137
|
-
|
|
128
|
+
preparePlatformClis,
|
|
138
129
|
reconcile: reconcileAllTenants,
|
|
139
130
|
probe: probeGateway,
|
|
140
131
|
recoverInstall: runInstallRecovery,
|
|
@@ -144,6 +135,9 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
144
135
|
environment: process.env,
|
|
145
136
|
...overrides,
|
|
146
137
|
};
|
|
138
|
+
if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
|
|
139
|
+
io.preparePlatformClis = overrides.prepareWindowsClis;
|
|
140
|
+
}
|
|
147
141
|
const { flags, positionals } = parseFlags(argv, {
|
|
148
142
|
pat: { type: "string" },
|
|
149
143
|
tenant: { type: "string" },
|
|
@@ -234,11 +228,11 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
234
228
|
|
|
235
229
|
let sharedFailure = null;
|
|
236
230
|
const prepareSharedClis = async ({ confirmedTools = null, inspectOnly = false } = {}) => {
|
|
237
|
-
if (io.platform !== "win32") return { binaries: {}, missingAfter: [] };
|
|
238
231
|
try {
|
|
239
|
-
const inspected = await io.
|
|
232
|
+
const inspected = await io.preparePlatformClis({
|
|
240
233
|
gatewayUrl,
|
|
241
234
|
tenantId: selected.id,
|
|
235
|
+
platform: io.platform,
|
|
242
236
|
skipInstall: true,
|
|
243
237
|
});
|
|
244
238
|
let prepared = inspected;
|
|
@@ -248,6 +242,7 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
248
242
|
installTools = [];
|
|
249
243
|
if (io.isTTY) {
|
|
250
244
|
for (const tool of inspected.missingAfter) {
|
|
245
|
+
if (!inspected.installCommands?.[tool]) continue;
|
|
251
246
|
const label = tool === "claude" ? "Claude Code" : "Codex";
|
|
252
247
|
if (confirmed(await io.promptText(`Install the official ${label} CLI for this user? [y/N] `))) {
|
|
253
248
|
installTools.push(tool);
|
|
@@ -256,16 +251,17 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
256
251
|
}
|
|
257
252
|
}
|
|
258
253
|
if (installTools.length) {
|
|
259
|
-
prepared = await io.
|
|
254
|
+
prepared = await io.preparePlatformClis({
|
|
260
255
|
gatewayUrl,
|
|
261
256
|
tenantId: selected.id,
|
|
257
|
+
platform: io.platform,
|
|
262
258
|
skipInstall: false,
|
|
263
259
|
installTools,
|
|
264
260
|
});
|
|
265
261
|
}
|
|
266
262
|
}
|
|
267
263
|
if (prepared.missingAfter.length) {
|
|
268
|
-
sharedFailure =
|
|
264
|
+
sharedFailure = describeCliFailure(prepared);
|
|
269
265
|
return prepared;
|
|
270
266
|
}
|
|
271
267
|
sharedFailure = null;
|
|
@@ -344,10 +340,13 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
344
340
|
},
|
|
345
341
|
},
|
|
346
342
|
}, overrides.recoveryOverrides || {});
|
|
347
|
-
if (recovery.fixed)
|
|
343
|
+
if (recovery.fixed) {
|
|
344
|
+
await prepareSharedClis({ inspectOnly: true });
|
|
345
|
+
report = await verifyConvergence();
|
|
346
|
+
}
|
|
348
347
|
}
|
|
349
348
|
|
|
350
|
-
if (!flags["no-recovery"]) {
|
|
349
|
+
if (!sharedFailure && !flags["no-recovery"]) {
|
|
351
350
|
for (const failed of report.tenants.filter((tenant) => tenant.status === "failed")) {
|
|
352
351
|
const tenant = orderedTenants.find((candidate) => candidate.id === failed.tenantId);
|
|
353
352
|
if (!tenant) continue;
|
package/src/commands/update.js
CHANGED
|
@@ -30,6 +30,7 @@ Usage:
|
|
|
30
30
|
impel update Update the CLI and reconcile every accessible tenant
|
|
31
31
|
impel update --check Report whether an update is available; change nothing
|
|
32
32
|
impel update --skip-apps Reconcile only isolated CLI profiles
|
|
33
|
+
impel update --skip-clis Do not install missing vendor CLIs
|
|
33
34
|
impel update --no-recovery Disable local and hosted recovery for this run
|
|
34
35
|
`;
|
|
35
36
|
|
|
@@ -85,6 +86,7 @@ function defaultSelfUpdate(spec) {
|
|
|
85
86
|
// NEW code performs them, not the process that started the update.
|
|
86
87
|
export function defaultRunConvergence({
|
|
87
88
|
skipApps = false,
|
|
89
|
+
skipClis = false,
|
|
88
90
|
noRecovery = false,
|
|
89
91
|
spawn = spawnSync,
|
|
90
92
|
execPath = process.execPath,
|
|
@@ -92,6 +94,7 @@ export function defaultRunConvergence({
|
|
|
92
94
|
} = {}) {
|
|
93
95
|
const args = [cliBin, "_converge"];
|
|
94
96
|
if (skipApps) args.push("--skip-apps");
|
|
97
|
+
if (skipClis) args.push("--skip-clis");
|
|
95
98
|
if (noRecovery) args.push("--no-recovery");
|
|
96
99
|
const result = spawn(execPath, args, {
|
|
97
100
|
stdio: "inherit",
|
|
@@ -157,6 +160,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
157
160
|
const { flags } = parseFlags(argv, {
|
|
158
161
|
check: { type: "boolean" },
|
|
159
162
|
"skip-apps": { type: "boolean" },
|
|
163
|
+
"skip-clis": { type: "boolean" },
|
|
160
164
|
"refresh-cache": { type: "boolean" },
|
|
161
165
|
repair: { type: "boolean" },
|
|
162
166
|
"no-recovery": { type: "boolean" },
|
|
@@ -263,6 +267,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
263
267
|
console.log("Tenants: discovering, installing, repairing, and upgrading every accessible tenant…");
|
|
264
268
|
const convergenceArgs = {
|
|
265
269
|
skipApps: Boolean(flags["skip-apps"]),
|
|
270
|
+
skipClis: Boolean(flags["skip-clis"]),
|
|
266
271
|
noRecovery: Boolean(flags["no-recovery"]),
|
|
267
272
|
};
|
|
268
273
|
if (!await io.runConvergence(convergenceArgs)) {
|
|
@@ -174,6 +174,7 @@ function announceUpload(session, failure) {
|
|
|
174
174
|
);
|
|
175
175
|
session.io.log("Sanitized payload preview:");
|
|
176
176
|
session.io.log(JSON.stringify(failure, null, 2));
|
|
177
|
+
session.io.log(`Install recovery ID: ${session.fingerprint.slice(0, 16)}`);
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
function terminalReturn(session, status, summary, extras = {}) {
|
|
@@ -280,6 +281,8 @@ export async function runInstallRecovery(options, overrides = {}) {
|
|
|
280
281
|
let inference;
|
|
281
282
|
try {
|
|
282
283
|
const seat = await io.resolveSeat(options.config);
|
|
284
|
+
seat.recoveryId = session.fingerprint.slice(0, 16);
|
|
285
|
+
seat.cliVersion = CLI_VERSION;
|
|
283
286
|
inference = io.createInference(seat);
|
|
284
287
|
io.log(`Install recovery: assisted diagnosis via ${inference.model} (org ${inference.orgId}).`);
|
|
285
288
|
} catch (error) {
|
|
@@ -293,7 +296,7 @@ export async function runInstallRecovery(options, overrides = {}) {
|
|
|
293
296
|
const messages = [
|
|
294
297
|
{ role: "user", content: buildFirstUserMessage(session, failure, deterministicSummary, goalReport) },
|
|
295
298
|
];
|
|
296
|
-
const tools = installRecoveryToolDefinitions();
|
|
299
|
+
const tools = installRecoveryToolDefinitions(session.actionContext);
|
|
297
300
|
const deadline = io.now() + RECOVERY_LIMITS.maxWallClockMs;
|
|
298
301
|
const callCounts = new Map();
|
|
299
302
|
let declinedSystemCalls = 0;
|
|
@@ -107,6 +107,7 @@ export function createRecoveryInference(seat, fetchImpl = fetch) {
|
|
|
107
107
|
const modelCandidates = [seat.model, ...FALLBACK_MODELS.filter((model) => model !== seat.model)];
|
|
108
108
|
|
|
109
109
|
async function completeOnce(model, { system, messages, tools }) {
|
|
110
|
+
const requestId = `impel-recovery-${seat.recoveryId || "untracked"}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
110
111
|
return requestJson(
|
|
111
112
|
url,
|
|
112
113
|
{
|
|
@@ -116,7 +117,8 @@ export function createRecoveryInference(seat, fetchImpl = fetch) {
|
|
|
116
117
|
accept: "application/json",
|
|
117
118
|
authorization: `Bearer ${seat.bearer}`,
|
|
118
119
|
"anthropic-version": "2023-06-01",
|
|
119
|
-
"
|
|
120
|
+
"user-agent": `impel-cli-install-recovery/${seat.cliVersion || "unknown"} recovery/${seat.recoveryId || "untracked"}`,
|
|
121
|
+
"x-request-id": requestId,
|
|
120
122
|
},
|
|
121
123
|
body: JSON.stringify({
|
|
122
124
|
model,
|
|
@@ -105,7 +105,7 @@ export const INSTALL_RECOVERY_TOOLS = Object.freeze({
|
|
|
105
105
|
install_vendor_cli: {
|
|
106
106
|
risk: "system",
|
|
107
107
|
description:
|
|
108
|
-
"Run the official vendor installer for one missing CLI
|
|
108
|
+
"Run the official vendor installer for one missing CLI on macOS or Windows. Uses a checksum-verifying native installer and requires the user's confirmation.",
|
|
109
109
|
schema: {
|
|
110
110
|
tool: { type: "string", enum: ["claude", "codex"], required: true },
|
|
111
111
|
},
|
|
@@ -138,8 +138,26 @@ export const INSTALL_RECOVERY_TOOLS = Object.freeze({
|
|
|
138
138
|
},
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
function recoveryToolAvailable(name, context) {
|
|
142
|
+
if (!context) return true;
|
|
143
|
+
switch (name) {
|
|
144
|
+
case "probe_gateway": return typeof context.probeGateway === "function";
|
|
145
|
+
case "check_auth_helper": return Boolean(context.tenantId) && context.platform !== "win32";
|
|
146
|
+
case "repair_impel_profile": return typeof context.repairProfiles === "function";
|
|
147
|
+
case "retry_step": return typeof context.retryStep === "function";
|
|
148
|
+
case "install_vendor_cli":
|
|
149
|
+
return ["darwin", "win32"].includes(context.platform)
|
|
150
|
+
&& typeof context.installVendorClis === "function";
|
|
151
|
+
case "install_vendor_app": return typeof context.installVendorApp === "function";
|
|
152
|
+
case "repair_user_path": return context.platform === "win32";
|
|
153
|
+
default: return true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function installRecoveryToolDefinitions(context = null) {
|
|
158
|
+
return Object.entries(INSTALL_RECOVERY_TOOLS)
|
|
159
|
+
.filter(([name]) => recoveryToolAvailable(name, context))
|
|
160
|
+
.map(([name, tool]) => ({
|
|
143
161
|
name,
|
|
144
162
|
description: tool.description,
|
|
145
163
|
input_schema: {
|
|
@@ -155,7 +173,7 @@ export function installRecoveryToolDefinitions() {
|
|
|
155
173
|
.map(([key]) => key),
|
|
156
174
|
additionalProperties: false,
|
|
157
175
|
},
|
|
158
|
-
|
|
176
|
+
}));
|
|
159
177
|
}
|
|
160
178
|
|
|
161
179
|
/** Validate a model-proposed call. Returns normalized input or null. */
|
|
@@ -469,7 +487,7 @@ async function retryStep(input, context) {
|
|
|
469
487
|
}
|
|
470
488
|
|
|
471
489
|
async function installVendorCli(input, context) {
|
|
472
|
-
if (
|
|
490
|
+
if (!["darwin", "win32"].includes(context.platform) || typeof context.installVendorClis !== "function") {
|
|
473
491
|
return result("failed", "Automated vendor CLI installation is unavailable on this platform.");
|
|
474
492
|
}
|
|
475
493
|
const outcome = await context.installVendorClis(input.tool);
|
package/src/macSetup.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
|
|
8
|
+
import { findNativeBinary } from "./nativeProcess.js";
|
|
9
|
+
import { syncSkillsSafe } from "./skills.js";
|
|
10
|
+
|
|
11
|
+
const MAX_INSTALLER_BYTES = 512 * 1024;
|
|
12
|
+
const INSTALLER_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
13
|
+
const INSTALLER_RUN_TIMEOUT_MS = 10 * 60 * 1_000;
|
|
14
|
+
const MAC_SIGNING_TEAMS = Object.freeze({ claude: "Q6L2SF6YDW", codex: "2DC432GLL2" });
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The script bytes are pinned per impel-cli release. Both vendor scripts then
|
|
18
|
+
* verify the downloaded native binary against the vendor release checksum.
|
|
19
|
+
*/
|
|
20
|
+
export const MAC_CLI_INSTALLERS = Object.freeze({
|
|
21
|
+
claude: Object.freeze({
|
|
22
|
+
url: "https://claude.ai/install.sh",
|
|
23
|
+
sha256: "b3f79015b54c751440a6488f07b1b64f9088742b9052bc1bd356d13108320d2a",
|
|
24
|
+
command: "curl -fsSL https://claude.ai/install.sh | bash -s stable",
|
|
25
|
+
args: Object.freeze(["stable"]),
|
|
26
|
+
environment: Object.freeze({}),
|
|
27
|
+
}),
|
|
28
|
+
codex: Object.freeze({
|
|
29
|
+
url: "https://chatgpt.com/codex/install.sh",
|
|
30
|
+
sha256: "1154e9daf713aacd1534efca8042bfd6665ad24bc1d1dfd86b8f439fe60a7a5d",
|
|
31
|
+
command: "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 CODEX_RELEASE=0.144.6 sh",
|
|
32
|
+
args: Object.freeze([]),
|
|
33
|
+
environment: Object.freeze({ CODEX_NON_INTERACTIVE: "1", CODEX_RELEASE: "0.144.6" }),
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function verifyMacCli(tool, binary, environment, run = spawnSync) {
|
|
38
|
+
try {
|
|
39
|
+
const result = run(binary, ["--version"], {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
env: environment,
|
|
42
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
43
|
+
timeout: 15_000,
|
|
44
|
+
});
|
|
45
|
+
if (result?.status !== 0 || result?.error) return false;
|
|
46
|
+
const explicitOverride = tool === "claude" ? environment.IMPEL_CLAUDE_BIN : environment.IMPEL_CODEX_BIN;
|
|
47
|
+
if (explicitOverride && path.resolve(explicitOverride) === path.resolve(binary)) return true;
|
|
48
|
+
const resolved = fs.realpathSync(binary);
|
|
49
|
+
const verified = run("/usr/bin/codesign", ["--verify", "--strict", resolved], {
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
52
|
+
timeout: 15_000,
|
|
53
|
+
});
|
|
54
|
+
if (verified?.status !== 0 || verified?.error) return false;
|
|
55
|
+
const details = run("/usr/bin/codesign", ["-dv", "--verbose=4", resolved], {
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
58
|
+
timeout: 15_000,
|
|
59
|
+
});
|
|
60
|
+
return details?.status === 0
|
|
61
|
+
&& String(details.stderr || details.stdout || "").includes(`TeamIdentifier=${MAC_SIGNING_TEAMS[tool]}`);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function detectMacClis(find, environment, verify) {
|
|
68
|
+
const detect = (tool) => find(
|
|
69
|
+
tool,
|
|
70
|
+
environment,
|
|
71
|
+
"darwin",
|
|
72
|
+
"IMPEL",
|
|
73
|
+
(binary) => verify(tool, binary, environment),
|
|
74
|
+
);
|
|
75
|
+
return { claude: detect("claude"), codex: detect("codex") };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function downloadPinnedInstaller(tool, { fetchImpl, fsImpl, installers }) {
|
|
79
|
+
const installer = installers[tool];
|
|
80
|
+
if (!installer) throw new Error(`unsupported macOS CLI installer: ${tool}`);
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timeout = setTimeout(() => controller.abort(), INSTALLER_DOWNLOAD_TIMEOUT_MS);
|
|
83
|
+
let response;
|
|
84
|
+
try {
|
|
85
|
+
response = await fetchImpl(installer.url, { redirect: "follow", signal: controller.signal });
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timeout);
|
|
88
|
+
}
|
|
89
|
+
if (!response.ok) throw new Error(`official ${tool} installer returned HTTP ${response.status}`);
|
|
90
|
+
const finalUrl = new URL(response.url || installer.url);
|
|
91
|
+
if (finalUrl.protocol !== "https:" || !["claude.ai", "chatgpt.com"].includes(finalUrl.hostname)) {
|
|
92
|
+
throw new Error(`official ${tool} installer redirected to an untrusted origin`);
|
|
93
|
+
}
|
|
94
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
95
|
+
if (bytes.length === 0 || bytes.length > MAX_INSTALLER_BYTES) {
|
|
96
|
+
throw new Error(`official ${tool} installer had an invalid size`);
|
|
97
|
+
}
|
|
98
|
+
const digest = crypto.createHash("sha256").update(bytes).digest("hex");
|
|
99
|
+
if (digest !== installer.sha256) {
|
|
100
|
+
throw new Error(`official ${tool} installer checksum did not match this impel-cli release`);
|
|
101
|
+
}
|
|
102
|
+
const source = bytes.toString("utf8");
|
|
103
|
+
if (!source.startsWith("#!")) throw new Error(`official ${tool} installer was not a shell script`);
|
|
104
|
+
const directory = fsImpl.mkdtempSync(path.join(os.tmpdir(), `impel-${tool}-installer-`));
|
|
105
|
+
const script = path.join(directory, "install.sh");
|
|
106
|
+
fsImpl.writeFileSync(script, bytes, { mode: 0o700 });
|
|
107
|
+
return { directory, script };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function installMacCli(tool, io) {
|
|
111
|
+
const installer = io.installers[tool];
|
|
112
|
+
let downloaded = null;
|
|
113
|
+
let downloadError = null;
|
|
114
|
+
for (let attempt = 0; attempt < 2 && !downloaded; attempt += 1) {
|
|
115
|
+
try {
|
|
116
|
+
downloaded = await downloadPinnedInstaller(tool, io);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
downloadError = error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!downloaded) throw downloadError || new Error(`official ${tool} installer download failed`);
|
|
122
|
+
try {
|
|
123
|
+
const environment = {
|
|
124
|
+
...vendorInstallerEnvironment(io.environment),
|
|
125
|
+
...(installer.environment || {}),
|
|
126
|
+
};
|
|
127
|
+
return io.run("/bin/bash", [downloaded.script, ...(installer.args || [])], {
|
|
128
|
+
env: environment,
|
|
129
|
+
stdio: "inherit",
|
|
130
|
+
timeout: INSTALLER_RUN_TIMEOUT_MS,
|
|
131
|
+
});
|
|
132
|
+
} finally {
|
|
133
|
+
io.fsImpl.rmSync(downloaded.directory, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function vendorInstallerEnvironment(environment) {
|
|
138
|
+
const allowed = new Set([
|
|
139
|
+
"HOME", "USER", "LOGNAME", "SHELL", "PATH", "TMPDIR", "LANG", "TERM",
|
|
140
|
+
"SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
|
|
141
|
+
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
|
|
142
|
+
]);
|
|
143
|
+
return Object.fromEntries(Object.entries(environment).filter(([name]) => (
|
|
144
|
+
allowed.has(name) || name.startsWith("LC_")
|
|
145
|
+
)));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assertUserOwnedMacInstall(environment, fsImpl, geteuid) {
|
|
149
|
+
if (environment.SUDO_USER || geteuid() === 0) {
|
|
150
|
+
throw new Error("refusing to install vendor CLIs under sudo or as root on macOS");
|
|
151
|
+
}
|
|
152
|
+
const home = environment.HOME || os.homedir();
|
|
153
|
+
if (fsImpl.existsSync(home)) {
|
|
154
|
+
const owner = fsImpl.statSync(home).uid;
|
|
155
|
+
if (Number.isInteger(owner) && owner !== geteuid()) {
|
|
156
|
+
throw new Error("the macOS home directory is not owned by the current user");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function installationFailure(result, thrown = null) {
|
|
162
|
+
const error = thrown || result?.error;
|
|
163
|
+
return error
|
|
164
|
+
? { code: error.code || null, message: error.message || String(error), status: null, signal: null }
|
|
165
|
+
: {
|
|
166
|
+
code: null,
|
|
167
|
+
message: null,
|
|
168
|
+
status: Number.isInteger(result?.status) ? result.status : null,
|
|
169
|
+
signal: result?.signal || null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function prepareMacClis({
|
|
174
|
+
gatewayUrl,
|
|
175
|
+
tenantId,
|
|
176
|
+
skipInstall = false,
|
|
177
|
+
installTools = ["claude", "codex"],
|
|
178
|
+
} = {}, dependencies = {}) {
|
|
179
|
+
const io = {
|
|
180
|
+
environment: process.env,
|
|
181
|
+
find: findNativeBinary,
|
|
182
|
+
verify: verifyMacCli,
|
|
183
|
+
run: spawnSync,
|
|
184
|
+
fetchImpl: fetch,
|
|
185
|
+
fsImpl: fs,
|
|
186
|
+
geteuid: () => typeof process.geteuid === "function" ? process.geteuid() : null,
|
|
187
|
+
installers: MAC_CLI_INSTALLERS,
|
|
188
|
+
ensureClaudeProfile: ensureImpelClaudeProfile,
|
|
189
|
+
ensureCodexProfile: ensureImpelCodexProfile,
|
|
190
|
+
syncSkills: syncSkillsSafe,
|
|
191
|
+
...dependencies,
|
|
192
|
+
};
|
|
193
|
+
const before = detectMacClis(io.find, io.environment, io.verify);
|
|
194
|
+
const missingBefore = Object.entries(before).filter(([, binary]) => !binary).map(([tool]) => tool);
|
|
195
|
+
const requested = new Set(installTools);
|
|
196
|
+
if (requested.size !== installTools.length || [...requested].some((tool) => !io.installers[tool])) {
|
|
197
|
+
throw new Error("installTools must contain unique supported macOS CLI names");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const installations = {};
|
|
201
|
+
if (missingBefore.length && !skipInstall) {
|
|
202
|
+
assertUserOwnedMacInstall(io.environment, io.fsImpl, io.geteuid);
|
|
203
|
+
for (const tool of missingBefore.filter((candidate) => requested.has(candidate))) {
|
|
204
|
+
try {
|
|
205
|
+
const result = await installMacCli(tool, io);
|
|
206
|
+
const succeeded = result?.status === 0 && !result?.error;
|
|
207
|
+
installations[tool] = {
|
|
208
|
+
attempted: true,
|
|
209
|
+
succeeded,
|
|
210
|
+
failure: succeeded ? null : installationFailure(result),
|
|
211
|
+
command: io.installers[tool].command,
|
|
212
|
+
};
|
|
213
|
+
} catch (error) {
|
|
214
|
+
installations[tool] = {
|
|
215
|
+
attempted: true,
|
|
216
|
+
succeeded: false,
|
|
217
|
+
failure: installationFailure(null, error),
|
|
218
|
+
command: io.installers[tool].command,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const installAttempted = Object.keys(installations).length > 0;
|
|
225
|
+
const binaries = installAttempted
|
|
226
|
+
? detectMacClis(io.find, io.environment, io.verify)
|
|
227
|
+
: before;
|
|
228
|
+
const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId);
|
|
229
|
+
const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId);
|
|
230
|
+
if (binaries.claude) {
|
|
231
|
+
await io.syncSkills({
|
|
232
|
+
client: "claude",
|
|
233
|
+
gatewayUrl,
|
|
234
|
+
env: { CLAUDE_CONFIG_DIR: claudeProfile.configDir },
|
|
235
|
+
label: "Impel isolated Claude (impel claude)",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (binaries.codex) {
|
|
239
|
+
await io.syncSkills({
|
|
240
|
+
client: "codex",
|
|
241
|
+
gatewayUrl,
|
|
242
|
+
env: { CODEX_HOME: codexProfile.codexHome },
|
|
243
|
+
label: "Impel isolated Codex (impel codex)",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
binaries,
|
|
249
|
+
missingBefore,
|
|
250
|
+
missingAfter: Object.entries(binaries).filter(([, binary]) => !binary).map(([tool]) => tool),
|
|
251
|
+
installAttempted,
|
|
252
|
+
installSucceeded: installAttempted
|
|
253
|
+
? Object.values(installations).every((installation) => installation.succeeded)
|
|
254
|
+
: null,
|
|
255
|
+
installFailure: Object.values(installations).find((installation) => installation.failure)?.failure || null,
|
|
256
|
+
installations,
|
|
257
|
+
installCommands: Object.fromEntries(missingBefore.map((tool) => [tool, io.installers[tool].command])),
|
|
258
|
+
profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
|
|
259
|
+
};
|
|
260
|
+
}
|
package/src/nativeProcess.js
CHANGED
|
@@ -75,12 +75,12 @@ function commonCandidates(tool, environment, platform) {
|
|
|
75
75
|
const home = environmentValue(environment, "USERPROFILE") || environmentValue(environment, "HOME") || os.homedir();
|
|
76
76
|
const locations = [];
|
|
77
77
|
|
|
78
|
-
if (tool === "claude") {
|
|
78
|
+
if (tool === "claude" || tool === "codex") {
|
|
79
79
|
locations.push(
|
|
80
|
-
[paths.join(home, ".local", "bin"),
|
|
81
|
-
[paths.join(home, ".claude", "local"), "claude"],
|
|
80
|
+
[paths.join(home, ".local", "bin"), tool],
|
|
82
81
|
);
|
|
83
82
|
}
|
|
83
|
+
if (tool === "claude") locations.push([paths.join(home, ".claude", "local"), "claude"]);
|
|
84
84
|
|
|
85
85
|
if (platform === "win32") {
|
|
86
86
|
const appData = environmentValue(environment, "APPDATA");
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
|
|
2
|
+
import { redactSecretText } from "./config.js";
|
|
3
|
+
import { findNativeBinary } from "./nativeProcess.js";
|
|
4
|
+
import { prepareMacClis } from "./macSetup.js";
|
|
5
|
+
import { prepareWindowsClis } from "./windowsSetup.js";
|
|
6
|
+
|
|
7
|
+
export async function preparePlatformClis(options = {}, dependencies = {}) {
|
|
8
|
+
const platform = options.platform || process.platform;
|
|
9
|
+
if (platform === "darwin") return prepareMacClis(options, dependencies);
|
|
10
|
+
if (platform === "win32") return prepareWindowsClis(options, dependencies);
|
|
11
|
+
|
|
12
|
+
const environment = dependencies.environment || process.env;
|
|
13
|
+
const find = dependencies.find || findNativeBinary;
|
|
14
|
+
const binaries = {
|
|
15
|
+
claude: find("claude", environment, platform),
|
|
16
|
+
codex: find("codex", environment, platform),
|
|
17
|
+
};
|
|
18
|
+
const claudeProfile = (dependencies.ensureClaudeProfile || ensureImpelClaudeProfile)(options.gatewayUrl, options.tenantId);
|
|
19
|
+
const codexProfile = (dependencies.ensureCodexProfile || ensureImpelCodexProfile)(options.gatewayUrl, options.tenantId);
|
|
20
|
+
const missingAfter = Object.entries(binaries).filter(([, binary]) => !binary).map(([tool]) => tool);
|
|
21
|
+
return {
|
|
22
|
+
binaries,
|
|
23
|
+
missingBefore: [...missingAfter],
|
|
24
|
+
missingAfter,
|
|
25
|
+
installAttempted: false,
|
|
26
|
+
installSucceeded: null,
|
|
27
|
+
installFailure: null,
|
|
28
|
+
installations: {},
|
|
29
|
+
installCommands: {},
|
|
30
|
+
profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function describeCliFailure(prepared) {
|
|
35
|
+
const labels = { claude: "Claude Code", codex: "Codex" };
|
|
36
|
+
const details = [];
|
|
37
|
+
for (const tool of prepared.missingAfter || []) {
|
|
38
|
+
const installation = prepared.installations?.[tool];
|
|
39
|
+
const failure = installation?.failure;
|
|
40
|
+
const reason = failure?.message || failure?.code
|
|
41
|
+
|| (Number.isInteger(failure?.status)
|
|
42
|
+
? `exit ${failure.status}`
|
|
43
|
+
: installation ? "not discoverable after installation" : "not installed or discoverable");
|
|
44
|
+
details.push(`${labels[tool] || tool}: ${redactSecretText(reason)}`);
|
|
45
|
+
const command = installation?.command || prepared.installCommands?.[tool];
|
|
46
|
+
if (command) details.push(`manual ${tool} installer: ${redactSecretText(command)}`);
|
|
47
|
+
}
|
|
48
|
+
return details.join("; ") || `missing vendor CLIs: ${(prepared.missingAfter || []).join(", ")}`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function effectiveUserId() {
|
|
2
|
+
return typeof process.geteuid === "function" ? process.geteuid() : null;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function commandMutatesUserState(argv = []) {
|
|
6
|
+
const [command, subcommand] = argv;
|
|
7
|
+
if (["setup", "update", "upgrade", "_converge"].includes(command)) return true;
|
|
8
|
+
if (command === "app" || command === "apps") {
|
|
9
|
+
return subcommand !== "status";
|
|
10
|
+
}
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function elevatedMacExecution({
|
|
15
|
+
argv = [],
|
|
16
|
+
platform = process.platform,
|
|
17
|
+
geteuid = effectiveUserId,
|
|
18
|
+
} = {}) {
|
|
19
|
+
return platform === "darwin"
|
|
20
|
+
&& commandMutatesUserState(argv)
|
|
21
|
+
&& geteuid() === 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function refuseElevatedMacExecution(argv = [], dependencies = {}) {
|
|
25
|
+
if (!elevatedMacExecution({ argv, ...dependencies })) return false;
|
|
26
|
+
console.error(
|
|
27
|
+
"impel: do not run setup, update, or app management with sudo on macOS. "
|
|
28
|
+
+ "Run the command as the signed-in user so Impel state and Keychain entries have the correct owner."
|
|
29
|
+
);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
return true;
|
|
32
|
+
}
|