impel-cli 0.17.0 → 0.17.2
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 +4 -1
- package/package.json +1 -1
- package/src/apps.js +18 -4
- package/src/cli.js +2 -0
- package/src/codesign.js +98 -24
- package/src/commands/apps.js +47 -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/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Useful setup options:
|
|
|
56
56
|
```sh
|
|
57
57
|
impel setup --pat <pat> # non-interactive credential input
|
|
58
58
|
impel setup --skip-apps # prepare CLI profiles only
|
|
59
|
-
impel setup --skip-clis #
|
|
59
|
+
impel setup --skip-clis # do not install missing vendor CLIs
|
|
60
60
|
impel setup --no-recovery # disable local and assisted recovery for this run
|
|
61
61
|
```
|
|
62
62
|
|
|
@@ -133,6 +133,7 @@ Useful update options:
|
|
|
133
133
|
```sh
|
|
134
134
|
impel update --check # inspect npm availability without changing state
|
|
135
135
|
impel update --skip-apps # reconcile CLI profiles only
|
|
136
|
+
impel update --skip-clis # do not install missing vendor CLIs
|
|
136
137
|
impel update --no-recovery # disable recovery for this run
|
|
137
138
|
```
|
|
138
139
|
|
|
@@ -231,6 +232,8 @@ compatibility for one release, but no command can enable native routing.
|
|
|
231
232
|
|
|
232
233
|
### macOS
|
|
233
234
|
|
|
235
|
+
- Missing Claude Code and Codex CLIs can be installed for the signed-in user
|
|
236
|
+
through the vendors' checksum-verifying native installers after confirmation.
|
|
234
237
|
- Tenant apps live in `~/Applications`.
|
|
235
238
|
- LaunchServices registration is non-launching and idempotent.
|
|
236
239
|
- Existing bundle fingerprints and stable signing identity prevent unnecessary
|
package/package.json
CHANGED
package/src/apps.js
CHANGED
|
@@ -1373,11 +1373,13 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
|
|
|
1373
1373
|
});
|
|
1374
1374
|
|
|
1375
1375
|
const macos = path.join(staging, "Contents", "MacOS");
|
|
1376
|
+
const vendorExecutable = path.join(macos, executableName);
|
|
1377
|
+
const signingIdentity = resolveVendoredAppSigningIdentity(vendorExecutable);
|
|
1376
1378
|
writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
|
|
1377
1379
|
schemaVersion: 4,
|
|
1378
1380
|
cliVersion: CLI_VERSION,
|
|
1379
1381
|
bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
|
|
1380
|
-
signingMode:
|
|
1382
|
+
signingMode: signingModeForIdentity(signingIdentity),
|
|
1381
1383
|
vendorVersion: bundleVersion(vendorPath),
|
|
1382
1384
|
sourceBundle: vendorPath,
|
|
1383
1385
|
bundleIdentifier: paths.claude.bundleIdentifier,
|
|
@@ -1403,7 +1405,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
|
|
|
1403
1405
|
asarHeaderSha256: newAsarHash,
|
|
1404
1406
|
}, null, 2) + "\n", 0o644);
|
|
1405
1407
|
|
|
1406
|
-
signVendoredApp(staging,
|
|
1408
|
+
signVendoredApp(staging, vendorExecutable, "Claude", signingIdentity);
|
|
1407
1409
|
replaceDirectory(bundle, staging);
|
|
1408
1410
|
} catch (error) {
|
|
1409
1411
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
@@ -1456,6 +1458,8 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1456
1458
|
const runtimeAsarPath = path.join(staging, "Contents", "Resources", "app.asar");
|
|
1457
1459
|
const fastModePatchCount = patchFastModeAuthGate(runtimeAsarPath);
|
|
1458
1460
|
const asarHash = asarHeaderHash(runtimeAsarPath);
|
|
1461
|
+
const vendorExecutable = path.join(vendorBundle, "Contents", "MacOS", executableName);
|
|
1462
|
+
const signingIdentity = resolveVendoredAppSigningIdentity(vendorExecutable);
|
|
1459
1463
|
const launchHost = ensureChatGPTLaunchHost();
|
|
1460
1464
|
writeAtomic(
|
|
1461
1465
|
path.join(staging, "Contents", "Info.plist"),
|
|
@@ -1487,7 +1491,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1487
1491
|
schemaVersion: 3,
|
|
1488
1492
|
cliVersion: CLI_VERSION,
|
|
1489
1493
|
bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
|
|
1490
|
-
signingMode:
|
|
1494
|
+
signingMode: signingModeForIdentity(signingIdentity),
|
|
1491
1495
|
vendorVersion: bundleVersion(vendorPath),
|
|
1492
1496
|
sourceBundle: vendorPath,
|
|
1493
1497
|
bundleIdentifier: paths.chatgpt.bundleIdentifier,
|
|
@@ -1505,7 +1509,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1505
1509
|
asarHeaderSha256: asarHash,
|
|
1506
1510
|
}, null, 2) + "\n", 0o644);
|
|
1507
1511
|
|
|
1508
|
-
signVendoredApp(staging,
|
|
1512
|
+
signVendoredApp(staging, vendorExecutable, "ChatGPT", signingIdentity);
|
|
1509
1513
|
replaceDirectory(bundle, staging);
|
|
1510
1514
|
} catch (error) {
|
|
1511
1515
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
@@ -2131,6 +2135,16 @@ if (process.type === "browser" && displayName) {
|
|
|
2131
2135
|
`;
|
|
2132
2136
|
}
|
|
2133
2137
|
|
|
2138
|
+
function resolveVendoredAppSigningIdentity(vendorExecutable) {
|
|
2139
|
+
if (process.platform !== "darwin" || !isMachO(vendorExecutable)) return null;
|
|
2140
|
+
return resolveSigningIdentity();
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
export function signingModeForIdentity(identity) {
|
|
2144
|
+
if (!identity) return desiredSigningMode();
|
|
2145
|
+
return identity.mode === "external" ? identity.descriptor : identity.mode;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2134
2148
|
function signVendoredApp(bundle, vendorExecutable, label, identity = null) {
|
|
2135
2149
|
if (process.platform !== "darwin" || !isMachO(vendorExecutable)) return;
|
|
2136
2150
|
// Resolve (and, on first use, create) the signing identity only once we know
|
package/src/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ import { cmdSessions } from "./commands/sessions.js";
|
|
|
19
19
|
import { cmdUpdate } from "./commands/update.js";
|
|
20
20
|
import { cmdExperimental } from "./commands/experimental.js";
|
|
21
21
|
import { cmdConverge } from "./commands/converge.js";
|
|
22
|
+
import { refuseElevatedMacExecution } from "./privileges.js";
|
|
22
23
|
|
|
23
24
|
const HELP = `impel — isolated Impel workspaces for every tenant
|
|
24
25
|
|
|
@@ -75,6 +76,7 @@ function normalizeTarget(token) {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
export async function main(argv) {
|
|
79
|
+
if (refuseElevatedMacExecution(argv)) return;
|
|
78
80
|
const [cmd, ...rest] = argv;
|
|
79
81
|
|
|
80
82
|
switch (cmd) {
|
package/src/codesign.js
CHANGED
|
@@ -117,7 +117,10 @@ export function resolveSigningIdentity(deps = {}) {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
try {
|
|
120
|
-
const identity =
|
|
120
|
+
const identity = withSigningIdentityLock(
|
|
121
|
+
{ configDir, fsImpl },
|
|
122
|
+
() => ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes }),
|
|
123
|
+
);
|
|
121
124
|
if (cache) cachedIdentity = identity;
|
|
122
125
|
return identity;
|
|
123
126
|
} catch (error) {
|
|
@@ -142,25 +145,33 @@ function ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes }) {
|
|
|
142
145
|
// therefore the designated requirement) stays stable across updates.
|
|
143
146
|
if (fsImpl.existsSync(keychain) && fsImpl.existsSync(passwordFile)) {
|
|
144
147
|
const password = fsImpl.readFileSync(passwordFile, "utf8").trim();
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
148
|
+
try {
|
|
149
|
+
if (!password) throw new Error("stored signing keychain password is empty");
|
|
150
|
+
unlockKeychain(run, keychain, password);
|
|
151
|
+
runOrThrow(run, "/usr/bin/security", ["set-keychain-settings", keychain], "configure signing keychain");
|
|
152
|
+
ensureKeychainInSearchList(run, keychain);
|
|
153
|
+
let existing = findLocalIdentitySha(run, keychain);
|
|
154
|
+
if (existing) return localIdentity(existing, keychain);
|
|
155
|
+
|
|
156
|
+
// Repair identities created before trust was established, plus imports
|
|
157
|
+
// interrupted after the private key reached the keychain.
|
|
158
|
+
authorizeCodesignKey(run, keychain, password);
|
|
159
|
+
trustExistingLocalCertificate({ run, keychain, fsImpl });
|
|
160
|
+
existing = findLocalIdentitySha(run, keychain);
|
|
161
|
+
if (existing) return localIdentity(existing, keychain);
|
|
162
|
+
throw new Error("existing signing identity could not be trusted");
|
|
163
|
+
} catch {
|
|
164
|
+
// This keychain and password file are entirely Impel-owned. A mismatched,
|
|
165
|
+
// empty, or half-imported pair can never provide stable identity, so reset
|
|
166
|
+
// it atomically instead of falling back forever on every future rebuild.
|
|
167
|
+
resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl });
|
|
168
|
+
}
|
|
169
|
+
} else if (fsImpl.existsSync(keychain) || fsImpl.existsSync(passwordFile)) {
|
|
170
|
+
resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl });
|
|
159
171
|
}
|
|
160
172
|
|
|
161
173
|
const password = randomBytes(24).toString("hex");
|
|
162
|
-
createLocalSigningIdentity({ run, dir, keychain, password, fsImpl });
|
|
163
|
-
fsImpl.writeFileSync(passwordFile, `${password}\n`, { mode: 0o600 });
|
|
174
|
+
createLocalSigningIdentity({ run, dir, keychain, passwordFile, password, fsImpl });
|
|
164
175
|
|
|
165
176
|
ensureKeychainInSearchList(run, keychain);
|
|
166
177
|
const sha = findLocalIdentitySha(run, keychain);
|
|
@@ -183,7 +194,7 @@ function findLocalIdentitySha(run, keychain) {
|
|
|
183
194
|
return parseIdentitySha(result.stdout, SIGNING_IDENTITY_NAME);
|
|
184
195
|
}
|
|
185
196
|
|
|
186
|
-
function createLocalSigningIdentity({ run, dir, keychain, password, fsImpl }) {
|
|
197
|
+
function createLocalSigningIdentity({ run, dir, keychain, passwordFile, password, fsImpl }) {
|
|
187
198
|
const work = fsImpl.mkdtempSync(path.join(os.tmpdir(), "impel-codesign-"));
|
|
188
199
|
const keyPath = path.join(work, "key.pem");
|
|
189
200
|
const certPath = path.join(work, "cert.pem");
|
|
@@ -211,26 +222,89 @@ function createLocalSigningIdentity({ run, dir, keychain, password, fsImpl }) {
|
|
|
211
222
|
"-name", SIGNING_IDENTITY_NAME,
|
|
212
223
|
], "package signing certificate");
|
|
213
224
|
|
|
214
|
-
|
|
215
|
-
|
|
225
|
+
runOrThrow(run, "/usr/bin/security", ["create-keychain", "-p", password, keychain], "create signing keychain");
|
|
226
|
+
try {
|
|
227
|
+
writePrivateAtomic(fsImpl, passwordFile, `${password}\n`);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl });
|
|
230
|
+
throw error;
|
|
216
231
|
}
|
|
217
232
|
// No auto-lock: codesign must reach the private key non-interactively.
|
|
218
|
-
run
|
|
233
|
+
runOrThrow(run, "/usr/bin/security", ["set-keychain-settings", keychain], "configure signing keychain");
|
|
219
234
|
unlockKeychain(run, keychain, password);
|
|
220
235
|
runOrThrow(run, "/usr/bin/security", [
|
|
221
236
|
"import", p12Path, "-k", keychain, "-P", p12Pass, "-T", "/usr/bin/codesign",
|
|
222
237
|
], "import signing identity");
|
|
223
238
|
// Grant codesign non-interactive access to the key so signing never pops a
|
|
224
239
|
// "codesign wants to use a key" GUI prompt. We own the keychain password.
|
|
225
|
-
|
|
226
|
-
"set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain,
|
|
227
|
-
], "authorize codesign for the signing key");
|
|
240
|
+
authorizeCodesignKey(run, keychain, password);
|
|
228
241
|
trustLocalCertificate(run, keychain, certPath);
|
|
229
242
|
} finally {
|
|
230
243
|
fsImpl.rmSync(work, { recursive: true, force: true });
|
|
231
244
|
}
|
|
232
245
|
}
|
|
233
246
|
|
|
247
|
+
function authorizeCodesignKey(run, keychain, password) {
|
|
248
|
+
runOrThrow(run, "/usr/bin/security", [
|
|
249
|
+
"set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain,
|
|
250
|
+
], "authorize codesign for the signing key");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function writePrivateAtomic(fsImpl, filePath, contents) {
|
|
254
|
+
const temporary = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
255
|
+
try {
|
|
256
|
+
fsImpl.writeFileSync(temporary, contents, { mode: 0o600 });
|
|
257
|
+
fsImpl.renameSync(temporary, filePath);
|
|
258
|
+
fsImpl.chmodSync?.(filePath, 0o600);
|
|
259
|
+
} finally {
|
|
260
|
+
fsImpl.rmSync(temporary, { force: true });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl }) {
|
|
265
|
+
if (fsImpl.existsSync(keychain)) {
|
|
266
|
+
run("/usr/bin/security", ["delete-keychain", keychain]);
|
|
267
|
+
}
|
|
268
|
+
fsImpl.rmSync(keychain, { force: true });
|
|
269
|
+
fsImpl.rmSync(passwordFile, { force: true });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const SIGNING_LOCK_STALE_MS = 5 * 60 * 1_000;
|
|
273
|
+
const SIGNING_LOCK_WAIT_MS = 15_000;
|
|
274
|
+
const SIGNING_LOCK_POLL_MS = 100;
|
|
275
|
+
const SIGNING_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
276
|
+
|
|
277
|
+
function withSigningIdentityLock({ configDir, fsImpl }, run) {
|
|
278
|
+
const dir = path.join(configDir, "codesign");
|
|
279
|
+
const lock = path.join(dir, ".identity-lock");
|
|
280
|
+
fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
281
|
+
const deadline = Date.now() + SIGNING_LOCK_WAIT_MS;
|
|
282
|
+
while (true) {
|
|
283
|
+
try {
|
|
284
|
+
fsImpl.mkdirSync(lock, { mode: 0o700 });
|
|
285
|
+
break;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (error?.code !== "EEXIST") throw error;
|
|
288
|
+
try {
|
|
289
|
+
if (Date.now() - fsImpl.statSync(lock).mtimeMs > SIGNING_LOCK_STALE_MS) {
|
|
290
|
+
fsImpl.rmSync(lock, { recursive: true, force: true });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
} catch (statError) {
|
|
294
|
+
if (statError?.code !== "ENOENT") throw statError;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (Date.now() >= deadline) throw new Error("timed out waiting for another Impel signing operation");
|
|
298
|
+
Atomics.wait(SIGNING_LOCK_SLEEP, 0, 0, SIGNING_LOCK_POLL_MS);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
return run();
|
|
303
|
+
} finally {
|
|
304
|
+
fsImpl.rmSync(lock, { recursive: true, force: true });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
234
308
|
function unlockKeychain(run, keychain, password) {
|
|
235
309
|
runOrThrow(
|
|
236
310
|
run,
|
package/src/commands/apps.js
CHANGED
|
@@ -43,6 +43,7 @@ import { syncAgentProfilesSafe } from "../agents.js";
|
|
|
43
43
|
import { secureManagedCodexHome } from "../codexSecurity.js";
|
|
44
44
|
import { impelCliInvocation } from "../selfInvocation.js";
|
|
45
45
|
import { createProgressLogger, withProgress } from "../progress.js";
|
|
46
|
+
import { findNativeBinary } from "../nativeProcess.js";
|
|
46
47
|
import {
|
|
47
48
|
ensureWindowsClaudeApp,
|
|
48
49
|
ensureWindowsChatGPTApp,
|
|
@@ -180,6 +181,21 @@ export function installedAppTenantIds(targets, {
|
|
|
180
181
|
return installed.sort((left, right) => left.localeCompare(right));
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
async function maybeRunLegacyUpdateHandoff(argv, overrides) {
|
|
185
|
+
if (overrides.singleTenantUpdate) return null;
|
|
186
|
+
const [action, target, ...rest] = argv;
|
|
187
|
+
if (action !== "update" || target !== "all" || rest.length > 0) return null;
|
|
188
|
+
|
|
189
|
+
// impel-cli <=0.16.5 re-executes a newly installed build with this exact
|
|
190
|
+
// command. Route that cross-version handoff through the current full-tenant
|
|
191
|
+
// reconciler so first-time tenant targets are not limited to local installs.
|
|
192
|
+
const runConvergence = overrides.runAllTenantConvergence || (async () => {
|
|
193
|
+
const { cmdConverge } = await import("./converge.js");
|
|
194
|
+
return cmdConverge([]);
|
|
195
|
+
});
|
|
196
|
+
return runConvergence();
|
|
197
|
+
}
|
|
198
|
+
|
|
183
199
|
async function maybeUpdateAllInstalledTenants(argv, overrides, platform) {
|
|
184
200
|
if (overrides.singleTenantUpdate) return null;
|
|
185
201
|
const [action = "status", rawTarget, ...rest] = argv;
|
|
@@ -310,6 +326,7 @@ export async function reconcileWindowsTenantApps({
|
|
|
310
326
|
syncSkills: syncSkillsSafe,
|
|
311
327
|
syncAgents: syncAgentProfilesSafe,
|
|
312
328
|
existsSync: fs.existsSync,
|
|
329
|
+
findBinary: findNativeBinary,
|
|
313
330
|
log: (message) => console.log(message),
|
|
314
331
|
...overrides,
|
|
315
332
|
};
|
|
@@ -360,17 +377,23 @@ export async function reconcileWindowsTenantApps({
|
|
|
360
377
|
homeDir,
|
|
361
378
|
environment,
|
|
362
379
|
});
|
|
380
|
+
const agentTargets = [];
|
|
363
381
|
for (const target of actionTargets) {
|
|
364
382
|
const { client, env, label } = appSkillTarget(target, paths);
|
|
365
|
-
|
|
383
|
+
if (io.findBinary(client, environment, "win32")) {
|
|
384
|
+
await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
|
|
385
|
+
agentTargets.push(target);
|
|
386
|
+
}
|
|
366
387
|
if (target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
|
|
367
388
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
389
|
+
if (agentTargets.length) {
|
|
390
|
+
await io.syncAgents({
|
|
391
|
+
profiles: agentTargets.map((target) => appAgentProfile(target, paths)),
|
|
392
|
+
gatewayUrl: config.gatewayUrl,
|
|
393
|
+
credential: config.pat,
|
|
394
|
+
tenantId: config.tenantId,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
374
397
|
const verification = {
|
|
375
398
|
claude: !actionTargets.includes("claude") || io.existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
|
|
376
399
|
chatgpt: !actionTargets.includes("chatgpt") || io.existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
|
|
@@ -389,6 +412,7 @@ export async function reconcileMacTenantApps({
|
|
|
389
412
|
skipVendorTargets = [],
|
|
390
413
|
confirmVendorInstall = async () => true,
|
|
391
414
|
homeDir = os.homedir(),
|
|
415
|
+
environment = process.env,
|
|
392
416
|
} = {}, overrides = {}) {
|
|
393
417
|
const io = {
|
|
394
418
|
status: appStatus,
|
|
@@ -402,6 +426,7 @@ export async function reconcileMacTenantApps({
|
|
|
402
426
|
secureCodexHome: secureManagedCodexHome,
|
|
403
427
|
openLauncher: openManagedLauncher,
|
|
404
428
|
existsSync: fs.existsSync,
|
|
429
|
+
findBinary: findNativeBinary,
|
|
405
430
|
log: (message) => console.log(message),
|
|
406
431
|
...overrides,
|
|
407
432
|
};
|
|
@@ -447,17 +472,23 @@ export async function reconcileMacTenantApps({
|
|
|
447
472
|
writeBundles: staleTargets,
|
|
448
473
|
});
|
|
449
474
|
const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
|
|
475
|
+
const agentItems = [];
|
|
450
476
|
for (const item of installed) {
|
|
451
477
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
452
|
-
|
|
478
|
+
if (io.findBinary(client, environment, "darwin")) {
|
|
479
|
+
await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
|
|
480
|
+
agentItems.push(item);
|
|
481
|
+
}
|
|
453
482
|
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
454
483
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
484
|
+
if (agentItems.length) {
|
|
485
|
+
await io.syncAgents({
|
|
486
|
+
profiles: agentItems.map((item) => appAgentProfile(item.target, paths)),
|
|
487
|
+
gatewayUrl: config.gatewayUrl,
|
|
488
|
+
credential: config.pat,
|
|
489
|
+
tenantId: config.tenantId,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
461
492
|
for (const item of installed) {
|
|
462
493
|
if (reopenTargets.has(item.target)) io.openLauncher(item.launcher);
|
|
463
494
|
}
|
|
@@ -712,6 +743,8 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
712
743
|
|
|
713
744
|
export async function cmdApps(argv, overrides = {}) {
|
|
714
745
|
const platform = overrides.platform || process.platform;
|
|
746
|
+
const legacyUpdateHandoff = await maybeRunLegacyUpdateHandoff(argv, overrides);
|
|
747
|
+
if (legacyUpdateHandoff !== null) return legacyUpdateHandoff;
|
|
715
748
|
const allTenantUpdate = await maybeUpdateAllInstalledTenants(argv, overrides, platform);
|
|
716
749
|
if (allTenantUpdate !== null) return allTenantUpdate;
|
|
717
750
|
if (platform === "win32") return cmdWindowsApps(argv, overrides);
|
package/src/commands/converge.js
CHANGED
|
@@ -5,6 +5,7 @@ import { runInstallRecovery } from "../installRecovery/engine.js";
|
|
|
5
5
|
import { printReconciliationSummary, reconcileAllTenants, selectDefaultTenant } from "../provisioning.js";
|
|
6
6
|
import { fetchTenants } from "../tenants.js";
|
|
7
7
|
import { promptText } from "../prompt.js";
|
|
8
|
+
import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
|
|
8
9
|
import { restoreNativeProfiles } from "./use.js";
|
|
9
10
|
|
|
10
11
|
function confirmed(answer) {
|
|
@@ -24,6 +25,7 @@ function mergeTenantReport(report, replacement) {
|
|
|
24
25
|
|
|
25
26
|
export async function cmdConverge(argv = [], overrides = {}) {
|
|
26
27
|
const skipApps = argv.includes("--skip-apps");
|
|
28
|
+
const skipClis = argv.includes("--skip-clis");
|
|
27
29
|
const noRecovery = argv.includes("--no-recovery");
|
|
28
30
|
const io = {
|
|
29
31
|
loadConfig,
|
|
@@ -36,8 +38,12 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
36
38
|
isTTY: process.stdin.isTTY,
|
|
37
39
|
platform: process.platform,
|
|
38
40
|
environment: process.env,
|
|
41
|
+
preparePlatformClis,
|
|
39
42
|
...overrides,
|
|
40
43
|
};
|
|
44
|
+
if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
|
|
45
|
+
io.preparePlatformClis = overrides.prepareWindowsClis;
|
|
46
|
+
}
|
|
41
47
|
const config = io.loadConfig();
|
|
42
48
|
if (!config?.pat) {
|
|
43
49
|
console.error("impel update: not authenticated; run `impel setup` first.");
|
|
@@ -93,8 +99,94 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
93
99
|
environment: io.environment,
|
|
94
100
|
confirmVendorInstall: confirmInstall,
|
|
95
101
|
}, overrides.reconcileOverrides || {});
|
|
102
|
+
|
|
103
|
+
let sharedFailure = null;
|
|
104
|
+
const prepareSharedClis = async ({ confirmedTools = null, inspectOnly = false } = {}) => {
|
|
105
|
+
try {
|
|
106
|
+
const inspected = await io.preparePlatformClis({
|
|
107
|
+
gatewayUrl: config.gatewayUrl,
|
|
108
|
+
tenantId: selected.id,
|
|
109
|
+
platform: io.platform,
|
|
110
|
+
skipInstall: true,
|
|
111
|
+
});
|
|
112
|
+
let prepared = inspected;
|
|
113
|
+
if (inspected.missingAfter.length && !inspectOnly && !skipClis) {
|
|
114
|
+
let installTools = confirmedTools;
|
|
115
|
+
if (installTools === null) {
|
|
116
|
+
installTools = [];
|
|
117
|
+
if (io.isTTY) {
|
|
118
|
+
for (const tool of inspected.missingAfter) {
|
|
119
|
+
if (!inspected.installCommands?.[tool]) continue;
|
|
120
|
+
const label = tool === "claude" ? "Claude Code" : "Codex";
|
|
121
|
+
if (confirmed(await io.promptText(`Install the official ${label} CLI for this user? [y/N] `))) {
|
|
122
|
+
installTools.push(tool);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (installTools.length) {
|
|
128
|
+
prepared = await io.preparePlatformClis({
|
|
129
|
+
gatewayUrl: config.gatewayUrl,
|
|
130
|
+
tenantId: selected.id,
|
|
131
|
+
platform: io.platform,
|
|
132
|
+
skipInstall: false,
|
|
133
|
+
installTools,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
sharedFailure = prepared.missingAfter.length ? describeCliFailure(prepared) : null;
|
|
138
|
+
return prepared;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
sharedFailure = redactSecretText(error?.message || error);
|
|
141
|
+
return { binaries: {}, missingAfter: ["claude", "codex"] };
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
await prepareSharedClis();
|
|
96
146
|
let report = await run();
|
|
97
|
-
if (
|
|
147
|
+
if (sharedFailure && !noRecovery) {
|
|
148
|
+
let sharedReady = false;
|
|
149
|
+
const recovery = await io.recoverInstall({
|
|
150
|
+
failure: {
|
|
151
|
+
scope: "shared",
|
|
152
|
+
platform: io.platform,
|
|
153
|
+
architecture: process.arch,
|
|
154
|
+
step: "update.shared_vendor_clis",
|
|
155
|
+
message: sharedFailure,
|
|
156
|
+
},
|
|
157
|
+
config,
|
|
158
|
+
goals: [{
|
|
159
|
+
id: "shared-vendor-clis-ready",
|
|
160
|
+
description: "Shared vendor CLI prerequisites are installed and discoverable",
|
|
161
|
+
run: () => sharedReady,
|
|
162
|
+
}],
|
|
163
|
+
explicit: true,
|
|
164
|
+
noRecovery: false,
|
|
165
|
+
actionContext: {
|
|
166
|
+
tenantId: selected.id,
|
|
167
|
+
installVendorClis: async (tool) => {
|
|
168
|
+
await prepareSharedClis({ confirmedTools: [tool] });
|
|
169
|
+
sharedReady = !sharedFailure;
|
|
170
|
+
return io.preparePlatformClis({
|
|
171
|
+
gatewayUrl: config.gatewayUrl,
|
|
172
|
+
tenantId: selected.id,
|
|
173
|
+
platform: io.platform,
|
|
174
|
+
skipInstall: true,
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
retryStep: async () => {
|
|
178
|
+
await prepareSharedClis({ inspectOnly: true });
|
|
179
|
+
sharedReady = !sharedFailure;
|
|
180
|
+
return sharedReady;
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
}, overrides.recoveryOverrides || {});
|
|
184
|
+
if (recovery.fixed) {
|
|
185
|
+
await prepareSharedClis({ inspectOnly: true });
|
|
186
|
+
report = await run();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!sharedFailure && !report.passed && !noRecovery) {
|
|
98
190
|
for (const failed of report.tenants.filter((tenant) => tenant.status === "failed")) {
|
|
99
191
|
const tenant = listing.tenants.find((candidate) => candidate.id === failed.tenantId);
|
|
100
192
|
if (!tenant) continue;
|
|
@@ -165,7 +257,8 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
165
257
|
}
|
|
166
258
|
printReconciliationSummary(report);
|
|
167
259
|
console.log(`CLI tenant: ${selected.id} (change with \`impel tenant use <tenant>\`)`);
|
|
168
|
-
if (!report.passed) {
|
|
260
|
+
if (!report.passed || sharedFailure) {
|
|
261
|
+
if (sharedFailure) console.error(`Shared update failure: ${sharedFailure}`);
|
|
169
262
|
console.error("impel update: one or more tenant surfaces remain incomplete.");
|
|
170
263
|
process.exitCode = 1;
|
|
171
264
|
return false;
|
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
|
+
}
|
package/src/provisioning.js
CHANGED
|
@@ -110,19 +110,21 @@ async function prepareTenantCli(config, tenant, io, binaries) {
|
|
|
110
110
|
try {
|
|
111
111
|
const profile = definition.ensure();
|
|
112
112
|
const root = definition.root(profile);
|
|
113
|
+
if (!binaries[client]) {
|
|
114
|
+
clients[client] = {
|
|
115
|
+
status: "failed",
|
|
116
|
+
root,
|
|
117
|
+
error: `${client === "claude" ? "Claude Code" : "Codex"} CLI is not installed or discoverable`,
|
|
118
|
+
};
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
113
121
|
await io.syncSkills({
|
|
114
122
|
client,
|
|
115
123
|
gatewayUrl,
|
|
116
124
|
env: definition.env(profile),
|
|
117
125
|
label: `Impel ${client === "claude" ? "Claude" : "Codex"} CLI (${tenant.id})`,
|
|
118
126
|
});
|
|
119
|
-
clients[client] =
|
|
120
|
-
? { status: "ready", root, error: null }
|
|
121
|
-
: {
|
|
122
|
-
status: "failed",
|
|
123
|
-
root,
|
|
124
|
-
error: `${client === "claude" ? "Claude Code" : "Codex"} CLI is not installed or discoverable`,
|
|
125
|
-
};
|
|
127
|
+
clients[client] = { status: "ready", root, error: null };
|
|
126
128
|
} catch (error) {
|
|
127
129
|
clients[client] = {
|
|
128
130
|
status: "failed",
|
|
@@ -133,7 +135,7 @@ async function prepareTenantCli(config, tenant, io, binaries) {
|
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
const agentProfiles = Object.entries(clients)
|
|
136
|
-
.filter(([, client]) => client.root)
|
|
138
|
+
.filter(([, client]) => client.root && client.status === "ready")
|
|
137
139
|
.map(([client, value]) => ({
|
|
138
140
|
client,
|
|
139
141
|
root: value.root,
|