impel-cli 0.7.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 +695 -0
- package/bin/impel.js +7 -0
- package/package.json +29 -0
- package/src/apps.js +1263 -0
- package/src/args.js +36 -0
- package/src/claudeSetup.js +207 -0
- package/src/cli.js +184 -0
- package/src/cliProfiles.js +216 -0
- package/src/codexSecurity.js +184 -0
- package/src/codexSetup.js +224 -0
- package/src/commands/apps.js +538 -0
- package/src/commands/auth.js +89 -0
- package/src/commands/doctor.js +215 -0
- package/src/commands/experimental.js +60 -0
- package/src/commands/launch.js +161 -0
- package/src/commands/mcp.js +94 -0
- package/src/commands/setup.js +350 -0
- package/src/commands/skills.js +108 -0
- package/src/commands/status.js +95 -0
- package/src/commands/tasks.js +359 -0
- package/src/commands/tenant.js +77 -0
- package/src/commands/token.js +25 -0
- package/src/commands/update.js +217 -0
- package/src/commands/use.js +208 -0
- package/src/config.js +98 -0
- package/src/doctor.js +546 -0
- package/src/nativeProcess.js +192 -0
- package/src/prompt.js +51 -0
- package/src/selfInvocation.js +21 -0
- package/src/skills.js +314 -0
- package/src/tenants.js +194 -0
- package/src/updates.js +181 -0
- package/src/windowsApps.js +439 -0
- package/src/windowsSetup.js +122 -0
package/src/tenants.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeGatewayUrl,
|
|
3
|
+
redactSecretText,
|
|
4
|
+
resolveDefaultAppUrl,
|
|
5
|
+
saveConfig,
|
|
6
|
+
} from "./config.js";
|
|
7
|
+
|
|
8
|
+
export const TENANT_CREDENTIAL_PREFIX = "impel_tenant_";
|
|
9
|
+
export const PRODUCT_ACCESS_WORKSPACE = "workspace";
|
|
10
|
+
export const PRODUCT_ACCESS_GATEWAY = "gateway";
|
|
11
|
+
export const PAT_SCOPE_CLAUDE = "claude-code-gateway";
|
|
12
|
+
export const PAT_SCOPE_CODEX = "codex-gateway";
|
|
13
|
+
export const PAT_SCOPE_TASKS = "tasks";
|
|
14
|
+
const PRODUCT_ACCESS_VALUES = new Set([PRODUCT_ACCESS_WORKSPACE, PRODUCT_ACCESS_GATEWAY]);
|
|
15
|
+
const PROVIDER_SCOPE = Object.freeze({ claude: PAT_SCOPE_CLAUDE, codex: PAT_SCOPE_CODEX });
|
|
16
|
+
const TENANT_ID_RE = /^[A-Za-z0-9_.-]{1,128}$/u;
|
|
17
|
+
const PAT_SCOPE_RE = /^[a-z0-9][a-z0-9:._-]{0,63}$/u;
|
|
18
|
+
|
|
19
|
+
export function normalizeProductAccess(value, { allowMissing = false } = {}) {
|
|
20
|
+
if ((value === undefined || value === null || value === "") && allowMissing) return null;
|
|
21
|
+
const productAccess = String(value || "").trim();
|
|
22
|
+
if (!PRODUCT_ACCESS_VALUES.has(productAccess)) {
|
|
23
|
+
throw new Error("tenant API returned an invalid product access value");
|
|
24
|
+
}
|
|
25
|
+
return productAccess;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function productAccessLabel(value) {
|
|
29
|
+
if (value === PRODUCT_ACCESS_WORKSPACE) return "Workspace member";
|
|
30
|
+
if (value === PRODUCT_ACCESS_GATEWAY) return "Gateway member";
|
|
31
|
+
return "Unknown";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function normalizePatScopes(value, { allowMissing = false } = {}) {
|
|
35
|
+
if ((value === undefined || value === null) && allowMissing) return null;
|
|
36
|
+
if (!Array.isArray(value) || value.some((scope) => typeof scope !== "string" || !PAT_SCOPE_RE.test(scope))) {
|
|
37
|
+
throw new Error("tenant API returned invalid PAT scopes");
|
|
38
|
+
}
|
|
39
|
+
return [...new Set(value)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function requiredProviderScope(provider) {
|
|
43
|
+
const scope = PROVIDER_SCOPE[provider];
|
|
44
|
+
if (!scope) throw new Error(`unsupported gateway provider "${redactSecretText(provider)}"`);
|
|
45
|
+
return scope;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function assertProviderScopes(scopes, providers, { requireLive = false } = {}) {
|
|
49
|
+
if (scopes === null || scopes === undefined) {
|
|
50
|
+
if (requireLive) {
|
|
51
|
+
throw new Error("the control plane did not return live PAT scopes; retry after it is upgraded");
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const missing = providers
|
|
56
|
+
.map(requiredProviderScope)
|
|
57
|
+
.filter((scope) => !scopes.includes(scope));
|
|
58
|
+
if (!missing.length) return;
|
|
59
|
+
const labels = missing.map((scope) => `"${scope}"`).join(" and ");
|
|
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 auth --pat <pat>\``,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeTenantId(value) {
|
|
66
|
+
const tenantId = String(value || "").trim();
|
|
67
|
+
if (!TENANT_ID_RE.test(tenantId) || tenantId === "." || tenantId === "..") {
|
|
68
|
+
throw new Error(`invalid tenant slug "${redactSecretText(tenantId)}"`);
|
|
69
|
+
}
|
|
70
|
+
return tenantId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeTenantName(value, fallback) {
|
|
74
|
+
const sanitized = redactSecretText(String(value || fallback)).trim().slice(0, 256);
|
|
75
|
+
return sanitized || fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function tenantCredential(pat, tenantId) {
|
|
79
|
+
if (!String(pat || "").startsWith("impel_pat_")) {
|
|
80
|
+
throw new Error("an Impel PAT is required to create a tenant credential");
|
|
81
|
+
}
|
|
82
|
+
const encodedTenant = Buffer.from(normalizeTenantId(tenantId), "utf8").toString("base64url");
|
|
83
|
+
return `${TENANT_CREDENTIAL_PREFIX}${encodedTenant}.${pat}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function fetchTenants(config, fetchImpl = fetch) {
|
|
87
|
+
if (!config?.pat) throw new Error("not authenticated; run `impel setup` (or `impel auth`) first");
|
|
88
|
+
const appUrl = normalizeGatewayUrl(config.appUrl || resolveDefaultAppUrl());
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const timeout = setTimeout(() => controller.abort(), 7_000);
|
|
91
|
+
let response;
|
|
92
|
+
try {
|
|
93
|
+
response = await fetchImpl(new URL("/api/cli/tenants", appUrl), {
|
|
94
|
+
headers: {
|
|
95
|
+
accept: "application/json",
|
|
96
|
+
authorization: `Bearer ${config.pat}`,
|
|
97
|
+
},
|
|
98
|
+
signal: controller.signal,
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = error?.name === "AbortError" ? "request timed out" : error?.message || error;
|
|
102
|
+
throw new Error(`could not reach ${appUrl}: ${redactSecretText(message)}`);
|
|
103
|
+
} finally {
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let payload;
|
|
108
|
+
try {
|
|
109
|
+
payload = await response.json();
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error(`tenant API returned HTTP ${response.status} without JSON`);
|
|
112
|
+
}
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error(redactSecretText(payload?.error || `tenant API returned HTTP ${response.status}`));
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(payload?.tenants) || typeof payload.defaultTenantId !== "string") {
|
|
117
|
+
throw new Error("tenant API returned an invalid response");
|
|
118
|
+
}
|
|
119
|
+
// Older control planes did not return productAccess. Keep discovery usable
|
|
120
|
+
// during a staged rollout, but never infer a privileged access level here.
|
|
121
|
+
const productAccess = normalizeProductAccess(payload.productAccess, { allowMissing: true });
|
|
122
|
+
const scopes = normalizePatScopes(payload.scopes, { allowMissing: true });
|
|
123
|
+
const tenants = payload.tenants.map((tenant) => {
|
|
124
|
+
const id = normalizeTenantId(tenant?.id || tenant?.slug);
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
slug: normalizeTenantId(tenant?.slug || id),
|
|
128
|
+
name: normalizeTenantName(tenant?.name, id),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
const defaultTenantId = normalizeTenantId(payload.defaultTenantId);
|
|
132
|
+
if (!tenants.some((tenant) => tenant.id === defaultTenantId)) {
|
|
133
|
+
throw new Error("tenant API default is not in the available tenant list");
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
tenants,
|
|
137
|
+
defaultTenantId,
|
|
138
|
+
patTenantId: payload.patTenantId || null,
|
|
139
|
+
productAccess,
|
|
140
|
+
scopes,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function ensureTenantSelection(config, { refresh = false } = {}) {
|
|
145
|
+
if (!config?.pat) throw new Error("not authenticated; run `impel setup` (or `impel auth`) first");
|
|
146
|
+
if (config.tenantId && !refresh) {
|
|
147
|
+
const tenantId = normalizeTenantId(config.tenantId);
|
|
148
|
+
return {
|
|
149
|
+
config,
|
|
150
|
+
tenantId,
|
|
151
|
+
tenants: null,
|
|
152
|
+
defaultTenantId: null,
|
|
153
|
+
productAccess: normalizeProductAccess(config.productAccess, { allowMissing: true }),
|
|
154
|
+
scopes: normalizePatScopes(config.scopes, { allowMissing: true }),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const listing = await fetchTenants(config);
|
|
159
|
+
const current = config.tenantId && listing.tenants.some((tenant) => tenant.id === config.tenantId)
|
|
160
|
+
? config.tenantId
|
|
161
|
+
: listing.defaultTenantId;
|
|
162
|
+
config.tenantId = current;
|
|
163
|
+
if (listing.productAccess) config.productAccess = listing.productAccess;
|
|
164
|
+
else delete config.productAccess;
|
|
165
|
+
if (listing.scopes) config.scopes = listing.scopes;
|
|
166
|
+
else delete config.scopes;
|
|
167
|
+
config.tenantsUpdatedAt = new Date().toISOString();
|
|
168
|
+
saveConfig(config);
|
|
169
|
+
return {
|
|
170
|
+
config,
|
|
171
|
+
tenantId: current,
|
|
172
|
+
tenants: listing.tenants,
|
|
173
|
+
defaultTenantId: listing.defaultTenantId,
|
|
174
|
+
productAccess: listing.productAccess,
|
|
175
|
+
scopes: listing.scopes,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function selectTenant(config, tenantId) {
|
|
180
|
+
const selected = normalizeTenantId(tenantId);
|
|
181
|
+
const listing = await fetchTenants(config);
|
|
182
|
+
const tenant = listing.tenants.find((candidate) => candidate.id === selected || candidate.slug === selected);
|
|
183
|
+
if (!tenant) {
|
|
184
|
+
throw new Error(`tenant "${selected}" is not available to this user`);
|
|
185
|
+
}
|
|
186
|
+
config.tenantId = tenant.id;
|
|
187
|
+
if (listing.productAccess) config.productAccess = listing.productAccess;
|
|
188
|
+
else delete config.productAccess;
|
|
189
|
+
if (listing.scopes) config.scopes = listing.scopes;
|
|
190
|
+
else delete config.scopes;
|
|
191
|
+
config.tenantsUpdatedAt = new Date().toISOString();
|
|
192
|
+
saveConfig(config);
|
|
193
|
+
return { tenant, listing };
|
|
194
|
+
}
|
package/src/updates.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Update discovery shared by `impel update`, launch-time notices, and the
|
|
2
|
+
// app-triggered background refresh.
|
|
3
|
+
//
|
|
4
|
+
// impel-cli is distributed as a public npm package. The package contains only
|
|
5
|
+
// the same dependency-free JavaScript users run locally; access to Impel is
|
|
6
|
+
// still enforced by PAT verification and live product/tenant authorization at
|
|
7
|
+
// the control plane and gateway. Update checks therefore use npm's public
|
|
8
|
+
// registry metadata and never require access to the private source repository.
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
import { CONFIG_DIR } from "./config.js";
|
|
16
|
+
|
|
17
|
+
export const UPDATE_CACHE_PATH = path.join(CONFIG_DIR, "update-check.json");
|
|
18
|
+
export const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
const CLI_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
21
|
+
const DEFAULT_UPDATE_PACKAGE = "impel-cli";
|
|
22
|
+
const DEFAULT_UPDATE_REGISTRY = "https://registry.npmjs.org";
|
|
23
|
+
const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u;
|
|
24
|
+
|
|
25
|
+
export function updatePackage() {
|
|
26
|
+
return process.env.IMPEL_UPDATE_PACKAGE || DEFAULT_UPDATE_PACKAGE;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function updateRegistry() {
|
|
30
|
+
return String(process.env.IMPEL_UPDATE_REGISTRY || DEFAULT_UPDATE_REGISTRY).replace(/\/+$/u, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function updateInstallSpec() {
|
|
34
|
+
return `${updatePackage()}@latest`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function installedVersion() {
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(CLI_ROOT, "package.json"), "utf8"));
|
|
40
|
+
return validVersion(pkg.version) ? pkg.version : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validVersion(value) {
|
|
47
|
+
return typeof value === "string" && VERSION_RE.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function prereleaseParts(value) {
|
|
51
|
+
return value ? value.split(".").map((part) => (/^\d+$/u.test(part) ? Number(part) : part)) : [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True only when `candidate` is a valid semver newer than `current`. */
|
|
55
|
+
export function isNewerVersion(candidate, current) {
|
|
56
|
+
const next = validVersion(candidate) ? candidate.match(VERSION_RE) : null;
|
|
57
|
+
const installed = validVersion(current) ? current.match(VERSION_RE) : null;
|
|
58
|
+
if (!next || !installed) return false;
|
|
59
|
+
|
|
60
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
61
|
+
const difference = Number(next[index]) - Number(installed[index]);
|
|
62
|
+
if (difference !== 0) return difference > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const nextPre = prereleaseParts(next[4]);
|
|
66
|
+
const installedPre = prereleaseParts(installed[4]);
|
|
67
|
+
if (nextPre.length === 0 || installedPre.length === 0) {
|
|
68
|
+
return nextPre.length === 0 && installedPre.length > 0;
|
|
69
|
+
}
|
|
70
|
+
const length = Math.max(nextPre.length, installedPre.length);
|
|
71
|
+
for (let index = 0; index < length; index += 1) {
|
|
72
|
+
if (nextPre[index] === undefined) return false;
|
|
73
|
+
if (installedPre[index] === undefined) return true;
|
|
74
|
+
if (nextPre[index] === installedPre[index]) continue;
|
|
75
|
+
if (typeof nextPre[index] === "number" && typeof installedPre[index] === "string") return false;
|
|
76
|
+
if (typeof nextPre[index] === "string" && typeof installedPre[index] === "number") return true;
|
|
77
|
+
return nextPre[index] > installedPre[index];
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read the latest published version from npm's registry metadata. */
|
|
83
|
+
export async function fetchRemoteVersion({
|
|
84
|
+
packageName = updatePackage(),
|
|
85
|
+
registry = updateRegistry(),
|
|
86
|
+
timeoutMs = 10_000,
|
|
87
|
+
fetchImpl = globalThis.fetch,
|
|
88
|
+
} = {}) {
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
91
|
+
try {
|
|
92
|
+
const encodedName = encodeURIComponent(packageName);
|
|
93
|
+
const response = await fetchImpl(`${String(registry).replace(/\/+$/u, "")}/${encodedName}/latest`, {
|
|
94
|
+
headers: { accept: "application/json" },
|
|
95
|
+
signal: controller.signal,
|
|
96
|
+
});
|
|
97
|
+
if (!response.ok) return null;
|
|
98
|
+
const metadata = await response.json();
|
|
99
|
+
return validVersion(metadata?.version) ? metadata.version : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timeout);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function readUpdateCache() {
|
|
108
|
+
try {
|
|
109
|
+
const cache = JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, "utf8"));
|
|
110
|
+
return cache && typeof cache === "object" ? cache : null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function writeUpdateCache(patch) {
|
|
117
|
+
const next = { ...(readUpdateCache() || {}), ...patch };
|
|
118
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
119
|
+
const tmp = `${UPDATE_CACHE_PATH}.tmp-${process.pid}`;
|
|
120
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
121
|
+
fs.renameSync(tmp, UPDATE_CACHE_PATH);
|
|
122
|
+
return next;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function cacheIsFresh(cache, now = Date.now()) {
|
|
126
|
+
return Boolean(cache?.checkedAt && now - cache.checkedAt < UPDATE_CHECK_TTL_MS);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Fetch npm's latest version and record it; returns the updated cache (or null). */
|
|
130
|
+
export async function refreshUpdateCache(dependencies = {}) {
|
|
131
|
+
const fetchLatest = dependencies.fetchRemoteVersion || fetchRemoteVersion;
|
|
132
|
+
const writeCache = dependencies.writeCache || writeUpdateCache;
|
|
133
|
+
const remoteVersion = await fetchLatest();
|
|
134
|
+
if (!remoteVersion) return null;
|
|
135
|
+
return writeCache({ remoteVersion, checkedAt: Date.now() });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One-line human notice when npm has a newer stable package. */
|
|
139
|
+
export function updateNoticeLine({ cache = readUpdateCache(), current = installedVersion() } = {}) {
|
|
140
|
+
const remote = cache?.remoteVersion;
|
|
141
|
+
if (!isNewerVersion(remote, current)) return null;
|
|
142
|
+
return `impel-cli update available (v${current} → v${remote}): run \`impel update\``;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Best-effort, never-blocking update visibility for launch surfaces: print the
|
|
147
|
+
* cached notice if one applies, and refresh a stale cache in a detached child
|
|
148
|
+
* so the NEXT launch sees a current answer. TTY-gated — notices are for
|
|
149
|
+
* humans, and piped/CI invocations must not spawn background work.
|
|
150
|
+
*/
|
|
151
|
+
export function maybePrintUpdateNotice({ stream = process.stderr } = {}) {
|
|
152
|
+
if (!stream.isTTY) return;
|
|
153
|
+
if (process.env.IMPEL_SKIP_UPDATE_CHECK === "1" || process.env.IMPEL_SKIP_UPDATE_CHECK === "true") {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const cache = readUpdateCache();
|
|
157
|
+
const line = updateNoticeLine({ cache });
|
|
158
|
+
if (line) stream.write(`${line}\n`);
|
|
159
|
+
if (!cacheIsFresh(cache)) spawnDetachedCacheRefresh();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function spawnDetachedCacheRefresh() {
|
|
163
|
+
spawnDetached(["update", "--refresh-cache"]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Background config/catalog/skills convergence; no-ops inside the TTL. */
|
|
167
|
+
export function spawnDetachedAppRefresh() {
|
|
168
|
+
spawnDetached(["app", "refresh", "--stale-only"]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function spawnDetached(args) {
|
|
172
|
+
try {
|
|
173
|
+
const child = spawn(process.execPath, [path.join(CLI_ROOT, "bin", "impel.js"), ...args], {
|
|
174
|
+
detached: true,
|
|
175
|
+
stdio: "ignore",
|
|
176
|
+
});
|
|
177
|
+
child.unref();
|
|
178
|
+
} catch {
|
|
179
|
+
// Purely opportunistic; the next explicit `impel update` still works.
|
|
180
|
+
}
|
|
181
|
+
}
|