impel-cli 0.18.15-beta.6 → 0.18.15-beta.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/README.md CHANGED
@@ -223,6 +223,11 @@ impel update --no-recovery # disable recovery for this run
223
223
  schema verified by that CLI. It does not mean cloning an untested moving vendor
224
224
  release.
225
225
 
226
+ Stable installations follow npm's `latest` tag. Prerelease installations made
227
+ from `impel-cli@next` remain on the `next` channel during `impel update`. If npm
228
+ metadata is temporarily unavailable, update preserves the installed CLI build
229
+ and continues tenant reconciliation without attempting a downgrade.
230
+
226
231
  ## Status and diagnosis
227
232
 
228
233
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.18.15-beta.6",
3
+ "version": "0.18.15-beta.8",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -269,7 +269,7 @@ export const CURRENT_CONFIG_VERSION = 26;
269
269
  // — which is what made every `impel update` re-trigger macOS permission
270
270
  // prompts. Bump this ONLY when a code change alters the bytes of a built
271
271
  // bundle; leave it alone for changes that don't touch bundle contents.
272
- export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-31.1";
272
+ export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-31.2";
273
273
 
274
274
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
275
275
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
@@ -347,7 +347,7 @@ export function bundleIsCurrent(status, {
347
347
  compatibility.vendorSignaturesPreserved === true
348
348
  && compatibility.resourceRoot === CHATGPT_RESOURCE_ROOT
349
349
  && compatibility.patches?.fastModeForGatewayAuth > 0
350
- && compatibility.patches?.tenantDisplayNamePreload === 1
350
+ && compatibility.patches?.tenantDisplayNamePreload === 2
351
351
  )),
352
352
  );
353
353
  }
@@ -1772,7 +1772,7 @@ function writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir
1772
1772
  patches: {
1773
1773
  fastModeForGatewayAuth: fastModePatchCount,
1774
1774
  desktopAPIForGatewayAuth: 0,
1775
- tenantDisplayNamePreload: 1,
1775
+ tenantDisplayNamePreload: 2,
1776
1776
  },
1777
1777
  vendorSignaturesPreserved: true,
1778
1778
  resourceRoot: CHATGPT_RESOURCE_ROOT,
@@ -2299,6 +2299,7 @@ secure_codex_home
2299
2299
  BROWSER_DATA=${shellQuote(paths.chatgpt.browserData)}
2300
2300
  mkdir -p "$BROWSER_DATA"
2301
2301
  export IMPEL_APP_DISPLAY_NAME=${shellQuote(paths.chatgpt.displayName)}
2302
+ export IMPEL_APP_BUNDLE_ID=${shellQuote(paths.chatgpt.bundleIdentifier)}
2302
2303
  PRELOAD="$HERE/../Resources/impel-chatgpt-preload.cjs"
2303
2304
  export NODE_OPTIONS="--require=\\\"$PRELOAD\\\""
2304
2305
  # Electron resolves resources from the Impel wrapper when the nested app is
