impel-cli 0.17.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.17.7",
3
+ "version": "0.17.8",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
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);
@@ -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
+ }