impel-cli 0.20.52 → 0.20.53

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/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.53 — Launch distributed managed CLIs
4
+
5
+ - Adds `impel claude` and `impel codex` fallback launch through an installed
6
+ tenant-managed app when a standalone CLI credential is not configured.
7
+ - Selects an explicit `--tenant`, configured tenant, or the sole installed
8
+ tenant; ambiguous installations fail closed with an actionable tenant list.
9
+ - Validates the fixed application root, adjacent managed manifest, runtime
10
+ ownership, platform, logical tenant identity, and enrollment credential
11
+ presence without reading or placing the credential on the command line.
12
+ - Keeps the existing standalone Claude and Codex launch behavior unchanged
13
+ whenever its separate CLI credential is configured.
14
+
3
15
  ## 0.20.52 — Open embedded Tasks tickets safely
4
16
 
5
17
  - Makes cards, list rows, fleet items, and the card menu open their exact
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.52",
3
+ "version": "0.20.53",
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
@@ -47,6 +47,8 @@ Work:
47
47
  impel codex [args...] Launch Codex with an isolated Impel profile
48
48
  impel codex --agent <id|exact-title> ... Run one fixed tenant agent without a parent hop
49
49
  impel codex --benchmark ... Tag native-agent MCP calls as benchmark traffic
50
+ impel claude|codex --tenant <id> ... Select an enrolled Managed Software Center tenant
51
+ when the CLI has no separate Impel authentication
50
52
  impel remote handoff|dispatch|handback Move or control provider-native sessions remotely
51
53
  impel remote status|viewer|proxy Inspect, control, or connect to a remote run
52
54
  impel tenant list List accessible organizations
@@ -15,6 +15,21 @@ import { createTenantRecoveryReportTracker } from "./tenantRecoveryReport.js";
15
15
 
16
16
  const RETRYABLE_TENANT_DISCOVERY_ERROR = /could not reach|request timed out|fetch failed|ECONNRESET|ECONNABORTED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|network error|socket/iu;
17
17
 
18
+ /**
19
+ * Wait before the single discovery retry.
20
+ *
21
+ * An immediate retry cannot change the answer for the failure it most often
22
+ * sees: the OS caches a negative DNS result, so a second lookup microseconds
23
+ * later reads the same cached entry. This is long enough for a resolver or
24
+ * interface blip to clear and short enough that nobody watching decides the
25
+ * command has hung.
26
+ */
27
+ export const TENANT_DISCOVERY_RETRY_DELAY_MS = 1_500;
28
+
29
+ function sleep(milliseconds) {
30
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
31
+ }
32
+
18
33
  function confirmed(answer) {
19
34
  return /^(?:y|yes)$/iu.test(String(answer || "").trim());
20
35
  }
@@ -36,6 +51,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
36
51
  environment: process.env,
37
52
  preparePlatformClis,
38
53
  discoveryLogger: console,
54
+ sleep,
39
55
  ...overrides,
40
56
  };
41
57
  if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
@@ -59,6 +75,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
59
75
  return false;
60
76
  }
61
77
  io.discoveryLogger.log("Tenants: discovery request failed transiently; retrying once…");
