impel-cli 0.20.61 → 0.20.63-canary.1
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 +11 -0
- package/package.json +1 -1
- package/scripts/bake-canary-brand.mjs +87 -0
- package/scripts/canary-version.mjs +173 -0
- package/scripts/notify-canary.mjs +45 -0
- package/scripts/npm-dist-tags.mjs +91 -0
- package/scripts/pack-canary.mjs +32 -0
- package/scripts/prepare-canary.mjs +28 -0
- package/scripts/required-checks.mjs +128 -0
- package/scripts/verify-canary-install.mjs +62 -0
- package/scripts/wait-for-required-checks.mjs +76 -0
- package/src/apps.js +11 -2
- package/src/cli.js +2 -1
- package/src/codexSecurity.js +14 -5
- package/src/commands/auth.js +22 -6
- package/src/commands/launch.js +33 -18
- package/src/commands/update.js +4 -2
- package/src/config.js +48 -5
- package/src/extension/index.js +2 -2
- package/src/managedProfileVersion.js +4 -1
- package/src/posthog.js +2 -2
- package/src/runtimeBrand.js +65 -6
- package/src/updates.js +12 -3
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Release notes
|
|
2
2
|
|
|
3
|
+
## 0.20.62 — Silence the Codex apps connector in desktop profiles
|
|
4
|
+
|
|
5
|
+
- Persists `features.apps = false` in the managed ChatGPT desktop `config.toml`
|
|
6
|
+
so the hosted `codex_apps` connector no longer handshakes against the gateway
|
|
7
|
+
passthrough without a credential (HTTP 401 "MCP startup incomplete") when
|
|
8
|
+
the embedded Codex is launched from that profile. 0.20.61 applied this gate
|
|
9
|
+
only as a runtime override on `impel codex`; the desktop writer still set
|
|
10
|
+
`enable_mcp_apps=false` alone, which governs MCP-UI rendering and leaves the
|
|
11
|
+
connector enabled. Bumps the managed config version so existing installs
|
|
12
|
+
rewrite the profile.
|
|
13
|
+
|
|
3
14
|
## 0.20.61 — Silence the Codex apps connector at startup
|
|
4
15
|
|
|
5
16
|
- Passes `features.apps=false` to tenant Codex CLI sessions so Codex no longer
|
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bake the canary channel into the runtime brand (canary release cycle, KTD7).
|
|
3
|
+
//
|
|
4
|
+
// `publish-canary.yml` runs this in the working tree immediately before
|
|
5
|
+
// `pnpm pack`, after the test suite has run on the unmodified tree. It
|
|
6
|
+
// rewrites the brand DEFAULT in `src/runtimeBrand.js` in place: the channel
|
|
7
|
+
// literal becomes `canary` and the three production `defaultOrigin` literals
|
|
8
|
+
// become the canary row of CHANNEL_ORIGINS, read from the very module being
|
|
9
|
+
// baked so the row has one source of truth.
|
|
10
|
+
//
|
|
11
|
+
// It is a one-shot publish step, not a formatter: every literal it replaces
|
|
12
|
+
// must occur exactly once in its shipped form, a file that is already baked
|
|
13
|
+
// is refused, and the result is re-imported and validated before the script
|
|
14
|
+
// exits 0 — a half-baked or doubly-baked tree can never be packed.
|
|
15
|
+
//
|
|
16
|
+
// Usage: node scripts/bake-canary-brand.mjs [path/to/runtimeBrand.js]
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TARGET = fileURLToPath(new URL("../src/runtimeBrand.js", import.meta.url));
|
|
23
|
+
const CANARY_MARKER = 'channel: "canary",';
|
|
24
|
+
|
|
25
|
+
function fail(message) {
|
|
26
|
+
process.stderr.write(`bake-canary-brand: ${message}\n`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Import `target` fresh (never from this process's module cache) with no brand in the environment. */
|
|
31
|
+
async function importBrandModule(target, cacheBuster) {
|
|
32
|
+
// The module reads IMPEL_CLI_RUNTIME_BRAND at import time; the bake is about
|
|
33
|
+
// the DEFAULT, so an inherited brand must not stand in for it.
|
|
34
|
+
delete process.env.IMPEL_CLI_RUNTIME_BRAND;
|
|
35
|
+
return import(`${pathToFileURL(target).href}?bake=${cacheBuster}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function replaceExactlyOnce(source, from, to) {
|
|
39
|
+
const occurrences = source.split(from).length - 1;
|
|
40
|
+
if (occurrences !== 1) {
|
|
41
|
+
fail(`expected exactly one occurrence of ${JSON.stringify(from)} in the brand source, found ${occurrences}`);
|
|
42
|
+
}
|
|
43
|
+
return source.replace(from, to);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
const target = path.resolve(process.argv[2] || DEFAULT_TARGET);
|
|
48
|
+
const original = fs.readFileSync(target, "utf8");
|
|
49
|
+
if (original.includes(CANARY_MARKER)) fail(`${target} is already baked for the canary channel`);
|
|
50
|
+
|
|
51
|
+
const { CHANNEL_ORIGINS } = await importBrandModule(target, "before");
|
|
52
|
+
const rows = [
|
|
53
|
+
['channel: "stable",', CANARY_MARKER],
|
|
54
|
+
...["gateway", "sessions", "controlPlane"].map((column) => [
|
|
55
|
+
`defaultOrigin: ${JSON.stringify(CHANNEL_ORIGINS.stable[column])}`,
|
|
56
|
+
`defaultOrigin: ${JSON.stringify(CHANNEL_ORIGINS.canary[column])}`,
|
|
57
|
+
]),
|
|
58
|
+
];
|
|
59
|
+
let baked = original;
|
|
60
|
+
for (const [from, to] of rows) baked = replaceExactlyOnce(baked, from, to);
|
|
61
|
+
|
|
62
|
+
// Atomic replace, then prove the result by importing it: the DEFAULT must
|
|
63
|
+
// now be the canary row and must pass the validator's own channel check.
|
|
64
|
+
const temporary = `${target}.bake-${process.pid}`;
|
|
65
|
+
fs.writeFileSync(temporary, baked, { mode: fs.statSync(target).mode & 0o777 });
|
|
66
|
+
fs.renameSync(temporary, target);
|
|
67
|
+
try {
|
|
68
|
+
const { RUNTIME_BRAND, validateRuntimeBrand } = await importBrandModule(target, "after");
|
|
69
|
+
validateRuntimeBrand(RUNTIME_BRAND);
|
|
70
|
+
const mismatch = ["gateway", "sessions", "controlPlane"].find((column) => (
|
|
71
|
+
RUNTIME_BRAND[column].defaultOrigin !== CHANNEL_ORIGINS.canary[column]
|
|
72
|
+
));
|
|
73
|
+
if (RUNTIME_BRAND.channel !== "canary" || mismatch) {
|
|
74
|
+
throw new Error(`baked DEFAULT is not the canary row (${mismatch || "channel"})`);
|
|
75
|
+
}
|
|
76
|
+
process.stdout.write(
|
|
77
|
+
`bake-canary-brand: ${target} now carries channel canary `
|
|
78
|
+
+ `(gateway ${RUNTIME_BRAND.gateway.defaultOrigin}, sessions ${RUNTIME_BRAND.sessions.defaultOrigin}, `
|
|
79
|
+
+ `controlPlane ${RUNTIME_BRAND.controlPlane.defaultOrigin})\n`,
|
|
80
|
+
);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
fs.writeFileSync(target, original);
|
|
83
|
+
fail(`baked brand failed verification and was restored: ${error?.message || error}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await main();
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Canary version computation and successor gate (canary release cycle, U4).
|
|
3
|
+
//
|
|
4
|
+
// `publish-canary.yml` publishes `<major>.<minor>.<patch + 1>-canary.<N>` on
|
|
5
|
+
// every push to `dev` (R15), where `N` is `GITHUB_RUN_NUMBER` (KTD6): it is
|
|
6
|
+
// monotonic across publishes and unique on a retry without a registry round
|
|
7
|
+
// trip, and a GitHub re-run keeps its number, so the same version comes back
|
|
8
|
+
// and the successor gate below refuses it. The script only reads
|
|
9
|
+
// `package.json`; the publish job applies the version to its working tree
|
|
10
|
+
// with `npm version --no-git-tag-version`, and nothing on `dev` is edited.
|
|
11
|
+
//
|
|
12
|
+
// Usage: node scripts/canary-version.mjs [package.json] [--dist-tags <file>]
|
|
13
|
+
// Prints the computed version on stdout. With `--dist-tags`, the file is
|
|
14
|
+
// the registry's dist-tags document and the version must strictly succeed
|
|
15
|
+
// its `canary` entry; an absent `canary` tag is the bootstrap case. Every
|
|
16
|
+
// refusal prints nothing on stdout and exits 1, so a caller that captures
|
|
17
|
+
// stdout can never pick up a half-computed version.
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const RELEASE_VERSION = /^(\d+)\.(\d+)\.(\d+)$/u;
|
|
24
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u;
|
|
25
|
+
|
|
26
|
+
/** `<major>.<minor>.<patch + 1>-canary.<runNumber>` from a bare release version. */
|
|
27
|
+
export function canaryVersion(packageVersion, runNumber) {
|
|
28
|
+
if (typeof packageVersion !== "string" || packageVersion === "") {
|
|
29
|
+
throw new Error(`package.json version must be a non-empty string, got ${JSON.stringify(packageVersion)}`);
|
|
30
|
+
}
|
|
31
|
+
if (/-/u.test(packageVersion) && SEMVER.test(packageVersion)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`package.json version ${packageVersion} already carries a prerelease suffix; `
|
|
34
|
+
+ "the canary base must be the bare release version committed on dev",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const match = RELEASE_VERSION.exec(packageVersion);
|
|
38
|
+
if (!match) {
|
|
39
|
+
throw new Error(`package.json version ${JSON.stringify(packageVersion)} is not a bare <major>.<minor>.<patch> release version`);
|
|
40
|
+
}
|
|
41
|
+
const text = typeof runNumber === "number" ? String(runNumber) : runNumber;
|
|
42
|
+
if (typeof text !== "string" || !/^\d+$/u.test(text) || Number(text) < 1 || !Number.isSafeInteger(Number(text))) {
|
|
43
|
+
throw new Error(`run number must be a positive integer, got ${JSON.stringify(runNumber)}`);
|
|
44
|
+
}
|
|
45
|
+
const [major, minor, patch] = match.slice(1).map(Number);
|
|
46
|
+
return `${major}.${minor}.${patch + 1}-canary.${Number(text)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseSemver(text) {
|
|
50
|
+
const match = SEMVER.exec(text);
|
|
51
|
+
if (!match) throw new Error(`unparsable semver: ${text}`);
|
|
52
|
+
return {
|
|
53
|
+
release: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
54
|
+
prerelease: match[4] ? match[4].split(".") : [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function compareIdentifiers(a, b) {
|
|
59
|
+
const aNumeric = /^\d+$/u.test(a);
|
|
60
|
+
const bNumeric = /^\d+$/u.test(b);
|
|
61
|
+
if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
|
|
62
|
+
if (aNumeric) return -1; // numeric identifiers sort before alphanumeric
|
|
63
|
+
if (bNumeric) return 1;
|
|
64
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** SemVer 2.0.0 precedence, the same ordering `publish-npm.yml`'s successor gate uses. */
|
|
68
|
+
export function compareSemver(leftText, rightText) {
|
|
69
|
+
const a = parseSemver(leftText);
|
|
70
|
+
const b = parseSemver(rightText);
|
|
71
|
+
for (let index = 0; index < 3; index += 1) {
|
|
72
|
+
if (a.release[index] !== b.release[index]) return Math.sign(a.release[index] - b.release[index]);
|
|
73
|
+
}
|
|
74
|
+
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0;
|
|
75
|
+
if (a.prerelease.length === 0) return 1; // a release outranks its prereleases
|
|
76
|
+
if (b.prerelease.length === 0) return -1;
|
|
77
|
+
const length = Math.max(a.prerelease.length, b.prerelease.length);
|
|
78
|
+
for (let index = 0; index < length; index += 1) {
|
|
79
|
+
if (a.prerelease[index] === undefined) return -1; // shorter prerelease sorts first
|
|
80
|
+
if (b.prerelease[index] === undefined) return 1;
|
|
81
|
+
const result = compareIdentifiers(a.prerelease[index], b.prerelease[index]);
|
|
82
|
+
if (result !== 0) return result;
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Require `version` to strictly succeed `distTags.canary`. Only the `canary`
|
|
89
|
+
* tag is the predecessor: `latest` and `next` belong to the stable lane and
|
|
90
|
+
* never stand in for it. Returns a description for the step summary.
|
|
91
|
+
*/
|
|
92
|
+
export function assertCanarySuccessor(version, distTags) {
|
|
93
|
+
const current = distTags?.canary;
|
|
94
|
+
if (current === undefined) {
|
|
95
|
+
return {
|
|
96
|
+
bootstrap: true,
|
|
97
|
+
current,
|
|
98
|
+
message: `No existing \`canary\` dist-tag; ${version} is the canary channel bootstrap.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (compareSemver(version, current) <= 0) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`version ${version} is not a strict semver successor of canary=${current}. `
|
|
104
|
+
+ "A GitHub re-run keeps its run number and is refused by design; push a new commit "
|
|
105
|
+
+ "to dev instead, or merge the released version back into dev if the base has moved.",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
bootstrap: false,
|
|
110
|
+
current,
|
|
111
|
+
message: `${version} strictly succeeds \`canary\`=${current}. Current tags: ${JSON.stringify(distTags)}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function fail(message) {
|
|
116
|
+
process.stderr.write(`canary-version: ${message}\n`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function main(argv) {
|
|
121
|
+
let packageJson = "package.json";
|
|
122
|
+
let distTagsFile = null;
|
|
123
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
124
|
+
const argument = argv[index];
|
|
125
|
+
if (argument === "--dist-tags") {
|
|
126
|
+
distTagsFile = argv[index + 1];
|
|
127
|
+
index += 1;
|
|
128
|
+
if (!distTagsFile) fail("--dist-tags requires a file path");
|
|
129
|
+
} else if (argument.startsWith("--")) {
|
|
130
|
+
fail(`unknown option ${argument}`);
|
|
131
|
+
} else {
|
|
132
|
+
packageJson = argument;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let version;
|
|
137
|
+
try {
|
|
138
|
+
const manifest = JSON.parse(fs.readFileSync(path.resolve(packageJson), "utf8"));
|
|
139
|
+
const runNumber = process.env.GITHUB_RUN_NUMBER;
|
|
140
|
+
if (runNumber === undefined || runNumber === "") fail("GITHUB_RUN_NUMBER is not set; the canary counter is the workflow run number");
|
|
141
|
+
version = canaryVersion(manifest.version, runNumber);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
fail(error?.message || String(error));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (distTagsFile) {
|
|
147
|
+
let distTags;
|
|
148
|
+
try {
|
|
149
|
+
distTags = JSON.parse(fs.readFileSync(path.resolve(distTagsFile), "utf8"));
|
|
150
|
+
} catch (error) {
|
|
151
|
+
fail(`cannot read dist-tags from ${distTagsFile}: ${error?.message || error}`);
|
|
152
|
+
}
|
|
153
|
+
if (!distTags || typeof distTags !== "object" || Array.isArray(distTags)) {
|
|
154
|
+
fail(`dist-tags document ${distTagsFile} is not a JSON object`);
|
|
155
|
+
}
|
|
156
|
+
let result;
|
|
157
|
+
try {
|
|
158
|
+
result = assertCanarySuccessor(version, distTags);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
fail(error?.message || String(error));
|
|
161
|
+
}
|
|
162
|
+
process.stderr.write(`canary-version: ${result.message}\n`);
|
|
163
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
164
|
+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Canary gate: dist-tag successor\n\n${result.message}\n`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
process.stdout.write(`${version}\n`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
172
|
+
main(process.argv.slice(2));
|
|
173
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const TARGET = "https://api.github.com/repos/UseImpel/impel-apps/dispatches";
|
|
8
|
+
|
|
9
|
+
async function main([version]) {
|
|
10
|
+
const token = process.env.IMPEL_APPS_DISPATCH_TOKEN;
|
|
11
|
+
const commit = process.env.GITHUB_SHA;
|
|
12
|
+
if (!version || !commit) throw new Error("canary version and GITHUB_SHA are required");
|
|
13
|
+
if (!token) {
|
|
14
|
+
const message = `IMPEL_APPS_DISPATCH_TOKEN is not configured; impel-apps will pick up impel-cli@${version} on its next canary rebuild.`;
|
|
15
|
+
console.warn(message);
|
|
16
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
17
|
+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${message}\n`);
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const response = await fetch(TARGET, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: {
|
|
24
|
+
Accept: "application/vnd.github+json",
|
|
25
|
+
Authorization: `Bearer ${token}`,
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
"User-Agent": "UseImpel/impel-cli canary publisher",
|
|
28
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
event_type: "impel-cli-canary",
|
|
32
|
+
client_payload: { version, source_commit: commit, dist_tag: "canary" },
|
|
33
|
+
}),
|
|
34
|
+
signal: AbortSignal.timeout(30_000),
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) throw new Error(`impel-apps dispatch failed: HTTP ${response.status}`);
|
|
37
|
+
console.log(`Dispatched impel-cli canary ${version} (${commit}) to UseImpel/impel-apps.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
41
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
42
|
+
console.error(`notify-canary: ${error?.message || error}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const DIST_TAGS_URL = "https://registry.npmjs.org/-/package/impel-cli/dist-tags";
|
|
7
|
+
|
|
8
|
+
export function assertCanaryDistTags({ beforeLatest, expectedCanary, tags }) {
|
|
9
|
+
const latest = tags.latest ?? "";
|
|
10
|
+
const canary = tags.canary ?? "";
|
|
11
|
+
if (latest !== beforeLatest) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`dist-tags.latest moved during the canary publish: before=${beforeLatest || "<none>"} `
|
|
14
|
+
+ `after=${latest || "<none>"}. The canary lane must never move latest; investigate before the next publish.`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
if (canary !== expectedCanary) {
|
|
18
|
+
throw new Error(`dist-tags.canary is ${canary || "<none>"}, expected ${expectedCanary}.`);
|
|
19
|
+
}
|
|
20
|
+
return { latest, canary };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readDistTags({ allowMissing, bustCache = false } = {}) {
|
|
24
|
+
const url = bustCache ? `${DIST_TAGS_URL}?t=${Date.now()}` : DIST_TAGS_URL;
|
|
25
|
+
let lastError;
|
|
26
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
29
|
+
if (response.status === 404 && allowMissing) return {};
|
|
30
|
+
if (!response.ok) throw new Error(`npm registry dist-tags lookup failed: HTTP ${response.status}`);
|
|
31
|
+
const tags = await response.json();
|
|
32
|
+
if (!tags || typeof tags !== "object" || Array.isArray(tags)) {
|
|
33
|
+
throw new Error("npm registry returned an invalid dist-tags document");
|
|
34
|
+
}
|
|
35
|
+
return tags;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
lastError = error;
|
|
38
|
+
if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw lastError;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function appendEnvironment(name, value) {
|
|
45
|
+
if (!process.env.GITHUB_ENV) throw new Error("GITHUB_ENV is not set");
|
|
46
|
+
fs.appendFileSync(process.env.GITHUB_ENV, `${name}=${value}\n`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function appendSummary(lines) {
|
|
50
|
+
if (!process.env.GITHUB_STEP_SUMMARY) throw new Error("GITHUB_STEP_SUMMARY is not set");
|
|
51
|
+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function main([command, ...args]) {
|
|
55
|
+
if (command === "fetch" && args.length === 1) {
|
|
56
|
+
const tags = await readDistTags({ allowMissing: true });
|
|
57
|
+
fs.writeFileSync(path.resolve(args[0]), `${JSON.stringify(tags)}\n`);
|
|
58
|
+
console.log(Object.keys(tags).length === 0 ? "No npm dist-tags found; using channel bootstrap." : "Fetched npm dist-tags.");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (command === "snapshot-latest" && args.length === 0) {
|
|
62
|
+
const tags = await readDistTags({ allowMissing: true });
|
|
63
|
+
const latest = tags.latest ?? "";
|
|
64
|
+
appendEnvironment("LATEST_BEFORE", latest);
|
|
65
|
+
console.log(`dist-tags.latest before publish: ${latest || "<none>"}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (command === "assert-canary" && args.length === 2) {
|
|
69
|
+
const [expectedCanary, beforeLatest] = args;
|
|
70
|
+
const tags = await readDistTags({ bustCache: true });
|
|
71
|
+
const { latest } = assertCanaryDistTags({ expectedCanary, beforeLatest, tags });
|
|
72
|
+
appendSummary([
|
|
73
|
+
"## Canary published",
|
|
74
|
+
"",
|
|
75
|
+
`- \`impel-cli@${expectedCanary}\` is \`canary\``,
|
|
76
|
+
`- \`latest\` is unchanged at \`${latest || "<none>"}\``,
|
|
77
|
+
]);
|
|
78
|
+
console.log(`Published impel-cli@${expectedCanary} under canary; latest is still ${latest || "<none>"}.`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
throw new Error(
|
|
82
|
+
"usage: node scripts/npm-dist-tags.mjs fetch <file> | snapshot-latest | assert-canary <version> <latest-before>",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
87
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
88
|
+
console.error(`npm-dist-tags: ${error?.message || error}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
function appendEnvironment(name, value) {
|
|
9
|
+
if (!process.env.GITHUB_ENV) throw new Error("GITHUB_ENV is not set");
|
|
10
|
+
fs.appendFileSync(process.env.GITHUB_ENV, `${name}=${value}\n`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function main([version]) {
|
|
14
|
+
const destination = process.env.RUNNER_TEMP;
|
|
15
|
+
if (!destination || !version) throw new Error("RUNNER_TEMP and a canary version are required");
|
|
16
|
+
execFileSync("pnpm", ["pack", "--pack-destination", destination], { stdio: "inherit" });
|
|
17
|
+
const tarball = path.join(destination, `impel-cli-${version}.tgz`);
|
|
18
|
+
if (!fs.existsSync(tarball)) throw new Error(`pnpm pack did not create ${tarball}`);
|
|
19
|
+
const prefix = path.join(destination, "impel-prefix");
|
|
20
|
+
execFileSync("npm", ["install", "--global", "--prefix", prefix, tarball], { stdio: "inherit" });
|
|
21
|
+
execFileSync(process.execPath, ["scripts/verify-canary-install.mjs", prefix, version], { stdio: "inherit" });
|
|
22
|
+
appendEnvironment("CANARY_TARBALL", tarball);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
26
|
+
try {
|
|
27
|
+
main(process.argv.slice(2));
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error(`pack-canary: ${error?.message || error}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const CANARY_VERSION = /^\d+\.\d+\.\d+-canary\.\d+$/u;
|
|
9
|
+
|
|
10
|
+
function main([version]) {
|
|
11
|
+
if (!CANARY_VERSION.test(version || "")) {
|
|
12
|
+
throw new Error(`the gate produced an invalid canary version: ${version || "<empty>"}`);
|
|
13
|
+
}
|
|
14
|
+
execFileSync("npm", ["version", version, "--no-git-tag-version"], { stdio: "inherit" });
|
|
15
|
+
execFileSync(process.execPath, ["scripts/bake-canary-brand.mjs"], { stdio: "inherit" });
|
|
16
|
+
const actual = JSON.parse(fs.readFileSync("package.json", "utf8")).version;
|
|
17
|
+
if (actual !== version) throw new Error(`package.json version ${actual} does not match ${version}`);
|
|
18
|
+
console.log(`Prepared ${version} canary package.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
22
|
+
try {
|
|
23
|
+
main(process.argv.slice(2));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error(`prepare-canary: ${error?.message || error}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The CI check names a publishable commit must carry (canary release cycle, U4).
|
|
3
|
+
//
|
|
4
|
+
// `publish-npm.yml`'s release gate inlines this list and must stay
|
|
5
|
+
// byte-identical (R19), so the canary publisher reads it from here and
|
|
6
|
+
// `test/publish-canary-workflow.test.js` asserts the two copies are equal:
|
|
7
|
+
// a renamed CI job that is not renamed in both places fails the suite, not a
|
|
8
|
+
// release. Every push to `dev` and `main` runs these via `ci.yml`, so they
|
|
9
|
+
// must exist on a publishable SHA; names must match the job names exactly.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// node scripts/required-checks.mjs <check-runs.ndjson>
|
|
13
|
+
// Evaluate the Checks API output (one `{name, status, conclusion}` JSON
|
|
14
|
+
// object per line). Prints the markdown summary on stdout and exits 0
|
|
15
|
+
// when every required check is green, 2 while any is still queued,
|
|
16
|
+
// running, or not yet reported (the caller polls), and 1 when a required
|
|
17
|
+
// check, or a present Windows vendor check, has a non-success conclusion.
|
|
18
|
+
// node scripts/required-checks.mjs --list
|
|
19
|
+
// Print the required names, one per line.
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
|
|
25
|
+
export const REQUIRED_CHECKS = Object.freeze([
|
|
26
|
+
"Node 18 on ubuntu-latest",
|
|
27
|
+
"Node 18 on macos-latest",
|
|
28
|
+
"Node 18 on windows-latest",
|
|
29
|
+
"Node 24 on ubuntu-latest",
|
|
30
|
+
"Node 24 on macos-latest",
|
|
31
|
+
"Node 24 on windows-latest",
|
|
32
|
+
"Pinned macOS CLI installer contract",
|
|
33
|
+
"Pinned ChatGPT/Codex config contract",
|
|
34
|
+
"Pinned Claude config contract",
|
|
35
|
+
"Windows standard-user Codex config contract",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// The Windows vendor contract workflow is path-filtered, so its jobs may
|
|
39
|
+
// legitimately be absent. Present => must be green. Absent => loud warning
|
|
40
|
+
// telling the operator when a manual dispatch is owed.
|
|
41
|
+
export const CONDITIONAL_CHECKS = Object.freeze([
|
|
42
|
+
"Pinned Claude MSIX install and replacement",
|
|
43
|
+
"Pinned ChatGPT/Codex Store install",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Judge one commit's check runs. A name with any completed/success run is
|
|
48
|
+
* green (a re-run supersedes an earlier failure); a name with no run yet, or
|
|
49
|
+
* whose runs are all still queued or in progress, is pending; a name whose
|
|
50
|
+
* runs have all completed without a success has failed. Failure outranks
|
|
51
|
+
* pending so a superseded or broken SHA is refused as soon as it is known.
|
|
52
|
+
*/
|
|
53
|
+
export function evaluateCheckRuns(runs, { required = REQUIRED_CHECKS, conditional = CONDITIONAL_CHECKS } = {}) {
|
|
54
|
+
const byName = new Map();
|
|
55
|
+
for (const run of runs) {
|
|
56
|
+
if (!byName.has(run.name)) byName.set(run.name, []);
|
|
57
|
+
byName.get(run.name).push(run);
|
|
58
|
+
}
|
|
59
|
+
const runsFor = (name) => byName.get(name) ?? [];
|
|
60
|
+
const green = (name) => runsFor(name).some((run) => run.status === "completed" && run.conclusion === "success");
|
|
61
|
+
const unfinished = (name) => runsFor(name).some((run) => run.status !== "completed");
|
|
62
|
+
const describe = (name) => {
|
|
63
|
+
const found = runsFor(name);
|
|
64
|
+
if (found.length === 0) return "MISSING";
|
|
65
|
+
return found.map((run) => `${run.status}/${run.conclusion ?? "none"}`).join(", ");
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const failures = [];
|
|
69
|
+
const pending = [];
|
|
70
|
+
const absentConditional = [];
|
|
71
|
+
for (const name of required) {
|
|
72
|
+
if (green(name)) continue;
|
|
73
|
+
if (runsFor(name).length === 0 || unfinished(name)) pending.push(name);
|
|
74
|
+
else failures.push(`required check "${name}": ${describe(name)}`);
|
|
75
|
+
}
|
|
76
|
+
for (const name of conditional) {
|
|
77
|
+
if (runsFor(name).length === 0) absentConditional.push(name);
|
|
78
|
+
else if (green(name)) continue;
|
|
79
|
+
else if (unfinished(name)) pending.push(name);
|
|
80
|
+
else failures.push(`Windows vendor check "${name}": ${describe(name)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const state = failures.length > 0 ? "failure" : pending.length > 0 ? "pending" : "success";
|
|
84
|
+
const summary = ["## Canary gate: checks on the pushed commit", ""];
|
|
85
|
+
summary.push("| Check | Result |", "| --- | --- |");
|
|
86
|
+
for (const name of [...required, ...conditional]) summary.push(`| ${name} | ${describe(name)} |`);
|
|
87
|
+
if (absentConditional.length > 0) {
|
|
88
|
+
summary.push(
|
|
89
|
+
"",
|
|
90
|
+
"> **Warning:** " + absentConditional.map((name) => `\`${name}\``).join(" and ")
|
|
91
|
+
+ " never ran on this commit. If this change touches bundle-affecting files"
|
|
92
|
+
+ " (src/apps.js, src/windowsApps.js, src/desktopTasks.js, src/codesign.js,"
|
|
93
|
+
+ " src/selfInvocation.js, or the Windows contract tests), dispatch the"
|
|
94
|
+
+ " \"Pinned Windows vendor app contract\" workflow against this commit.",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (failures.length > 0) summary.push("", "**Gate failed:**", ...failures.map((failure) => `- ${failure}`));
|
|
98
|
+
if (state === "pending") summary.push("", "**Still waiting on:** " + pending.map((name) => `\`${name}\``).join(", "));
|
|
99
|
+
summary.push("", `Total check runs found on this commit: ${runs.length}`);
|
|
100
|
+
|
|
101
|
+
return { state, failures, pending, absentConditional, summary: summary.join("\n") + "\n" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function main(argv) {
|
|
105
|
+
if (argv[0] === "--list") {
|
|
106
|
+
process.stdout.write(REQUIRED_CHECKS.join("\n") + "\n");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (argv.length !== 1) {
|
|
110
|
+
process.stderr.write("usage: node scripts/required-checks.mjs <check-runs.ndjson> | --list\n");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
const lines = fs.readFileSync(path.resolve(argv[0]), "utf8").split("\n").filter((line) => line.trim() !== "");
|
|
114
|
+
const runs = lines.map((line) => JSON.parse(line));
|
|
115
|
+
const result = evaluateCheckRuns(runs);
|
|
116
|
+
process.stdout.write(result.summary);
|
|
117
|
+
for (const failure of result.failures) process.stderr.write(`${failure}\n`);
|
|
118
|
+
if (result.state === "failure") process.exit(1);
|
|
119
|
+
if (result.state === "pending") {
|
|
120
|
+
process.stderr.write(`waiting for ${result.pending.map((name) => `"${name}": ${runs.some((run) => run.name === name) ? "pending" : "MISSING"}`).join(", ")}\n`);
|
|
121
|
+
process.exit(2);
|
|
122
|
+
}
|
|
123
|
+
process.stderr.write("All required checks are green on the pushed commit.\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
127
|
+
main(process.argv.slice(2));
|
|
128
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
export function assertCanaryInstall({ version, expectedVersion, brand, origins }) {
|
|
8
|
+
assert.equal(version, `${expectedVersion} (canary)`);
|
|
9
|
+
assert.deepEqual({
|
|
10
|
+
channel: brand.channel,
|
|
11
|
+
gateway: brand.gateway,
|
|
12
|
+
sessions: brand.sessions,
|
|
13
|
+
resolvedSessions: brand.resolvedSessions,
|
|
14
|
+
controlPlane: brand.controlPlane,
|
|
15
|
+
}, {
|
|
16
|
+
channel: "canary",
|
|
17
|
+
gateway: origins.gateway,
|
|
18
|
+
sessions: origins.sessions,
|
|
19
|
+
resolvedSessions: origins.sessions,
|
|
20
|
+
controlPlane: origins.controlPlane,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function main([prefix, expectedVersion]) {
|
|
25
|
+
if (!prefix || !expectedVersion) throw new Error("usage: node scripts/verify-canary-install.mjs <npm-prefix> <version>");
|
|
26
|
+
// Assert the installed package's defaults, never an Actions-level override.
|
|
27
|
+
for (const name of ["IMPEL_GATEWAY_URL", "IMPEL_SESSIONS_URL", "IMPEL_APP_URL", "IMPEL_CLI_RUNTIME_BRAND"]) {
|
|
28
|
+
delete process.env[name];
|
|
29
|
+
}
|
|
30
|
+
const cli = path.join(prefix, "bin", "impel");
|
|
31
|
+
const root = path.join(prefix, "lib", "node_modules", "impel-cli");
|
|
32
|
+
const version = execFileSync(cli, ["--version"], {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
env: { ...process.env, IMPEL_SKIP_UPDATE_CHECK: "1", IMPEL_DISABLE_TELEMETRY: "1" },
|
|
35
|
+
}).trim();
|
|
36
|
+
const load = (file) => import(pathToFileURL(path.join(root, "src", file)).href);
|
|
37
|
+
const [config, sessions, runtime] = await Promise.all([
|
|
38
|
+
load("config.js"),
|
|
39
|
+
load("sessionCollector.js"),
|
|
40
|
+
load("runtimeBrand.js"),
|
|
41
|
+
]);
|
|
42
|
+
const { resolveDefaultGateway, resolveDefaultAppUrl } = config;
|
|
43
|
+
const { DEFAULT_SESSIONS_URL, resolveSessionsUrl } = sessions;
|
|
44
|
+
const { CHANNEL_ORIGINS, RUNTIME_BRAND } = runtime;
|
|
45
|
+
assertCanaryInstall({
|
|
46
|
+
version,
|
|
47
|
+
expectedVersion,
|
|
48
|
+
brand: {
|
|
49
|
+
channel: RUNTIME_BRAND.channel,
|
|
50
|
+
gateway: resolveDefaultGateway(),
|
|
51
|
+
sessions: DEFAULT_SESSIONS_URL,
|
|
52
|
+
resolvedSessions: resolveSessionsUrl({}),
|
|
53
|
+
controlPlane: resolveDefaultAppUrl(),
|
|
54
|
+
},
|
|
55
|
+
origins: CHANNEL_ORIGINS.canary,
|
|
56
|
+
});
|
|
57
|
+
console.log(`Installed canary ${version} resolves ${JSON.stringify(CHANNEL_ORIGINS.canary)}.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
61
|
+
await main(process.argv.slice(2));
|
|
62
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { evaluateCheckRuns } from "./required-checks.mjs";
|
|
7
|
+
|
|
8
|
+
const POLL_INTERVAL_MS = 30_000;
|
|
9
|
+
const TIMEOUT_MS = 60 * 60 * 1_000;
|
|
10
|
+
|
|
11
|
+
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
12
|
+
|
|
13
|
+
async function fetchCheckRuns(repository, sha, token) {
|
|
14
|
+
const runs = [];
|
|
15
|
+
for (let page = 1; ; page += 1) {
|
|
16
|
+
const response = await fetch(
|
|
17
|
+
`https://api.github.com/repos/${repository}/commits/${sha}/check-runs?per_page=100&page=${page}`,
|
|
18
|
+
{
|
|
19
|
+
headers: {
|
|
20
|
+
Accept: "application/vnd.github+json",
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
"User-Agent": "UseImpel/impel-cli canary publisher",
|
|
23
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
24
|
+
},
|
|
25
|
+
signal: AbortSignal.timeout(30_000),
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
if (!response.ok) throw new Error(`Checks API request failed: HTTP ${response.status}`);
|
|
29
|
+
const body = await response.json();
|
|
30
|
+
for (const { name, status, conclusion } of body.check_runs) {
|
|
31
|
+
runs.push({ name, status, conclusion });
|
|
32
|
+
}
|
|
33
|
+
if (body.check_runs.length < 100) return runs;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeSummary(summary) {
|
|
38
|
+
if (!process.env.GITHUB_STEP_SUMMARY) throw new Error("GITHUB_STEP_SUMMARY is not set");
|
|
39
|
+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
const { GITHUB_REPOSITORY: repository, GITHUB_SHA: sha, GH_TOKEN: token } = process.env;
|
|
44
|
+
if (!repository || !sha || !token) throw new Error("GITHUB_REPOSITORY, GITHUB_SHA, and GH_TOKEN are required");
|
|
45
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
46
|
+
let lastSummary = "";
|
|
47
|
+
while (Date.now() < deadline) {
|
|
48
|
+
try {
|
|
49
|
+
const result = evaluateCheckRuns(await fetchCheckRuns(repository, sha, token));
|
|
50
|
+
lastSummary = result.summary;
|
|
51
|
+
if (result.state === "success") {
|
|
52
|
+
writeSummary(result.summary);
|
|
53
|
+
console.log(`All required checks are green on ${sha}.`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (result.state === "failure") {
|
|
57
|
+
writeSummary(result.summary);
|
|
58
|
+
throw new Error(`A required check on ${sha} completed without success; nothing is published for this commit.`);
|
|
59
|
+
}
|
|
60
|
+
console.log(`Required checks are still pending on ${sha}; retrying in 30 seconds.`);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (/completed without success/u.test(error?.message || "")) throw error;
|
|
63
|
+
console.warn(`Checks API request failed (${error?.message || error}); retrying in 30 seconds.`);
|
|
64
|
+
}
|
|
65
|
+
await delay(POLL_INTERVAL_MS);
|
|
66
|
+
}
|
|
67
|
+
if (lastSummary) writeSummary(lastSummary);
|
|
68
|
+
throw new Error(`Canary gate timed out after 60 minutes waiting for required checks on ${sha}.`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
72
|
+
main().catch((error) => {
|
|
73
|
+
console.error(`wait-for-required-checks: ${error?.message || error}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
});
|
|
76
|
+
}
|
package/src/apps.js
CHANGED
|
@@ -1589,7 +1589,7 @@ function mergeManagedChatGPTToml(current, managed) {
|
|
|
1589
1589
|
* as staleness so the profile heals immediately instead of waiting out the
|
|
1590
1590
|
* manifest TTL.
|
|
1591
1591
|
*/
|
|
1592
|
-
export function managedChatGPTConfigDrifted(paths) {
|
|
1592
|
+
export function managedChatGPTConfigDrifted(paths, platform = process.platform) {
|
|
1593
1593
|
let current;
|
|
1594
1594
|
try {
|
|
1595
1595
|
current = fs.readFileSync(path.join(paths.chatgpt.codexHome, "config.toml"), "utf8");
|
|
@@ -1603,6 +1603,12 @@ export function managedChatGPTConfigDrifted(paths) {
|
|
|
1603
1603
|
// out the refresh TTL. Configs from generations before the key existed are
|
|
1604
1604
|
// merely stale (the manifest version bump rewrites them), never drift —
|
|
1605
1605
|
// require it only once this install's manifest proves it was written.
|
|
1606
|
+
//
|
|
1607
|
+
// Only macOS demands the enabled value. The impel-apps profile generator
|
|
1608
|
+
// deliberately emits `false` off macOS, where no companion check proves
|
|
1609
|
+
// codex-code-mode-host shipped; requiring `true` there would flag every
|
|
1610
|
+
// generated Windows profile as drifted and regenerate it on each refresh.
|
|
1611
|
+
// Off macOS, require only that the key survives the vendor settings writer.
|
|
1606
1612
|
let manifestConfigVersion = 0;
|
|
1607
1613
|
try {
|
|
1608
1614
|
manifestConfigVersion = Number(JSON.parse(
|
|
@@ -1612,8 +1618,11 @@ export function managedChatGPTConfigDrifted(paths) {
|
|
|
1612
1618
|
// Without a readable manifest the version gate stays closed.
|
|
1613
1619
|
}
|
|
1614
1620
|
const featuresTable = current.match(/^\[features\]\n(?:[^[\n][^\n]*\n|\n)*/mu)?.[0] || "";
|
|
1621
|
+
const requiredCodeModeHost = platform === "darwin"
|
|
1622
|
+
? /^code_mode_host = true$/mu
|
|
1623
|
+
: /^code_mode_host = (?:true|false)$/mu;
|
|
1615
1624
|
const featureKeysDropped = manifestConfigVersion >= 35
|
|
1616
|
-
&&
|
|
1625
|
+
&& !requiredCodeModeHost.test(featuresTable);
|
|
1617
1626
|
return !current.includes(CHATGPT_CONFIG_START)
|
|
1618
1627
|
|| readTopLevelTomlString(current, "model_provider") !== RUNTIME_BRAND.cli.providerId
|
|
1619
1628
|
|| !readTopLevelTomlString(current, "chatgpt_base_url")
|
package/src/cli.js
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
TELEMETRY_FLUSH_COMMAND,
|
|
35
35
|
} from "./posthog.js";
|
|
36
36
|
import { maybePrintTelemetryNotice } from "./telemetryNotice.js";
|
|
37
|
+
import { versionLine } from "./runtimeBrand.js";
|
|
37
38
|
|
|
38
39
|
const HELP = `impel — isolated Impel workspaces for every tenant
|
|
39
40
|
|
|
@@ -93,7 +94,7 @@ Config file:
|
|
|
93
94
|
function printVersion() {
|
|
94
95
|
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
95
96
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
96
|
-
console.log(pkg.version);
|
|
97
|
+
console.log(versionLine(pkg.version));
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
// Accepts "claude" | "codex" | "all" | undefined; returns a normalized target
|
package/src/codexSecurity.js
CHANGED
|
@@ -169,17 +169,26 @@ export function enableManagedCodexDesktopCodeModeHost(
|
|
|
169
169
|
"true",
|
|
170
170
|
configPath,
|
|
171
171
|
);
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
172
|
+
// Codex contributes the hosted `codex_apps` connector whenever the stable
|
|
173
|
+
// `apps` feature is on. It handshakes against chatgpt_base_url with the
|
|
174
|
+
// native OpenAI account credential, which a gateway-routed profile does not
|
|
175
|
+
// have, so the gateway answers HTTP 401 at every startup. `enable_mcp_apps`
|
|
176
|
+
// only governs MCP-UI rendering and does not gate the connector; keep it
|
|
177
|
+
// off as well since the managed desktop never renders apps.
|
|
178
|
+
const withoutMcpApps = upsertManagedScalar(
|
|
177
179
|
withCodeModeHost,
|
|
178
180
|
"features",
|
|
179
181
|
"enable_mcp_apps",
|
|
180
182
|
"false",
|
|
181
183
|
configPath,
|
|
182
184
|
);
|
|
185
|
+
return upsertManagedScalar(
|
|
186
|
+
withoutMcpApps,
|
|
187
|
+
"features",
|
|
188
|
+
"apps",
|
|
189
|
+
"false",
|
|
190
|
+
configPath,
|
|
191
|
+
);
|
|
183
192
|
}
|
|
184
193
|
|
|
185
194
|
function assertSafeManagedPath(target, expectedType) {
|
package/src/commands/auth.js
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
loadConfig,
|
|
4
4
|
CONFIG_PATH,
|
|
5
5
|
saveConfig,
|
|
6
|
+
foreignChannelOrigin,
|
|
6
7
|
normalizeGatewayUrl,
|
|
7
8
|
resolveDefaultGateway,
|
|
8
9
|
resolveDefaultAppUrl,
|
|
@@ -12,7 +13,8 @@ import { promptSecret } from "../prompt.js";
|
|
|
12
13
|
import { applyCliUser, fetchTenants, normalizeTenantId } from "../tenants.js";
|
|
13
14
|
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
14
15
|
|
|
15
|
-
export async function cmdAuth(argv) {
|
|
16
|
+
export async function cmdAuth(argv, overrides = {}) {
|
|
17
|
+
const io = { fetchTenants, promptSecret, ...overrides };
|
|
16
18
|
const { flags } = parseFlags(argv, {
|
|
17
19
|
pat: { type: "string" },
|
|
18
20
|
gateway: { type: "string" },
|
|
@@ -20,11 +22,16 @@ export async function cmdAuth(argv) {
|
|
|
20
22
|
tenant: { type: "string" },
|
|
21
23
|
});
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
// `auth` is the one command that must run against a config whose origins
|
|
26
|
+
// belong to another channel: it is how a device that carried a production
|
|
27
|
+
// config is moved onto the canary origins (and the message `loadConfig`
|
|
28
|
+
// prints elsewhere tells the tester to run it).
|
|
29
|
+
const canary = RUNTIME_BRAND.channel === "canary";
|
|
30
|
+
const existing = loadConfig({ rejectForeignOrigins: !canary });
|
|
24
31
|
|
|
25
32
|
let pat = flags.pat;
|
|
26
33
|
if (!pat) {
|
|
27
|
-
pat = await promptSecret(`${RUNTIME_BRAND.product.displayName} Personal Access Token (${RUNTIME_BRAND.auth.patPrefix}...): `);
|
|
34
|
+
pat = await io.promptSecret(`${RUNTIME_BRAND.product.displayName} Personal Access Token (${RUNTIME_BRAND.auth.patPrefix}...): `);
|
|
28
35
|
}
|
|
29
36
|
if (!pat) {
|
|
30
37
|
console.error("impel: no PAT provided, aborting.");
|
|
@@ -37,12 +44,21 @@ export async function cmdAuth(argv) {
|
|
|
37
44
|
);
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
// Under the canary brand the stored origins are ignored in favour of the
|
|
48
|
+
// brand defaults; a production origin can only survive here if it is asked
|
|
49
|
+
// for explicitly, and then it is refused below rather than written.
|
|
50
|
+
const gatewayUrl = normalizeGatewayUrl(flags.gateway || (canary ? null : existing?.gatewayUrl) || resolveDefaultGateway());
|
|
51
|
+
const appUrl = normalizeGatewayUrl(flags.app || (canary ? null : existing?.appUrl) || resolveDefaultAppUrl());
|
|
52
|
+
for (const [key, value] of [["gatewayUrl", gatewayUrl], ["appUrl", appUrl]]) {
|
|
53
|
+
const expected = foreignChannelOrigin(key, value);
|
|
54
|
+
if (expected) {
|
|
55
|
+
throw new Error(`${key} ${value} is not a ${RUNTIME_BRAND.channel} origin; this ${RUNTIME_BRAND.channel} build talks only to ${expected}.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
42
58
|
|
|
43
59
|
const config = { ...(existing || {}), pat, gatewayUrl, appUrl, updatedAt: new Date().toISOString() };
|
|
44
60
|
try {
|
|
45
|
-
const listing = await fetchTenants(config);
|
|
61
|
+
const listing = await io.fetchTenants(config);
|
|
46
62
|
const requestedTenant = flags.tenant ? normalizeTenantId(flags.tenant) : null;
|
|
47
63
|
const selected = requestedTenant
|
|
48
64
|
? listing.tenants.find((tenant) => tenant.id === requestedTenant || tenant.slug === requestedTenant)
|
package/src/commands/launch.js
CHANGED
|
@@ -79,23 +79,37 @@ export const IMPEL_CODEX_SPECIALIST_DELEGATION_INSTRUCTIONS =
|
|
|
79
79
|
export const IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS =
|
|
80
80
|
`${IMPEL_CODEX_SPECIALIST_DELEGATION_INSTRUCTIONS} ${IMPEL_CUSTOM_AGENT_VERBATIM_RELAY_APPENDIX}`;
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
82
|
+
function impelCodexRuntimeOverrides(platform) {
|
|
83
|
+
return [
|
|
84
|
+
// Pinned Codex ships `code_mode_host` as a stable, default-on feature and
|
|
85
|
+
// fails closed when a code_mode_only model is selected while it is off —
|
|
86
|
+
// which is every 5.6-family model in the managed catalog. The flag is
|
|
87
|
+
// therefore derived from the platform rather than pinned to one value: the
|
|
88
|
+
// two failure modes it sits between are platform-specific, and forcing
|
|
89
|
+
// either answer onto both platforms regresses the other.
|
|
90
|
+
//
|
|
91
|
+
// macOS: resolveReviewedVendorCliBinary already refuses to launch a Codex
|
|
92
|
+
// whose codex-code-mode-host companion is missing, unsigned, or from the
|
|
93
|
+
// wrong team (see verifyReviewedMacVendorCli in ../vendorCliBinaries.js).
|
|
94
|
+
// Reaching launch proves the helper is present, so disabling the feature is
|
|
95
|
+
// pure loss.
|
|
96
|
+
//
|
|
97
|
+
// Other platforms: verifyReviewedWindowsVendorCli gates on version only and
|
|
98
|
+
// cannot prove the companion shipped, so keep the feature off rather than
|
|
99
|
+
// risk aborting startup on a distribution that omits it.
|
|
100
|
+
`features.code_mode_host=${platform === "darwin" ? "true" : "false"}`,
|
|
101
|
+
// Codex contributes the hosted codex_apps connector whenever the stable
|
|
102
|
+
// `apps` feature is on. It handshakes against chatgpt_base_url with no
|
|
103
|
+
// gateway credential, so headless tenant sessions get an HTTP 401 at
|
|
104
|
+
// startup. `enable_mcp_apps` only governs MCP-UI rendering and does not
|
|
105
|
+
// gate the connector; keep it off as well since the CLI never renders apps.
|
|
106
|
+
"features.apps=false",
|
|
107
|
+
"features.enable_mcp_apps=false",
|
|
108
|
+
// CLI profiles do not load desktop/plugin lifecycle bundles. Plugin hooks
|
|
109
|
+
// are user-layer code and newer Codex versions review every one at startup.
|
|
110
|
+
"features.plugins=false",
|
|
111
|
+
];
|
|
112
|
+
}
|
|
99
113
|
|
|
100
114
|
const IMPEL_CODEX_UNRESTRICTED_ARGUMENT = "--dangerously-bypass-approvals-and-sandbox";
|
|
101
115
|
const IMPEL_CLAUDE_MANAGED_AGENT_ARGUMENTS = [
|
|
@@ -112,6 +126,7 @@ const IMPEL_CLAUDE_MANAGED_AGENT_ARGUMENTS = [
|
|
|
112
126
|
export function impelLaunchArguments(tool, argv, {
|
|
113
127
|
claudeManagedAgent = null,
|
|
114
128
|
codexAgentProfile = null,
|
|
129
|
+
platform = process.platform,
|
|
115
130
|
} = {}) {
|
|
116
131
|
if (!RUNTIME_BRAND.features.agents) return [...argv];
|
|
117
132
|
if (tool === "claude") {
|
|
@@ -172,7 +187,7 @@ export function impelLaunchArguments(tool, argv, {
|
|
|
172
187
|
`developer_instructions=${JSON.stringify(IMPEL_CODEX_PARENT_DELEGATION_INSTRUCTIONS)}`,
|
|
173
188
|
IMPEL_CODEX_UNRESTRICTED_ARGUMENT,
|
|
174
189
|
]),
|
|
175
|
-
...
|
|
190
|
+
...impelCodexRuntimeOverrides(platform).flatMap((override) => ["-c", override]),
|
|
176
191
|
...argv,
|
|
177
192
|
];
|
|
178
193
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
convergenceSummaryDiagnostics,
|
|
16
16
|
readRecentConvergenceSummary,
|
|
17
17
|
} from "../convergenceSummary.js";
|
|
18
|
-
import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
18
|
+
import { brandedEnvironmentName, brandedText, RUNTIME_BRAND, versionLine } from "../runtimeBrand.js";
|
|
19
19
|
import { IMPEL_CLI_ENTRYPOINT } from "../selfInvocation.js";
|
|
20
20
|
import {
|
|
21
21
|
fetchRemoteVersion,
|
|
@@ -68,7 +68,9 @@ export function postInstallCliRunnable(expectedVersion, { spawn = spawnSync, exe
|
|
|
68
68
|
windowsHide: true,
|
|
69
69
|
});
|
|
70
70
|
if (run.status !== 0 || run.error) return false;
|
|
71
|
-
|
|
71
|
+
// The freshly installed build is the same package on the same channel, so
|
|
72
|
+
// its `--version` line carries the same channel suffix this build prints.
|
|
73
|
+
return String(run.stdout || "").trim() === versionLine(expectedVersion.trim());
|
|
72
74
|
} catch {
|
|
73
75
|
return false;
|
|
74
76
|
}
|
package/src/config.js
CHANGED
|
@@ -47,8 +47,39 @@ export function normalizeGatewayUrl(url) {
|
|
|
47
47
|
return LEGACY_GATEWAY_URLS.has(normalized) ? DEFAULT_GATEWAY_URL : normalized;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const CHANNEL_ORIGIN_KEYS = Object.freeze({
|
|
51
|
+
gatewayUrl: Object.freeze({ brandDefault: DEFAULT_GATEWAY_URL, environmentSuffix: "GATEWAY_URL" }),
|
|
52
|
+
appUrl: Object.freeze({ brandDefault: DEFAULT_APP_URL, environmentSuffix: "APP_URL" }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Under the canary brand, an origin the CLI would use must be the canary row
|
|
57
|
+
* the brand carries, or the explicit branded env override (how an engineer
|
|
58
|
+
* points a build at a local service). Returns the origin `key` is required to
|
|
59
|
+
* be when `value` is foreign, else null. The stable brand accepts every
|
|
60
|
+
* origin, exactly as before channels existed.
|
|
61
|
+
*
|
|
62
|
+
* Why this exists: the canary CLI replaces the stable one on a device under
|
|
63
|
+
* the same package name, so an engineer's existing production config would
|
|
64
|
+
* otherwise keep a canary build talking to production with production
|
|
65
|
+
* credentials (R17).
|
|
66
|
+
*/
|
|
67
|
+
export function foreignChannelOrigin(key, value) {
|
|
68
|
+
if (RUNTIME_BRAND.channel !== "canary" || !value) return null;
|
|
69
|
+
const { brandDefault, environmentSuffix } = CHANNEL_ORIGIN_KEYS[key];
|
|
70
|
+
const normalized = normalizeGatewayUrl(value);
|
|
71
|
+
const override = process.env[brandedEnvironmentName(environmentSuffix)];
|
|
72
|
+
if (normalized === brandDefault || (override && normalized === normalizeGatewayUrl(override))) return null;
|
|
73
|
+
return brandDefault;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns the parsed config object, or null if it doesn't exist yet. Throws
|
|
78
|
+
* on malformed JSON, and — under the canary brand — on a stored origin outside
|
|
79
|
+
* the canary row unless `rejectForeignOrigins` is false (`impel auth` reads
|
|
80
|
+
* that way so it can move a device off a production config).
|
|
81
|
+
*/
|
|
82
|
+
export function loadConfig({ rejectForeignOrigins = true } = {}) {
|
|
52
83
|
let raw;
|
|
53
84
|
try {
|
|
54
85
|
raw = fs.readFileSync(CONFIG_PATH, "utf8");
|
|
@@ -56,15 +87,27 @@ export function loadConfig() {
|
|
|
56
87
|
if (err.code === "ENOENT") return null;
|
|
57
88
|
throw err;
|
|
58
89
|
}
|
|
90
|
+
let config;
|
|
59
91
|
try {
|
|
60
|
-
|
|
61
|
-
if (config?.gatewayUrl) config.gatewayUrl = normalizeGatewayUrl(config.gatewayUrl);
|
|
62
|
-
return config;
|
|
92
|
+
config = JSON.parse(raw);
|
|
63
93
|
} catch {
|
|
64
94
|
throw new Error(
|
|
65
95
|
`${CONFIG_PATH} exists but isn't valid JSON. Fix or delete it, then run \`${RUNTIME_BRAND.cli.command} auth\` again.`
|
|
66
96
|
);
|
|
67
97
|
}
|
|
98
|
+
if (config?.gatewayUrl) config.gatewayUrl = normalizeGatewayUrl(config.gatewayUrl);
|
|
99
|
+
if (rejectForeignOrigins) {
|
|
100
|
+
for (const key of Object.keys(CHANNEL_ORIGIN_KEYS)) {
|
|
101
|
+
const expected = foreignChannelOrigin(key, config?.[key]);
|
|
102
|
+
if (expected) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`${CONFIG_PATH} stores ${key} ${normalizeGatewayUrl(config[key])}, but this ${RUNTIME_BRAND.channel} build talks only to ${expected}; `
|
|
105
|
+
+ `run \`${RUNTIME_BRAND.cli.command} auth\` to re-enrol this device against the ${RUNTIME_BRAND.channel} origins.`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return config;
|
|
68
111
|
}
|
|
69
112
|
|
|
70
113
|
/** Writes the config atomically-ish and locks it down to 0600 (owner read/write only). */
|
package/src/extension/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
3
|
import { main as upstreamMain } from "../cli.js";
|
|
4
|
-
import { brandedText, RUNTIME_BRAND, validateRuntimeBrand } from "../runtimeBrand.js";
|
|
4
|
+
import { brandedText, RUNTIME_BRAND, validateRuntimeBrand, versionLine } from "../runtimeBrand.js";
|
|
5
5
|
|
|
6
6
|
const ALIASES = Object.freeze({
|
|
7
7
|
apps: "app",
|
|
@@ -95,7 +95,7 @@ export function createImpelCliExtension({ brand, entrypoint, version }) {
|
|
|
95
95
|
return;
|
|
96
96
|
}
|
|
97
97
|
if (["--version", "-v"].includes(rawCommand)) {
|
|
98
|
-
process.stdout.write(`${version}\n`);
|
|
98
|
+
process.stdout.write(`${versionLine(version)}\n`);
|
|
99
99
|
return;
|
|
100
100
|
}
|
|
101
101
|
const command = ALIASES[rawCommand] || rawCommand;
|
|
@@ -14,4 +14,7 @@
|
|
|
14
14
|
// reasserting the FALLBACK_MODELS floor, records provenance in
|
|
15
15
|
// models-manifest.json, and (with cross-app models enabled) routes the CLI
|
|
16
16
|
// provider through the experimental OpenAI-compatible gateway path.
|
|
17
|
-
|
|
17
|
+
// v47 persists `features.apps = false` in the managed ChatGPT desktop profile
|
|
18
|
+
// so the hosted codex_apps connector stops handshaking against the gateway
|
|
19
|
+
// without a credential (the same gate 0.20.61 passed to CLI sessions).
|
|
20
|
+
export const CURRENT_CONFIG_VERSION = 47;
|
package/src/posthog.js
CHANGED
|
@@ -101,7 +101,7 @@ export const TELEMETRY_CONTRACT = Object.freeze({
|
|
|
101
101
|
name: "cli_bug_report",
|
|
102
102
|
origin: "server",
|
|
103
103
|
properties: [
|
|
104
|
-
{ key: "channel", type: "string", required: true, values: ["latest", "next"] },
|
|
104
|
+
{ key: "channel", type: "string", required: true, values: ["latest", "next", "canary"] },
|
|
105
105
|
{ key: "cliVersion", type: "string", required: true, maxLength: 32 },
|
|
106
106
|
{ key: "hasMessage", type: "boolean", required: true },
|
|
107
107
|
{
|
|
@@ -123,7 +123,7 @@ export const TELEMETRY_CONTRACT = Object.freeze({
|
|
|
123
123
|
name: "cli_command_run",
|
|
124
124
|
origin: "cli",
|
|
125
125
|
properties: [
|
|
126
|
-
{ key: "channel", type: "string", required: true, values: ["latest", "next"] },
|
|
126
|
+
{ key: "channel", type: "string", required: true, values: ["latest", "next", "canary"] },
|
|
127
127
|
{ key: "cliVersion", type: "string", required: true, maxLength: 32 },
|
|
128
128
|
{ key: "command", type: "string", required: true, maxLength: 64 },
|
|
129
129
|
{
|
package/src/runtimeBrand.js
CHANGED
|
@@ -14,8 +14,34 @@ const SUPPORTED_COMMANDS = new Set([
|
|
|
14
14
|
"models", "agents", "update", "use", "experimental",
|
|
15
15
|
]);
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The closed per-channel origin row (canary release cycle, KTD9).
|
|
19
|
+
*
|
|
20
|
+
* A brand names its channel and every default origin must be that channel's
|
|
21
|
+
* row: a canary build pointed at production, or a stable build pointed at
|
|
22
|
+
* dev, is rejected at validation. `channel` is absent from the shipped
|
|
23
|
+
* source's third-party brands and defaults to `stable`, which constrains
|
|
24
|
+
* nothing beyond "not the canary row" so embedders' own origins stay valid.
|
|
25
|
+
* `scripts/bake-canary-brand.mjs` rewrites DEFAULT below to the canary row at
|
|
26
|
+
* publish time; the literals there are what it substitutes.
|
|
27
|
+
*/
|
|
28
|
+
export const CHANNEL_ORIGINS = Object.freeze({
|
|
29
|
+
stable: Object.freeze({
|
|
30
|
+
gateway: "https://gateway.useimpel.com",
|
|
31
|
+
sessions: "https://sessions.useimpel.com",
|
|
32
|
+
controlPlane: "https://www.useimpel.com",
|
|
33
|
+
}),
|
|
34
|
+
canary: Object.freeze({
|
|
35
|
+
gateway: "https://gateway.dev.useimpel.com",
|
|
36
|
+
sessions: "https://sessions.dev.useimpel.com",
|
|
37
|
+
controlPlane: "https://next.dev.useimpel.com",
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
const CANARY_ORIGIN_SET = new Set(Object.values(CHANNEL_ORIGINS.canary));
|
|
41
|
+
|
|
17
42
|
const DEFAULT = Object.freeze({
|
|
18
43
|
schemaVersion: 1,
|
|
44
|
+
channel: "canary",
|
|
19
45
|
product: Object.freeze({ id: "impel", displayName: "Impel" }),
|
|
20
46
|
cli: Object.freeze({
|
|
21
47
|
command: "impel",
|
|
@@ -27,9 +53,9 @@ const DEFAULT = Object.freeze({
|
|
|
27
53
|
}),
|
|
28
54
|
auth: Object.freeze({ patPrefix: "impel_pat_", tenantPrefix: "impel_tenant_" }),
|
|
29
55
|
tenant: Object.freeze({ defaultId: null, displayName: null }),
|
|
30
|
-
gateway: Object.freeze({ defaultOrigin: "https://gateway.useimpel.com" }),
|
|
31
|
-
sessions: Object.freeze({ defaultOrigin: "https://sessions.useimpel.com" }),
|
|
32
|
-
controlPlane: Object.freeze({ defaultOrigin: "https://
|
|
56
|
+
gateway: Object.freeze({ defaultOrigin: "https://gateway.dev.useimpel.com" }),
|
|
57
|
+
sessions: Object.freeze({ defaultOrigin: "https://sessions.dev.useimpel.com" }),
|
|
58
|
+
controlPlane: Object.freeze({ defaultOrigin: "https://next.dev.useimpel.com" }),
|
|
33
59
|
updates: Object.freeze({ registry: null }),
|
|
34
60
|
apps: Object.freeze({
|
|
35
61
|
displayPrefix: "Impel",
|
|
@@ -74,10 +100,32 @@ function pathSegment(name, value) {
|
|
|
74
100
|
return result;
|
|
75
101
|
}
|
|
76
102
|
|
|
103
|
+
function channel(value) {
|
|
104
|
+
if (value == null) return "stable";
|
|
105
|
+
if (value === "stable" || value === "canary") return value;
|
|
106
|
+
throw new Error("impel-cli runtime brand channel must be stable or canary");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Validate one default origin against the brand's channel row (R10). */
|
|
110
|
+
function channelOrigin(name, value, brandChannel) {
|
|
111
|
+
const normalized = origin(name, value);
|
|
112
|
+
const column = name.split(".")[0];
|
|
113
|
+
if (brandChannel === "canary") {
|
|
114
|
+
const expected = CHANNEL_ORIGINS.canary[column];
|
|
115
|
+
if (normalized !== expected) {
|
|
116
|
+
throw new Error(`impel-cli runtime brand ${name} must be ${expected} on the canary channel`);
|
|
117
|
+
}
|
|
118
|
+
} else if (CANARY_ORIGIN_SET.has(normalized)) {
|
|
119
|
+
throw new Error(`impel-cli runtime brand ${name} is a canary origin; the stable channel must not carry it`);
|
|
120
|
+
}
|
|
121
|
+
return normalized;
|
|
122
|
+
}
|
|
123
|
+
|
|
77
124
|
export function validateRuntimeBrand(input) {
|
|
78
125
|
if (!input || Array.isArray(input) || typeof input !== "object" || input.schemaVersion !== 1) {
|
|
79
126
|
throw new Error("impel-cli runtime brand schemaVersion must be 1");
|
|
80
127
|
}
|
|
128
|
+
const brandChannel = channel(input.channel);
|
|
81
129
|
const command = text("cli.command", input.cli?.command, SAFE_ID);
|
|
82
130
|
const packageName = text("cli.packageName", input.cli?.packageName || command, SAFE_PACKAGE);
|
|
83
131
|
if (path.isAbsolute(packageName)) throw new Error("impel-cli runtime brand cli.packageName is invalid");
|
|
@@ -92,6 +140,7 @@ export function validateRuntimeBrand(input) {
|
|
|
92
140
|
}
|
|
93
141
|
return Object.freeze({
|
|
94
142
|
schemaVersion: 1,
|
|
143
|
+
channel: brandChannel,
|
|
95
144
|
product: Object.freeze({
|
|
96
145
|
id: text("product.id", input.product?.id, SAFE_ID),
|
|
97
146
|
displayName: text("product.displayName", input.product?.displayName),
|
|
@@ -112,14 +161,15 @@ export function validateRuntimeBrand(input) {
|
|
|
112
161
|
defaultId: defaultTenant,
|
|
113
162
|
displayName: input.tenant?.displayName == null ? defaultTenant : text("tenant.displayName", input.tenant.displayName),
|
|
114
163
|
}),
|
|
115
|
-
gateway: Object.freeze({ defaultOrigin:
|
|
164
|
+
gateway: Object.freeze({ defaultOrigin: channelOrigin("gateway.defaultOrigin", input.gateway?.defaultOrigin, brandChannel) }),
|
|
116
165
|
sessions: Object.freeze({
|
|
117
|
-
defaultOrigin:
|
|
166
|
+
defaultOrigin: channelOrigin(
|
|
118
167
|
"sessions.defaultOrigin",
|
|
119
168
|
input.sessions?.defaultOrigin || input.gateway?.defaultOrigin,
|
|
169
|
+
brandChannel,
|
|
120
170
|
),
|
|
121
171
|
}),
|
|
122
|
-
controlPlane: Object.freeze({ defaultOrigin:
|
|
172
|
+
controlPlane: Object.freeze({ defaultOrigin: channelOrigin("controlPlane.defaultOrigin", input.controlPlane?.defaultOrigin, brandChannel) }),
|
|
123
173
|
updates: Object.freeze({
|
|
124
174
|
registry: input.updates?.registry == null
|
|
125
175
|
? null
|
|
@@ -158,6 +208,15 @@ export function brandedEnvironmentName(suffix) {
|
|
|
158
208
|
return `${RUNTIME_BRAND.cli.environmentPrefix}_${suffix}`;
|
|
159
209
|
}
|
|
160
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The line `--version` prints. Off the stable channel it names the channel so
|
|
213
|
+
* a tester can tell a canary process from a stable one at a glance (R8); the
|
|
214
|
+
* stable output is the bare version, byte-identical to before channels existed.
|
|
215
|
+
*/
|
|
216
|
+
export function versionLine(version) {
|
|
217
|
+
return RUNTIME_BRAND.channel === "stable" ? String(version) : `${version} (${RUNTIME_BRAND.channel})`;
|
|
218
|
+
}
|
|
219
|
+
|
|
161
220
|
export function brandedText(value) {
|
|
162
221
|
return String(value)
|
|
163
222
|
.replaceAll("impel-cli", `${RUNTIME_BRAND.cli.command}-cli`)
|
package/src/updates.js
CHANGED
|
@@ -36,13 +36,22 @@ export function updateRegistry() {
|
|
|
36
36
|
).replace(/\/+$/u, "");
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/** The closed set of npm dist-tags the CLI can follow. */
|
|
40
|
+
export function normalizeUpdateTag(tag) {
|
|
41
|
+
if (tag === "latest" || tag === "next" || tag === "canary") return tag;
|
|
41
42
|
throw new Error(`unsupported npm update tag "${tag}"`);
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Keep prerelease installs on npm's prerelease channel.
|
|
47
|
+
*
|
|
48
|
+
* A canary build is keyed on the brand channel rather than its version
|
|
49
|
+
* string: `<patch+1>-canary.<N>` is a prerelease, but it must follow the
|
|
50
|
+
* `canary` dist-tag, not `next` — and under the stable brand that same
|
|
51
|
+
* version is only ever a prerelease that follows `next`.
|
|
52
|
+
*/
|
|
45
53
|
export function updateTagForVersion(version) {
|
|
54
|
+
if (RUNTIME_BRAND.channel === "canary") return "canary";
|
|
46
55
|
const parsed = validVersion(version) ? version.match(VERSION_RE) : null;
|
|
47
56
|
return parsed?.[4] ? "next" : "latest";
|
|
48
57
|
}
|