@@ -2313,13 +2314,14 @@ function vendoredChatGPTDisplayNamePreload() {
2313
2314
  return `"use strict";
2314
2315
 
2315
2316
  const displayName = process.env.IMPEL_APP_DISPLAY_NAME;
2316
- if (process.type === "browser" && displayName) {
2317
+ const bundleIdentifier = process.env.IMPEL_APP_BUNDLE_ID;
2318
+ if (process.type === "browser" && displayName && bundleIdentifier) {
2317
2319
  const Module = require("node:module");
2318
2320
  const path = require("node:path");
2319
2321
  const load = Module._load;
2320
2322
  let objc;
2321
2323
  let appKit;
2322
- let launchServicesNamed = false;
2324
+ let launchServicesIdentified = false;
2323
2325
  let activationPolicyRefreshed = false;
2324
2326
 
2325
2327
  const loadNativeBridge = () => {
@@ -2331,20 +2333,31 @@ if (process.type === "browser" && displayName) {
2331
2333
  return { objc, appKit };
2332
2334
  };
2333
2335
 
2334
- const setLaunchServicesApplicationName = () => {
2335
- if (launchServicesNamed) return;
2336
+ const setLaunchServicesApplicationIdentity = () => {
2337
+ if (launchServicesIdentified) return;
2336
2338
  try {
2337
2339
  const native = loadNativeBridge();
2338
2340
  new native.objc.NobjcLibrary(
2339
2341
  "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/ApplicationServices",
2340
2342
  );
2341
2343
  const title = native.appKit.NSString.stringWithUTF8String$(displayName);
2344
+ const identifier = native.appKit.NSString.stringWithUTF8String$(bundleIdentifier);
2342
2345
  const info = native.appKit.NSMutableDictionary.dictionaryWithDictionary$(
2343
2346
  native.appKit.NSBundle.mainBundle().infoDictionary(),
2344
2347
  );
2345
2348
  for (const key of ["CFBundleName", "CFBundleDisplayName"]) {
2346
2349
  info.setObject$forKey$(title, native.appKit.NSString.stringWithUTF8String$(key));
2347
2350
  }
2351
+ // The signed vendor executable runs from the nested ChatGPT.app, so
2352
+ // LaunchServices initially checks it in as com.openai.codex. When the
2353
+ // native app is also open, Dock groups both processes under ChatGPT even
2354
+ // though LSDisplayName was already tenant-branded. Check the process in
2355
+ // again with the unique outer-wrapper identity to keep each tenant's
2356
+ // Dock item distinct while preserving the vendor executable signature.
2357
+ info.setObject$forKey$(
2358
+ identifier,
2359
+ native.appKit.NSString.stringWithUTF8String$("CFBundleIdentifier"),
2360
+ );
2348
2361
  native.objc.callFunction(
2349
2362
  "_LSSetApplicationLaunchServicesServerConnectionStatus",
2350
2363
  { returns: "v", args: ["Q", "@"] },
@@ -2368,7 +2381,7 @@ if (process.type === "browser" && displayName) {
2368
2381
  title,
2369
2382
  null,
2370
2383
  );
2371
- launchServicesNamed = status === 0;
2384
+ launchServicesIdentified = status === 0;
2372
2385
  } catch {
2373
2386
  // Keep the signed vendor app usable if LaunchServices changes this
2374
2387
  // private-but-established process-title API in a future macOS release.
@@ -2403,7 +2416,7 @@ if (process.type === "browser" && displayName) {
2403
2416
  const setName = loaded.app.setName.bind(loaded.app);
2404
2417
  setName(displayName);
2405
2418
  loaded.app.setName = () => setName(displayName);
2406
- setLaunchServicesApplicationName();
2419
+ setLaunchServicesApplicationIdentity();
2407
2420
  setNativeApplicationMenuTitle();
2408
2421
  const setApplicationMenu = loaded.Menu.setApplicationMenu.bind(loaded.Menu);
2409
2422
  loaded.Menu.setApplicationMenu = (menu) => {
@@ -2414,11 +2427,11 @@ if (process.type === "browser" && displayName) {
2414
2427
  loaded.app.setActivationPolicy("accessory");
2415
2428
  setTimeout(() => {
2416
2429
  loaded.app.setActivationPolicy("regular");
2417
- launchServicesNamed = false;
2418
- setLaunchServicesApplicationName();
2430
+ launchServicesIdentified = false;
2431
+ setLaunchServicesApplicationIdentity();
2419
2432
  setApplicationMenu(menu);
2420
2433
  setNativeApplicationMenuTitle();
2421
- }, 0);
2434
+ }, 100);
2422
2435
  }
2423
2436
  setTimeout(setNativeApplicationMenuTitle, 0);
2424
2437
  return result;
@@ -13,6 +13,8 @@ import { promptText } from "../prompt.js";
13
13
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
14
14
  import { restoreNativeProfiles } from "./use.js";
15
15
 
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
+
16
18
  function confirmed(answer) {
17
19
  return /^(?:y|yes)$/iu.test(String(answer || "").trim());
18
20
  }
@@ -44,6 +46,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
44
46
  platform: process.platform,
45
47
  environment: process.env,
46
48
  preparePlatformClis,
49
+ discoveryLogger: console,
47
50
  ...overrides,
48
51
  };
49
52
  if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
@@ -58,10 +61,22 @@ export async function cmdConverge(argv = [], overrides = {}) {
58
61
  let listing;
59
62
  try {
60
63
  listing = await io.fetchTenants(config);
64
+ // Tenant discovery is the entry gate for the whole convergence. A single
65
+ // transient timeout should not force a human to rerun every update step.
61
66
  } catch (error) {
62
- console.error(`impel update: tenant discovery failed (${redactSecretText(error?.message || error)})`);
63
- process.exitCode = 1;
64
- return false;
67
+ if (!RETRYABLE_TENANT_DISCOVERY_ERROR.test(String(error?.message || error))) {
68
+ console.error(`impel update: tenant discovery failed (${redactSecretText(error?.message || error)})`);
69
+ process.exitCode = 1;
70
+ return false;
71
+ }
72
+ io.discoveryLogger.log("Tenants: discovery request failed transiently; retrying once…");
73
+ try {
74
+ listing = await io.fetchTenants(config);
75
+ } catch (retryError) {
76
+ console.error(`impel update: tenant discovery failed (${redactSecretText(retryError?.message || retryError)})`);
77
+ process.exitCode = 1;
78
+ return false;
79
+ }
65
80
  }
66
81
  const selected = selectDefaultTenant(listing, { currentTenantId: config.tenantId });
67
82
  config.tenantId = selected.id;
@@ -18,6 +18,7 @@ import {
18
18
  isNewerVersion,
19
19
  refreshUpdateCache,
20
20
  updateInstallSpec,
21
+ updateTagForVersion,
21
22
  writeUpdateCache,
22
23
  } from "../updates.js";
23
24
 
@@ -200,30 +201,41 @@ export async function cmdUpdate(argv, overrides = {}) {
200
201
  }
201
202
 
202
203
  const current = io.installedVersion();
203
- const remote = await io.progress("Checking npm for impel-cli updates", () => io.fetchRemoteVersion());
204
+ const updateTag = updateTagForVersion(current);
205
+ const installSpec = updateInstallSpec(updateTag);
206
+ const remote = await io.progress(
207
+ "Checking npm for impel-cli updates",
208
+ () => io.fetchRemoteVersion({ tag: updateTag }),
209
+ );
204
210
  if (remote) io.writeCache({ remoteVersion: remote, checkedAt: Date.now() });
205
211
 
206
212
  console.log(`impel-cli v${current ?? "?"}`);
207
- console.log(`npm latest: ${remote ? `v${remote}` : "unknown — registry check failed"}`);
213
+ console.log(`npm ${updateTag}: ${remote ? `v${remote}` : "unknown — registry check failed"}`);
208
214
 
209
215
  const updateAvailable = Boolean(current && remote && isNewerVersion(remote, current));
210
216
  const upToDate = Boolean(current && remote && !updateAvailable);
211
217
  if (flags.check) {
212
- console.log(upToDate ? "Up to date." : "Update available: run `impel update`.");
213
- if (!remote) process.exitCode = 1;
218
+ if (!remote) {
219
+ console.log("Update status unavailable; the installed CLI was not changed.");
220
+ process.exitCode = 1;
221
+ } else {
222
+ console.log(upToDate ? "Up to date." : "Update available: run `impel update`.");
223
+ }
214
224
  return;
215
225
  }
216
226
 
217
227
  // ── CLI ────────────────────────────────────────────────────────────────
218
- if (upToDate) {
228
+ if (!remote) {
229
+ console.warn("CLI: npm update check unavailable; keeping the installed build.");
230
+ } else if (upToDate) {
219
231
  console.log("CLI: already up to date.");
220
232
  } else {
221
- console.log("CLI: installing the latest build…");
222
- if (!await io.progress("Installing the latest impel-cli build", () => io.selfUpdate(updateInstallSpec()))) {
233
+ console.log(`CLI: installing the npm ${updateTag} build…`);
234
+ if (!await io.progress(`Installing the npm ${updateTag} impel-cli build`, () => io.selfUpdate(installSpec))) {
223
235
  console.error("impel update: `npm install -g` failed; the CLI was not updated.");
224
236
  if (io.platform === "win32") {
225
237
  console.error(" Verify `npm --version` in PowerShell, then retry `impel update`.");
226
- console.error(" Manual recovery: `npm install --global impel-cli@latest`.");
238
+ console.error(` Manual recovery: \`npm install --global ${installSpec}\`.`);
227
239
  }
