impel-cli 0.16.5 → 0.17.0
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 +224 -971
- package/package.json +2 -2
- package/src/cli.js +33 -41
- package/src/commands/apps.js +215 -0
- package/src/commands/converge.js +174 -0
- package/src/commands/setup.js +312 -428
- package/src/commands/status.js +133 -83
- package/src/commands/tasks.js +1 -1
- package/src/commands/tenant.js +3 -5
- package/src/commands/update.js +39 -85
- package/src/commands/use.js +67 -238
- package/src/installRecovery/engine.js +9 -27
- package/src/installRecovery/redact.js +4 -0
- package/src/installRecovery/tools.js +1 -1
- package/src/provisioning.js +354 -0
- package/src/shellEntries.js +186 -0
- package/src/skills.js +1 -1
- package/src/tenants.js +1 -1
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { syncAgentProfilesSafe } from "./agents.js";
|
|
6
|
+
import { crossAppModelsEnabled, redactSecretText } from "./config.js";
|
|
7
|
+
import { ensureImpelClaudeProfile, ensureImpelCodexProfile, tenantCliProfilePaths } from "./cliProfiles.js";
|
|
8
|
+
import { findNativeBinary } from "./nativeProcess.js";
|
|
9
|
+
import { mapWithConcurrency } from "./progress.js";
|
|
10
|
+
import { registerTenantShellEntries } from "./shellEntries.js";
|
|
11
|
+
import { resolveSkillsGateway, syncSkillsSafe } from "./skills.js";
|
|
12
|
+
import {
|
|
13
|
+
normalizeTenantId,
|
|
14
|
+
PAT_SCOPE_CLAUDE,
|
|
15
|
+
PAT_SCOPE_CODEX,
|
|
16
|
+
tenantCredential,
|
|
17
|
+
} from "./tenants.js";
|
|
18
|
+
import { reconcileTenantApps } from "./commands/apps.js";
|
|
19
|
+
|
|
20
|
+
export function selectDefaultTenant(listing, { requested = null, currentTenantId = null } = {}) {
|
|
21
|
+
const normalized = requested ? normalizeTenantId(requested) : null;
|
|
22
|
+
if (normalized) {
|
|
23
|
+
const explicit = listing.tenants.find((tenant) => tenant.id === normalized || tenant.slug === normalized);
|
|
24
|
+
if (!explicit) throw new Error(`tenant "${normalized}" is not available to this user`);
|
|
25
|
+
return explicit;
|
|
26
|
+
}
|
|
27
|
+
return listing.tenants.find((tenant) => tenant.id === currentTenantId)
|
|
28
|
+
|| listing.tenants.find((tenant) => tenant.id === listing.defaultTenantId)
|
|
29
|
+
|| listing.tenants[0];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function mapSettledWithConcurrency(items, limit, fn) {
|
|
33
|
+
return mapWithConcurrency(items, limit, async (item, index) => {
|
|
34
|
+
try {
|
|
35
|
+
return { status: "fulfilled", value: await fn(item, index) };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { status: "rejected", reason: error };
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildTenantInventory(listing, {
|
|
43
|
+
requestedTenantId = null,
|
|
44
|
+
currentTenantId = null,
|
|
45
|
+
localTenantIds = [],
|
|
46
|
+
} = {}) {
|
|
47
|
+
const live = [...listing.tenants].sort((left, right) => left.id.localeCompare(right.id));
|
|
48
|
+
const liveIds = new Set(live.map((tenant) => tenant.id));
|
|
49
|
+
const local = [...new Set(localTenantIds.map((tenantId) => normalizeTenantId(tenantId)))].sort();
|
|
50
|
+
const selected = selectDefaultTenant(listing, {
|
|
51
|
+
requested: requestedTenantId,
|
|
52
|
+
currentTenantId,
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
live,
|
|
56
|
+
selectedTenantId: selected.id,
|
|
57
|
+
missing: live.filter((tenant) => !local.includes(tenant.id)),
|
|
58
|
+
existing: live.filter((tenant) => local.includes(tenant.id)),
|
|
59
|
+
retainedInaccessible: local.filter((tenantId) => !liveIds.has(tenantId)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function locallyKnownTenantIds({ homeDir = os.homedir(), readDirectory = fs.readdirSync } = {}) {
|
|
64
|
+
const roots = [
|
|
65
|
+
path.join(homeDir, ".config", "impel", "cli", "tenants"),
|
|
66
|
+
path.join(homeDir, ".config", "impel", "apps", "tenants"),
|
|
67
|
+
];
|
|
68
|
+
const ids = new Set();
|
|
69
|
+
for (const root of roots) {
|
|
70
|
+
try {
|
|
71
|
+
for (const entry of readDirectory(root, { withFileTypes: true })) {
|
|
72
|
+
if (!entry.isDirectory()) continue;
|
|
73
|
+
try { ids.add(normalizeTenantId(entry.name)); } catch { /* ignore invalid local debris */ }
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error?.code !== "ENOENT") throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...ids].sort((left, right) => left.localeCompare(right));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function providerSupported(scopes, scope) {
|
|
83
|
+
return scopes == null || scopes.includes(scope);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function prepareTenantCli(config, tenant, io, binaries) {
|
|
87
|
+
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
88
|
+
const definitions = {
|
|
89
|
+
claude: {
|
|
90
|
+
supported: providerSupported(config.scopes, PAT_SCOPE_CLAUDE),
|
|
91
|
+
ensure: () => io.ensureClaude(config.gatewayUrl, tenant.id, {
|
|
92
|
+
crossAppModels: crossAppModelsEnabled(config),
|
|
93
|
+
}),
|
|
94
|
+
root: (profile) => profile.configDir,
|
|
95
|
+
env: (profile) => ({ CLAUDE_CONFIG_DIR: profile.configDir }),
|
|
96
|
+
},
|
|
97
|
+
codex: {
|
|
98
|
+
supported: providerSupported(config.scopes, PAT_SCOPE_CODEX),
|
|
99
|
+
ensure: () => io.ensureCodex(config.gatewayUrl, tenant.id),
|
|
100
|
+
root: (profile) => profile.codexHome,
|
|
101
|
+
env: (profile) => ({ CODEX_HOME: profile.codexHome }),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const clients = {};
|
|
105
|
+
for (const [client, definition] of Object.entries(definitions)) {
|
|
106
|
+
if (!definition.supported) {
|
|
107
|
+
clients[client] = { status: "unsupported", root: null, error: null };
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const profile = definition.ensure();
|
|
112
|
+
const root = definition.root(profile);
|
|
113
|
+
await io.syncSkills({
|
|
114
|
+
client,
|
|
115
|
+
gatewayUrl,
|
|
116
|
+
env: definition.env(profile),
|
|
117
|
+
label: `Impel ${client === "claude" ? "Claude" : "Codex"} CLI (${tenant.id})`,
|
|
118
|
+
});
|
|
119
|
+
clients[client] = binaries[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
|
+
};
|
|
126
|
+
} catch (error) {
|
|
127
|
+
clients[client] = {
|
|
128
|
+
status: "failed",
|
|
129
|
+
root: null,
|
|
130
|
+
error: redactSecretText(error?.message || error),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const agentProfiles = Object.entries(clients)
|
|
136
|
+
.filter(([, client]) => client.root)
|
|
137
|
+
.map(([client, value]) => ({
|
|
138
|
+
client,
|
|
139
|
+
root: value.root,
|
|
140
|
+
label: `Impel ${client === "claude" ? "Claude" : "Codex"} CLI (${tenant.id})`,
|
|
141
|
+
}));
|
|
142
|
+
if (agentProfiles.length) {
|
|
143
|
+
try {
|
|
144
|
+
await io.syncAgents({
|
|
145
|
+
profiles: agentProfiles,
|
|
146
|
+
gatewayUrl: config.gatewayUrl,
|
|
147
|
+
credential: tenantCredential(config.pat, tenant.id),
|
|
148
|
+
tenantId: tenant.id,
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const message = redactSecretText(error?.message || error);
|
|
152
|
+
for (const client of Object.values(clients)) {
|
|
153
|
+
if (client.root) {
|
|
154
|
+
client.status = "failed";
|
|
155
|
+
client.error = message;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return clients;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function reconcileAllTenants({
|
|
164
|
+
config,
|
|
165
|
+
listing,
|
|
166
|
+
mode = "update",
|
|
167
|
+
includeApps = true,
|
|
168
|
+
platform = process.platform,
|
|
169
|
+
homeDir = os.homedir(),
|
|
170
|
+
environment = process.env,
|
|
171
|
+
concurrency = 4,
|
|
172
|
+
confirmVendorInstall = async () => false,
|
|
173
|
+
} = {}, overrides = {}) {
|
|
174
|
+
const io = {
|
|
175
|
+
ensureClaude: ensureImpelClaudeProfile,
|
|
176
|
+
ensureCodex: ensureImpelCodexProfile,
|
|
177
|
+
syncSkills: syncSkillsSafe,
|
|
178
|
+
syncAgents: syncAgentProfilesSafe,
|
|
179
|
+
findBinary: findNativeBinary,
|
|
180
|
+
reconcileApps: reconcileTenantApps,
|
|
181
|
+
registerShellEntries: registerTenantShellEntries,
|
|
182
|
+
knownTenantIds: locallyKnownTenantIds,
|
|
183
|
+
...overrides,
|
|
184
|
+
};
|
|
185
|
+
// Dependency-injection compatibility for older embedders/tests. Production
|
|
186
|
+
// uses the explicit reconciler above and never calls the public app parser.
|
|
187
|
+
if (overrides.installApps && !overrides.reconcileApps) {
|
|
188
|
+
io.reconcileApps = async ({ tenant, mode: appMode, skipVendor }) => {
|
|
189
|
+
const args = [appMode === "setup" ? "install" : "update", "all", "--tenant", tenant.id];
|
|
190
|
+
if (skipVendor) args.push("--skip-vendor");
|
|
191
|
+
const completed = await overrides.installApps(args);
|
|
192
|
+
if (completed === false) throw new Error("desktop app provisioning did not complete");
|
|
193
|
+
return { targets: ["claude", "chatgpt"], unsupported: [] };
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const inventory = buildTenantInventory(listing, {
|
|
197
|
+
currentTenantId: config.tenantId,
|
|
198
|
+
localTenantIds: io.knownTenantIds({ homeDir }),
|
|
199
|
+
});
|
|
200
|
+
const liveTenants = inventory.live;
|
|
201
|
+
const inaccessible = inventory.retainedInaccessible;
|
|
202
|
+
const binaries = {
|
|
203
|
+
claude: io.findBinary("claude", environment, platform),
|
|
204
|
+
codex: io.findBinary("codex", environment, platform),
|
|
205
|
+
};
|
|
206
|
+
const results = new Map(liveTenants.map((tenant) => [tenant.id, {
|
|
207
|
+
tenantId: tenant.id,
|
|
208
|
+
tenantName: tenant.name,
|
|
209
|
+
cli: "pending",
|
|
210
|
+
apps: includeApps && ["darwin", "win32"].includes(platform) ? "pending" : "unavailable",
|
|
211
|
+
shell: includeApps && ["darwin", "win32"].includes(platform) ? "pending" : "unavailable",
|
|
212
|
+
clients: {
|
|
213
|
+
claude: { cli: "pending", app: includeApps && ["darwin", "win32"].includes(platform) ? "pending" : "unsupported", shell: includeApps && ["darwin", "win32"].includes(platform) ? "pending" : "unsupported" },
|
|
214
|
+
codex: { cli: "pending", app: includeApps && ["darwin", "win32"].includes(platform) ? "pending" : "unsupported", shell: includeApps && ["darwin", "win32"].includes(platform) ? "pending" : "unsupported" },
|
|
215
|
+
},
|
|
216
|
+
errors: [],
|
|
217
|
+
}]));
|
|
218
|
+
|
|
219
|
+
const cliOutcomes = await mapSettledWithConcurrency(liveTenants, concurrency, (tenant) => (
|
|
220
|
+
prepareTenantCli(config, tenant, io, binaries)
|
|
221
|
+
));
|
|
222
|
+
cliOutcomes.forEach((outcome, index) => {
|
|
223
|
+
const result = results.get(liveTenants[index].id);
|
|
224
|
+
if (outcome.status === "fulfilled") {
|
|
225
|
+
for (const client of ["claude", "codex"]) {
|
|
226
|
+
const clientResult = outcome.value[client];
|
|
227
|
+
result.clients[client].cli = clientResult.status;
|
|
228
|
+
if (clientResult.error) result.errors.push(clientResult.error);
|
|
229
|
+
}
|
|
230
|
+
const states = [result.clients.claude.cli, result.clients.codex.cli];
|
|
231
|
+
result.cli = states.includes("failed")
|
|
232
|
+
? "failed"
|
|
233
|
+
: states.includes("ready") ? "ready" : "unavailable";
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
result.cli = "failed";
|
|
237
|
+
result.clients.claude.cli = "failed";
|
|
238
|
+
result.clients.codex.cli = "failed";
|
|
239
|
+
result.errors.push(redactSecretText(outcome.reason?.message || outcome.reason));
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (includeApps && ["darwin", "win32"].includes(platform)) {
|
|
244
|
+
const vendorPreparedTargets = new Set();
|
|
245
|
+
for (const tenant of liveTenants) {
|
|
246
|
+
const result = results.get(tenant.id);
|
|
247
|
+
let supported = new Set();
|
|
248
|
+
try {
|
|
249
|
+
const installed = await io.reconcileApps({
|
|
250
|
+
baseConfig: config,
|
|
251
|
+
tenant,
|
|
252
|
+
mode,
|
|
253
|
+
targets: ["claude", "chatgpt"],
|
|
254
|
+
skipVendorTargets: [...vendorPreparedTargets],
|
|
255
|
+
platform,
|
|
256
|
+
homeDir,
|
|
257
|
+
environment,
|
|
258
|
+
confirmVendorInstall,
|
|
259
|
+
}, overrides.appOverrides || {});
|
|
260
|
+
supported = new Set(installed.targets || []);
|
|
261
|
+
for (const target of supported) vendorPreparedTargets.add(target);
|
|
262
|
+
result.clients.claude.app = supported.has("claude") ? "ready" : "unsupported";
|
|
263
|
+
result.clients.codex.app = supported.has("chatgpt") ? "ready" : "unsupported";
|
|
264
|
+
result.apps = supported.size ? "ready" : "unavailable";
|
|
265
|
+
} catch (error) {
|
|
266
|
+
result.apps = "failed";
|
|
267
|
+
for (const client of Object.values(result.clients)) {
|
|
268
|
+
if (client.app === "pending") client.app = "failed";
|
|
269
|
+
if (client.shell === "pending") client.shell = "failed";
|
|
270
|
+
}
|
|
271
|
+
result.shell = "failed";
|
|
272
|
+
result.errors.push(redactSecretText(error?.message || error));
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (supported.size === 0) {
|
|
277
|
+
result.shell = "unavailable";
|
|
278
|
+
result.clients.claude.shell = "unsupported";
|
|
279
|
+
result.clients.codex.shell = "unsupported";
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const entries = io.registerShellEntries({
|
|
284
|
+
tenantId: tenant.id,
|
|
285
|
+
tenantName: tenant.name,
|
|
286
|
+
targets: [...supported],
|
|
287
|
+
platform,
|
|
288
|
+
homeDir,
|
|
289
|
+
environment,
|
|
290
|
+
});
|
|
291
|
+
const entryProducts = new Set(entries.map((entry) => entry.product));
|
|
292
|
+
result.clients.claude.shell = supported.has("claude")
|
|
293
|
+
? (entryProducts.has("claude") ? "ready" : "failed")
|
|
294
|
+
: "unsupported";
|
|
295
|
+
result.clients.codex.shell = supported.has("chatgpt")
|
|
296
|
+
? (entryProducts.has("chatgpt") ? "ready" : "failed")
|
|
297
|
+
: "unsupported";
|
|
298
|
+
result.shell = [...supported].every((target) => entryProducts.has(target)) ? "ready" : "failed";
|
|
299
|
+
if (result.shell === "failed") throw new Error("operating-system app registration did not complete");
|
|
300
|
+
} catch (error) {
|
|
301
|
+
result.shell = "failed";
|
|
302
|
+
for (const client of Object.values(result.clients)) {
|
|
303
|
+
if (client.shell === "pending") client.shell = "failed";
|
|
304
|
+
}
|
|
305
|
+
result.errors.push(redactSecretText(error?.message || error));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const values = liveTenants.map((tenant) => {
|
|
311
|
+
const result = results.get(tenant.id);
|
|
312
|
+
const states = Object.values(result.clients).flatMap((client) => [client.cli, client.app, client.shell]);
|
|
313
|
+
result.status = states.includes("failed") ? "failed" : states.includes("pending") ? "partial" : "ready";
|
|
314
|
+
return result;
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
selectedTenantId: config.tenantId,
|
|
318
|
+
tenants: values,
|
|
319
|
+
inaccessible,
|
|
320
|
+
passed: values.every((result) => (
|
|
321
|
+
(result.cli === "ready" || result.cli === "unavailable")
|
|
322
|
+
&& (result.apps === "ready" || result.apps === "unavailable")
|
|
323
|
+
&& (result.shell === "ready" || result.shell === "unavailable")
|
|
324
|
+
)),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function printReconciliationSummary(report, log = console.log) {
|
|
329
|
+
log("Tenant readiness:");
|
|
330
|
+
for (const tenant of report.tenants) {
|
|
331
|
+
const detail = tenant.errors.length ? ` — ${tenant.errors.join("; ")}` : "";
|
|
332
|
+
log(
|
|
333
|
+
` ${tenant.tenantId}: Claude CLI ${tenant.clients.claude.cli}, Codex CLI ${tenant.clients.codex.cli}, `
|
|
334
|
+
+ `Claude app ${tenant.clients.claude.app}, ChatGPT app ${tenant.clients.codex.app}, system launch ${tenant.shell}${detail}`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (report.inaccessible.length) {
|
|
338
|
+
log(` Retained local data for inaccessible tenants: ${report.inaccessible.join(", ")}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function tenantCliPathsReady(tenantId, existsSync = fs.existsSync) {
|
|
343
|
+
const paths = tenantCliProfilePaths(tenantId);
|
|
344
|
+
return existsSync(path.join(paths.claudeConfigDir, "settings.json"))
|
|
345
|
+
&& existsSync(path.join(paths.codexHome, "config.toml"));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function tenantCliClientReadiness(tenantId, existsSync = fs.existsSync) {
|
|
349
|
+
const paths = tenantCliProfilePaths(tenantId);
|
|
350
|
+
return {
|
|
351
|
+
claude: existsSync(path.join(paths.claudeConfigDir, "settings.json")),
|
|
352
|
+
codex: existsSync(path.join(paths.codexHome, "config.toml")),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
import { appPaths, CLAUDE_CONFIG_ID } from "./apps.js";
|
|
7
|
+
import { redactSecretText } from "./config.js";
|
|
8
|
+
import { environmentValue, nativeCommandInvocation, resolveNativeBinary } from "./nativeProcess.js";
|
|
9
|
+
import { IMPEL_CLI_ENTRYPOINT } from "./selfInvocation.js";
|
|
10
|
+
import { normalizeTenantId } from "./tenants.js";
|
|
11
|
+
import { windowsClaudeUserData } from "./windowsApps.js";
|
|
12
|
+
|
|
13
|
+
const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
14
|
+
|
|
15
|
+
function safeShortcutSegment(value, fallback) {
|
|
16
|
+
const cleaned = String(value || "")
|
|
17
|
+
.replace(/[<>:"/\\|?*\u0000-\u001F]/gu, " ")
|
|
18
|
+
.replace(/[. ]+$/gu, "")
|
|
19
|
+
.replace(/\s+/gu, " ")
|
|
20
|
+
.trim()
|
|
21
|
+
.slice(0, 80);
|
|
22
|
+
const reserved = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
|
|
23
|
+
return cleaned && !reserved.test(cleaned) ? cleaned : fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function powershellLiteral(value) {
|
|
27
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Serialize one argv token using the CommandLineToArgvW rules used by native
|
|
31
|
+
// Windows executables. The resulting string is stored in a .lnk Arguments
|
|
32
|
+
// field; no tenant-controlled value is evaluated as PowerShell source.
|
|
33
|
+
export function windowsShortcutArgument(value) {
|
|
34
|
+
const text = String(value);
|
|
35
|
+
if (/[\u0000\r\n]/u.test(text)) throw new Error("cannot put a NUL or line break in a Windows shortcut argument");
|
|
36
|
+
if (text && !/[\s"]/u.test(text)) return text;
|
|
37
|
+
return `"${text
|
|
38
|
+
.replace(/(\\*)"/gu, "$1$1\\\"")
|
|
39
|
+
.replace(/(\\*)$/u, "$1$1")}"`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function windowsTenantShortcutName(product, tenantId, tenantName) {
|
|
43
|
+
const id = normalizeTenantId(tenantId);
|
|
44
|
+
const name = safeShortcutSegment(tenantName, id);
|
|
45
|
+
const label = product === "claude" ? "Impel Claude" : "Impel ChatGPT";
|
|
46
|
+
return `${label} (${name} · ${id}).lnk`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function registerWindowsTenantShortcut({
|
|
50
|
+
product,
|
|
51
|
+
tenantId,
|
|
52
|
+
tenantName,
|
|
53
|
+
environment = process.env,
|
|
54
|
+
run = spawnSync,
|
|
55
|
+
execPath = process.execPath,
|
|
56
|
+
cliEntrypoint = IMPEL_CLI_ENTRYPOINT,
|
|
57
|
+
iconPath = null,
|
|
58
|
+
mkdirSync = fs.mkdirSync,
|
|
59
|
+
}) {
|
|
60
|
+
const appData = environmentValue(environment, "APPDATA");
|
|
61
|
+
if (!appData) throw new Error("Windows APPDATA is unavailable; Start menu entry was not created");
|
|
62
|
+
const shortcutDirectory = path.win32.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Impel");
|
|
63
|
+
mkdirSync(shortcutDirectory, { recursive: true });
|
|
64
|
+
const shortcutPath = path.win32.join(
|
|
65
|
+
shortcutDirectory,
|
|
66
|
+
windowsTenantShortcutName(product, tenantId, tenantName),
|
|
67
|
+
);
|
|
68
|
+
const command = [
|
|
69
|
+
"&",
|
|
70
|
+
powershellLiteral(execPath),
|
|
71
|
+
powershellLiteral(cliEntrypoint),
|
|
72
|
+
powershellLiteral("_app-launch"),
|
|
73
|
+
powershellLiteral(product),
|
|
74
|
+
powershellLiteral("--tenant"),
|
|
75
|
+
powershellLiteral(normalizeTenantId(tenantId)),
|
|
76
|
+
].join(" ");
|
|
77
|
+
const launcherTarget = resolveNativeBinary("powershell", environment, "win32");
|
|
78
|
+
const launcherArgs = [
|
|
79
|
+
"-NoLogo",
|
|
80
|
+
"-NoProfile",
|
|
81
|
+
"-NonInteractive",
|
|
82
|
+
"-ExecutionPolicy",
|
|
83
|
+
"Bypass",
|
|
84
|
+
"-Command",
|
|
85
|
+
command,
|
|
86
|
+
];
|
|
87
|
+
const createScript = [
|
|
88
|
+
"$w=New-Object -ComObject WScript.Shell",
|
|
89
|
+
"$s=$w.CreateShortcut($env:IMPEL_SHORTCUT_PATH)",
|
|
90
|
+
"$s.TargetPath=$env:IMPEL_SHORTCUT_TARGET",
|
|
91
|
+
"$s.Arguments=$env:IMPEL_SHORTCUT_ARGS",
|
|
92
|
+
"$s.WorkingDirectory=$env:IMPEL_SHORTCUT_WORKDIR",
|
|
93
|
+
"if($env:IMPEL_SHORTCUT_ICON){$s.IconLocation=$env:IMPEL_SHORTCUT_ICON}",
|
|
94
|
+
"$s.WindowStyle=7",
|
|
95
|
+
"$s.Save()",
|
|
96
|
+
].join("; ");
|
|
97
|
+
const powershell = nativeCommandInvocation("powershell", [
|
|
98
|
+
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", createScript,
|
|
99
|
+
], environment, "win32");
|
|
100
|
+
const result = run(powershell.command, powershell.args, {
|
|
101
|
+
encoding: "utf8",
|
|
102
|
+
env: {
|
|
103
|
+
...environment,
|
|
104
|
+
IMPEL_SHORTCUT_PATH: shortcutPath,
|
|
105
|
+
IMPEL_SHORTCUT_TARGET: launcherTarget,
|
|
106
|
+
IMPEL_SHORTCUT_ARGS: launcherArgs.map(windowsShortcutArgument).join(" "),
|
|
107
|
+
IMPEL_SHORTCUT_WORKDIR: path.win32.dirname(execPath),
|
|
108
|
+
IMPEL_SHORTCUT_ICON: iconPath || "",
|
|
109
|
+
},
|
|
110
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
111
|
+
windowsHide: true,
|
|
112
|
+
windowsVerbatimArguments: powershell.windowsVerbatimArguments,
|
|
113
|
+
});
|
|
114
|
+
if (result?.error || result?.status !== 0) {
|
|
115
|
+
throw new Error(`could not create ${path.win32.basename(shortcutPath)} (${redactSecretText(result?.error?.message || result?.stderr || `exit ${result?.status}`)})`);
|
|
116
|
+
}
|
|
117
|
+
return shortcutPath;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function writeShellEntryManifest(manifestPath, entries, {
|
|
121
|
+
mkdirSync = fs.mkdirSync,
|
|
122
|
+
writeFileSync = fs.writeFileSync,
|
|
123
|
+
renameSync = fs.renameSync,
|
|
124
|
+
rmSync = fs.rmSync,
|
|
125
|
+
} = {}) {
|
|
126
|
+
mkdirSync(path.dirname(manifestPath), { recursive: true, mode: 0o700 });
|
|
127
|
+
const temporary = `${manifestPath}.tmp-${process.pid}`;
|
|
128
|
+
try {
|
|
129
|
+
writeFileSync(temporary, `${JSON.stringify({
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
entries: entries.map(({ product, path: entryPath }) => ({ product, path: entryPath })),
|
|
132
|
+
updatedAt: new Date().toISOString(),
|
|
133
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
134
|
+
renameSync(temporary, manifestPath);
|
|
135
|
+
} finally {
|
|
136
|
+
rmSync(temporary, { force: true });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function registerTenantShellEntries({
|
|
141
|
+
tenantId,
|
|
142
|
+
tenantName,
|
|
143
|
+
targets = ["claude", "chatgpt"],
|
|
144
|
+
platform = process.platform,
|
|
145
|
+
homeDir = os.homedir(),
|
|
146
|
+
environment = process.env,
|
|
147
|
+
existsSync = fs.existsSync,
|
|
148
|
+
run = spawnSync,
|
|
149
|
+
writeManifest = writeShellEntryManifest,
|
|
150
|
+
shortcut = registerWindowsTenantShortcut,
|
|
151
|
+
} = {}) {
|
|
152
|
+
const id = normalizeTenantId(tenantId);
|
|
153
|
+
const paths = appPaths(homeDir, id, {
|
|
154
|
+
tenantName,
|
|
155
|
+
claudeUserData: platform === "win32" ? windowsClaudeUserData(environment, id) : null,
|
|
156
|
+
});
|
|
157
|
+
const registered = [];
|
|
158
|
+
if (platform === "darwin") {
|
|
159
|
+
for (const product of targets) {
|
|
160
|
+
const launcher = paths[product].launcher;
|
|
161
|
+
if (!existsSync(launcher)) throw new Error(`managed launcher is missing: ${launcher}`);
|
|
162
|
+
const result = run(LSREGISTER, ["-f", launcher], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
163
|
+
if (result?.error || result?.status !== 0) {
|
|
164
|
+
throw new Error(`LaunchServices registration failed for ${launcher} (${redactSecretText(result?.error?.message || result?.stderr || `exit ${result?.status}`)})`);
|
|
165
|
+
}
|
|
166
|
+
registered.push({ product, path: launcher, status: "ready" });
|
|
167
|
+
}
|
|
168
|
+
} else if (platform === "win32") {
|
|
169
|
+
const available = {
|
|
170
|
+
claude: existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
|
|
171
|
+
chatgpt: existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
|
|
172
|
+
};
|
|
173
|
+
for (const product of targets) {
|
|
174
|
+
if (!available[product]) throw new Error(`managed ${product} profile is missing for tenant ${id}`);
|
|
175
|
+
registered.push({
|
|
176
|
+
product,
|
|
177
|
+
path: shortcut({ product, tenantId: id, tenantName, environment, run }),
|
|
178
|
+
status: "ready",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (registered.length) {
|
|
183
|
+
writeManifest(path.join(paths.tenantRoot, "shell-entries.json"), registered);
|
|
184
|
+
}
|
|
185
|
+
return registered;
|
|
186
|
+
}
|
package/src/skills.js
CHANGED
|
@@ -409,7 +409,7 @@ export async function syncSkills({
|
|
|
409
409
|
const first = failures[0];
|
|
410
410
|
logger.warn(
|
|
411
411
|
`impel: skill sync for ${displayLabel} finished with warnings (${first.phase}: ${first.reason}). ` +
|
|
412
|
-
`Skills may be stale; re-run \`impel
|
|
412
|
+
`Skills may be stale; re-run \`impel update\`.`
|
|
413
413
|
);
|
|
414
414
|
return { client, label: displayLabel, synced: false, marketplaceName, failures };
|
|
415
415
|
}
|
package/src/tenants.js
CHANGED
|
@@ -58,7 +58,7 @@ export function assertProviderScopes(scopes, providers, { requireLive = false }
|
|
|
58
58
|
if (!missing.length) return;
|
|
59
59
|
const labels = missing.map((scope) => `"${scope}"`).join(" and ");
|
|
60
60
|
throw new Error(
|
|
61
|
-
`this PAT is missing the ${labels} scope${missing.length === 1 ? "" : "s"}; create a fresh PAT in Impel Gateway setup, then run \`impel
|
|
61
|
+
`this PAT is missing the ${labels} scope${missing.length === 1 ? "" : "s"}; create a fresh PAT in Impel Gateway setup, then run \`impel setup\` and provide it`,
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
|