impel-cli 0.17.6 → 0.17.8
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/package.json +1 -1
- package/src/apps.js +93 -32
- package/src/cli.js +8 -0
- package/src/commands/apps.js +31 -11
- package/src/commands/nuke.js +230 -0
package/package.json
CHANGED
package/src/apps.js
CHANGED
|
@@ -41,19 +41,26 @@ const APP_DEFINITIONS = {
|
|
|
41
41
|
// CLI release therefore installs and accepts only the exact app builds tested
|
|
42
42
|
// with its compatibility patches. Updating a pin requires a new CLI release.
|
|
43
43
|
export const PINNED_VENDOR_APPS = Object.freeze({
|
|
44
|
+
// 1.22209.3 (the previous pin) routes every managed 3P gateway profile into
|
|
45
|
+
// the Claude Code marketing page at app://localhost/code/disabled: the
|
|
46
|
+
// desktop Code surface never becomes available, the hardcoded-off
|
|
47
|
+
// claude_code_web gate traps /code/*, and the page's "Back to Claude" link
|
|
48
|
+
// targets "/" so the persisted Code mode loops straight back. 1.20186.9 is
|
|
49
|
+
// the last build verified working end-to-end for gateway profiles. Do not
|
|
50
|
+
// re-upgrade without a verified fix for the Code-surface routing contract.
|
|
44
51
|
claude: Object.freeze({
|
|
45
|
-
version: "1.
|
|
46
|
-
claudeCodeVersion: "2.1.
|
|
47
|
-
claudeCodeCommit: "
|
|
52
|
+
version: "1.20186.9",
|
|
53
|
+
claudeCodeVersion: "2.1.209",
|
|
54
|
+
claudeCodeCommit: "0fe048596fd45e79e99353cbe2c3d1b1ac069568",
|
|
48
55
|
bundleName: "Claude.app",
|
|
49
56
|
downloads: Object.freeze({
|
|
50
57
|
arm64: Object.freeze({
|
|
51
|
-
url: "https://downloads.claude.ai/releases/darwin/universal/1.
|
|
52
|
-
sha256: "
|
|
58
|
+
url: "https://downloads.claude.ai/releases/darwin/universal/1.20186.9/Claude-69f150a4c9316d5c8cd7b9f130ed583d15c0383e.zip",
|
|
59
|
+
sha256: "9364648c74bd525aa293a28103eb37262a5553de21c7ca907766d7fc40623015",
|
|
53
60
|
}),
|
|
54
61
|
x64: Object.freeze({
|
|
55
|
-
url: "https://downloads.claude.ai/releases/darwin/universal/1.
|
|
56
|
-
sha256: "
|
|
62
|
+
url: "https://downloads.claude.ai/releases/darwin/universal/1.20186.9/Claude-69f150a4c9316d5c8cd7b9f130ed583d15c0383e.zip",
|
|
63
|
+
sha256: "9364648c74bd525aa293a28103eb37262a5553de21c7ca907766d7fc40623015",
|
|
57
64
|
}),
|
|
58
65
|
}),
|
|
59
66
|
}),
|
|
@@ -85,10 +92,12 @@ const ULTRA_REASONING_LEVEL = { effort: "ultra", description: "Maximum reasoning
|
|
|
85
92
|
const FAST_SERVICE_TIER = { id: "priority", name: "Fast", description: "1.5x speed, increased usage" };
|
|
86
93
|
const FAST_MODE_AUTH_GATE = /([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)&&!([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)!=null&&\4\?\.requirements\?\.featureRequirements\?\.fast_mode!==!1/gu;
|
|
87
94
|
const CHATGPT_RESOURCE_ROOT = "linked_vendor_resources_with_patched_asar";
|
|
88
|
-
const CLAUDE_PLAN_USAGE_GUARD = "if(!
|
|
89
|
-
const IMPEL_CLAUDE_PLAN_USAGE_GUARD =
|
|
90
|
-
const
|
|
91
|
-
const
|
|
95
|
+
const CLAUDE_PLAN_USAGE_GUARD = "if(!Ze().hasOrgPolicyBackend())return;if((r=fr())";
|
|
96
|
+
const IMPEL_CLAUDE_PLAN_USAGE_GUARD = `${"if(!wze())return;".padEnd("if(!Ze().hasOrgPolicyBackend())return;".length, " ")}if((r=fr())`;
|
|
97
|
+
const CLAUDE_PLAN_USAGE_REQUEST = 'P.net.fetch(`${ht()}/api/organizations/${o}/usage`,{signal:AbortSignal.timeout(mcr)})';
|
|
98
|
+
const IMPEL_CLAUDE_PLAN_USAGE_REQUEST = 'P.net.fetch("https://gateway.useimpel.com/ui/a/u",{headers:{key:dR(Oe()).apiKey}})'.padEnd(CLAUDE_PLAN_USAGE_REQUEST.length, " ");
|
|
99
|
+
const CLAUDE_DRAFT_AGENT_MENTIONS = 'agentMentionsEnabled:"draft"===Be&&s';
|
|
100
|
+
const IMPEL_CLAUDE_BOUND_AGENT_MENTIONS = 'agentMentionsEnabled:"draft"===Be?s:Le?.agent??!1';
|
|
92
101
|
const CLAUDE_AGENT_MENTION_FILTER = 'return e.filter(e=>"user"===e.source&&(!s||e.name.toLowerCase().includes(s))).slice(0,10)';
|
|
93
102
|
const IMPEL_CLAUDE_BOUND_AGENT_MENTION_FILTER = 'return e.filter(e=>"user"===e.source&&(!s||e.name.toLowerCase().includes(s))&&("string"!=typeof y||e.name===y)).slice(0,10)';
|
|
94
103
|
const CLAUDE_AGENT_MENTION_SELECT = 'onSelect:t=>(e(String(t)),Promise.resolve(null))';
|
|
@@ -174,7 +183,7 @@ export const CURRENT_CONFIG_VERSION = 17;
|
|
|
174
183
|
// — which is what made every `impel update` re-trigger macOS permission
|
|
175
184
|
// prompts. Bump this ONLY when a code change alters the bytes of a built
|
|
176
185
|
// bundle; leave it alone for changes that don't touch bundle contents.
|
|
177
|
-
export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-
|
|
186
|
+
export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-21.1";
|
|
178
187
|
|
|
179
188
|
/** Parse the tenant's install manifest, or null when absent/corrupt. */
|
|
180
189
|
export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
|
|
@@ -245,6 +254,7 @@ export function bundleIsCurrent(status, {
|
|
|
245
254
|
compatibility.patches?.followupBoundAgentEnable === 1
|
|
246
255
|
&& compatibility.patches?.followupBoundAgentFilter === 1
|
|
247
256
|
&& compatibility.patches?.followupBoundAgentChip === 1
|
|
257
|
+
&& claudePatchSetMatchesCompatibility(compatibility)
|
|
248
258
|
&& managedClaudeRendererMatchesCompatibility(status, compatibility)
|
|
249
259
|
))
|
|
250
260
|
&& (status.target !== "chatgpt" || (
|
|
@@ -278,6 +288,36 @@ function wrapperLaunchesOnTahoe(status) {
|
|
|
278
288
|
}
|
|
279
289
|
}
|
|
280
290
|
|
|
291
|
+
// The exact patch and renderer-asset groups this CLI build writes. A bundle
|
|
292
|
+
// whose compatibility metadata records a DIFFERENT set — even a superset — was
|
|
293
|
+
// built by different patching logic (e.g. a dev working tree that reused the
|
|
294
|
+
// released fingerprint), so it must be reported stale and rebuilt rather than
|
|
295
|
+
// trusted to behave like a reviewed release.
|
|
296
|
+
const CLAUDE_COMPATIBILITY_PATCH_KEYS = Object.freeze([
|
|
297
|
+
"aggregatePlanUsageGuard",
|
|
298
|
+
"aggregatePlanUsageRequest",
|
|
299
|
+
"safeStorageName",
|
|
300
|
+
"followupBoundAgentEnable",
|
|
301
|
+
"followupBoundAgentFilter",
|
|
302
|
+
"followupBoundAgentChip",
|
|
303
|
+
]);
|
|
304
|
+
const CLAUDE_COMPATIBILITY_RENDERER_GROUPS = Object.freeze(["agentMentions"]);
|
|
305
|
+
|
|
306
|
+
function claudePatchSetMatchesCompatibility(compatibility) {
|
|
307
|
+
try {
|
|
308
|
+
const patchKeys = Object.keys(compatibility.patches ?? {}).sort();
|
|
309
|
+
const rendererGroups = Object.keys(compatibility.rendererAssets ?? {}).sort();
|
|
310
|
+
return (
|
|
311
|
+
patchKeys.length === CLAUDE_COMPATIBILITY_PATCH_KEYS.length
|
|
312
|
+
&& [...CLAUDE_COMPATIBILITY_PATCH_KEYS].sort().every((key, index) => patchKeys[index] === key)
|
|
313
|
+
&& rendererGroups.length === CLAUDE_COMPATIBILITY_RENDERER_GROUPS.length
|
|
314
|
+
&& [...CLAUDE_COMPATIBILITY_RENDERER_GROUPS].sort().every((key, index) => rendererGroups[index] === key)
|
|
315
|
+
);
|
|
316
|
+
} catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
281
321
|
function managedClaudeRendererMatchesCompatibility(status, compatibility) {
|
|
282
322
|
try {
|
|
283
323
|
const renderers = compatibility.rendererAssets?.agentMentions;
|
|
@@ -618,27 +658,13 @@ export function installManagedAppFiles({
|
|
|
618
658
|
|
|
619
659
|
const installed = [];
|
|
620
660
|
for (const target of targets) {
|
|
621
|
-
if (target === "claude") {
|
|
622
|
-
migrateLegacyClaudeAppSessions(paths.claude.userData, homeDir);
|
|
623
|
-
writeClaudeNative3PSelection(paths);
|
|
624
|
-
writeClaudeCodeSettings(paths);
|
|
625
|
-
writeClaudeConfig(paths, config, models);
|
|
626
|
-
ensureClaudeSessionHooks(paths.claude.userData, config.tenantId, "claude_desktop");
|
|
627
|
-
}
|
|
628
|
-
else {
|
|
629
|
-
writeChatGPTConfig(
|
|
630
|
-
paths,
|
|
631
|
-
config,
|
|
632
|
-
models,
|
|
633
|
-
vendorCodexModels,
|
|
634
|
-
chatgptInvocations,
|
|
635
|
-
generatedAt,
|
|
636
|
-
);
|
|
637
|
-
ensureCodexSessionHooks(paths.chatgpt.codexHome, config.tenantId, "codex_desktop");
|
|
638
|
-
}
|
|
639
661
|
// Non-bundle targets are the background-refresh / fast-open path: configs,
|
|
640
662
|
// token helper, catalog, and manifest only. Bundle swaps require the app
|
|
641
663
|
// to be closed (see quitBlockingApps), so those paths never attempt one.
|
|
664
|
+
// A stale bundle is rebuilt BEFORE its profile config is rewritten: the
|
|
665
|
+
// generated config is only reviewed against the pinned vendor build, so a
|
|
666
|
+
// failed rebuild must leave the old bundle paired with its old config
|
|
667
|
+
// rather than stranding it with a contract written for a different app.
|
|
642
668
|
if (bundleTargets.has(target)) {
|
|
643
669
|
if (resolvedVendorPaths[target] && isVendableVendorApp(target, resolvedVendorPaths[target])) {
|
|
644
670
|
assertPinnedVendorAppVersion(target, resolvedVendorPaths[target]);
|
|
@@ -656,6 +682,24 @@ export function installManagedAppFiles({
|
|
|
656
682
|
fs.rmSync(appPaths(homeDir)[target].launcher, { recursive: true, force: true });
|
|
657
683
|
}
|
|
658
684
|
}
|
|
685
|
+
if (target === "claude") {
|
|
686
|
+
migrateLegacyClaudeAppSessions(paths.claude.userData, homeDir);
|
|
687
|
+
writeClaudeNative3PSelection(paths);
|
|
688
|
+
writeClaudeCodeSettings(paths);
|
|
689
|
+
writeClaudeConfig(paths, config, models);
|
|
690
|
+
ensureClaudeSessionHooks(paths.claude.userData, config.tenantId, "claude_desktop");
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
writeChatGPTConfig(
|
|
694
|
+
paths,
|
|
695
|
+
config,
|
|
696
|
+
models,
|
|
697
|
+
vendorCodexModels,
|
|
698
|
+
chatgptInvocations,
|
|
699
|
+
generatedAt,
|
|
700
|
+
);
|
|
701
|
+
ensureCodexSessionHooks(paths.chatgpt.codexHome, config.tenantId, "codex_desktop");
|
|
702
|
+
}
|
|
659
703
|
installed.push({
|
|
660
704
|
target,
|
|
661
705
|
launcher: paths[target].launcher,
|
|
@@ -1672,6 +1716,23 @@ function patchFixedWidthAsarString(asarPath, original, replacement, label) {
|
|
|
1672
1716
|
function patchClaudePlanUsageRequest(asarPath) {
|
|
1673
1717
|
const archive = fs.readFileSync(asarPath);
|
|
1674
1718
|
const source = archive.toString("latin1");
|
|
1719
|
+
const legacyPatches = [];
|
|
1720
|
+
for (
|
|
1721
|
+
let offset = source.indexOf(CLAUDE_PLAN_USAGE_REQUEST);
|
|
1722
|
+
offset !== -1;
|
|
1723
|
+
offset = source.indexOf(CLAUDE_PLAN_USAGE_REQUEST, offset + CLAUDE_PLAN_USAGE_REQUEST.length)
|
|
1724
|
+
) {
|
|
1725
|
+
legacyPatches.push({
|
|
1726
|
+
offset,
|
|
1727
|
+
original: CLAUDE_PLAN_USAGE_REQUEST,
|
|
1728
|
+
replacement: IMPEL_CLAUDE_PLAN_USAGE_REQUEST,
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
if (legacyPatches.length > 0) {
|
|
1732
|
+
return applyFixedWidthAsarPatches(
|
|
1733
|
+
asarPath, archive, legacyPatches, "Claude plan-usage gateway request",
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1675
1736
|
const credentialAccessor = source.match(
|
|
1676
1737
|
/function ([A-Za-z_$][\w$]*)\(e\)\{var t;return [\s\S]{0,500}?\[custom-3p\] Credentials loaded from managed config/u,
|
|
1677
1738
|
)?.[1];
|
|
@@ -1687,10 +1748,10 @@ function patchClaudePlanUsageRequest(asarPath) {
|
|
|
1687
1748
|
return applyFixedWidthAsarPatches(asarPath, archive, [], "Claude plan-usage gateway request");
|
|
1688
1749
|
}
|
|
1689
1750
|
|
|
1690
|
-
const requestPattern = /
|
|
1751
|
+
const requestPattern = /([A-Za-z_$][\w$]*)\.net\.fetch\(`\$\{[A-Za-z_$][\w$]*\(\)\}\/api\/organizations\/\$\{[A-Za-z_$][\w$]*\}\/usage`,\{signal:AbortSignal\.timeout\([A-Za-z_$][\w$]*\)\}\)/gu;
|
|
1691
1752
|
const patches = [...source.matchAll(requestPattern)].map((match) => {
|
|
1692
1753
|
const original = match[0];
|
|
1693
|
-
const replacement =
|
|
1754
|
+
const replacement = `${match[1]}.net.fetch("https://gateway.useimpel.com/ui/a/u",{headers:{key:${credentialAccessor}(${configAccessor}()).apiKey}})`;
|
|
1694
1755
|
if (replacement.length > original.length) {
|
|
1695
1756
|
throw new Error("Claude plan-usage gateway request replacement no longer fits the vendor ASAR contract");
|
|
1696
1757
|
}
|
package/src/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ import { cmdDoctor } from "./commands/doctor.js";
|
|
|
17
17
|
import { cmdSetup } from "./commands/setup.js";
|
|
18
18
|
import { cmdSessions } from "./commands/sessions.js";
|
|
19
19
|
import { cmdUpdate } from "./commands/update.js";
|
|
20
|
+
import { cmdNuke } from "./commands/nuke.js";
|
|
20
21
|
import { cmdExperimental } from "./commands/experimental.js";
|
|
21
22
|
import { cmdConverge } from "./commands/converge.js";
|
|
22
23
|
import { refuseElevatedMacExecution } from "./privileges.js";
|
|
@@ -48,6 +49,10 @@ Diagnostics:
|
|
|
48
49
|
impel status Authentication, current tenant, and local readiness
|
|
49
50
|
impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
|
|
50
51
|
|
|
52
|
+
Reset:
|
|
53
|
+
impel nuke [--yes] Erase ALL Impel-managed local state (apps, profiles,
|
|
54
|
+
caches, keychain items, auth) for a clean reinstall
|
|
55
|
+
|
|
51
56
|
impel help Show this help
|
|
52
57
|
impel --version Show the installed version
|
|
53
58
|
|
|
@@ -134,6 +139,9 @@ export async function main(argv) {
|
|
|
134
139
|
case "apps":
|
|
135
140
|
return cmdApps(rest);
|
|
136
141
|
|
|
142
|
+
case "nuke":
|
|
143
|
+
return cmdNuke(rest);
|
|
144
|
+
|
|
137
145
|
case "skills":
|
|
138
146
|
case "skill":
|
|
139
147
|
return cmdSkills(rest);
|
package/src/commands/apps.js
CHANGED
|
@@ -772,7 +772,7 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
772
772
|
return refreshApps(targets, {
|
|
773
773
|
staleOnly: Boolean(flags["stale-only"]),
|
|
774
774
|
tenantId: flags.tenant || null,
|
|
775
|
-
});
|
|
775
|
+
}, overrides);
|
|
776
776
|
}
|
|
777
777
|
if (action === "uninstall") {
|
|
778
778
|
const config = loadConfig();
|
|
@@ -1163,8 +1163,20 @@ function claudeConfigIsCurrent(configPath, config, gatewayUrl) {
|
|
|
1163
1163
|
// run (config writes only, never a bundle swap, never quits anything) and
|
|
1164
1164
|
// silent on any failure: it is invoked detached from the apps' token helper,
|
|
1165
1165
|
// so there is no user to talk to and nothing may leak to stdout.
|
|
1166
|
-
async function refreshApps(targets, { staleOnly = false, tenantId = null } = {}) {
|
|
1167
|
-
const
|
|
1166
|
+
async function refreshApps(targets, { staleOnly = false, tenantId = null } = {}, overrides = {}) {
|
|
1167
|
+
const io = {
|
|
1168
|
+
loadStoredConfig: loadConfig,
|
|
1169
|
+
selectedConfig: selectedAppConfig,
|
|
1170
|
+
status: appStatus,
|
|
1171
|
+
bundleCurrent: bundleIsCurrent,
|
|
1172
|
+
fetchModels: fetchGatewayModels,
|
|
1173
|
+
installFiles: installManagedAppFiles,
|
|
1174
|
+
syncSkills: syncSkillsSafe,
|
|
1175
|
+
syncAgents: syncAgentProfilesSafe,
|
|
1176
|
+
secureCodexHome: secureManagedCodexHome,
|
|
1177
|
+
...overrides,
|
|
1178
|
+
};
|
|
1179
|
+
const stored = io.loadStoredConfig();
|
|
1168
1180
|
if (!stored?.pat) return;
|
|
1169
1181
|
const requestedTenantId = tenantId
|
|
1170
1182
|
? normalizeTenantId(tenantId)
|
|
@@ -1181,17 +1193,25 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
|
|
|
1181
1193
|
|
|
1182
1194
|
let config;
|
|
1183
1195
|
try {
|
|
1184
|
-
config = await
|
|
1196
|
+
config = await io.selectedConfig(targets, tenantId ? requestedTenantId : null);
|
|
1185
1197
|
} catch {
|
|
1186
1198
|
return;
|
|
1187
1199
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1200
|
+
// Refresh only targets whose installed bundle was built by THIS CLI's
|
|
1201
|
+
// bundle logic. The generated config contract is reviewed against the
|
|
1202
|
+
// pinned vendor build, so rewriting configs (and advancing the manifest)
|
|
1203
|
+
// under a stale bundle would strand an old app with a config written for a
|
|
1204
|
+
// different app version. Stale bundles wait for the next foreground
|
|
1205
|
+
// install/open/update, which swaps bundle and config together.
|
|
1206
|
+
const statuses = io.status(targets, os.homedir(), config.tenantId, config.tenantName);
|
|
1207
|
+
const installedTargets = statuses.filter((status) => (
|
|
1208
|
+
status.launcherInstalled && io.bundleCurrent(status)
|
|
1209
|
+
));
|
|
1190
1210
|
if (installedTargets.length === 0) return;
|
|
1191
1211
|
|
|
1192
1212
|
let catalog;
|
|
1193
1213
|
try {
|
|
1194
|
-
catalog = await
|
|
1214
|
+
catalog = await io.fetchModels(config);
|
|
1195
1215
|
} catch {
|
|
1196
1216
|
return;
|
|
1197
1217
|
}
|
|
@@ -1204,7 +1224,7 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
|
|
|
1204
1224
|
supportedTargetSet.has(status.target)
|
|
1205
1225
|
));
|
|
1206
1226
|
if (supportedStatuses.length === 0) return;
|
|
1207
|
-
|
|
1227
|
+
io.installFiles({
|
|
1208
1228
|
config,
|
|
1209
1229
|
targets: supportedStatuses.map((status) => status.target),
|
|
1210
1230
|
models: catalog.models,
|
|
@@ -1217,10 +1237,10 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
|
|
|
1217
1237
|
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
1218
1238
|
for (const status of supportedStatuses) {
|
|
1219
1239
|
const { client, env, label } = appSkillTarget(status.target, tenantPaths);
|
|
1220
|
-
await
|
|
1221
|
-
if (status.target === "chatgpt")
|
|
1240
|
+
await io.syncSkills({ client, gatewayUrl, env, label });
|
|
1241
|
+
if (status.target === "chatgpt") io.secureCodexHome(tenantPaths.chatgpt.codexHome);
|
|
1222
1242
|
}
|
|
1223
|
-
await
|
|
1243
|
+
await io.syncAgents({
|
|
1224
1244
|
profiles: supportedStatuses.map((status) => appAgentProfile(status.target, tenantPaths)),
|
|
1225
1245
|
gatewayUrl: config.gatewayUrl,
|
|
1226
1246
|
credential: config.pat,
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// `impel nuke` — erase every Impel-managed footprint on this machine so the
|
|
2
|
+
// next `impel setup` runs as a true first install. Removes managed app
|
|
3
|
+
// launchers (including stale staging/rotation artifacts), all isolated app and
|
|
4
|
+
// CLI profiles, macOS system remnants (caches, preference domains, HTTP
|
|
5
|
+
// storages, saved state, launch agents), Impel-owned Safe Storage Keychain
|
|
6
|
+
// items, and the CLI's own config/auth state. Never touches vendor apps,
|
|
7
|
+
// native `~/.claude`/`~/.codex` profiles, or the shared legacy "Claude" Safe
|
|
8
|
+
// Storage item that the vendor Claude app also uses.
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
import { CONFIG_DIR } from "../config.js";
|
|
16
|
+
import { parseFlags } from "../args.js";
|
|
17
|
+
import { promptText } from "../prompt.js";
|
|
18
|
+
import { appProcessPattern } from "../apps.js";
|
|
19
|
+
import { windowsClaudeUserData } from "../windowsApps.js";
|
|
20
|
+
|
|
21
|
+
// Launcher artifacts the CLI may have written into ~/Applications: the
|
|
22
|
+
// tenant-scoped bundles, the pre-v0.8 global bundles, and any interrupted
|
|
23
|
+
// `.tmp-<pid>` staging or `.previous-<pid>` rotation directories they left
|
|
24
|
+
// behind. Anchored so unrelated apps that merely start with "Impel" survive.
|
|
25
|
+
const MANAGED_LAUNCHER_RE = /^Impel (Claude|ChatGPT)(\.app| \()/u;
|
|
26
|
+
|
|
27
|
+
// Every Impel-managed bundle identifier starts with this prefix; the vendor
|
|
28
|
+
// apps use com.anthropic.* / com.openai.* and are never matched.
|
|
29
|
+
const MANAGED_BUNDLE_PREFIX = "com.useimpel.";
|
|
30
|
+
|
|
31
|
+
// macOS per-app state locations that accumulate entries keyed by bundle id.
|
|
32
|
+
const MAC_LIBRARY_LOCATIONS = [
|
|
33
|
+
"Caches",
|
|
34
|
+
"Preferences",
|
|
35
|
+
"HTTPStorages",
|
|
36
|
+
"Saved Application State",
|
|
37
|
+
"Logs",
|
|
38
|
+
"Application Support",
|
|
39
|
+
"WebKit",
|
|
40
|
+
"Containers",
|
|
41
|
+
"Group Containers",
|
|
42
|
+
"LaunchAgents",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const HELP = `impel nuke - erase all Impel-managed state on this machine
|
|
46
|
+
|
|
47
|
+
Removes managed desktop app launchers, every isolated app and CLI profile,
|
|
48
|
+
macOS caches/preferences/keychain items owned by the managed apps, and the
|
|
49
|
+
CLI's auth/config state, so \`impel setup\` runs as a true first install.
|
|
50
|
+
|
|
51
|
+
Never touches the vendor Claude/ChatGPT apps, native ~/.claude or ~/.codex
|
|
52
|
+
profiles, or non-Impel Keychain items.
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
impel nuke Show what will be erased and ask for confirmation
|
|
56
|
+
impel nuke --yes Erase without prompting (for scripts)
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
function listEntries(directory) {
|
|
60
|
+
try {
|
|
61
|
+
return fs.readdirSync(directory);
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Electron keys Safe Storage entries as "<app name> Safe Storage". Collect the
|
|
69
|
+
* exact names our managed profiles recorded plus the deterministic
|
|
70
|
+
* tenant-derived names, so orphans from half-removed installs still match.
|
|
71
|
+
* The legacy shared "Claude" name is excluded unconditionally: the vendor
|
|
72
|
+
* Claude app keys the operator's own encrypted data under it.
|
|
73
|
+
*/
|
|
74
|
+
function keychainCandidates(appsRoot) {
|
|
75
|
+
const names = new Set();
|
|
76
|
+
const tenantsRoot = path.join(appsRoot, "tenants");
|
|
77
|
+
for (const tenantId of listEntries(tenantsRoot)) {
|
|
78
|
+
for (const target of ["Impel Claude", "Impel ChatGPT"]) {
|
|
79
|
+
names.add(`${target} [${tenantId}] Safe Storage`);
|
|
80
|
+
}
|
|
81
|
+
const metadataPath = path.join(tenantsRoot, tenantId, "claude", "safe-storage.json");
|
|
82
|
+
try {
|
|
83
|
+
const appName = JSON.parse(fs.readFileSync(metadataPath, "utf8"))?.appName;
|
|
84
|
+
if (typeof appName === "string" && appName.trim()) names.add(`${appName} Safe Storage`);
|
|
85
|
+
} catch {
|
|
86
|
+
// Absent or corrupt metadata leaves only the derived names.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
names.delete("Claude Safe Storage");
|
|
90
|
+
return [...names].sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function managedLauncherEntries(homeDir) {
|
|
94
|
+
const applications = path.join(homeDir, "Applications");
|
|
95
|
+
return listEntries(applications)
|
|
96
|
+
.filter((entry) => MANAGED_LAUNCHER_RE.test(entry))
|
|
97
|
+
.map((entry) => path.join(applications, entry));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function macLibraryRemnants(homeDir) {
|
|
101
|
+
const remnants = [];
|
|
102
|
+
for (const location of MAC_LIBRARY_LOCATIONS) {
|
|
103
|
+
const root = path.join(homeDir, "Library", location);
|
|
104
|
+
for (const entry of listEntries(root)) {
|
|
105
|
+
if (entry.startsWith(MANAGED_BUNDLE_PREFIX)) remnants.push(path.join(root, entry));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return remnants;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function windowsProfileRemnants(appsRoot, environment) {
|
|
112
|
+
const remnants = [];
|
|
113
|
+
for (const tenantId of listEntries(path.join(appsRoot, "tenants"))) {
|
|
114
|
+
try {
|
|
115
|
+
const userData = windowsClaudeUserData(environment, tenantId);
|
|
116
|
+
if (userData && fs.existsSync(userData)) remnants.push(userData);
|
|
117
|
+
} catch {
|
|
118
|
+
// Windows vendor install absent; nothing tenant-scoped to remove.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return remnants;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Gracefully quit, then kill, every process from a managed launcher bundle. */
|
|
125
|
+
function stopManagedProcesses(launcherPaths, run) {
|
|
126
|
+
const names = launcherPaths
|
|
127
|
+
.map((launcher) => path.basename(launcher))
|
|
128
|
+
.filter((entry) => entry.endsWith(".app"))
|
|
129
|
+
.map((entry) => entry.replace(/\.app$/u, ""));
|
|
130
|
+
for (const name of names) {
|
|
131
|
+
run("/usr/bin/osascript", ["-e", `quit app "${name}"`]);
|
|
132
|
+
}
|
|
133
|
+
for (const name of names) {
|
|
134
|
+
const pattern = appProcessPattern(name);
|
|
135
|
+
run("/usr/bin/pkill", ["-f", pattern]);
|
|
136
|
+
run("/usr/bin/pkill", ["-9", "-f", pattern]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function cmdNuke(argv = [], overrides = {}) {
|
|
141
|
+
const { flags } = parseFlags(argv, {
|
|
142
|
+
yes: { type: "boolean" },
|
|
143
|
+
help: { type: "boolean" },
|
|
144
|
+
});
|
|
145
|
+
if (flags.help) {
|
|
146
|
+
console.log(HELP);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const io = {
|
|
151
|
+
homeDir: os.homedir(),
|
|
152
|
+
configDir: CONFIG_DIR,
|
|
153
|
+
platform: process.platform,
|
|
154
|
+
environment: process.env,
|
|
155
|
+
log: (message) => console.log(message),
|
|
156
|
+
confirm: promptText,
|
|
157
|
+
run: (command, args) => spawnSync(command, args, { stdio: "ignore" }),
|
|
158
|
+
...overrides,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const appsRoot = io.environment.IMPEL_APP_HOME || path.join(io.configDir, "apps");
|
|
162
|
+
const darwin = io.platform === "darwin";
|
|
163
|
+
const launchers = darwin ? managedLauncherEntries(io.homeDir) : [];
|
|
164
|
+
const libraryRemnants = darwin ? macLibraryRemnants(io.homeDir) : [];
|
|
165
|
+
const keychainItems = darwin ? keychainCandidates(appsRoot) : [];
|
|
166
|
+
const windowsRemnants = io.platform === "win32"
|
|
167
|
+
? windowsProfileRemnants(appsRoot, io.environment)
|
|
168
|
+
: [];
|
|
169
|
+
const configDirExists = fs.existsSync(io.configDir);
|
|
170
|
+
|
|
171
|
+
const total = launchers.length + libraryRemnants.length + keychainItems.length
|
|
172
|
+
+ windowsRemnants.length + (configDirExists ? 1 : 0);
|
|
173
|
+
if (total === 0) {
|
|
174
|
+
io.log("impel nuke: no Impel-managed state found; this machine is already clean.");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
io.log("impel nuke will erase ALL Impel-managed state on this machine:");
|
|
179
|
+
if (launchers.length) io.log(` App launchers and staging artifacts: ${launchers.length}`);
|
|
180
|
+
if (libraryRemnants.length) io.log(` macOS caches/preferences/state entries: ${libraryRemnants.length}`);
|
|
181
|
+
if (keychainItems.length) io.log(` Keychain Safe Storage items: ${keychainItems.length}`);
|
|
182
|
+
if (windowsRemnants.length) io.log(` Windows managed Claude profiles: ${windowsRemnants.length}`);
|
|
183
|
+
if (configDirExists) io.log(` CLI state, profiles, vendor cache, and auth: ${io.configDir}`);
|
|
184
|
+
io.log("Vendor apps and native ~/.claude and ~/.codex profiles are not touched.");
|
|
185
|
+
|
|
186
|
+
if (!flags.yes) {
|
|
187
|
+
const answer = await io.confirm('Type "nuke" to erase everything (anything else aborts): ');
|
|
188
|
+
if (answer !== "nuke") {
|
|
189
|
+
io.log("impel nuke: aborted; nothing was changed.");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (darwin && launchers.length) {
|
|
195
|
+
stopManagedProcesses(launchers, io.run);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const launcher of launchers) {
|
|
199
|
+
fs.rmSync(launcher, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
if (launchers.length) io.log(`Removed ${launchers.length} managed launcher artifact${launchers.length === 1 ? "" : "s"}.`);
|
|
202
|
+
|
|
203
|
+
for (const remnant of libraryRemnants) {
|
|
204
|
+
// Preference domains are registered with cfprefsd; deleting the domain
|
|
205
|
+
// first stops the daemon from resurrecting the plist from its cache.
|
|
206
|
+
if (remnant.endsWith(".plist") && path.dirname(remnant).endsWith("Preferences")) {
|
|
207
|
+
io.run("/usr/bin/defaults", ["delete", path.basename(remnant, ".plist")]);
|
|
208
|
+
}
|
|
209
|
+
fs.rmSync(remnant, { recursive: true, force: true });
|
|
210
|
+
}
|
|
211
|
+
if (libraryRemnants.length) io.log(`Removed ${libraryRemnants.length} macOS state entr${libraryRemnants.length === 1 ? "y" : "ies"}.`);
|
|
212
|
+
|
|
213
|
+
for (const item of keychainItems) {
|
|
214
|
+
// Missing items exit non-zero; that just means there is nothing to remove.
|
|
215
|
+
io.run("/usr/bin/security", ["delete-generic-password", "-s", item]);
|
|
216
|
+
}
|
|
217
|
+
if (keychainItems.length) io.log(`Cleared ${keychainItems.length} Impel Safe Storage Keychain item${keychainItems.length === 1 ? "" : "s"}.`);
|
|
218
|
+
|
|
219
|
+
for (const remnant of windowsRemnants) {
|
|
220
|
+
fs.rmSync(remnant, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
if (windowsRemnants.length) io.log(`Removed ${windowsRemnants.length} Windows managed profile${windowsRemnants.length === 1 ? "" : "s"}.`);
|
|
223
|
+
|
|
224
|
+
if (configDirExists) {
|
|
225
|
+
fs.rmSync(io.configDir, { recursive: true, force: true });
|
|
226
|
+
io.log(`Removed ${io.configDir} (profiles, vendor cache, sessions, and auth).`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
io.log("impel nuke: complete. Run `impel setup` for a clean install.");
|
|
230
|
+
}
|