u1s1-cli 1.14.5 → 1.15.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/dist/agent-setup.d.ts +7 -0
- package/dist/agent-setup.js +14 -0
- package/dist/agents/agent-config.d.ts +83 -0
- package/dist/agents/agent-config.js +154 -0
- package/dist/agents/claude.d.ts +80 -0
- package/dist/agents/claude.js +233 -0
- package/dist/agents/codex.d.ts +111 -0
- package/dist/agents/codex.js +392 -0
- package/dist/agents/launch.d.ts +109 -0
- package/dist/agents/launch.js +444 -0
- package/dist/agents/pi.d.ts +9 -0
- package/dist/agents/pi.js +562 -0
- package/dist/agents/prefs.d.ts +35 -0
- package/dist/agents/prefs.js +96 -0
- package/dist/agents/runtime.d.ts +354 -0
- package/dist/agents/runtime.js +884 -0
- package/dist/agents/status.d.ts +20 -0
- package/dist/agents/status.js +241 -0
- package/dist/api.js +2 -1
- package/dist/config-command.d.ts +18 -0
- package/dist/config-command.js +335 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +35 -1
- package/dist/device-auth.d.ts +39 -17
- package/dist/device-auth.js +61 -72
- package/dist/embed.d.ts +13 -0
- package/dist/embed.js +15 -0
- package/dist/gateway-route.d.ts +57 -0
- package/dist/gateway-route.js +157 -0
- package/dist/index.js +22 -556
- package/dist/launcher.d.ts +48 -0
- package/dist/launcher.js +235 -0
- package/dist/login.js +3 -2
- package/dist/mcp-server.d.ts +105 -0
- package/dist/mcp-server.js +449 -0
- package/dist/pi-ui-text.d.ts +57 -0
- package/dist/pi-ui-text.js +148 -0
- package/dist/search-tools.js +2 -1
- package/dist/usage.js +2 -2
- package/package.json +3 -2
- package/scripts/p0-claude-code/README.md +39 -0
- package/scripts/p0-claude-code/launch-claude.ts +94 -0
- package/scripts/p0-claude-code/replay.ts +74 -0
- package/scripts/p0-claude-code/seed-local.mjs +145 -0
- package/scripts/p0-claude-code/tap.mjs +105 -0
- package/scripts/patch-pi.js +76 -55
- package/scripts/pi-patch-report.mjs +224 -0
- package/scripts/pi-upgrade.mjs +829 -0
- package/scripts/render-regression.mjs +67 -5
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Upgrade the embedded Pi (@earendil-works/pi-*) to a given version and regenerate both patch tracks.
|
|
4
|
+
*
|
|
5
|
+
* One command for steps 1–4 of docs/pi-patch-inventory-2026-11-13.md §3.5; step 5 (rules that no
|
|
6
|
+
* longer match) is reported, never rewritten:
|
|
7
|
+
* 1. packages/cli/package.json: every @earendil-works/pi-* to the new version, plus pi packages the
|
|
8
|
+
* new version newly depends on; undici follows pi-coding-agent's exact pin
|
|
9
|
+
* 2. pnpm-workspace.yaml: minimumReleaseAgeExclude (pi entries) and patchedDependencies
|
|
10
|
+
* 3. patches/@earendil-works__pi-{coding-agent,tui}@<ver>.patch: regenerated from the pristine npm
|
|
11
|
+
* tarballs + scripts/patch-pi.js (so the two tracks stay byte-identical), old files removed
|
|
12
|
+
* 4. packages/cli/package-lock.json (npm --package-lock-only in a scratch copy) and pnpm-lock.yaml
|
|
13
|
+
* (pnpm install)
|
|
14
|
+
* and writes a report (report.json / report.md / report.public.md): per-rule status against the new
|
|
15
|
+
* pristine sources, UI strings the new version added that no rule covers, and the Breaking sections of
|
|
16
|
+
* the upstream CHANGELOGs shipped in the tarballs. report.public.md is the one meant for PR / issue
|
|
17
|
+
* bodies: everything quoted from upstream sits in code blocks (no @mentions, no cross-references) and
|
|
18
|
+
* third-party service names and price words are masked.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* node packages/cli/scripts/pi-upgrade.mjs [--version <x.y.z>|latest] [--dry-run] [--force]
|
|
22
|
+
* [--resolve-only] [--skip-install] [--work-dir <dir>] [--report-dir <dir>] [--pnpm <cmd>] [--json]
|
|
23
|
+
*
|
|
24
|
+
* --version target version; default "latest" = newest stable release at or below the npm
|
|
25
|
+
* `latest` tag that is at least minimumReleaseAge (pnpm-workspace.yaml) old
|
|
26
|
+
* --resolve-only print the version decision as JSON and exit; touches nothing, downloads no tarball
|
|
27
|
+
* --dry-run download, patch and report, but leave the working tree untouched
|
|
28
|
+
* --force write the upgrade even when some rule no longer matches (still exits 1)
|
|
29
|
+
* --skip-install do not run pnpm install (pnpm-lock.yaml is then left stale)
|
|
30
|
+
* --pnpm pnpm command to run (default "pnpm"; locally e.g. the npx-cached pnpm 11 binary)
|
|
31
|
+
*
|
|
32
|
+
* Network: npm registry only (npm view / npm pack / npm install --package-lock-only, pnpm install).
|
|
33
|
+
* Exit codes: 0 done (or nothing to do), 1 some rule missing / wrong count (report written), 2 error.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { spawnSync } from "node:child_process";
|
|
37
|
+
import { closeSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { tmpdir } from "node:os";
|
|
39
|
+
import { dirname, join, resolve } from "node:path";
|
|
40
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
41
|
+
|
|
42
|
+
export const PI_SCOPE = "@earendil-works/";
|
|
43
|
+
export const PI_PREFIX = `${PI_SCOPE}pi-`;
|
|
44
|
+
export const CODING_AGENT = `${PI_SCOPE}pi-coding-agent`;
|
|
45
|
+
export const TUI = `${PI_SCOPE}pi-tui`;
|
|
46
|
+
/** Packages that carry a pnpm patch (patches/@earendil-works__<name>@<ver>.patch). */
|
|
47
|
+
export const PATCHED = [CODING_AGENT, TUI];
|
|
48
|
+
/** Not a rule target: its descriptions are translated by u1s1's in-process extension (src/pi-ui-text.ts). */
|
|
49
|
+
const SLASH_COMMANDS_FILE = "dist/core/slash-commands.js";
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// versions
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
const STABLE = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
56
|
+
|
|
57
|
+
export function compareVersions(a, b) {
|
|
58
|
+
const pa = a.match(STABLE);
|
|
59
|
+
const pb = b.match(STABLE);
|
|
60
|
+
if (!pa || !pb) throw new Error(`not a stable x.y.z version: ${!pa ? a : b}`);
|
|
61
|
+
for (let i = 1; i <= 3; i++) {
|
|
62
|
+
const d = Number(pa[i]) - Number(pb[i]);
|
|
63
|
+
if (d !== 0) return Math.sign(d);
|
|
64
|
+
}
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** minimumReleaseAge (minutes) from pnpm-workspace.yaml text; 0 when not set. */
|
|
69
|
+
export function readMinimumReleaseAge(workspaceYaml) {
|
|
70
|
+
const m = workspaceYaml.match(/^minimumReleaseAge:\s*(\d+)\s*$/m);
|
|
71
|
+
return m ? Number(m[1]) : 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pick the version to upgrade to.
|
|
76
|
+
* meta: { versions: string[], time: Record<version, ISO>, distTags: Record<tag, version> }
|
|
77
|
+
* requested: "latest" or an explicit x.y.z. Explicit versions are taken as asked (but flagged when young).
|
|
78
|
+
*/
|
|
79
|
+
export function selectVersion({ meta, current, requested = "latest", now = Date.now(), minAgeMinutes = 0 }) {
|
|
80
|
+
const ageOf = (v) => (meta.time?.[v] ? Math.floor((now - Date.parse(meta.time[v])) / 60_000) : undefined);
|
|
81
|
+
if (requested !== "latest") {
|
|
82
|
+
if (!STABLE.test(requested)) throw new Error(`--version must be x.y.z or "latest", got ${requested}`);
|
|
83
|
+
if (!meta.versions.includes(requested)) throw new Error(`${requested} is not published on npm`);
|
|
84
|
+
const ageMinutes = ageOf(requested);
|
|
85
|
+
return {
|
|
86
|
+
current,
|
|
87
|
+
version: requested,
|
|
88
|
+
publishedAt: meta.time?.[requested],
|
|
89
|
+
ageMinutes,
|
|
90
|
+
young: ageMinutes === undefined || ageMinutes < minAgeMinutes,
|
|
91
|
+
skippedYoung: [],
|
|
92
|
+
upToDate: requested === current,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const latest = meta.distTags?.latest;
|
|
96
|
+
if (!latest || !STABLE.test(latest)) throw new Error(`npm has no stable "latest" tag (${latest})`);
|
|
97
|
+
const stable = meta.versions.filter((v) => STABLE.test(v) && compareVersions(v, latest) <= 0);
|
|
98
|
+
const oldEnough = (v) => {
|
|
99
|
+
const age = ageOf(v);
|
|
100
|
+
return age !== undefined && age >= minAgeMinutes;
|
|
101
|
+
};
|
|
102
|
+
const eligible = stable.filter(oldEnough).sort(compareVersions);
|
|
103
|
+
const version = eligible.at(-1);
|
|
104
|
+
const skippedYoung = stable.filter((v) => !oldEnough(v) && (!version || compareVersions(v, version) > 0)).sort(compareVersions);
|
|
105
|
+
return {
|
|
106
|
+
current,
|
|
107
|
+
version,
|
|
108
|
+
publishedAt: version ? meta.time[version] : undefined,
|
|
109
|
+
ageMinutes: version ? ageOf(version) : undefined,
|
|
110
|
+
young: false,
|
|
111
|
+
skippedYoung,
|
|
112
|
+
upToDate: !version || (STABLE.test(current) && compareVersions(version, current) <= 0),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Exact versions of every pi package the upgrade needs: the pi packages already pinned plus every
|
|
118
|
+
* @earendil-works/* the new versions depend on, transitively. Ranges (1.2.0 used ^1.2.0) resolve to the
|
|
119
|
+
* target version; exact upstream pins win. fetchDeps(name, version) → dependencies object (may be async).
|
|
120
|
+
*/
|
|
121
|
+
export async function resolvePiClosure({ roots, target, fetchDeps }) {
|
|
122
|
+
const versions = {};
|
|
123
|
+
const queue = roots.map((name) => [name, target]);
|
|
124
|
+
while (queue.length) {
|
|
125
|
+
const [name, version] = queue.shift();
|
|
126
|
+
if (versions[name] === version) continue;
|
|
127
|
+
if (versions[name]) throw new Error(`${name} is wanted at both ${versions[name]} and ${version}`);
|
|
128
|
+
versions[name] = version;
|
|
129
|
+
const deps = (await fetchDeps(name, version)) ?? {};
|
|
130
|
+
for (const [dep, spec] of Object.entries(deps)) {
|
|
131
|
+
if (!dep.startsWith(PI_SCOPE)) continue;
|
|
132
|
+
queue.push([dep, STABLE.test(spec) ? spec : target]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return Object.fromEntries(Object.entries(versions).sort(([a], [b]) => a.localeCompare(b)));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// file rewrites (pure: text/object in, text/object out)
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/** Current pi pins from package.json dependencies. */
|
|
143
|
+
export function piPins(pkg) {
|
|
144
|
+
return Object.fromEntries(Object.entries(pkg.dependencies ?? {}).filter(([name]) => name.startsWith(PI_PREFIX)));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* New package.json object: pi packages set to `closure`, new ones added, undici synced; dependencies
|
|
149
|
+
* stay sorted by name. Returns { pkg, changes: [{ name, from, to }] }.
|
|
150
|
+
*/
|
|
151
|
+
export function planPackageJson(pkg, closure, undici) {
|
|
152
|
+
const deps = { ...pkg.dependencies };
|
|
153
|
+
const changes = [];
|
|
154
|
+
const set = (name, to) => {
|
|
155
|
+
if (deps[name] === to) return;
|
|
156
|
+
changes.push({ name, from: deps[name] ?? null, to });
|
|
157
|
+
deps[name] = to;
|
|
158
|
+
};
|
|
159
|
+
for (const [name, version] of Object.entries(closure)) set(name, version);
|
|
160
|
+
if (undici) set("undici", undici);
|
|
161
|
+
const sorted = Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b)));
|
|
162
|
+
return { pkg: { ...pkg, dependencies: sorted }, changes };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const patchFileName = (name, version) => `${name.replace("/", "__")}@${version}.patch`;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Rewrite pnpm-workspace.yaml line by line (keeps comments and every unrelated entry):
|
|
169
|
+
* - minimumReleaseAgeExclude: drop every `@earendil-works/pi-*@x` item; add `exclude` (name@version
|
|
170
|
+
* list, only packages still younger than minimumReleaseAge) where the first pi item used to be
|
|
171
|
+
* - patchedDependencies: `@earendil-works/<pkg>@<old>` → `<new>` with the new patch file name; entries
|
|
172
|
+
* whose package is in `dropPatched` (empty generated patch) are removed
|
|
173
|
+
*/
|
|
174
|
+
export function planWorkspaceYaml(text, { versions, exclude = [], dropPatched = [] }) {
|
|
175
|
+
const lines = text.split("\n");
|
|
176
|
+
const out = [];
|
|
177
|
+
let block = null;
|
|
178
|
+
let insertAt = -1;
|
|
179
|
+
const piExclude = /^\s+-\s+'?@earendil-works\/pi-[\w-]+@[^'\s]+'?\s*$/;
|
|
180
|
+
const patchedEntry = /^(\s+)'(@earendil-works\/pi-[\w-]+)@[^']+':\s*patches\/\S+\s*$/;
|
|
181
|
+
for (const line of lines) {
|
|
182
|
+
const top = line.match(/^([A-Za-z][\w-]*):/);
|
|
183
|
+
if (top) {
|
|
184
|
+
if (block === "minimumReleaseAgeExclude" && insertAt < 0) insertAt = out.length;
|
|
185
|
+
block = top[1];
|
|
186
|
+
}
|
|
187
|
+
if (block === "minimumReleaseAgeExclude" && piExclude.test(line)) {
|
|
188
|
+
if (insertAt < 0) insertAt = out.length;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const p = block === "patchedDependencies" ? line.match(patchedEntry) : null;
|
|
192
|
+
if (p) {
|
|
193
|
+
const [, indent, name] = p;
|
|
194
|
+
if (dropPatched.includes(name)) continue;
|
|
195
|
+
const version = versions[name];
|
|
196
|
+
if (!version) throw new Error(`patchedDependencies has ${name} but the upgrade has no version for it`);
|
|
197
|
+
out.push(`${indent}'${name}@${version}': patches/${patchFileName(name, version)}`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
out.push(line);
|
|
201
|
+
}
|
|
202
|
+
if (exclude.length) {
|
|
203
|
+
if (insertAt < 0) throw new Error("pnpm-workspace.yaml has no minimumReleaseAgeExclude list to add to");
|
|
204
|
+
// the list may have ended in blank lines before the next key; keep the new items inside the list
|
|
205
|
+
while (insertAt > 0 && out[insertAt - 1].trim() === "") insertAt--;
|
|
206
|
+
out.splice(insertAt, 0, ...[...exclude].sort().map((spec) => ` - '${spec}'`));
|
|
207
|
+
}
|
|
208
|
+
return out.join("\n");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// report inputs
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* String and template literals of a JS source, skipping comments and regex literals (an apostrophe in a
|
|
217
|
+
* comment would otherwise throw a regex-only scan out of step). Template expressions become "…"; templates
|
|
218
|
+
* nested inside them are returned separately. Good enough for tsc output; not a full JS lexer.
|
|
219
|
+
*/
|
|
220
|
+
export function jsStringLiterals(src) {
|
|
221
|
+
const out = [];
|
|
222
|
+
let i = 0;
|
|
223
|
+
let prev = ""; // last significant char, to tell a regex literal from a division
|
|
224
|
+
const regexAfter = /[(,=:[!&|?{};+\-*%<>~^]/;
|
|
225
|
+
const readQuoted = (q) => {
|
|
226
|
+
let s = "";
|
|
227
|
+
for (i++; i < src.length && src[i] !== q && src[i] !== "\n"; i++) {
|
|
228
|
+
if (src[i] === "\\") s += src[i++];
|
|
229
|
+
s += src[i];
|
|
230
|
+
}
|
|
231
|
+
i++;
|
|
232
|
+
return s;
|
|
233
|
+
};
|
|
234
|
+
// returns the template text with expressions as "…"; i ends after the closing backtick
|
|
235
|
+
const readTemplate = () => {
|
|
236
|
+
let s = "";
|
|
237
|
+
for (i++; i < src.length && src[i] !== "`"; i++) {
|
|
238
|
+
if (src[i] === "\\") s += src[i++] + src[i];
|
|
239
|
+
else if (src[i] === "$" && src[i + 1] === "{") {
|
|
240
|
+
i += 2;
|
|
241
|
+
scanCode(true);
|
|
242
|
+
i--; // scanCode stopped just past "}"; the loop's i++ must not skip the next char
|
|
243
|
+
s += "…";
|
|
244
|
+
} else s += src[i];
|
|
245
|
+
}
|
|
246
|
+
i++;
|
|
247
|
+
return s;
|
|
248
|
+
};
|
|
249
|
+
// scans code until the matching "}" (inside ${…}) or the end; pushes literals it meets
|
|
250
|
+
const scanCode = (inExpr) => {
|
|
251
|
+
let depth = 0;
|
|
252
|
+
while (i < src.length) {
|
|
253
|
+
const c = src[i];
|
|
254
|
+
if (c === "/" && src[i + 1] === "/") {
|
|
255
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
256
|
+
} else if (c === "/" && src[i + 1] === "*") {
|
|
257
|
+
const end = src.indexOf("*/", i + 2);
|
|
258
|
+
i = end < 0 ? src.length : end + 2;
|
|
259
|
+
} else if (c === '"' || c === "'") {
|
|
260
|
+
out.push(readQuoted(c));
|
|
261
|
+
prev = "a";
|
|
262
|
+
} else if (c === "`") {
|
|
263
|
+
out.push(readTemplate());
|
|
264
|
+
prev = "a";
|
|
265
|
+
} else if (c === "/" && (prev === "" || regexAfter.test(prev))) {
|
|
266
|
+
let inClass = false;
|
|
267
|
+
for (i++; i < src.length && src[i] !== "\n"; i++) {
|
|
268
|
+
if (src[i] === "\\") i++;
|
|
269
|
+
else if (src[i] === "[") inClass = true;
|
|
270
|
+
else if (src[i] === "]") inClass = false;
|
|
271
|
+
else if (src[i] === "/" && !inClass) break;
|
|
272
|
+
}
|
|
273
|
+
i++;
|
|
274
|
+
prev = "a";
|
|
275
|
+
} else if (/[A-Za-z_$]/.test(c)) {
|
|
276
|
+
const word = src.slice(i).match(/^[\w$]+/)[0];
|
|
277
|
+
i += word.length;
|
|
278
|
+
prev = /^(?:return|typeof|case|in|of|else|void|yield|await|delete|new|throw)$/.test(word) ? "(" : "a";
|
|
279
|
+
} else {
|
|
280
|
+
if (c === "{") depth++;
|
|
281
|
+
if (c === "}") {
|
|
282
|
+
if (inExpr && depth === 0) {
|
|
283
|
+
i++;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
depth--;
|
|
287
|
+
}
|
|
288
|
+
if (!/\s/.test(c)) prev = /[\w$)\]]/.test(c) ? "a" : c;
|
|
289
|
+
i++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
scanCode(false);
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Whether a (normalized) string literal reads like user-facing English rather than code. */
|
|
298
|
+
export function looksLikeUiText(s) {
|
|
299
|
+
if (s.length < 3 || s.length > 300) return false;
|
|
300
|
+
if (!/[A-Za-z]{2}/.test(s)) return false;
|
|
301
|
+
if (/\\x1b|\\u001b|\\u\{|^\s*[-#.]|^\w+:\/\/|\.(?:js|json|ts|md|jsonl|html)\b/.test(s)) return false;
|
|
302
|
+
if (/^[\w$.:/@-]+$/.test(s)) return /^[A-Z][a-z]+[.!?…:]?$/.test(s); // one word: only "Continue", "Fork"…
|
|
303
|
+
if (/[{};=]|=>|\(\)/.test(s)) return false;
|
|
304
|
+
return /[A-Za-z]{2,}\s+\S/.test(s) || /^[A-Z][a-z]+[.!?…:]$/.test(s);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** User-facing string literals of one JS source; template expressions collapse to "…". */
|
|
308
|
+
export function extractUiStrings(source) {
|
|
309
|
+
const found = new Set();
|
|
310
|
+
for (const literal of jsStringLiterals(source)) {
|
|
311
|
+
const s = literal.trim();
|
|
312
|
+
if (!s.includes("\n") && looksLikeUiText(s)) found.add(s);
|
|
313
|
+
}
|
|
314
|
+
return found;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* UI strings present in the new files but not in the old ones (same files, any position), minus those
|
|
319
|
+
* some rule's `from` text already quotes. files: { [relPath]: { old?: string, new?: string } }.
|
|
320
|
+
*/
|
|
321
|
+
export function newUiStrings(files, ruleFroms = []) {
|
|
322
|
+
const out = [];
|
|
323
|
+
for (const [file, { old: before = "", new: after = "" }] of Object.entries(files)) {
|
|
324
|
+
const oldSet = extractUiStrings(before);
|
|
325
|
+
for (const s of extractUiStrings(after)) {
|
|
326
|
+
if (oldSet.has(s)) continue;
|
|
327
|
+
const needle = s.split("…")[0];
|
|
328
|
+
if (needle.length >= 3 && ruleFroms.some((from) => from.includes(needle))) continue;
|
|
329
|
+
out.push({ file, text: s });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const unquote = (lit) => lit.slice(1, -1).replace(/\$\{[^}]*\}/g, "…").replace(/\\(.)/g, "$1");
|
|
336
|
+
const QUOTED = String.raw`("(?:[^"\\]|\\.)*"|` + "`(?:[^`\\\\]|\\\\.)*`)";
|
|
337
|
+
|
|
338
|
+
/** Split a source on `name: "<cmd>"` and read the named fields of each entry: [{ name, fields }]. */
|
|
339
|
+
function namedEntries(source, fieldNames) {
|
|
340
|
+
const parts = source.split(/\bname:\s*"([^"]+)"/);
|
|
341
|
+
const out = [];
|
|
342
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
343
|
+
const body = parts[i + 1];
|
|
344
|
+
const fields = {};
|
|
345
|
+
for (const f of fieldNames) {
|
|
346
|
+
const m = body.match(new RegExp(`\\b${f}:\\s*${QUOTED}`));
|
|
347
|
+
if (m) fields[f] = unquote(m[1]);
|
|
348
|
+
}
|
|
349
|
+
out.push({ name: parts[i], fields });
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** BUILTIN_SLASH_COMMANDS of Pi's dist/core/slash-commands.js: [{ name, description, argumentHint? }]. */
|
|
355
|
+
export function parseSlashCommands(js) {
|
|
356
|
+
const block = js.match(/BUILTIN_SLASH_COMMANDS\s*=\s*\[([\s\S]*?)\n\];/);
|
|
357
|
+
if (!block) return null;
|
|
358
|
+
return namedEntries(block[1], ["description", "argumentHint"]).map(({ name, fields }) => ({ name, ...fields }));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** BUILTIN_COMMAND_TEXT of src/pi-ui-text.ts, as the same shape (upstream → description). */
|
|
362
|
+
export function parseUiTextTable(ts) {
|
|
363
|
+
const block = ts.match(/BUILTIN_COMMAND_TEXT[^=]*=\s*\[([\s\S]*?)\n\];/);
|
|
364
|
+
if (!block) return null;
|
|
365
|
+
return namedEntries(block[1], ["upstream", "argumentHint"]).map(({ name, fields }) => ({
|
|
366
|
+
name,
|
|
367
|
+
description: fields.upstream,
|
|
368
|
+
...(fields.argumentHint !== undefined && { argumentHint: fields.argumentHint }),
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Where u1s1's slash-command text table (translated by the in-process extension) no longer matches the
|
|
374
|
+
* new Pi: [{ name, kind: added|removed|changed|order, ours?, upstream? }]. Same check as
|
|
375
|
+
* test/pi-ui-text.test.ts, done before install so the report can name it.
|
|
376
|
+
*/
|
|
377
|
+
export function slashCommandDrift(ours, upstream) {
|
|
378
|
+
const key = (c) => `${c.description ?? ""}\u0000${c.argumentHint ?? ""}`;
|
|
379
|
+
const mine = new Map(ours.map((c) => [c.name, c]));
|
|
380
|
+
const theirs = new Map(upstream.map((c) => [c.name, c]));
|
|
381
|
+
const drift = [];
|
|
382
|
+
for (const c of upstream) {
|
|
383
|
+
const m = mine.get(c.name);
|
|
384
|
+
if (!m) drift.push({ name: c.name, kind: "added", upstream: c.description });
|
|
385
|
+
else if (key(m) !== key(c)) drift.push({ name: c.name, kind: "changed", ours: m.description, upstream: c.description });
|
|
386
|
+
}
|
|
387
|
+
for (const c of ours) if (!theirs.has(c.name)) drift.push({ name: c.name, kind: "removed", ours: c.description });
|
|
388
|
+
if (!drift.length && ours.map((c) => c.name).join() !== upstream.map((c) => c.name).join()) {
|
|
389
|
+
drift.push({ name: "*", kind: "order" });
|
|
390
|
+
}
|
|
391
|
+
return drift;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Breaking-change bullets of every CHANGELOG version in (from, to].
|
|
396
|
+
* Returns [{ version, items: string[] }] newest first, as the CHANGELOG lists them.
|
|
397
|
+
*/
|
|
398
|
+
export function extractBreaking(changelog, from, to) {
|
|
399
|
+
const out = [];
|
|
400
|
+
let version = null;
|
|
401
|
+
let inBreaking = false;
|
|
402
|
+
let items = null;
|
|
403
|
+
for (const line of changelog.split("\n")) {
|
|
404
|
+
const v = line.match(/^##\s+\[?(\d+\.\d+\.\d+)\]?/);
|
|
405
|
+
if (v) {
|
|
406
|
+
version = v[1];
|
|
407
|
+
inBreaking = false;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (/^###\s/.test(line)) {
|
|
411
|
+
inBreaking = /breaking/i.test(line);
|
|
412
|
+
if (inBreaking && version && compareVersions(version, from) > 0 && compareVersions(version, to) <= 0) {
|
|
413
|
+
items = out.find((e) => e.version === version)?.items;
|
|
414
|
+
if (!items) {
|
|
415
|
+
items = [];
|
|
416
|
+
out.push({ version, items });
|
|
417
|
+
}
|
|
418
|
+
} else inBreaking = false;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (!inBreaking || !items) continue;
|
|
422
|
+
if (/^\s*[-*]\s+/.test(line)) items.push(line.replace(/^\s*[-*]\s+/, "").trim());
|
|
423
|
+
else if (line.trim() && items.length) items[items.length - 1] += ` ${line.trim()}`;
|
|
424
|
+
}
|
|
425
|
+
return out.filter((e) => e.items.length);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// report
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
const STATUS_LABEL = { hit: "命中", applied: "已含(上游已是目标文本)", count: "命中次数不对", missing: "失配" };
|
|
433
|
+
|
|
434
|
+
/** pi-patch-report status → hit | applied | count | missing */
|
|
435
|
+
export function ruleState(status) {
|
|
436
|
+
if (status === "clean") return "hit";
|
|
437
|
+
if (status.startsWith("applied")) return "applied";
|
|
438
|
+
if (status === "count") return "count";
|
|
439
|
+
return "missing";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Third-party service names and price words never go into PR / issue text (they are public). This is a
|
|
444
|
+
* mask, not a parser: anything matching is replaced, the rest of the line is kept.
|
|
445
|
+
*/
|
|
446
|
+
const THIRD_PARTY =
|
|
447
|
+
/\b(?:deepseek|wokey|open\s?ai|anthropic|claude(?:\s+code)?|chat\s?gpt|codex|gpt-?[\w.]*|gemini|google|vertex(?:\s+ai)?|bedrock|azure|aws|amazon|openrouter|litellm|vllm|ollama|mistral|groq|xai|grok|kimi|moonshot|qwen|zhipu|glm-?[\w.]*|minimax|copilot|microsoft|radius|passin|blockwallets|cloudflare|fireworks|together\s?ai|deepinfra|cerebras|perplexity|cohere|huggingface|nvidia)\b/gi;
|
|
448
|
+
const PRICE = /\b(?:price[sd]?|pricing|costs?|billing|billed|markup|discounts?|cheap(?:er|est)?)\b|[$¥¥€]\s?\d+(?:[.,]\d+)*|加价|成本|定价|价格|售价|折扣|渠道|供应商/gi;
|
|
449
|
+
export function maskPublic(text) {
|
|
450
|
+
return text.replace(THIRD_PARTY, "[第三方]").replace(PRICE, "[略]");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Text inside a fenced block: no mentions, no #refs, no autolinks; also strip markdown links to their text. */
|
|
454
|
+
function fenced(lines) {
|
|
455
|
+
const body = lines.join("\n").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/```/g, "'''");
|
|
456
|
+
return ["```text", body, "```"];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function buildReport({ current, target, packages, undici, rules, slashCommands = [], uiStrings, breaking, patches }) {
|
|
460
|
+
const tally = { hit: 0, applied: 0, count: 0, missing: 0 };
|
|
461
|
+
const items = rules.map((r) => {
|
|
462
|
+
const state = ruleState(r.status);
|
|
463
|
+
tally[state]++;
|
|
464
|
+
return { id: `${r.table}[${r.index}]`, file: r.file, status: r.status, state, from: r.from };
|
|
465
|
+
});
|
|
466
|
+
const problems = items.filter((r) => r.state === "count" || r.state === "missing");
|
|
467
|
+
return {
|
|
468
|
+
current,
|
|
469
|
+
target,
|
|
470
|
+
ok: problems.length === 0 && slashCommands.length === 0,
|
|
471
|
+
packages,
|
|
472
|
+
undici,
|
|
473
|
+
patches,
|
|
474
|
+
rules: { total: items.length, ...tally, problems, items },
|
|
475
|
+
slashCommands,
|
|
476
|
+
uiStrings,
|
|
477
|
+
breaking,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const DRIFT_LABEL = { added: "上游新增", removed: "上游已删", changed: "上游原文变了", order: "顺序变了" };
|
|
482
|
+
|
|
483
|
+
const MAX_LIST = 150;
|
|
484
|
+
|
|
485
|
+
/** Markdown report. publicSafe: mask third-party/price words and keep every quote inside code blocks. */
|
|
486
|
+
export function renderMarkdown(report, { publicSafe = false } = {}) {
|
|
487
|
+
const q = publicSafe ? maskPublic : (s) => s;
|
|
488
|
+
const r = report.rules;
|
|
489
|
+
const lines = [];
|
|
490
|
+
lines.push(`<!-- pi-upgrade version=${report.target} ok=${report.ok} -->`);
|
|
491
|
+
lines.push(`## Pi ${report.current} → ${report.target}`, "");
|
|
492
|
+
if (report.ok) lines.push("补丁规则全部命中,斜杠命令文案表与新版一致。", "");
|
|
493
|
+
if (r.problems.length) lines.push(`**有 ${r.problems.length} 条补丁规则对不上新版本,需要人工改 \`packages/cli/scripts/patch-pi.js\`。**`, "");
|
|
494
|
+
if (report.slashCommands.length) {
|
|
495
|
+
lines.push(`**有 ${report.slashCommands.length} 个内置斜杠命令和 \`packages/cli/src/pi-ui-text.ts\` 的文案表对不上,需要人工补表。**`, "");
|
|
496
|
+
}
|
|
497
|
+
lines.push("### 依赖版本", "", "| 包 | 旧 | 新 |", "|---|---|---|");
|
|
498
|
+
for (const c of report.packages) lines.push(`| \`${c.name}\` | ${c.from ?? "(新增)"} | ${c.to} |`);
|
|
499
|
+
lines.push("");
|
|
500
|
+
if (report.patches?.length) {
|
|
501
|
+
lines.push("### 重新生成的 pnpm 补丁", "");
|
|
502
|
+
for (const p of report.patches) lines.push(`- \`patches/${p.file}\`:${p.empty ? "无改动(不再需要这份补丁)" : `${p.hunks} 个 hunk`}`);
|
|
503
|
+
lines.push("");
|
|
504
|
+
}
|
|
505
|
+
lines.push("### 补丁规则(`patch-pi.js`,对照新版原始文件)", "");
|
|
506
|
+
lines.push(`共 ${r.total} 条:命中 ${r.hit},已含 ${r.applied},命中次数不对 ${r.count},失配 ${r.missing}。`, "");
|
|
507
|
+
if (r.problems.length) {
|
|
508
|
+
lines.push(...fenced(r.problems.map((p) => `${STATUS_LABEL[p.state]} ${p.id} ${p.file} ${q(p.from)}`)), "");
|
|
509
|
+
}
|
|
510
|
+
lines.push("### 斜杠命令文案表(`src/pi-ui-text.ts`,由 u1s1 扩展翻译)", "");
|
|
511
|
+
if (report.slashCommands.length) {
|
|
512
|
+
const out = report.slashCommands.map((d) =>
|
|
513
|
+
[`${DRIFT_LABEL[d.kind]} /${d.name}`, d.ours !== undefined && ` 表里:${q(d.ours)}`, d.upstream !== undefined && ` 新版:${q(d.upstream)}`]
|
|
514
|
+
.filter(Boolean)
|
|
515
|
+
.join("\n"),
|
|
516
|
+
);
|
|
517
|
+
lines.push(...fenced(out), "");
|
|
518
|
+
} else lines.push("一致。", "");
|
|
519
|
+
lines.push(`### 新版新增、补丁和扩展都没覆盖的界面文字(${report.uiStrings.length} 条)`, "");
|
|
520
|
+
if (report.uiStrings.length) {
|
|
521
|
+
lines.push("从补丁目标文件里新出现的英文字面量自动提取,可能有少量误报;中国站会看到这些英文。", "");
|
|
522
|
+
const shown = report.uiStrings.slice(0, MAX_LIST).map((u) => `${u.file}: ${q(u.text)}`);
|
|
523
|
+
if (report.uiStrings.length > MAX_LIST) shown.push(`…另有 ${report.uiStrings.length - MAX_LIST} 条,见完整报告`);
|
|
524
|
+
lines.push(...fenced(shown), "");
|
|
525
|
+
} else lines.push("无。", "");
|
|
526
|
+
lines.push("### 上游 CHANGELOG 的 Breaking Changes", "");
|
|
527
|
+
if (report.breaking.length) {
|
|
528
|
+
const out = [];
|
|
529
|
+
for (const b of report.breaking) {
|
|
530
|
+
out.push(`[${b.package} ${b.version}]`);
|
|
531
|
+
for (const item of b.items) out.push(`- ${q(item)}`);
|
|
532
|
+
out.push("");
|
|
533
|
+
}
|
|
534
|
+
lines.push(...fenced(out.slice(0, -1)), "");
|
|
535
|
+
} else lines.push("无。", "");
|
|
536
|
+
if (!publicSafe && r.items.length) {
|
|
537
|
+
lines.push("<details><summary>全部规则状态</summary>", "");
|
|
538
|
+
lines.push(...fenced(r.items.map((i) => `${i.status.padEnd(16)} ${i.id} ${i.file}`)), "", "</details>", "");
|
|
539
|
+
}
|
|
540
|
+
return lines.join("\n");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// side effects (npm, git, filesystem)
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
function run(cmd, args, { cwd, env, allowFail = false, input } = {}) {
|
|
548
|
+
const res = spawnSync(cmd, args, { cwd, env: env ?? process.env, encoding: "utf8", input, maxBuffer: 256 * 1024 * 1024 });
|
|
549
|
+
if (res.error) throw new Error(`${cmd} ${args.join(" ")}: ${res.error.message}`);
|
|
550
|
+
if (res.status !== 0 && !allowFail) {
|
|
551
|
+
throw new Error(`${cmd} ${args.join(" ")} exited ${res.status}\n${(res.stderr || res.stdout || "").trim().slice(-2000)}`);
|
|
552
|
+
}
|
|
553
|
+
return res;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const npmJson = (args, opts) => {
|
|
557
|
+
const out = run("npm", [...args, "--json"], opts).stdout.trim();
|
|
558
|
+
return out ? JSON.parse(out) : undefined;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
function npmMeta(name) {
|
|
562
|
+
const m = npmJson(["view", name, "versions", "time", "dist-tags"]);
|
|
563
|
+
return { versions: m.versions, time: m.time, distTags: m["dist-tags"] };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function npmPack(spec, dest) {
|
|
567
|
+
mkdirSync(dest, { recursive: true });
|
|
568
|
+
const [info] = npmJson(["pack", spec, "--pack-destination", dest, "--ignore-scripts"], { cwd: dest });
|
|
569
|
+
return join(dest, info.filename);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Unpack an npm tarball's package/ into `into` (which must not exist yet). */
|
|
573
|
+
function unpack(tgz, into) {
|
|
574
|
+
const tmp = `${into}.unpack`;
|
|
575
|
+
mkdirSync(tmp, { recursive: true });
|
|
576
|
+
run("tar", ["-xzf", tgz, "-C", tmp]);
|
|
577
|
+
cpSync(join(tmp, "package"), into, { recursive: true });
|
|
578
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** git diff of two package trees in the format of the committed patches (a/dist/… b/dist/…, 7-digit index). */
|
|
582
|
+
function diffTrees(work, before, after) {
|
|
583
|
+
const dir = mkdtempSync(join(work, "diff-"));
|
|
584
|
+
cpSync(before, join(dir, "a"), { recursive: true });
|
|
585
|
+
cpSync(after, join(dir, "b"), { recursive: true });
|
|
586
|
+
const emptyConfig = join(dir, "empty.gitconfig");
|
|
587
|
+
writeFileSync(emptyConfig, "");
|
|
588
|
+
const env = { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: emptyConfig, LC_ALL: "C" };
|
|
589
|
+
const res = run(
|
|
590
|
+
"git",
|
|
591
|
+
["-c", "core.abbrev=7", "diff", "--no-index", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "--no-prefix", "a", "b"],
|
|
592
|
+
{ cwd: dir, env, allowFail: true },
|
|
593
|
+
);
|
|
594
|
+
if (res.status !== 0 && res.status !== 1) throw new Error(`git diff failed: ${res.stderr}`);
|
|
595
|
+
rmSync(dir, { recursive: true, force: true });
|
|
596
|
+
return res.stdout;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function parseArgs(argv) {
|
|
600
|
+
const opts = { version: "latest", pnpm: "pnpm" };
|
|
601
|
+
const value = (i) => {
|
|
602
|
+
if (argv[i + 1] === undefined || argv[i + 1].startsWith("--")) throw new Error(`${argv[i]} needs a value`);
|
|
603
|
+
return argv[i + 1];
|
|
604
|
+
};
|
|
605
|
+
for (let i = 0; i < argv.length; i++) {
|
|
606
|
+
const a = argv[i];
|
|
607
|
+
if (a === "--dry-run") opts.dryRun = true;
|
|
608
|
+
else if (a === "--force") opts.force = true;
|
|
609
|
+
else if (a === "--resolve-only") opts.resolveOnly = true;
|
|
610
|
+
else if (a === "--skip-install") opts.skipInstall = true;
|
|
611
|
+
else if (a === "--json") opts.json = true;
|
|
612
|
+
else if (a === "--version") opts.version = value(i++);
|
|
613
|
+
else if (a === "--work-dir") opts.workDir = resolve(value(i++));
|
|
614
|
+
else if (a === "--report-dir") opts.reportDir = resolve(value(i++));
|
|
615
|
+
else if (a === "--pnpm") opts.pnpm = value(i++);
|
|
616
|
+
else if (a === "-h" || a === "--help") opts.help = true;
|
|
617
|
+
else throw new Error(`unknown argument ${a}`);
|
|
618
|
+
}
|
|
619
|
+
return opts;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function main(argv) {
|
|
623
|
+
const opts = parseArgs(argv);
|
|
624
|
+
if (opts.help) {
|
|
625
|
+
console.log(readFileSync(fileURLToPath(import.meta.url), "utf8").match(/\/\*\*([\s\S]*?)\*\//)[1].replace(/^ \* ?/gm, ""));
|
|
626
|
+
return 0;
|
|
627
|
+
}
|
|
628
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
629
|
+
const cliRoot = join(here, "..");
|
|
630
|
+
const repoRoot = join(cliRoot, "..", "..");
|
|
631
|
+
const pkgPath = join(cliRoot, "package.json");
|
|
632
|
+
const wsPath = join(repoRoot, "pnpm-workspace.yaml");
|
|
633
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
634
|
+
const wsText = readFileSync(wsPath, "utf8");
|
|
635
|
+
const pins = piPins(pkg);
|
|
636
|
+
const current = pins[CODING_AGENT];
|
|
637
|
+
const minAgeMinutes = readMinimumReleaseAge(wsText);
|
|
638
|
+
|
|
639
|
+
const decision = selectVersion({ meta: npmMeta(CODING_AGENT), current, requested: opts.version, minAgeMinutes });
|
|
640
|
+
if (opts.resolveOnly) {
|
|
641
|
+
console.log(JSON.stringify({ ...decision, minAgeMinutes }));
|
|
642
|
+
return 0;
|
|
643
|
+
}
|
|
644
|
+
if (!decision.version) {
|
|
645
|
+
console.log(`没有发布满 ${minAgeMinutes} 分钟的版本可升(跳过:${decision.skippedYoung.join(", ") || "无"})`);
|
|
646
|
+
return 0;
|
|
647
|
+
}
|
|
648
|
+
if (opts.version === "latest" && decision.upToDate) {
|
|
649
|
+
console.log(`已是最新:${current}(可升的最新版 ${decision.version}${decision.skippedYoung.length ? `;未满 ${minAgeMinutes} 分钟、已跳过 ${decision.skippedYoung.join(", ")}` : ""})`);
|
|
650
|
+
return 0;
|
|
651
|
+
}
|
|
652
|
+
const target = decision.version;
|
|
653
|
+
const log = (s) => console.error(`[pi-upgrade] ${s}`);
|
|
654
|
+
log(`${current} → ${target}${decision.young ? `(发布不满 ${minAgeMinutes} 分钟)` : ""}`);
|
|
655
|
+
|
|
656
|
+
const work = opts.workDir ?? mkdtempSync(join(tmpdir(), "pi-upgrade-"));
|
|
657
|
+
mkdirSync(work, { recursive: true });
|
|
658
|
+
const reportDir = opts.reportDir ?? join(work, "report");
|
|
659
|
+
log(`工作目录 ${work}`);
|
|
660
|
+
|
|
661
|
+
// 1. versions of every pi package + undici
|
|
662
|
+
const manifests = {};
|
|
663
|
+
const fetchDeps = (name, version) => {
|
|
664
|
+
const m = npmJson(["view", `${name}@${version}`, "version", "dependencies"]);
|
|
665
|
+
if (!m || (m.version ?? m) !== version) throw new Error(`${name}@${version} is not published on npm`);
|
|
666
|
+
manifests[name] = m;
|
|
667
|
+
return m.dependencies;
|
|
668
|
+
};
|
|
669
|
+
const closure = await resolvePiClosure({ roots: Object.keys(pins).sort(), target, fetchDeps });
|
|
670
|
+
const undiciSpec = manifests[CODING_AGENT]?.dependencies?.undici;
|
|
671
|
+
const undici = undiciSpec && STABLE.test(undiciSpec) ? undiciSpec : undefined;
|
|
672
|
+
if (undiciSpec && !undici) log(`pi-coding-agent 的 undici 是范围 ${undiciSpec},不同步`);
|
|
673
|
+
const { pkg: nextPkg, changes } = planPackageJson(pkg, closure, undici);
|
|
674
|
+
|
|
675
|
+
// 2. pristine tarballs: new (all pi packages, for CHANGELOGs), old pi-coding-agent (for the UI diff)
|
|
676
|
+
const tgz = {};
|
|
677
|
+
for (const [name, version] of Object.entries(closure)) tgz[name] = npmPack(`${name}@${version}`, join(work, "tgz"));
|
|
678
|
+
const oldTgz = current && current !== target ? npmPack(`${CODING_AGENT}@${current}`, join(work, "tgz-old")) : undefined;
|
|
679
|
+
|
|
680
|
+
const orig = join(work, "orig");
|
|
681
|
+
const fixture = join(work, "fixture");
|
|
682
|
+
rmSync(orig, { recursive: true, force: true });
|
|
683
|
+
rmSync(fixture, { recursive: true, force: true });
|
|
684
|
+
for (const name of PATCHED) {
|
|
685
|
+
unpack(tgz[name], join(orig, name));
|
|
686
|
+
unpack(tgz[name], join(fixture, "node_modules", name));
|
|
687
|
+
}
|
|
688
|
+
const oldCa = join(work, "old", CODING_AGENT);
|
|
689
|
+
rmSync(join(work, "old"), { recursive: true, force: true });
|
|
690
|
+
if (oldTgz) unpack(oldTgz, oldCa);
|
|
691
|
+
|
|
692
|
+
// 3. patch-pi.js on the fixture (exactly what npm's postinstall does), then diff = the pnpm patches
|
|
693
|
+
mkdirSync(join(fixture, "scripts"), { recursive: true });
|
|
694
|
+
writeFileSync(join(fixture, "package.json"), '{"type":"module"}\n');
|
|
695
|
+
cpSync(join(here, "patch-pi.js"), join(fixture, "scripts", "patch-pi.js"));
|
|
696
|
+
const patchLog = run(process.execPath, [join(fixture, "scripts", "patch-pi.js")], {
|
|
697
|
+
cwd: fixture,
|
|
698
|
+
env: { ...process.env, U1S1_PATCH_PI_SCAN_ROOT: fixture },
|
|
699
|
+
}).stdout.trim();
|
|
700
|
+
if (patchLog) log(`patch-pi.js: ${patchLog.replace(/\n/g, " | ")}`);
|
|
701
|
+
|
|
702
|
+
const patches = PATCHED.map((name) => {
|
|
703
|
+
const text = diffTrees(work, join(orig, name), join(fixture, "node_modules", name));
|
|
704
|
+
const file = patchFileName(name, closure[name]);
|
|
705
|
+
const path = join(work, "patches", file);
|
|
706
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
707
|
+
writeFileSync(path, text);
|
|
708
|
+
return { name, file, path, text, empty: text === "", hunks: (text.match(/^@@ /gm) ?? []).length };
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// 4. rule status against the pristine new sources (pi-patch-report does the classification)
|
|
712
|
+
const reportArgs = [join(here, "pi-patch-report.mjs"), "--json", "--pi", join(orig, CODING_AGENT), "--tui", join(orig, TUI), "--patch-pi", join(here, "patch-pi.js")];
|
|
713
|
+
for (const p of patches) reportArgs.push("--patch", p.path);
|
|
714
|
+
// pi-patch-report calls process.exit right after printing; a pipe would cut the JSON off at 64 KB, a file does not
|
|
715
|
+
const rrOut = join(work, "pi-patch-report.json");
|
|
716
|
+
const fd = openSync(rrOut, "w");
|
|
717
|
+
const rr = spawnSync(process.execPath, reportArgs, { stdio: ["ignore", fd, "pipe"], encoding: "utf8" });
|
|
718
|
+
closeSync(fd);
|
|
719
|
+
if (rr.status !== 0 && rr.status !== 1) throw new Error(`pi-patch-report failed: ${rr.stderr || rr.error?.message}`);
|
|
720
|
+
const ruleResults = JSON.parse(readFileSync(rrOut, "utf8")).rules;
|
|
721
|
+
|
|
722
|
+
// 5. slash-command descriptions: u1s1's extension table vs the new BUILTIN_SLASH_COMMANDS
|
|
723
|
+
const readOr = (p) => (existsSync(p) ? readFileSync(p, "utf8") : "");
|
|
724
|
+
const ours = parseUiTextTable(readOr(join(cliRoot, "src", "pi-ui-text.ts")));
|
|
725
|
+
const builtin = parseSlashCommands(readOr(join(orig, CODING_AGENT, SLASH_COMMANDS_FILE)));
|
|
726
|
+
if (!ours || !builtin) throw new Error(`cannot read ${!ours ? "BUILTIN_COMMAND_TEXT in src/pi-ui-text.ts" : `BUILTIN_SLASH_COMMANDS in ${SLASH_COMMANDS_FILE}`}`);
|
|
727
|
+
const slashCommands = slashCommandDrift(ours, builtin);
|
|
728
|
+
|
|
729
|
+
// 6. new UI strings in the rule target files, old vs new pi-coding-agent
|
|
730
|
+
const uiFiles = {};
|
|
731
|
+
for (const file of new Set(ruleResults.map((r) => r.file).filter((f) => f.startsWith("dist/")))) {
|
|
732
|
+
uiFiles[file] = { old: readOr(join(oldTgz ? oldCa : join(orig, CODING_AGENT), file)), new: readOr(join(orig, CODING_AGENT, file)) };
|
|
733
|
+
}
|
|
734
|
+
const ruleFroms = jsStringLiterals(readFileSync(join(here, "patch-pi.js"), "utf8"));
|
|
735
|
+
const uiStrings = newUiStrings(uiFiles, ruleFroms);
|
|
736
|
+
|
|
737
|
+
// 7. Breaking sections of every CHANGELOG shipped in the new tarballs
|
|
738
|
+
const breaking = [];
|
|
739
|
+
const seen = new Set();
|
|
740
|
+
for (const [name, file] of Object.entries(tgz)) {
|
|
741
|
+
const dir = join(work, "changelog", name);
|
|
742
|
+
rmSync(dir, { recursive: true, force: true });
|
|
743
|
+
unpack(file, dir);
|
|
744
|
+
const changelog = join(dir, "CHANGELOG.md");
|
|
745
|
+
if (!existsSync(changelog) || !current) continue;
|
|
746
|
+
for (const entry of extractBreaking(readFileSync(changelog, "utf8"), current, closure[name])) {
|
|
747
|
+
const items = entry.items.filter((i) => !seen.has(i));
|
|
748
|
+
items.forEach((i) => seen.add(i));
|
|
749
|
+
if (items.length) breaking.push({ package: name.slice(PI_SCOPE.length), version: entry.version, items });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const report = buildReport({
|
|
754
|
+
current,
|
|
755
|
+
target,
|
|
756
|
+
packages: changes,
|
|
757
|
+
undici,
|
|
758
|
+
rules: ruleResults,
|
|
759
|
+
slashCommands,
|
|
760
|
+
uiStrings,
|
|
761
|
+
breaking,
|
|
762
|
+
patches: patches.map(({ file, empty, hunks }) => ({ file, empty, hunks })),
|
|
763
|
+
});
|
|
764
|
+
mkdirSync(reportDir, { recursive: true });
|
|
765
|
+
writeFileSync(join(reportDir, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
766
|
+
writeFileSync(join(reportDir, "report.md"), renderMarkdown(report));
|
|
767
|
+
writeFileSync(join(reportDir, "report.public.md"), renderMarkdown(report, { publicSafe: true }));
|
|
768
|
+
|
|
769
|
+
if (opts.json) console.log(JSON.stringify(report, null, 2));
|
|
770
|
+
else console.log(renderMarkdown(report));
|
|
771
|
+
log(`报告:${reportDir}/report.{json,md,public.md}`);
|
|
772
|
+
|
|
773
|
+
const exitCode = report.ok ? 0 : 1;
|
|
774
|
+
if (!report.ok && !opts.force) {
|
|
775
|
+
log(`补丁规则 ${report.rules.problems.length} 条、斜杠命令文案表 ${report.slashCommands.length} 处对不上,不改工作区(--force 可照写)`);
|
|
776
|
+
return exitCode;
|
|
777
|
+
}
|
|
778
|
+
if (opts.dryRun) {
|
|
779
|
+
log("--dry-run:不改工作区");
|
|
780
|
+
return exitCode;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// 8. write the working tree
|
|
784
|
+
writeFileSync(pkgPath, `${JSON.stringify(nextPkg, null, 2)}\n`);
|
|
785
|
+
const young = Object.entries(closure)
|
|
786
|
+
.filter(([name, version]) => {
|
|
787
|
+
const age = npmMeta(name).time?.[version];
|
|
788
|
+
return !age || Date.now() - Date.parse(age) < minAgeMinutes * 60_000;
|
|
789
|
+
})
|
|
790
|
+
.map(([name, version]) => `${name}@${version}`);
|
|
791
|
+
writeFileSync(wsPath, planWorkspaceYaml(wsText, { versions: closure, exclude: young, dropPatched: patches.filter((p) => p.empty).map((p) => p.name) }));
|
|
792
|
+
const patchesDir = join(repoRoot, "patches");
|
|
793
|
+
for (const name of PATCHED) {
|
|
794
|
+
const prefix = `${name.replace("/", "__")}@`;
|
|
795
|
+
for (const f of readdirSync(patchesDir)) if (f.startsWith(prefix) && f.endsWith(".patch")) unlinkSync(join(patchesDir, f));
|
|
796
|
+
}
|
|
797
|
+
for (const p of patches) if (!p.empty) writeFileSync(join(patchesDir, p.file), p.text);
|
|
798
|
+
log("已改 package.json、pnpm-workspace.yaml、patches/");
|
|
799
|
+
|
|
800
|
+
const npmDir = join(work, "npm-lock");
|
|
801
|
+
rmSync(npmDir, { recursive: true, force: true });
|
|
802
|
+
mkdirSync(npmDir, { recursive: true });
|
|
803
|
+
cpSync(pkgPath, join(npmDir, "package.json"));
|
|
804
|
+
cpSync(join(cliRoot, "package-lock.json"), join(npmDir, "package-lock.json"));
|
|
805
|
+
run("npm", ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: npmDir });
|
|
806
|
+
cpSync(join(npmDir, "package-lock.json"), join(cliRoot, "package-lock.json"));
|
|
807
|
+
log("已更新 packages/cli/package-lock.json");
|
|
808
|
+
|
|
809
|
+
if (!opts.skipInstall) {
|
|
810
|
+
const pn = opts.pnpm.split(" ");
|
|
811
|
+
const res = spawnSync(pn[0], [...pn.slice(1), "install", "--no-frozen-lockfile"], { cwd: repoRoot, stdio: ["ignore", process.stderr, process.stderr] });
|
|
812
|
+
if (res.status !== 0) throw new Error(`pnpm install exited ${res.status ?? res.error?.message}`);
|
|
813
|
+
log("已更新 pnpm-lock.yaml(pnpm install)");
|
|
814
|
+
}
|
|
815
|
+
return exitCode;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
819
|
+
// exitCode, not exit(): a large report written to a pipe would be cut off by an immediate exit
|
|
820
|
+
main(process.argv.slice(2)).then(
|
|
821
|
+
(code) => {
|
|
822
|
+
process.exitCode = code;
|
|
823
|
+
},
|
|
824
|
+
(err) => {
|
|
825
|
+
console.error(`[pi-upgrade] 失败:${err?.stack ?? err}`);
|
|
826
|
+
process.exitCode = 2;
|
|
827
|
+
},
|
|
828
|
+
);
|
|
829
|
+
}
|