78
+ await io.sleep(TENANT_DISCOVERY_RETRY_DELAY_MS);
62
79
  try {
63
80
  listing = await io.fetchTenants(config);
64
81
  } catch (retryError) {
@@ -29,6 +29,12 @@ import { IMPEL_NATIVE_BENCHMARK_ENV } from "../selfInvocation.js";
29
29
  import { resolveReviewedVendorCliBinary } from "../vendorCliBinaries.js";
30
30
  import { PINNED_VENDOR_CLI_VERSIONS } from "../vendorCliVersions.js";
31
31
  import { RUNTIME_BRAND } from "../runtimeBrand.js";
32
+ import {
33
+ installedManagedAppCandidates,
34
+ parseManagedTenantArgument,
35
+ runManagedAppCli,
36
+ selectManagedAppCandidate,
37
+ } from "../managedApps.js";
32
38
  import {
33
39
  cacheProviderReadiness,
34
40
  cachedProviderReadiness,
@@ -462,7 +468,23 @@ export async function cmdLaunch(tool, argv) {
462
468
  }
463
469
  const config = loadConfig();
464
470
  if (!config?.pat) {
465
- console.error(`impel ${tool}: not authenticated. Run \`impel auth\` first.`);
471
+ try {
472
+ const parsed = parseManagedTenantArgument(nativeArgv);
473
+ const candidate = selectManagedAppCandidate(installedManagedAppCandidates(tool), {
474
+ requestedTenant: parsed.tenantId,
475
+ configuredTenant: config?.tenantId || null,
476
+ });
477
+ if (candidate) {
478
+ const exitCode = await runManagedAppCli(candidate, tool, parsed.argv);
479
+ if (exitCode !== 0) process.exitCode = exitCode;
480
+ return;
481
+ }
482
+ } catch (error) {
483
+ console.error(`impel ${tool}: ${redactSecretText(error?.message || error)}`);
484
+ process.exitCode = 1;
485
+ return;
486
+ }
487
+ console.error(`impel ${tool}: not authenticated and no enrolled managed application is installed. Run \`impel auth\` or install it from Managed Software Center.`);
466
488
  process.exitCode = 1;
467
489
  return;
468
490
  }
@@ -0,0 +1,194 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+
6
+ const TENANT = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
7
+ const APPLICATION_BY_TOOL = Object.freeze({ claude: "claude", codex: "chatgpt" });
8
+
9
+ function regularFile(file) {
10
+ try {
11
+ const stat = fs.lstatSync(file);
12
+ return stat.isFile() && !stat.isSymbolicLink();
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ function parseCandidate(manifestPath, launcher, tool, platform, home) {
19
+ if (!regularFile(manifestPath) || !regularFile(launcher)) return null;
20
+ let manifest;
21
+ try {
22
+ const raw = fs.readFileSync(manifestPath, "utf8");
23
+ if (Buffer.byteLength(raw, "utf8") > 512 * 1024) return null;
24
+ manifest = JSON.parse(raw);
25
+ } catch {
26
+ return null;
27
+ }
28
+ const applicationId = APPLICATION_BY_TOOL[tool];
29
+ const tenantId = manifest?.tenant?.id;
30
+ const expectedPlatform = platform === "win32" ? "windows" : "macos";
31
+ if (
32
+ manifest?.schemaVersion !== 2
33
+ || manifest?.kind !== "com.useimpel.managed-application"
34
+ || manifest?.runtime?.owner !== "impel-apps"
35
+ || manifest?.runtime?.entrypoint !== "engine/main.mjs"
36
+ || manifest?.platform !== expectedPlatform
37
+ || manifest?.application?.id !== applicationId
38
+ || manifest?.application?.adapter !== applicationId
39
+ || !TENANT.test(tenantId || "")
40
+ || String(tenantId).includes("..")
41
+ || manifest?.logicalApplicationId !== `${tenantId}.${applicationId}`
42
+ ) return null;
43
+ const credential = path.join(home, ".config", "impel", "apps", "credentials", `${tenantId}.json`);
44
+ if (!regularFile(credential)) return null;
45
+ return {
46
+ tenantId,
47
+ tenantDisplayName: typeof manifest.tenant.displayName === "string" ? manifest.tenant.displayName : tenantId,
48
+ launcher,
49
+ manifestPath,
50
+ };
51
+ }
52
+
53
+ function macCandidates(tool, home, applicationsRoot) {
54
+ let entries;
55
+ try {
56
+ entries = fs.readdirSync(applicationsRoot, { withFileTypes: true });
57
+ } catch {
58
+ return [];
59
+ }
60
+ const candidates = [];
61
+ for (const entry of entries) {
62
+ if (!entry.isDirectory() || !entry.name.endsWith(".app")) continue;
63
+ const root = path.join(applicationsRoot, entry.name, "Contents");
64
+ const candidate = parseCandidate(
65
+ path.join(root, "Resources", "app-manifest.json"),
66
+ path.join(root, "MacOS", "impel-apps-launcher"),
67
+ tool,
68
+ "darwin",
69
+ home,
70
+ );
71
+ if (candidate) candidates.push(candidate);
72
+ }
73
+ return candidates;
74
+ }
75
+
76
+ function windowsCandidates(tool, home, applicationsRoot) {
77
+ const candidates = [];
78
+ let tenants;
79
+ try {
80
+ tenants = fs.readdirSync(applicationsRoot, { withFileTypes: true });
81
+ } catch {
82
+ return candidates;
83
+ }
84
+ for (const tenant of tenants) {
85
+ if (!tenant.isDirectory() || !TENANT.test(tenant.name) || tenant.name.includes("..")) continue;
86
+ const root = path.join(applicationsRoot, tenant.name, APPLICATION_BY_TOOL[tool]);
87
+ const candidate = parseCandidate(
88
+ path.join(root, "resources", "app-manifest.json"),
89
+ path.join(root, "impel-apps-launcher.exe"),
90
+ tool,
91
+ "win32",
92
+ home,
93
+ );
94
+ if (candidate) candidates.push(candidate);
95
+ }
96
+ return candidates;
97
+ }
98
+
99
+ export function installedManagedAppCandidates(tool, {
100
+ platform = process.platform,
101
+ home = os.homedir(),
102
+ applicationsRoot = platform === "win32"
103
+ ? path.join(process.env.ProgramFiles || "C:\\Program Files", "Impel", "Apps")
104
+ : "/Applications",
105
+ } = {}) {
106
+ if (!APPLICATION_BY_TOOL[tool]) return [];
107
+ const candidates = platform === "win32"
108
+ ? windowsCandidates(tool, home, applicationsRoot)
109
+ : platform === "darwin" ? macCandidates(tool, home, applicationsRoot) : [];
110
+ return candidates.sort((left, right) => left.tenantId.localeCompare(right.tenantId));
111
+ }
112
+
113
+ export function parseManagedTenantArgument(argv) {
114
+ const passthrough = [];
115
+ let tenantId = null;
116
+ let literal = false;
117
+ for (let index = 0; index < argv.length; index += 1) {
118
+ const argument = argv[index];
119
+ if (literal) {
120
+ passthrough.push(argument);
121
+ continue;
122
+ }
123
+ if (argument === "--") {
124
+ literal = true;
125
+ passthrough.push(argument);
126
+ continue;
127
+ }
128
+ if (argument === "--tenant") {
129
+ tenantId = argv[index += 1] || null;
130
+ continue;
131
+ }
132
+ if (argument.startsWith("--tenant=")) {
133
+ tenantId = argument.slice("--tenant=".length) || null;
134
+ continue;
135
+ }
136
+ passthrough.push(argument);
137
+ }
138
+ if (tenantId !== null && (!TENANT.test(tenantId) || tenantId.includes(".."))) {
139
+ throw new Error("--tenant requires a valid managed tenant id");
140
+ }
141
+ return { tenantId, argv: passthrough };
142
+ }
143
+
144
+ export function selectManagedAppCandidate(candidates, {
145
+ requestedTenant = null,
146
+ environment = process.env,
147
+ configuredTenant = null,
148
+ } = {}) {
149
+ const tenantId = requestedTenant || environment.IMPEL_MANAGED_TENANT || configuredTenant;
150
+ if (tenantId) {
151
+ const selected = candidates.find((candidate) => candidate.tenantId === tenantId);
152
+ if (!selected) throw new Error(`no installed managed application is available for tenant "${tenantId}"`);
153
+ return selected;
154
+ }
155
+ if (candidates.length === 1) return candidates[0];
156
+ if (candidates.length === 0) return null;
157
+ throw new Error(
158
+ `more than one managed tenant is installed (${candidates.map(({ tenantId: id }) => id).join(", ")}); pass --tenant <id>`,
159
+ );
160
+ }
161
+
162
+ function childExitCode(code, signal) {
163
+ if (Number.isInteger(code)) return code;
164
+ const signalNumber = signal ? os.constants.signals[signal] : null;
165
+ return Number.isInteger(signalNumber) ? 128 + signalNumber : 1;
166
+ }
167
+
168
+ export function runManagedAppCli(candidate, tool, argv, {
169
+ environment = process.env,
170
+ cwd = process.cwd(),
171
+ spawnProcess = spawn,
172
+ } = {}) {
173
+ return new Promise((resolve) => {
174
+ const child = spawnProcess(candidate.launcher, ["--managed-cli", tool, "--", ...argv], {
175
+ cwd,
176
+ env: environment,
177
+ stdio: "inherit",
178
+ windowsHide: false,
179
+ });
180
+ let settled = false;
181
+ child.once("error", (error) => {
182
+ if (settled) return;
183
+ settled = true;
184
+ console.error(`impel ${tool}: could not launch the managed application: ${error.message}`);
185
+ resolve(127);
186
+ });
187
+ child.once("exit", (code, signal) => {
188
+ if (settled) return;
189
+ settled = true;
190
+ resolve(childExitCode(code, signal));
191
+ });
192
+ });
193
+ }
194
+
package/src/tenants.js CHANGED
@@ -25,6 +25,10 @@ const PRODUCT_ACCESS_VALUES = new Set([
25
25
  const PROVIDER_SCOPE = Object.freeze({ claude: PAT_SCOPE_CLAUDE, codex: PAT_SCOPE_CODEX });
26
26
  const TENANT_ID_RE = /^[A-Za-z0-9_.-]{1,128}$/u;
27
27
  const PAT_SCOPE_RE = /^[a-z0-9][a-z0-9:._-]{0,63}$/u;
28
+ // Node surfaces a name-resolution failure through these `getaddrinfo` codes.
29
+ // `EAI_AGAIN` is a transient resolver error; `ENOTFOUND` is a negative answer
30
+ // the OS then caches, which is why an immediate retry re-reads the same result.
31
+ const DNS_FAILURE_CODES = new Set(["ENOTFOUND", "EAI_AGAIN"]);
28
32
 
29
33
  export function normalizeProductAccess(value, { allowMissing = false } = {}) {
30
34
  if ((value === undefined || value === null || value === "") && allowMissing) return null;
@@ -136,7 +140,15 @@ export async function fetchTenants(config, fetchImpl = fetchHttp1) {
136
140
  signal: controller.signal,
137
141
  });
138
142
  } catch (error) {
139
- const message = error?.name === "AbortError" ? "request timed out" : error?.message || error;
143
+ // A resolver failure is not a control-plane outage, and the raw
144
+ // `getaddrinfo ENOTFOUND` reads like one. Name the machine's own DNS so the
145
+ // remedy is looked for locally. The `could not reach` prefix is preserved
146
+ // because `cmdConverge` matches on it to decide the failure is retryable.
147
+ const message = error?.name === "AbortError"
148
+ ? "request timed out"
149
+ : DNS_FAILURE_CODES.has(error?.code)
150
+ ? `${error.code}; this machine could not resolve the hostname. Check its DNS resolver or VPN`
151
+ : error?.message || error;
140
152
  throw new Error(`could not reach ${appUrl}: ${redactSecretText(message)}`);
141
153
  } finally {
142
154
  clearTimeout(timeout);