228
240
  const config = io.loadConfig();
229
241
  let recovered = false;
@@ -239,7 +251,7 @@ export async function cmdUpdate(argv, overrides = {}) {
239
251
  platform: io.platform,
240
252
  architecture: process.arch,
241
253
  step: "install.impel_cli",
242
- command: `npm install --global ${RUNTIME_BRAND.cli.packageName}@latest`,
254
+ command: `npm install --global ${installSpec}`,
243
255
  message: `The npm-verified global ${RUNTIME_BRAND.cli.packageName} update failed.`,
244
256
  },
245
257
  config,
@@ -265,7 +277,7 @@ export async function cmdUpdate(argv, overrides = {}) {
265
277
  }
266
278
  },
267
279
  retryStep: async () => {
268
- updateState.ok = io.selfUpdate(updateInstallSpec()) === true;
280
+ updateState.ok = io.selfUpdate(installSpec) === true;
269
281
  return updateState.ok;
270
282
  },
271
283
  },
@@ -294,7 +306,7 @@ export async function cmdUpdate(argv, overrides = {}) {
294
306
  console.error(`impel update: npm reported success but the running CLI still resolves v${postInstall ?? "?"} (expected v${remote}).`);
295
307
  console.error(` Running CLI: ${CLI_BIN}`);
296
308
  console.error(" This usually means the `impel` on PATH belongs to a different npm prefix than `npm prefix -g`.");
297
- console.error(" Fix: run `npm prefix -g`, confirm it owns the `impel` shim on PATH, then `npm install --global impel-cli@latest` there.");
309
+ console.error(` Fix: run \`npm prefix -g\`, confirm it owns the \`impel\` shim on PATH, then \`npm install --global ${installSpec}\` there.`);
298
310
  process.exitCode = 1;
299
311
  return;
300
312
  }
