peaks-loop 4.0.38 → 4.0.40
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/CHANGELOG.md +47 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/code-review-commands.js +43 -1
- package/dist/cli/commands/code-runtime-commands.js +16 -4
- package/dist/cli/commands/core/skill-command.d.ts +44 -0
- package/dist/cli/commands/core/skill-command.js +67 -3
- package/dist/cli/commands/dispatch-commands.js +42 -12
- package/dist/services/code/auto-compact-orchestrator.js +2 -2
- package/dist/services/context/auto-compact-dispatcher.js +3 -1
- package/dist/services/hooks/auto-compact-hook-install.d.ts +14 -1
- package/dist/services/hooks/auto-compact-hook-install.js +39 -15
- package/dist/services/lint/detect-ocr-18.d.ts +7 -1
- package/dist/services/lint/detect-ocr-18.js +81 -8
- package/dist/services/lint/ocr-18-acquire.d.ts +113 -0
- package/dist/services/lint/ocr-18-acquire.js +350 -0
- package/dist/services/lint/ocr-multilang-adapter.js +3 -0
- package/dist/services/web/playwright-loader.js +5 -24
- package/dist/services/workflow/provision-dispatch-node.d.ts +42 -0
- package/dist/services/workflow/provision-dispatch-node.js +66 -0
- package/dist/services/workspace/workspace-claude-settings-materializer.js +65 -6
- package/dist/shared/npm-cache.d.ts +2 -0
- package/dist/shared/npm-cache.js +31 -0
- package/package.json +5 -5
- package/skills/bee/peaks-perf-audit/SKILL.md +31 -3
- package/skills/bee/peaks-prd/SKILL.md +28 -0
- package/skills/bee/peaks-qa/SKILL.md +28 -0
- package/skills/bee/peaks-rd/SKILL.md +28 -0
- package/skills/bee/peaks-reviewer/SKILL.md +28 -0
- package/skills/bee/peaks-sc/SKILL.md +28 -0
- package/skills/bee/peaks-security-audit/SKILL.md +31 -3
- package/skills/bee/peaks-txt/SKILL.md +28 -0
- package/skills/bee/peaks-ui/SKILL.md +28 -0
- package/skills/peaks-audit/SKILL.md +28 -0
- package/skills/peaks-code/SKILL.md +28 -0
- package/skills/peaks-content/SKILL.md +28 -0
- package/skills/peaks-doctor/SKILL.md +28 -0
- package/skills/peaks-final-review/SKILL.md +28 -0
- package/skills/peaks-ide/SKILL.md +28 -0
- package/skills/peaks-issue-fix-orchestrator/SKILL.md +29 -1
- package/skills/peaks-resume/SKILL.md +28 -0
- package/skills/peaks-slice-decompose/SKILL.md +28 -0
- package/skills/peaks-solo/SKILL.md +28 -0
- package/skills/peaks-sop/SKILL.md +29 -1
- package/skills/peaks-status/SKILL.md +28 -0
- package/skills/peaks-test/SKILL.md +28 -0
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* 5-state OCR 1.8.x detect. Mirrors the ECC detect shape.
|
|
3
3
|
*/
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
7
|
import { resolveNpxInvocation } from './npx-resolver.js';
|
|
6
8
|
import { OCR_18_PACKAGE } from './ocr-multilang-adapter.js';
|
|
9
|
+
import { npmExecCacheRoots } from '../../shared/npm-cache.js';
|
|
7
10
|
/** Named code for "npx itself could not be launched" — distinct from "npx is absent". */
|
|
8
11
|
export const NPX_PROBE_UNRESOLVED_CODE = 'NPX_PROBE_UNRESOLVED';
|
|
9
12
|
// 2026-09-10: `npx` on Windows is an `npx.cmd` shim, which Node refuses to spawn
|
|
@@ -14,7 +17,9 @@ export const NPX_PROBE_UNRESOLVED_CODE = 'NPX_PROBE_UNRESOLVED';
|
|
|
14
17
|
// than being collapsed into "absent".
|
|
15
18
|
function probeNpx() {
|
|
16
19
|
const { command, args, baseEnv } = resolveNpxInvocation(['--version']);
|
|
17
|
-
|
|
20
|
+
// `windowsHide` on every spawn (repo convention): without it this probe pops
|
|
21
|
+
// a console window on the user's desktop.
|
|
22
|
+
const probe = spawnSync(command, args, { encoding: 'utf8', windowsHide: true, env: baseEnv });
|
|
18
23
|
if (probe.status === 0)
|
|
19
24
|
return { available: true };
|
|
20
25
|
const error = probe.error;
|
|
@@ -27,12 +32,77 @@ function probeNpx() {
|
|
|
27
32
|
}
|
|
28
33
|
return { available: false, reason: 'probe-failed', detail: `npx --version exited ${probe.status ?? 'null'}` };
|
|
29
34
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
return
|
|
35
|
+
/** The scoped directory and exact version a `name@version` spec names. */
|
|
36
|
+
function splitSpec(spec) {
|
|
37
|
+
const at = spec.lastIndexOf('@');
|
|
38
|
+
return { dir: spec.slice(0, at), version: spec.slice(at + 1) };
|
|
34
39
|
}
|
|
35
|
-
|
|
40
|
+
/** The version the `package.json` at `modulesRoot/<dir>` declares, or `null`. */
|
|
41
|
+
function installedVersion(modulesRoot, dir) {
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(readFileSync(join(modulesRoot, dir, 'package.json'), 'utf8'));
|
|
44
|
+
return typeof parsed.version === 'string' ? parsed.version : null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Nothing installed under this root is the normal case for most of them.
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function safeReaddir(dir) {
|
|
52
|
+
try {
|
|
53
|
+
return readdirSync(dir);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// No exec cache at this location is the normal case on a fresh machine.
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Every `node_modules` tree npm consults BEFORE it installs `--package <spec>`.
|
|
62
|
+
*
|
|
63
|
+
* 2026-09-11: this probe used to run `npx --package <pkg> -- ocr version`, which
|
|
64
|
+
* INSTALLED the package when it was not already resolvable — a network fetch, a
|
|
65
|
+
* write into the npm cache, and a console window titled `npm i …` on the user's
|
|
66
|
+
* desktop for seconds. A probe may look; it may not fetch. Presence is decided
|
|
67
|
+
* the way npm decides it (`libnpmexec/lib/index.js`, npm 11.9.0, read on this
|
|
68
|
+
* machine): the LOCAL tree first, matched by `node.pkgid === spec.raw` (name AND
|
|
69
|
+
* exact version), then the `_npx` cache entry; only a double miss installs.
|
|
70
|
+
*/
|
|
71
|
+
function resolvableModuleRoots(cwd) {
|
|
72
|
+
const roots = [];
|
|
73
|
+
// The local tree: npm anchors on the project root above the cwd and Node
|
|
74
|
+
// resolves modules by walking up, so an ancestor's `node_modules` counts too.
|
|
75
|
+
let dir = resolve(cwd);
|
|
76
|
+
for (;;) {
|
|
77
|
+
roots.push(join(dir, 'node_modules'));
|
|
78
|
+
const parent = dirname(dir);
|
|
79
|
+
if (parent === dir)
|
|
80
|
+
break;
|
|
81
|
+
dir = parent;
|
|
82
|
+
}
|
|
83
|
+
for (const cacheRoot of npmExecCacheRoots()) {
|
|
84
|
+
for (const entry of safeReaddir(cacheRoot)) {
|
|
85
|
+
roots.push(join(cacheRoot, entry, 'node_modules'));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return roots;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether the pinned package is already resolvable. LOOKS, never fetches: it
|
|
92
|
+
* spawns nothing at all, so there is no install and no window to hide. The
|
|
93
|
+
* version must match the pin exactly — any other version is one npm exec would
|
|
94
|
+
* install over, which is the write this probe exists to avoid.
|
|
95
|
+
*/
|
|
96
|
+
function probeOcr18(cwd) {
|
|
97
|
+
const { dir, version } = splitSpec(OCR_18_PACKAGE);
|
|
98
|
+
return resolvableModuleRoots(cwd).some((modulesRoot) => installedVersion(modulesRoot, dir) === version);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `cwd` is where the local npm tree is looked for (defaults to the process
|
|
102
|
+
* cwd, like `runOcr18`'s own `cwd`); the npx cache roots are the machine's.
|
|
103
|
+
*/
|
|
104
|
+
export function detectOcr18(options = {}) {
|
|
105
|
+
const cwd = options.cwd ?? process.cwd();
|
|
36
106
|
const probe = probeNpx();
|
|
37
107
|
if (!probe.available) {
|
|
38
108
|
if (probe.reason === 'not-launchable') {
|
|
@@ -52,13 +122,16 @@ export function detectOcr18() {
|
|
|
52
122
|
nextActions: ['Install Node.js ≥ 20 with npm to enable `npx --package`.']
|
|
53
123
|
};
|
|
54
124
|
}
|
|
55
|
-
if (!probeOcr18()) {
|
|
125
|
+
if (!probeOcr18(cwd)) {
|
|
56
126
|
return {
|
|
57
127
|
state: 'ocr18-missing',
|
|
58
128
|
npxAvailable: true,
|
|
59
129
|
package: OCR_18_PACKAGE,
|
|
60
130
|
warnings: [`could not resolve ${OCR_18_PACKAGE}`],
|
|
61
|
-
|
|
131
|
+
// 2026-09-11: names the explicit acquisition verb rather than an `npm i`
|
|
132
|
+
// for a human to hand-type. Acquiring is its own VISIBLE step; this probe
|
|
133
|
+
// stays read-only and never performs it (see `ocr-18-acquire.ts`).
|
|
134
|
+
nextActions: ['Run `peaks code-review acquire-ocr-18` to fetch the reviewer.']
|
|
62
135
|
};
|
|
63
136
|
}
|
|
64
137
|
return {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type ShellProbeRunner } from '../env/shell-probe.js';
|
|
2
|
+
/**
|
|
3
|
+
* The network wait, named BEFORE it starts and never skipped silently.
|
|
4
|
+
*
|
|
5
|
+
* The size is not quoted as a figure the way `INSTALL_SIZE_WARNING` quotes
|
|
6
|
+
* chromium's 704 MiB: this package's download size is not measured here, and a
|
|
7
|
+
* guessed number in a warning is worse than no number. What the user needs to
|
|
8
|
+
* know before the block is that this touches the network and is one-time.
|
|
9
|
+
*/
|
|
10
|
+
export declare const ACQUIRE_NETWORK_WARNING = "fetching @alibaba-group/open-code-review@1.8.9 from the npm registry (needs network, one time)";
|
|
11
|
+
/** Which shell the acquisition runs through, in preference order. */
|
|
12
|
+
export type AcquireShellKind = 'bash' | 'powershell' | 'direct';
|
|
13
|
+
export interface AcquireShell {
|
|
14
|
+
readonly kind: AcquireShellKind;
|
|
15
|
+
/** Absolute path to the shell, or `null` for `direct` (nothing is spawned). */
|
|
16
|
+
readonly path: string | null;
|
|
17
|
+
/**
|
|
18
|
+
* ALWAYS populated: which shell was chosen, and when it is not the first
|
|
19
|
+
* choice, why. A caller that drops this makes the choice silent again.
|
|
20
|
+
*/
|
|
21
|
+
readonly note: string;
|
|
22
|
+
}
|
|
23
|
+
export interface AcquireOutcome {
|
|
24
|
+
readonly ok: boolean;
|
|
25
|
+
/** `''` on success, else `OCR18_ACQUIRE_BUSY` | `OCR18_ACQUIRE_FAILED` | `OCR18_ACQUIRE_TIMEOUT`. */
|
|
26
|
+
readonly code: string;
|
|
27
|
+
readonly message: string;
|
|
28
|
+
/** Which shell ran it, and whether that was the preferred one. */
|
|
29
|
+
readonly shell: AcquireShell;
|
|
30
|
+
readonly durationMs: number;
|
|
31
|
+
/** `[ACQUIRE_NETWORK_WARNING]` whenever this call actually spawned the installer. */
|
|
32
|
+
readonly warnings: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `<homedir>/.peaks/ocr/install.lock` — the OCR acquisition lock.
|
|
36
|
+
*
|
|
37
|
+
* MACHINE-GLOBAL, like the web install lock and for the same reason: what it
|
|
38
|
+
* guards is npm's per-user exec cache (`~/.npm/_npx`, `%LOCALAPPDATA%
|
|
39
|
+
* \npm-cache\_npx`), which every project and every session of this user
|
|
40
|
+
* shares. A per-project lock would serialize nothing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function ocrAcquireLockPath(): string;
|
|
43
|
+
/**
|
|
44
|
+
* Take the OCR acquire lock (O_EXCL create), reclaiming a STALE one — dead
|
|
45
|
+
* owner, unreadable body, or older than `ACQUIRE_LOCK_STALE_MS`. `false` when
|
|
46
|
+
* another process holds a live lock.
|
|
47
|
+
*
|
|
48
|
+
* Same protocol as `web-install-service.acquireInstallLock`, deliberately
|
|
49
|
+
* including the R15 detail that **the reclaim is a rename, not an unlink**:
|
|
50
|
+
* `O_EXCL` serializes the create only if the removal before it cannot be
|
|
51
|
+
* replayed against a fresh file — `A unlink → A create → B unlink (A's FRESH
|
|
52
|
+
* lock) → B create` leaves both holders. A file can only be moved once, so
|
|
53
|
+
* exactly one reclaimer wins and the create that follows decides.
|
|
54
|
+
*
|
|
55
|
+
* The two copies are not shared because the web module's lock is bound to its
|
|
56
|
+
* own artifact root and this change does not reach into it. If a third
|
|
57
|
+
* acquirer appears, lift the pair into a shared helper with a lock-path
|
|
58
|
+
* parameter rather than writing a third copy.
|
|
59
|
+
*/
|
|
60
|
+
export declare function acquireOcrLock(): boolean;
|
|
61
|
+
/** Release the OCR acquire lock, but only when this process is its owner. */
|
|
62
|
+
export declare function releaseOcrLock(): void;
|
|
63
|
+
export interface ResolveAcquireShellOptions {
|
|
64
|
+
/** Override `process.platform` (tests simulate a host without Git Bash). */
|
|
65
|
+
readonly platform?: NodeJS.Platform;
|
|
66
|
+
/** Override `process.env` (the `PEAKS_GIT_BASH` pin and `SystemRoot` live here). */
|
|
67
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
68
|
+
/**
|
|
69
|
+
* Test seam for BOTH lookups: it answers the Git Bash default paths inside
|
|
70
|
+
* `probeShell` and the PowerShell path below, so one injected map can
|
|
71
|
+
* simulate "Windows with no Git Bash" on a host that has it.
|
|
72
|
+
*/
|
|
73
|
+
readonly probeFile?: (absPath: string) => boolean | Promise<boolean>;
|
|
74
|
+
/** Test seam forwarded to `probeShell` for its `where bash` PATH sweep. */
|
|
75
|
+
readonly runner?: ShellProbeRunner;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the shell the acquisition runs through: Git Bash → PowerShell →
|
|
79
|
+
* no shell. Never throws, and never returns a shell without a `note`.
|
|
80
|
+
*/
|
|
81
|
+
export declare function resolveAcquireShell(options?: ResolveAcquireShellOptions): Promise<AcquireShell>;
|
|
82
|
+
/**
|
|
83
|
+
* The exact argv of the one-time acquisition. Exported so a unit test can
|
|
84
|
+
* assert the arguments WITHOUT spawning anything — an install is not a test's
|
|
85
|
+
* side effect (same rule as `installCommandLine`).
|
|
86
|
+
*
|
|
87
|
+
* `--yes` is what makes this non-interactive: without it `npx` stops to ask
|
|
88
|
+
* "Ok to proceed?" on a machine that has not cached the package, which is the
|
|
89
|
+
* hang this verb exists to avoid.
|
|
90
|
+
*/
|
|
91
|
+
export declare function acquireCommandArgs(): readonly string[];
|
|
92
|
+
/**
|
|
93
|
+
* The same argv as ONE shell line, for the bash / PowerShell branches.
|
|
94
|
+
*
|
|
95
|
+
* Every token is double-quoted, and that is load-bearing rather than
|
|
96
|
+
* cosmetic: a bare `@alibaba-group/…` is PowerShell's array syntax. Every
|
|
97
|
+
* token here is a module constant built from `OCR_18_PACKAGE`, so quoting is
|
|
98
|
+
* sufficient — no token carries a `"`, `\`, `$` or backtick that the two
|
|
99
|
+
* shells would escape differently, and none is caller-supplied.
|
|
100
|
+
*/
|
|
101
|
+
export declare function acquireCommandLine(): string;
|
|
102
|
+
export interface AcquireOcr18Options {
|
|
103
|
+
/** True when the caller prints a JSON envelope to stdout and must not be handed npm's. */
|
|
104
|
+
readonly asJson?: boolean;
|
|
105
|
+
/** Pre-resolved shell. Production callers omit it; tests inject one. */
|
|
106
|
+
readonly shell?: AcquireShell;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Run the acquisition once, under the lock. Returns an outcome on every path —
|
|
110
|
+
* a held lock, a non-zero exit, a timeout, a thrown spawn — so no caller ever
|
|
111
|
+
* sees an exception from here, and never a silent no-op.
|
|
112
|
+
*/
|
|
113
|
+
export declare function acquireOcr18(options?: AcquireOcr18Options): Promise<AcquireOutcome>;
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OCR 1.8.x acquisition — the ONE step that is allowed to install the reviewer.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists as its own module
|
|
5
|
+
*
|
|
6
|
+
* `detect-ocr-18` is a *read-only probe*, but it used to produce its answer by
|
|
7
|
+
* running `npx --package <pin> -- ocr version`, which INSTALLS the package when
|
|
8
|
+
* it is not already cached. Dogfooding 4.0.38: a window titled `npm i @…`
|
|
9
|
+
* appeared on the user's desktop, opened by a command whose own help text says
|
|
10
|
+
* "Read-only probe". The probe no longer spawns at all (see
|
|
11
|
+
* `detect-ocr-18.ts`); the install it was performing as a side effect lives
|
|
12
|
+
* HERE, where it is explicit, named, and on the human's channel — the same
|
|
13
|
+
* split `peaks web status` (probe) / `peaks web install` (acquire) already
|
|
14
|
+
* uses, and whose discipline this module mirrors rather than reinvents.
|
|
15
|
+
*
|
|
16
|
+
* ## Visibility is the requirement, not a nicety
|
|
17
|
+
*
|
|
18
|
+
* The user's words: *"最起码可以看到进度"* — at minimum the progress must be
|
|
19
|
+
* visible. So the installer's stdio is INHERITED, never piped-and-dropped and
|
|
20
|
+
* never detached: npm's own progress lands on the terminal the caller is
|
|
21
|
+
* already watching. Two consequences, both deliberate:
|
|
22
|
+
*
|
|
23
|
+
* - **`windowsHide: true` is not the opposite of visibility.** It stops
|
|
24
|
+
* Windows from allocating a NEW console window; inherited stdio keeps
|
|
25
|
+
* writing to the console that already exists. The window in the bug report
|
|
26
|
+
* was *new*; the progress is *inherited*. `detached: true` is deliberately
|
|
27
|
+
* absent — a silent background install is exactly what was rejected.
|
|
28
|
+
* - **In `--json` mode the child's STDOUT is dropped** (`'ignore'`) so npm's
|
|
29
|
+
* output cannot corrupt the JSON envelope `printResult` writes to stdout,
|
|
30
|
+
* while its STDERR stays inherited — npm puts progress and warnings there,
|
|
31
|
+
* so the wait is still explained on screen. Human mode inherits both.
|
|
32
|
+
*
|
|
33
|
+
* ## Shell preference — and why this is NOT `resolveHookShell`
|
|
34
|
+
*
|
|
35
|
+
* Order: **Git Bash, then PowerShell, then a plain spawn with no shell.**
|
|
36
|
+
*
|
|
37
|
+
* Slice `4637baa8` pinned the *hook* shell to PowerShell on Windows, and it is
|
|
38
|
+
* still right: a hook fires on every Bash tool call, and MSYS2's bash
|
|
39
|
+
* force-allocates its own console window, so the hook must avoid bash. An
|
|
40
|
+
* ACQUISITION is the opposite case — it runs once, takes seconds, and the user
|
|
41
|
+
* wants to watch it.
|
|
42
|
+
*
|
|
43
|
+
* **The two preferences are not a contradiction waiting to be "reconciled" —
|
|
44
|
+
* they answer two different questions** ("run constantly, be invisible" vs
|
|
45
|
+
* "run once, be visible"). Changing `resolveHookShell` to this order would put
|
|
46
|
+
* a console window back on every tool call; changing this order to match the
|
|
47
|
+
* hook would take away the shell the user explicitly asked for
|
|
48
|
+
* (*"优先使用git bash没有才是powershell"*). Leave both alone.
|
|
49
|
+
*
|
|
50
|
+
* The Git Bash half is NOT re-implemented: `probeShell`
|
|
51
|
+
* (`src/services/env/shell-probe.ts`) already owns the lookup (the
|
|
52
|
+
* `PEAKS_GIT_BASH` pin, the default install paths, the `where bash` PATH
|
|
53
|
+
* sweep) and already refuses to fall back silently. This module adds the
|
|
54
|
+
* PowerShell step and keeps `probeShell`'s refusal visible: the resolution
|
|
55
|
+
* carries a `note` naming the shell that was used and, when it is not the
|
|
56
|
+
* first choice, why it is not. Silently using a different shell is how this
|
|
57
|
+
* confusion started.
|
|
58
|
+
*/
|
|
59
|
+
import { spawnSync } from 'node:child_process';
|
|
60
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
61
|
+
import { homedir } from 'node:os';
|
|
62
|
+
import { dirname, join } from 'node:path';
|
|
63
|
+
import { getErrorMessage } from 'peaks-loop-shared/result';
|
|
64
|
+
import { probeShell } from '../env/shell-probe.js';
|
|
65
|
+
import { isProcessAlive } from '../web/daemon-registry.js';
|
|
66
|
+
import { resolveNpxInvocation } from './npx-resolver.js';
|
|
67
|
+
import { OCR_18_PACKAGE } from './ocr-multilang-adapter.js';
|
|
68
|
+
/**
|
|
69
|
+
* The network wait, named BEFORE it starts and never skipped silently.
|
|
70
|
+
*
|
|
71
|
+
* The size is not quoted as a figure the way `INSTALL_SIZE_WARNING` quotes
|
|
72
|
+
* chromium's 704 MiB: this package's download size is not measured here, and a
|
|
73
|
+
* guessed number in a warning is worse than no number. What the user needs to
|
|
74
|
+
* know before the block is that this touches the network and is one-time.
|
|
75
|
+
*/
|
|
76
|
+
export const ACQUIRE_NETWORK_WARNING = `fetching ${OCR_18_PACKAGE} from the npm registry (needs network, one time)`;
|
|
77
|
+
/**
|
|
78
|
+
* A held lock older than this is reclaimed even if its owner is alive.
|
|
79
|
+
* Deliberately longer than `ACQUIRE_TIMEOUT_MS`, so a timed-out install still
|
|
80
|
+
* owns its lock while it winds down.
|
|
81
|
+
*/
|
|
82
|
+
const ACQUIRE_LOCK_STALE_MS = 30 * 60_000;
|
|
83
|
+
/**
|
|
84
|
+
* A blocking `spawnSync` needs a ceiling or "never hang" is not true. The
|
|
85
|
+
* package is far smaller than chromium, so this is the web installer's 20 min
|
|
86
|
+
* cut down; the child is killed at this point and a partial fetch is recovered
|
|
87
|
+
* by simply running the verb again (npm exec is idempotent).
|
|
88
|
+
*/
|
|
89
|
+
const ACQUIRE_TIMEOUT_MS = 10 * 60_000;
|
|
90
|
+
/** 0600 mirrors the web install lock's reasoning: ours, and nobody else's. */
|
|
91
|
+
const LOCK_FILE_MODE = 0o600;
|
|
92
|
+
/**
|
|
93
|
+
* `<homedir>/.peaks/ocr/install.lock` — the OCR acquisition lock.
|
|
94
|
+
*
|
|
95
|
+
* MACHINE-GLOBAL, like the web install lock and for the same reason: what it
|
|
96
|
+
* guards is npm's per-user exec cache (`~/.npm/_npx`, `%LOCALAPPDATA%
|
|
97
|
+
* \npm-cache\_npx`), which every project and every session of this user
|
|
98
|
+
* shares. A per-project lock would serialize nothing.
|
|
99
|
+
*/
|
|
100
|
+
export function ocrAcquireLockPath() {
|
|
101
|
+
return join(homedir(), '.peaks', 'ocr', 'install.lock');
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Take the OCR acquire lock (O_EXCL create), reclaiming a STALE one — dead
|
|
105
|
+
* owner, unreadable body, or older than `ACQUIRE_LOCK_STALE_MS`. `false` when
|
|
106
|
+
* another process holds a live lock.
|
|
107
|
+
*
|
|
108
|
+
* Same protocol as `web-install-service.acquireInstallLock`, deliberately
|
|
109
|
+
* including the R15 detail that **the reclaim is a rename, not an unlink**:
|
|
110
|
+
* `O_EXCL` serializes the create only if the removal before it cannot be
|
|
111
|
+
* replayed against a fresh file — `A unlink → A create → B unlink (A's FRESH
|
|
112
|
+
* lock) → B create` leaves both holders. A file can only be moved once, so
|
|
113
|
+
* exactly one reclaimer wins and the create that follows decides.
|
|
114
|
+
*
|
|
115
|
+
* The two copies are not shared because the web module's lock is bound to its
|
|
116
|
+
* own artifact root and this change does not reach into it. If a third
|
|
117
|
+
* acquirer appears, lift the pair into a shared helper with a lock-path
|
|
118
|
+
* parameter rather than writing a third copy.
|
|
119
|
+
*/
|
|
120
|
+
export function acquireOcrLock() {
|
|
121
|
+
const target = ocrAcquireLockPath();
|
|
122
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
123
|
+
if (tryCreateLock(target)) {
|
|
124
|
+
return ownsLock(target);
|
|
125
|
+
}
|
|
126
|
+
if (isLiveLock(readLock(target))) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const claim = `${target}.reclaim-${String(process.pid)}`;
|
|
130
|
+
try {
|
|
131
|
+
renameSync(target, claim);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Another reclaimer moved it first (ENOENT), or it cannot be moved.
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
if (isLiveLock(readLock(claim))) {
|
|
138
|
+
// We moved a lock a racer had just legitimately (re)created: put it back
|
|
139
|
+
// rather than steal a lock its owner is already installing under.
|
|
140
|
+
try {
|
|
141
|
+
renameSync(claim, target);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
try {
|
|
145
|
+
unlinkSync(claim);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Nothing left to clean up.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
unlinkSync(claim);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Already gone; the create below is the decider.
|
|
158
|
+
}
|
|
159
|
+
return tryCreateLock(target) && ownsLock(target);
|
|
160
|
+
}
|
|
161
|
+
/** Release the OCR acquire lock, but only when this process is its owner. */
|
|
162
|
+
export function releaseOcrLock() {
|
|
163
|
+
const target = ocrAcquireLockPath();
|
|
164
|
+
const existing = readLock(target);
|
|
165
|
+
// An UNREADABLE body is not proof of ownership, so it is left alone: the
|
|
166
|
+
// worst case is one `ACQUIRE_LOCK_STALE_MS` wait, where unlinking a racer's
|
|
167
|
+
// half-written `wx` create would hand its lock to whoever asked next.
|
|
168
|
+
if (existing === null || existing.pid !== process.pid) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
unlinkSync(target);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Already released (or never held).
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function isLiveLock(body) {
|
|
179
|
+
return body !== null && isProcessAlive(body.pid) && Date.now() - Date.parse(body.startedAt) <= ACQUIRE_LOCK_STALE_MS;
|
|
180
|
+
}
|
|
181
|
+
function ownsLock(target) {
|
|
182
|
+
return readLock(target)?.pid === process.pid;
|
|
183
|
+
}
|
|
184
|
+
function tryCreateLock(target) {
|
|
185
|
+
const body = { pid: process.pid, startedAt: new Date().toISOString() };
|
|
186
|
+
try {
|
|
187
|
+
writeFileSync(target, JSON.stringify(body), { flag: 'wx', encoding: 'utf8', mode: LOCK_FILE_MODE });
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// `EEXIST` (held) and any other write failure both mean "not acquired".
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function readLock(target) {
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(readFileSync(target, 'utf8'));
|
|
198
|
+
const { pid, startedAt } = parsed;
|
|
199
|
+
if (typeof pid !== 'number' || typeof startedAt !== 'string') {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return { pid, startedAt };
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Resolve the shell the acquisition runs through: Git Bash → PowerShell →
|
|
210
|
+
* no shell. Never throws, and never returns a shell without a `note`.
|
|
211
|
+
*/
|
|
212
|
+
export async function resolveAcquireShell(options = {}) {
|
|
213
|
+
const platform = options.platform ?? process.platform;
|
|
214
|
+
const env = options.env ?? process.env;
|
|
215
|
+
const probeFile = options.probeFile ?? existsSync;
|
|
216
|
+
const probe = await probeShell({
|
|
217
|
+
platform,
|
|
218
|
+
env,
|
|
219
|
+
probeFile,
|
|
220
|
+
...(options.runner !== undefined ? { runner: options.runner } : {})
|
|
221
|
+
});
|
|
222
|
+
if (probe.available && probe.path !== null) {
|
|
223
|
+
return { kind: 'bash', path: probe.path, note: `bash: ${probe.path} (${probe.reason})` };
|
|
224
|
+
}
|
|
225
|
+
const powershell = await findPowershell(platform, env, probeFile);
|
|
226
|
+
if (powershell !== null) {
|
|
227
|
+
return {
|
|
228
|
+
kind: 'powershell',
|
|
229
|
+
path: powershell,
|
|
230
|
+
note: `PowerShell: ${powershell} — Git Bash is absent on this host (${probe.reason})`
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
kind: 'direct',
|
|
235
|
+
path: null,
|
|
236
|
+
note: 'no shell: this host has neither Git Bash nor PowerShell, so npx is launched directly'
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The Windows PowerShell that ships with the OS, or `null`.
|
|
241
|
+
*
|
|
242
|
+
* Its location is not searched for on PATH: `%SystemRoot%\System32\
|
|
243
|
+
* WindowsPowerShell\v1.0\powershell.exe` is where Windows has put it since
|
|
244
|
+
* Windows 7, and a PATH that cannot see it is a PATH problem the `direct`
|
|
245
|
+
* branch below already survives.
|
|
246
|
+
*/
|
|
247
|
+
async function findPowershell(platform, env, probeFile) {
|
|
248
|
+
if (platform !== 'win32') {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const root = env['SystemRoot'] ?? env['windir'] ?? 'C:\\Windows';
|
|
252
|
+
const candidate = join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
253
|
+
return (await probeFile(candidate)) ? candidate : null;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The exact argv of the one-time acquisition. Exported so a unit test can
|
|
257
|
+
* assert the arguments WITHOUT spawning anything — an install is not a test's
|
|
258
|
+
* side effect (same rule as `installCommandLine`).
|
|
259
|
+
*
|
|
260
|
+
* `--yes` is what makes this non-interactive: without it `npx` stops to ask
|
|
261
|
+
* "Ok to proceed?" on a machine that has not cached the package, which is the
|
|
262
|
+
* hang this verb exists to avoid.
|
|
263
|
+
*/
|
|
264
|
+
export function acquireCommandArgs() {
|
|
265
|
+
return ['--yes', '--package', OCR_18_PACKAGE, '--', 'ocr', 'version'];
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The same argv as ONE shell line, for the bash / PowerShell branches.
|
|
269
|
+
*
|
|
270
|
+
* Every token is double-quoted, and that is load-bearing rather than
|
|
271
|
+
* cosmetic: a bare `@alibaba-group/…` is PowerShell's array syntax. Every
|
|
272
|
+
* token here is a module constant built from `OCR_18_PACKAGE`, so quoting is
|
|
273
|
+
* sufficient — no token carries a `"`, `\`, `$` or backtick that the two
|
|
274
|
+
* shells would escape differently, and none is caller-supplied.
|
|
275
|
+
*/
|
|
276
|
+
export function acquireCommandLine() {
|
|
277
|
+
return ['npx', ...acquireCommandArgs()].map((token) => `"${token}"`).join(' ');
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Run the acquisition once, under the lock. Returns an outcome on every path —
|
|
281
|
+
* a held lock, a non-zero exit, a timeout, a thrown spawn — so no caller ever
|
|
282
|
+
* sees an exception from here, and never a silent no-op.
|
|
283
|
+
*/
|
|
284
|
+
export async function acquireOcr18(options = {}) {
|
|
285
|
+
const startedAt = Date.now();
|
|
286
|
+
const shell = options.shell ?? (await resolveAcquireShell());
|
|
287
|
+
let locked = false;
|
|
288
|
+
try {
|
|
289
|
+
locked = acquireOcrLock();
|
|
290
|
+
if (!locked) {
|
|
291
|
+
return failure('OCR18_ACQUIRE_BUSY', 'another OCR 1.8.x acquisition is already running on this machine; ' +
|
|
292
|
+
`the lock (${ocrAcquireLockPath()}) prevents a second download — retry once it finishes`, shell, startedAt);
|
|
293
|
+
}
|
|
294
|
+
// R2's discipline, mirrored: name the network wait BEFORE the block, on the
|
|
295
|
+
// channel a human reads, so the pause is never unexplained.
|
|
296
|
+
process.stderr.write(`peaks code-review: ${ACQUIRE_NETWORK_WARNING}\n`);
|
|
297
|
+
const result = runOcrAcquire(shell, options.asJson === true);
|
|
298
|
+
if (result.error !== undefined && result.error !== null) {
|
|
299
|
+
return spawnFailure(result.error, shell, startedAt);
|
|
300
|
+
}
|
|
301
|
+
if (result.status !== 0) {
|
|
302
|
+
return failure('OCR18_ACQUIRE_FAILED', `\`npx --package ${OCR_18_PACKAGE} -- ocr version\` exited with status ` +
|
|
303
|
+
`${String(result.status)} (shell: ${shell.kind})`, shell, startedAt);
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
ok: true,
|
|
307
|
+
code: '',
|
|
308
|
+
message: '',
|
|
309
|
+
shell,
|
|
310
|
+
durationMs: Date.now() - startedAt,
|
|
311
|
+
warnings: [ACQUIRE_NETWORK_WARNING]
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return failure('OCR18_ACQUIRE_FAILED', getErrorMessage(error), shell, startedAt);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
// Every path releases, including a thrown spawn — but only if THIS call
|
|
319
|
+
// took the lock; releasing someone else's would let a second install in.
|
|
320
|
+
if (locked) {
|
|
321
|
+
releaseOcrLock();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* One blocking acquisition. See the module docstring for why `stdio` is the
|
|
327
|
+
* visibility switch and why `windowsHide` complements rather than hides it.
|
|
328
|
+
*/
|
|
329
|
+
function runOcrAcquire(shell, asJson) {
|
|
330
|
+
const stdio = asJson ? ['ignore', 'ignore', 'inherit'] : 'inherit';
|
|
331
|
+
const options = { stdio, timeout: ACQUIRE_TIMEOUT_MS, windowsHide: true };
|
|
332
|
+
if (shell.kind === 'powershell' && shell.path !== null) {
|
|
333
|
+
return spawnSync(shell.path, ['-NoProfile', '-NonInteractive', '-Command', acquireCommandLine()], options);
|
|
334
|
+
}
|
|
335
|
+
if (shell.kind === 'bash' && shell.path !== null) {
|
|
336
|
+
return spawnSync(shell.path, ['-c', acquireCommandLine()], options);
|
|
337
|
+
}
|
|
338
|
+
// No shell at all: bypass the Windows `npx.cmd` shim the way every other
|
|
339
|
+
// spawn in this repo does (see `resolveNpxInvocation`). `shell: true` is NOT
|
|
340
|
+
// an alternative — it concatenates and splits the `--package` argv.
|
|
341
|
+
const invocation = resolveNpxInvocation(acquireCommandArgs());
|
|
342
|
+
return spawnSync(invocation.command, [...invocation.args], { ...options, env: invocation.baseEnv });
|
|
343
|
+
}
|
|
344
|
+
function spawnFailure(error, shell, startedAt) {
|
|
345
|
+
const code = error.code === 'ETIMEDOUT' ? 'OCR18_ACQUIRE_TIMEOUT' : 'OCR18_ACQUIRE_FAILED';
|
|
346
|
+
return failure(code, `the acquisition did not complete: ${error.message}`, shell, startedAt);
|
|
347
|
+
}
|
|
348
|
+
function failure(code, message, shell, startedAt) {
|
|
349
|
+
return { ok: false, code, message, shell, durationMs: Date.now() - startedAt, warnings: [] };
|
|
350
|
+
}
|
|
@@ -76,6 +76,9 @@ export function runOcr18(options) {
|
|
|
76
76
|
const spawnOptions = {
|
|
77
77
|
cwd: options.cwd,
|
|
78
78
|
encoding: 'utf8',
|
|
79
|
+
// Repo convention: without this the child pops a console window on the
|
|
80
|
+
// user's desktop — and this child can run for the length of a full review.
|
|
81
|
+
windowsHide: true,
|
|
79
82
|
timeout: options.timeoutMs ?? 60_000,
|
|
80
83
|
maxBuffer: 32 * 1024 * 1024
|
|
81
84
|
};
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
32
32
|
import { createRequire } from 'node:module';
|
|
33
|
-
import { homedir } from 'node:os';
|
|
34
33
|
import { basename, dirname, join } from 'node:path';
|
|
35
34
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
35
|
+
import { npmExecCacheRoots } from '../../shared/npm-cache.js';
|
|
36
36
|
import { isInsidePath } from '../../shared/path-utils.js';
|
|
37
37
|
/**
|
|
38
38
|
* Exact pin, no caret (tech-doc §3.2). AC2 is a BYTE-COUNT contract and the
|
|
@@ -88,7 +88,10 @@ export function resolvePlaywrightModule() {
|
|
|
88
88
|
* exact pin exists to prevent.
|
|
89
89
|
*/
|
|
90
90
|
function resolveFromNpxCache() {
|
|
91
|
-
|
|
91
|
+
// The roots themselves live in `shared/npm-cache.ts` — the exec cache has
|
|
92
|
+
// exactly one definition, shared with the OCR probe, so the two cannot
|
|
93
|
+
// disagree about what "already installed" means.
|
|
94
|
+
for (const cacheRoot of npmExecCacheRoots()) {
|
|
92
95
|
for (const entry of safeReaddir(cacheRoot)) {
|
|
93
96
|
const resolved = tryResolveFrom(join(cacheRoot, entry, 'node_modules'));
|
|
94
97
|
if (resolved !== null) {
|
|
@@ -98,28 +101,6 @@ function resolveFromNpxCache() {
|
|
|
98
101
|
}
|
|
99
102
|
return null;
|
|
100
103
|
}
|
|
101
|
-
/**
|
|
102
|
-
* `<npm cache>/_npx` candidates: the per-user defaults, and nothing else.
|
|
103
|
-
*
|
|
104
|
-
* `npm_config_cache` / `NPM_CONFIG_CACHE` used to be taken first "when
|
|
105
|
-
* configured". They are not configuration this module may trust: under
|
|
106
|
-
* `npm run`, npm exports the value a repo's own `.npmrc` chose, so a committed
|
|
107
|
-
* `.npmrc` plus a committed `_npx`-shaped tree selected the package that
|
|
108
|
-
* `import()` then executed (security review S2, reproduced). The default roots
|
|
109
|
-
* below are where npm actually puts an `npx` cache.
|
|
110
|
-
*/
|
|
111
|
-
function npxCacheRoots() {
|
|
112
|
-
const roots = [join(homedir(), '.npm', '_npx')];
|
|
113
|
-
if (process.platform === 'win32') {
|
|
114
|
-
for (const key of ['LOCALAPPDATA', 'APPDATA']) {
|
|
115
|
-
const base = process.env[key];
|
|
116
|
-
if (base !== undefined && base.length > 0) {
|
|
117
|
-
roots.push(join(base, 'npm-cache', '_npx'));
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return roots;
|
|
122
|
-
}
|
|
123
104
|
/**
|
|
124
105
|
* Resolve `playwright` as if from `moduleRoot`, and admit it only when it
|
|
125
106
|
* passes `verifyPinnedPackage` against that same root. The anchor is what
|