package/src/updates.js CHANGED
@@ -36,8 +36,19 @@ export function updateRegistry() {
36
36
  ).replace(/\/+$/u, "");
37
37
  }
38
38
 
39
- export function updateInstallSpec() {
40
- return `${updatePackage()}@latest`;
39
+ function normalizeUpdateTag(tag) {
40
+ if (tag === "latest" || tag === "next") return tag;
41
+ throw new Error(`unsupported npm update tag "${tag}"`);
42
+ }
43
+
44
+ /** Keep prerelease installs on npm's prerelease channel. */
45
+ export function updateTagForVersion(version) {
46
+ const parsed = validVersion(version) ? version.match(VERSION_RE) : null;
47
+ return parsed?.[4] ? "next" : "latest";
48
+ }
49
+
50
+ export function updateInstallSpec(tag = "latest") {
51
+ return `${updatePackage()}@${normalizeUpdateTag(tag)}`;
41
52
  }
42
53
 
43
54
  export function installedVersion() {
@@ -88,15 +99,17 @@ export function isNewerVersion(candidate, current) {
88
99
  function fetchRemoteVersionWithNpm({
89
100
  packageName,
90
101
  registry,
102
+ tag,
91
103
  timeoutMs,
92
104
  spawnSyncImpl = spawnSync,
93
105
  environment = process.env,
94
106
  platform = process.platform,
95
107
  }) {
96
108
  try {
109
+ const target = tag === "latest" ? packageName : `${packageName}@${tag}`;
97
110
  const invocation = nativeCommandInvocation(
98
111
  "npm",
99
- ["view", packageName, "version", "--json", "--registry", registry],
112
+ ["view", target, "version", "--json", "--registry", registry],
100
113
  environment,
101
114
  platform,
102
115
  );
@@ -122,39 +135,71 @@ function fetchRemoteVersionWithNpm({
122
135
  export async function fetchRemoteVersion({
123
136
  packageName = updatePackage(),
124
137
  registry = updateRegistry(),
138
+ tag = "latest",
125
139
  timeoutMs = 10_000,
126
140
  fetchImpl = null,
141
+ fallbackToNpm = fetchImpl === null,
127
142
  spawnSyncImpl = spawnSync,
128
143
  environment = process.env,
129
144
  platform = process.platform,
130
145
  } = {}) {
146
+ const normalizedTag = normalizeUpdateTag(tag);
131
147
  const normalizedRegistry = String(registry).replace(/\/+$/u, "");
132
148
  if (!fetchImpl && normalizedRegistry !== DEFAULT_UPDATE_REGISTRY) {
133
149
  return fetchRemoteVersionWithNpm({
134
150
  packageName,
135
151
  registry: normalizedRegistry,
152
+ tag: normalizedTag,
153
+ timeoutMs,
154
+ spawnSyncImpl,
155
+ environment,
156
+ platform,
157
+ });
158
+ }
159
+ // Windows npm commonly has proxy, certificate, or registry settings that
160
+ // Node's bare fetch does not inherit. Prefer the already-working npm client
161
+ // there, then retain the direct registry request as a fallback.
162
+ if (!fetchImpl && platform === "win32") {
163
+ const npmVersion = fetchRemoteVersionWithNpm({
164
+ packageName,
165
+ registry: normalizedRegistry,
166
+ tag: normalizedTag,
136
167
  timeoutMs,
137
168
  spawnSyncImpl,
138
169
  environment,
139
170
  platform,
140
171
  });
172
+ if (npmVersion) return npmVersion;
141
173
  }
142
174
  const controller = new AbortController();
143
175
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
176
+ let remoteVersion = null;
144
177
  try {
145
178
  const encodedName = encodeURIComponent(packageName);
146
- const response = await (fetchImpl || globalThis.fetch)(`${normalizedRegistry}/${encodedName}/latest`, {
179
+ const response = await (fetchImpl || globalThis.fetch)(`${normalizedRegistry}/${encodedName}/${normalizedTag}`, {
147
180
  headers: { accept: "application/json" },
148
181
  signal: controller.signal,
149
182
  });
150
- if (!response.ok) return null;
151
- const metadata = await response.json();
152
- return validVersion(metadata?.version) ? metadata.version : null;
183
+ if (response.ok) {
184
+ const metadata = await response.json();
185
+ remoteVersion = validVersion(metadata?.version) ? metadata.version : null;
186
+ }
153
187
  } catch {
154
- return null;
188
+ // npm is the authoritative fallback below. It also honors npm's proxy and
189
+ // registry configuration, which is especially important on Windows.
155
190
  } finally {
156
191
  clearTimeout(timeout);
157
192
  }
193
+ if (remoteVersion || !fallbackToNpm) return remoteVersion;
194
+ return fetchRemoteVersionWithNpm({
195
+ packageName,
196
+ registry: normalizedRegistry,
197
+ tag: normalizedTag,
198
+ timeoutMs,
199
+ spawnSyncImpl,
200
+ environment,
201
+ platform,
202
+ });
158
203
  }
159
204
 
160
205
  export function readUpdateCache() {
@@ -198,7 +243,9 @@ export function cacheIsFresh(cache, now = Date.now()) {
198
243
  export async function refreshUpdateCache(dependencies = {}) {
199
244
  const fetchLatest = dependencies.fetchRemoteVersion || fetchRemoteVersion;
200
245
  const writeCache = dependencies.writeCache || writeUpdateCache;
201
- const remoteVersion = await fetchLatest();
246
+ const currentVersion = (dependencies.installedVersion || installedVersion)();
247
+ const tag = updateTagForVersion(currentVersion);
248
+ const remoteVersion = await fetchLatest({ tag });
202
249
  if (!remoteVersion) {
203
250
  try {
204
251
  writeCache({ lastFailedAt: Date.now() });
@@ -207,7 +254,7 @@ export async function refreshUpdateCache(dependencies = {}) {
207
254
  }
208
255
  return null;
209
256
  }
210
- return writeCache({ remoteVersion, checkedAt: Date.now() });
257
+ return writeCache({ remoteVersion, updateTag: tag, checkedAt: Date.now() });
211
258
  }
212
259
 
213
260
  /** One-line human notice when npm has a newer stable package. */