peaks-loop 4.0.36 → 4.0.38

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/peaks.js +71 -1
  5. package/dist/cli/cli-helpers.js +7 -0
  6. package/dist/cli/commands/_register.js +2 -0
  7. package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
  8. package/dist/cli/commands/best-practice-scan-command.js +67 -9
  9. package/dist/cli/commands/code-runtime-commands.js +21 -5
  10. package/dist/cli/commands/hooks-commands.js +41 -7
  11. package/dist/cli/commands/job-commands.js +107 -25
  12. package/dist/cli/commands/scan-commands.js +1 -1
  13. package/dist/cli/commands/web-commands.d.ts +28 -0
  14. package/dist/cli/commands/web-commands.js +327 -0
  15. package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
  16. package/dist/cli/commands/web-lifecycle-commands.js +321 -0
  17. package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
  18. package/dist/services/best-practice/scan-orchestrator.js +14 -5
  19. package/dist/services/code/orchestrator-can-do.js +27 -4
  20. package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
  21. package/dist/services/context/build-dispatch-system-prompt.js +55 -3
  22. package/dist/services/context/context-audit-hint.d.ts +79 -0
  23. package/dist/services/context/context-audit-hint.js +150 -0
  24. package/dist/services/hooks/auto-compact-hook-install.js +10 -1
  25. package/dist/services/hooks/write-gate.js +88 -0
  26. package/dist/services/lint/detect-eslint.d.ts +2 -0
  27. package/dist/services/lint/detect-eslint.js +23 -9
  28. package/dist/services/lint/detect-ocr-18.d.ts +2 -0
  29. package/dist/services/lint/detect-ocr-18.js +36 -5
  30. package/dist/services/lint/npx-resolver.d.ts +6 -0
  31. package/dist/services/lint/npx-resolver.js +38 -14
  32. package/dist/services/lint/ocr-multilang-adapter.js +9 -2
  33. package/dist/services/release/version-precheck-service.js +9 -2
  34. package/dist/services/scan/file-size-scan.d.ts +29 -0
  35. package/dist/services/scan/file-size-scan.js +63 -0
  36. package/dist/services/session/caller-binding-service.d.ts +24 -0
  37. package/dist/services/session/caller-binding-service.js +34 -0
  38. package/dist/services/session/getSessionDir.js +15 -10
  39. package/dist/services/skills/hooks-codegate-superpowers.d.ts +74 -0
  40. package/dist/services/skills/hooks-codegate-superpowers.js +129 -3
  41. package/dist/services/skills/hooks-settings-service.d.ts +26 -0
  42. package/dist/services/skills/hooks-settings-service.js +186 -62
  43. package/dist/services/slice/slice-check-service.d.ts +14 -0
  44. package/dist/services/slice/slice-check-service.js +110 -50
  45. package/dist/services/slice/slice-check-types.d.ts +12 -7
  46. package/dist/services/slice/slice-check-types.js +8 -3
  47. package/dist/services/slice/slice-decompose-runners.js +24 -21
  48. package/dist/services/sop/sop-check-service.js +12 -1
  49. package/dist/services/web/bounded-output.d.ts +34 -0
  50. package/dist/services/web/bounded-output.js +68 -0
  51. package/dist/services/web/browser-acquire.d.ts +14 -0
  52. package/dist/services/web/browser-acquire.js +84 -0
  53. package/dist/services/web/browser-session-manager.d.ts +111 -0
  54. package/dist/services/web/browser-session-manager.js +413 -0
  55. package/dist/services/web/daemon-entry.d.ts +1 -0
  56. package/dist/services/web/daemon-entry.js +65 -0
  57. package/dist/services/web/daemon-registry.d.ts +42 -0
  58. package/dist/services/web/daemon-registry.js +164 -0
  59. package/dist/services/web/daemon-supervisor.d.ts +144 -0
  60. package/dist/services/web/daemon-supervisor.js +455 -0
  61. package/dist/services/web/playwright-loader.d.ts +89 -0
  62. package/dist/services/web/playwright-loader.js +253 -0
  63. package/dist/services/web/snapshot-pruner.d.ts +48 -0
  64. package/dist/services/web/snapshot-pruner.js +241 -0
  65. package/dist/services/web/untrusted-envelope.d.ts +27 -0
  66. package/dist/services/web/untrusted-envelope.js +44 -0
  67. package/dist/services/web/web-artifact-paths.d.ts +79 -0
  68. package/dist/services/web/web-artifact-paths.js +163 -0
  69. package/dist/services/web/web-client.d.ts +19 -0
  70. package/dist/services/web/web-client.js +55 -0
  71. package/dist/services/web/web-daemon-service.d.ts +38 -0
  72. package/dist/services/web/web-daemon-service.js +416 -0
  73. package/dist/services/web/web-fallback.d.ts +70 -0
  74. package/dist/services/web/web-fallback.js +121 -0
  75. package/dist/services/web/web-install-service.d.ts +91 -0
  76. package/dist/services/web/web-install-service.js +346 -0
  77. package/dist/services/web/web-login-profile.d.ts +89 -0
  78. package/dist/services/web/web-login-profile.js +612 -0
  79. package/dist/services/web/web-login-staging.d.ts +27 -0
  80. package/dist/services/web/web-login-staging.js +173 -0
  81. package/dist/services/web/web-protocol.d.ts +58 -0
  82. package/dist/services/web/web-protocol.js +58 -0
  83. package/dist/services/web/web-status-report.d.ts +33 -0
  84. package/dist/services/web/web-status-report.js +47 -0
  85. package/dist/services/workspace/claude-settings-template.d.ts +59 -7
  86. package/dist/services/workspace/claude-settings-template.js +139 -67
  87. package/dist/services/workspace/workspace-claude-settings-materializer.js +46 -19
  88. package/dist/services/workspace/workspace-service.js +33 -0
  89. package/package.json +5 -5
  90. package/scripts/copy-templates.mjs +12 -0
  91. package/scripts/sync-version.mjs +20 -0
  92. package/skills/peaks-code/SKILL.md +10 -0
  93. package/skills/peaks-code/references/browser-workflow.md +10 -1
@@ -0,0 +1,91 @@
1
+ /**
2
+ * R2: the size of the one-time download, named so it is never a surprise.
3
+ *
4
+ * Measured, not guessed (perf baseline S3, P6): one install of the pin writes
5
+ * **704 MiB** — `chromium-1243` 433 MiB plus `chromium_headless_shell-1243`
6
+ * 271 MiB. The old "~150–300 MB" was the wire estimate and understated the
7
+ * figure the machine actually pays. Nothing reaps obsolete revisions either, so
8
+ * each pin bump adds another ~700 MiB beside the old one (1.4 GiB held here).
9
+ */
10
+ export declare const INSTALL_SIZE_WARNING = "downloading chromium (~700 MB on disk, one time)";
11
+ /** What the cache says, without downloading anything (tech-doc §5.3). */
12
+ export interface BrowserProbe {
13
+ readonly installed: boolean;
14
+ /** The resolved Playwright PACKAGE version, or `null` when the package is absent. */
15
+ readonly version: string | null;
16
+ readonly executablePath: string | null;
17
+ }
18
+ export interface InstallOutcome {
19
+ readonly ok: boolean;
20
+ /** `''` on success, otherwise `WEB_INSTALL_BUSY` | `WEB_INSTALL_FAILED` | `WEB_INSTALL_TIMEOUT`. */
21
+ readonly code: string;
22
+ readonly message: string;
23
+ /** The size warning whenever this call actually ran the installer. */
24
+ readonly warnings: readonly string[];
25
+ }
26
+ /**
27
+ * `PEAKS_WEB_DISABLED=1`, and nothing else. `'0'`, `'true'`, `''` and `'1 '`
28
+ * are all "not disabled" — the flag is an explicit opt-out, so a typo must fail
29
+ * towards the local browser rather than silently skipping it.
30
+ */
31
+ export declare function isWebDisabled(env: NodeJS.ProcessEnv): boolean;
32
+ /**
33
+ * Is the pinned browser on disk? Never throws, never spawns, never downloads.
34
+ *
35
+ * **It answers about the artifact `launch()` starts** (R6). `executablePath()`
36
+ * names the `chromium` build, but `chromium.launch()` with no options and
37
+ * `headless` defaulting true resolves `chromium-headless-shell`
38
+ * (`registry.getExecutableName`) — a SEPARATE ~271 MiB download. Probing the
39
+ * full chromium therefore answered "installed" for a machine on which no op
40
+ * could launch: `launch()` failed, S1's net downloaded, the probe kept saying
41
+ * installed, and `peaks web install` short-circuited to a no-op success. See
42
+ * `headlessShellDir` for how the right path is obtained.
43
+ *
44
+ * `version` is reported even when the executable is missing, because "the
45
+ * package is cached but its browser is not" is a different diagnosis from
46
+ * "nothing is installed" — the first is one `peaks web install` away.
47
+ */
48
+ export declare function probeBrowserInstalled(): Promise<BrowserProbe>;
49
+ /**
50
+ * The exact argv of the one-time download (tech-doc §3.3):
51
+ * `npx --yes --package playwright@<pin> -- playwright install chromium`, with
52
+ * `--force` before the browser name for the recovery path.
53
+ *
54
+ * Exported so the exact arguments are asserted by a unit test WITHOUT spawning
55
+ * anything — the download is ~700 MB and must not be a test's side effect.
56
+ */
57
+ export declare function installCommandLine(options?: {
58
+ force?: boolean;
59
+ }): string[];
60
+ /**
61
+ * Take the chromium install lock (O_EXCL). `false` when another process holds a
62
+ * live lock; a STALE one — owner pid dead, unreadable body, or older than
63
+ * `INSTALL_LOCK_STALE_MS` — is reclaimed and retried once (R6).
64
+ *
65
+ * **The reclaim is a rename, not an unlink** (R15). `O_EXCL` serializes the
66
+ * *create*, but only if the removal that precedes it cannot be replayed against
67
+ * a fresh file: two callers that both read the same dead-pid body used to
68
+ * interleave as `A unlink → A create → B unlink (A's FRESH lock) → B create`,
69
+ * leaving both holding. `rename` gives the reclaim the same atomicity the
70
+ * create has — a given file can only be moved once, so exactly one reclaimer
71
+ * wins — and the O_EXCL create serializes whatever follows.
72
+ */
73
+ export declare function acquireInstallLock(): boolean;
74
+ /** Release the install lock, but only when this process is its owner. */
75
+ export declare function releaseInstallLock(): void;
76
+ /**
77
+ * Run `playwright install chromium` once, under the lock. Returns an outcome on
78
+ * every path — a held lock, a non-zero exit, a timeout, a thrown spawn — so a
79
+ * caller never sees an exception from here (R2).
80
+ *
81
+ * **Callers are CLIs, never the daemon** (R3). The spawn below blocks the
82
+ * event loop for the whole download (one real acquisition measured 170 s), so
83
+ * running it inside the daemon starved `/health` — `status` read `orphaned`,
84
+ * `stop` could not prove ownership and left the daemon running — while the CLI
85
+ * gave up at 30 s and reported failure. Acquisition is therefore delegated: the
86
+ * daemon answers the op with the tier-3 envelope and this verb, on the human's
87
+ * channel, performs the download.
88
+ */
89
+ export declare function installChromium(options?: {
90
+ readonly force?: boolean;
91
+ }): Promise<InstallOutcome>;
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Browser acquisition: the disable gate, the cache probe and the one-time
3
+ * chromium install (slice S3, file 18; AC5, R2, R6).
4
+ *
5
+ * Three properties this module owns, all of them testable without a browser:
6
+ *
7
+ * - **The gate is a value, not a side effect.** `isWebDisabled` reads one env
8
+ * var and only the exact string `'1'` counts (a truthy `'true'` / `'0'` /
9
+ * trailing space are all "not disabled"). It is step 1 of the ordered gate
10
+ * in `browser-acquire.ts` and of the `install` verb, so nothing it guards
11
+ * can be reached by accident.
12
+ * - **The probe never downloads and never spawns** (R6, tech-doc §5.3). It
13
+ * asks the resolved Playwright package where its executable WOULD be and
14
+ * checks the filesystem: no network, no process, so `peaks web status` — the
15
+ * diagnosis path — can report cache state on a machine with nothing
16
+ * installed. We never delete anything in that cache; recovery from a
17
+ * half-download is delegated to Playwright's own `install --force`
18
+ * (design §10.1).
19
+ * - **The install never throws and never hangs** (R2). It returns an outcome
20
+ * for every path — a held lock, a non-zero exit, a timeout — and it takes
21
+ * the `install.lock` around the spawn so two callers cannot download 700 MB
22
+ * each (R6). The lock is released on every path, including a thrown spawn.
23
+ */
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
25
+ import { dirname, join, sep } from 'node:path';
26
+ import { spawnSync } from 'node:child_process';
27
+ import { getErrorMessage } from 'peaks-loop-shared/result';
28
+ import { resolveNpxInvocation } from '../lint/npx-resolver.js';
29
+ import { isProcessAlive } from './daemon-registry.js';
30
+ import { loadPlaywright, PLAYWRIGHT_VERSION_PIN, playwrightVersion } from './playwright-loader.js';
31
+ import { webInstallLockPath } from './web-artifact-paths.js';
32
+ /**
33
+ * R2: the size of the one-time download, named so it is never a surprise.
34
+ *
35
+ * Measured, not guessed (perf baseline S3, P6): one install of the pin writes
36
+ * **704 MiB** — `chromium-1243` 433 MiB plus `chromium_headless_shell-1243`
37
+ * 271 MiB. The old "~150–300 MB" was the wire estimate and understated the
38
+ * figure the machine actually pays. Nothing reaps obsolete revisions either, so
39
+ * each pin bump adds another ~700 MiB beside the old one (1.4 GiB held here).
40
+ */
41
+ export const INSTALL_SIZE_WARNING = 'downloading chromium (~700 MB on disk, one time)';
42
+ /**
43
+ * A held install lock older than this is reclaimed even if its owner is alive.
44
+ *
45
+ * Deliberately long: the whole point of the lock is to prevent a SECOND 700 MB
46
+ * download (R6), and a premature reclaim is exactly that. It must therefore
47
+ * outlast any plausible install — the spawn timeout below is shorter, so a
48
+ * timed-out install still owns its lock while it winds down.
49
+ */
50
+ const INSTALL_LOCK_STALE_MS = 30 * 60_000;
51
+ /**
52
+ * A blocking `spawnSync` needs a ceiling or "never hang" is not true (R2). The
53
+ * child is killed at this point; a partial download is left on disk and is
54
+ * recovered by `peaks web install --force` (Playwright's own path, R6).
55
+ */
56
+ const INSTALL_TIMEOUT_MS = 20 * 60_000;
57
+ /** Lock and log are ours; 0600 mirrors the daemon record's reasoning. */
58
+ const LOCK_FILE_MODE = 0o600;
59
+ /**
60
+ * `PEAKS_WEB_DISABLED=1`, and nothing else. `'0'`, `'true'`, `''` and `'1 '`
61
+ * are all "not disabled" — the flag is an explicit opt-out, so a typo must fail
62
+ * towards the local browser rather than silently skipping it.
63
+ */
64
+ export function isWebDisabled(env) {
65
+ return env['PEAKS_WEB_DISABLED'] === '1';
66
+ }
67
+ /**
68
+ * Is the pinned browser on disk? Never throws, never spawns, never downloads.
69
+ *
70
+ * **It answers about the artifact `launch()` starts** (R6). `executablePath()`
71
+ * names the `chromium` build, but `chromium.launch()` with no options and
72
+ * `headless` defaulting true resolves `chromium-headless-shell`
73
+ * (`registry.getExecutableName`) — a SEPARATE ~271 MiB download. Probing the
74
+ * full chromium therefore answered "installed" for a machine on which no op
75
+ * could launch: `launch()` failed, S1's net downloaded, the probe kept saying
76
+ * installed, and `peaks web install` short-circuited to a no-op success. See
77
+ * `headlessShellDir` for how the right path is obtained.
78
+ *
79
+ * `version` is reported even when the executable is missing, because "the
80
+ * package is cached but its browser is not" is a different diagnosis from
81
+ * "nothing is installed" — the first is one `peaks web install` away.
82
+ */
83
+ export async function probeBrowserInstalled() {
84
+ const version = await playwrightVersion();
85
+ if (version === null) {
86
+ return { installed: false, version: null, executablePath: null };
87
+ }
88
+ try {
89
+ const chromiumPath = (await loadPlaywright()).chromium.executablePath();
90
+ const shellDir = headlessShellDir(chromiumPath);
91
+ if (shellDir === null) {
92
+ // Not a registry shape we recognise: answer about the path Playwright
93
+ // handed us, exactly as this probe did before R6, rather than about a
94
+ // directory we invented.
95
+ return { installed: existsSync(chromiumPath), version, executablePath: chromiumPath };
96
+ }
97
+ // Absent is the answer, and the whole point: `null` here is what steers
98
+ // `peaks web install` to download and `acquireChromium` to refuse.
99
+ const executablePath = headlessShellExecutable(shellDir);
100
+ return { installed: executablePath !== null, version, executablePath };
101
+ }
102
+ catch {
103
+ // The package resolved but would not load — a broken cache, not a download.
104
+ return { installed: false, version, executablePath: null };
105
+ }
106
+ }
107
+ /**
108
+ * The `chromium_headless_shell-<rev>` directory that sits beside
109
+ * `chromiumExecutablePath`'s own revision directory, or `null` when the path is
110
+ * not shaped like Playwright's registry.
111
+ *
112
+ * Playwright's public API cannot name the shell — `BrowserType.executablePath()`
113
+ * takes no options and always answers with the `chromium` build — so it is
114
+ * derived from the one path Playwright does hand out. One naming rule is
115
+ * assumed, and it is the one the cache itself shows: `<rev>` directories are
116
+ * named after the browser, `chromium-1243` beside `chromium_headless_shell-1243`.
117
+ */
118
+ function headlessShellDir(chromiumExecutablePath) {
119
+ const segments = chromiumExecutablePath.split(/[\\/]/);
120
+ let revision = -1;
121
+ for (let i = segments.length - 1; i > 0; i -= 1) {
122
+ if (/^chromium-\d+$/.test(segments[i] ?? '')) {
123
+ revision = i;
124
+ break;
125
+ }
126
+ }
127
+ const revisionDir = segments[revision];
128
+ if (revision <= 0 || revisionDir === undefined) {
129
+ return null;
130
+ }
131
+ return join(segments.slice(0, revision).join(sep), revisionDir.replace('chromium-', 'chromium_headless_shell-'));
132
+ }
133
+ /**
134
+ * The shell executable inside `shellDir`, or `null`.
135
+ *
136
+ * The shell's own platform directory is deliberately NOT assumed — it is
137
+ * `chrome-headless-shell-<platform>` where chromium's is `chrome-<platform>` —
138
+ * so the file is looked for rather than named from a copy of Playwright's
139
+ * per-platform path table.
140
+ */
141
+ function headlessShellExecutable(shellDir) {
142
+ for (const entry of safeReaddir(shellDir)) {
143
+ const platformDir = join(shellDir, entry);
144
+ for (const file of safeReaddir(platformDir)) {
145
+ if (/^chrome-headless-shell(\.exe)?$/.test(file)) {
146
+ return join(platformDir, file);
147
+ }
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+ function safeReaddir(dir) {
153
+ try {
154
+ return readdirSync(dir);
155
+ }
156
+ catch {
157
+ // Absent is the normal case on a machine that never installed the shell.
158
+ return [];
159
+ }
160
+ }
161
+ /**
162
+ * The exact argv of the one-time download (tech-doc §3.3):
163
+ * `npx --yes --package playwright@<pin> -- playwright install chromium`, with
164
+ * `--force` before the browser name for the recovery path.
165
+ *
166
+ * Exported so the exact arguments are asserted by a unit test WITHOUT spawning
167
+ * anything — the download is ~700 MB and must not be a test's side effect.
168
+ */
169
+ export function installCommandLine(options = {}) {
170
+ return [
171
+ '--yes',
172
+ '--package',
173
+ `playwright@${PLAYWRIGHT_VERSION_PIN}`,
174
+ '--',
175
+ 'playwright',
176
+ 'install',
177
+ ...(options.force === true ? ['--force'] : []),
178
+ 'chromium'
179
+ ];
180
+ }
181
+ /**
182
+ * Take the chromium install lock (O_EXCL). `false` when another process holds a
183
+ * live lock; a STALE one — owner pid dead, unreadable body, or older than
184
+ * `INSTALL_LOCK_STALE_MS` — is reclaimed and retried once (R6).
185
+ *
186
+ * **The reclaim is a rename, not an unlink** (R15). `O_EXCL` serializes the
187
+ * *create*, but only if the removal that precedes it cannot be replayed against
188
+ * a fresh file: two callers that both read the same dead-pid body used to
189
+ * interleave as `A unlink → A create → B unlink (A's FRESH lock) → B create`,
190
+ * leaving both holding. `rename` gives the reclaim the same atomicity the
191
+ * create has — a given file can only be moved once, so exactly one reclaimer
192
+ * wins — and the O_EXCL create serializes whatever follows.
193
+ */
194
+ export function acquireInstallLock() {
195
+ const target = webInstallLockPath();
196
+ mkdirSync(dirname(target), { recursive: true });
197
+ if (tryCreateInstallLock(target)) {
198
+ return ownsInstallLock(target);
199
+ }
200
+ const existing = readInstallLock(target);
201
+ if (isLiveInstallLock(existing)) {
202
+ return false;
203
+ }
204
+ const claim = `${target}.reclaim-${String(process.pid)}`;
205
+ try {
206
+ renameSync(target, claim);
207
+ }
208
+ catch {
209
+ // Another reclaimer moved it first (ENOENT), or it cannot be moved.
210
+ return false;
211
+ }
212
+ if (isLiveInstallLock(readInstallLock(claim))) {
213
+ // We moved a lock a racer had just legitimately (re)created: put it back,
214
+ // rather than steal a lock its owner is already downstairs downloading
215
+ // under.
216
+ try {
217
+ renameSync(claim, target);
218
+ }
219
+ catch {
220
+ try {
221
+ unlinkSync(claim);
222
+ }
223
+ catch {
224
+ // Nothing left to clean up.
225
+ }
226
+ }
227
+ return false;
228
+ }
229
+ try {
230
+ unlinkSync(claim);
231
+ }
232
+ catch {
233
+ // The claimed file is already gone; the create below is the decider.
234
+ }
235
+ return tryCreateInstallLock(target) && ownsInstallLock(target);
236
+ }
237
+ /** Release the install lock, but only when this process is its owner. */
238
+ export function releaseInstallLock() {
239
+ const target = webInstallLockPath();
240
+ const existing = readInstallLock(target);
241
+ // An UNREADABLE body is not proof of ownership, so it is left alone: the
242
+ // worst case is one `INSTALL_LOCK_STALE_MS` wait, where unlinking a racer's
243
+ // half-written `wx` create would hand its lock to whoever asked next.
244
+ if (existing === null || existing.pid !== process.pid) {
245
+ return;
246
+ }
247
+ try {
248
+ unlinkSync(target);
249
+ }
250
+ catch {
251
+ // Already released (or never held).
252
+ }
253
+ }
254
+ /** Is this body a lock we must not touch — an owner that is alive and in date? */
255
+ function isLiveInstallLock(body) {
256
+ return (body !== null && isProcessAlive(body.pid) && Date.now() - Date.parse(body.startedAt) <= INSTALL_LOCK_STALE_MS);
257
+ }
258
+ /** Does the file at `target` still name this process? */
259
+ function ownsInstallLock(target) {
260
+ return readInstallLock(target)?.pid === process.pid;
261
+ }
262
+ /**
263
+ * Run `playwright install chromium` once, under the lock. Returns an outcome on
264
+ * every path — a held lock, a non-zero exit, a timeout, a thrown spawn — so a
265
+ * caller never sees an exception from here (R2).
266
+ *
267
+ * **Callers are CLIs, never the daemon** (R3). The spawn below blocks the
268
+ * event loop for the whole download (one real acquisition measured 170 s), so
269
+ * running it inside the daemon starved `/health` — `status` read `orphaned`,
270
+ * `stop` could not prove ownership and left the daemon running — while the CLI
271
+ * gave up at 30 s and reported failure. Acquisition is therefore delegated: the
272
+ * daemon answers the op with the tier-3 envelope and this verb, on the human's
273
+ * channel, performs the download.
274
+ */
275
+ export async function installChromium(options = {}) {
276
+ const { force = false } = options;
277
+ let locked = false;
278
+ try {
279
+ locked = acquireInstallLock();
280
+ if (!locked) {
281
+ return failure('WEB_INSTALL_BUSY', 'another `playwright install chromium` is already running on this machine; ' +
282
+ `the lock (${webInstallLockPath()}) prevents a second download — retry once it finishes`);
283
+ }
284
+ // Say the size BEFORE the block, so the wait is explained on the channel a
285
+ // human reads.
286
+ process.stderr.write(`peaks web: ${INSTALL_SIZE_WARNING}\n`);
287
+ const invocation = resolveNpxInvocation(installCommandLine({ force }));
288
+ const result = spawnSync(invocation.command, [...invocation.args], {
289
+ // `ignore`, never `inherit`: the installer's output would otherwise land
290
+ // on the CLI's stdout and break `--json`.
291
+ stdio: 'ignore',
292
+ timeout: INSTALL_TIMEOUT_MS,
293
+ // The one-time download must not pop a console window at the user.
294
+ windowsHide: true
295
+ });
296
+ if (result.error !== undefined) {
297
+ return spawnFailure(result.error);
298
+ }
299
+ if (result.status !== 0) {
300
+ return failure('WEB_INSTALL_FAILED', `\`playwright install chromium\` exited with status ${String(result.status)}; ` +
301
+ 'a partial download is recovered by `peaks web install --force`');
302
+ }
303
+ return { ok: true, code: '', message: '', warnings: [INSTALL_SIZE_WARNING] };
304
+ }
305
+ catch (error) {
306
+ return failure('WEB_INSTALL_FAILED', getErrorMessage(error));
307
+ }
308
+ finally {
309
+ // R6: every path releases, including a thrown spawn — but only if THIS call
310
+ // took the lock; releasing someone else's would let a second download in.
311
+ if (locked) {
312
+ releaseInstallLock();
313
+ }
314
+ }
315
+ }
316
+ function spawnFailure(error) {
317
+ const code = error.code === 'ETIMEDOUT' ? 'WEB_INSTALL_TIMEOUT' : 'WEB_INSTALL_FAILED';
318
+ return failure(code, `\`playwright install chromium\` did not complete: ${error.message}`);
319
+ }
320
+ function failure(code, message) {
321
+ return { ok: false, code, message, warnings: [] };
322
+ }
323
+ function tryCreateInstallLock(target) {
324
+ const body = { pid: process.pid, startedAt: new Date().toISOString() };
325
+ try {
326
+ writeFileSync(target, JSON.stringify(body), { flag: 'wx', encoding: 'utf8', mode: LOCK_FILE_MODE });
327
+ return true;
328
+ }
329
+ catch {
330
+ // `EEXIST` (held) and any other write failure both mean "not acquired".
331
+ return false;
332
+ }
333
+ }
334
+ function readInstallLock(target) {
335
+ try {
336
+ const parsed = JSON.parse(readFileSync(target, 'utf8'));
337
+ const { pid, startedAt } = parsed;
338
+ if (typeof pid !== 'number' || typeof startedAt !== 'string') {
339
+ return null;
340
+ }
341
+ return { pid, startedAt };
342
+ }
343
+ catch {
344
+ return null;
345
+ }
346
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A profile name is ONE path component: LOWER-case letters, digits, dot,
3
+ * underscore and hyphen, 1–64 of them. `..` matches this charset (it is two
4
+ * dots and nothing else), which is exactly why `resolveProfileName`'s
5
+ * dot-segment rejection is not redundant.
6
+ *
7
+ * This describes the CANONICAL form. NTFS and default APFS fold case, so `Work`
8
+ * and `work` are ONE directory on those filesystems; `resolveProfileName`
9
+ * therefore lower-cases what the caller typed and uses the folded result, rather
10
+ * than rejecting a name the caller means perfectly well. The fold is reported
11
+ * back so it is never silent.
12
+ */
13
+ export declare const PROFILE_NAME_RE: RegExp;
14
+ /** Bound a caller-supplied name before it is embedded in a message. */
15
+ export declare function cappedEcho(raw: string): string;
16
+ /** `<homedir>/.peaks/web-profiles/<name>/`. The name must be resolved first. */
17
+ export declare function webProfileDir(name: string): string;
18
+ /**
19
+ * Validate a caller-supplied `--profile` and return the CANONICAL name — the
20
+ * input lower-cased, which is the one name the filesystem will actually store.
21
+ * `--profile Work` resolves to `work`.
22
+ *
23
+ * The fold is deliberate (user decision, 2026-09-10) and is never silent: the
24
+ * call site compares what it typed with what came back and reports the
25
+ * difference. What has no sensible canonical form is still refused — see
26
+ * `hasTrailingDotOrSpace`.
27
+ *
28
+ * Two refusals are DELIBERATE rather than incidental (S4 repair, security S6):
29
+ *
30
+ * - a LEADING dot is refused, because it makes `folded.split('.')[0]` empty.
31
+ * `.con` is the Windows device under a name the stem test cannot see, and
32
+ * `..foo` is a dot segment that `PROFILE_NAME_RE` accepts — it used to be
33
+ * stopped by `assertUnder` instead, which surfaced `WEB_PATH_ESCAPE` under
34
+ * the wrong code. Both now carry this function's own message.
35
+ * - the caller's input is echoed CAPPED, like the CLI's gate refusal, so a
36
+ * huge `--profile` cannot be reproduced whole in a message.
37
+ *
38
+ * Throws `WEB_PROFILE_NAME_INVALID`, and returns the NAME rather than a path so
39
+ * a call site cannot mistake one for the other.
40
+ */
41
+ export declare function resolveProfileName(raw: string): string;
42
+ /**
43
+ * `<homedir>/.peaks/web-profiles/<name>/storageState.json` — the persisted
44
+ * artifact, and the only thing `login` writes.
45
+ */
46
+ export declare function loginStorageStatePath(name: string): string;
47
+ export interface WebLoginOutcome {
48
+ readonly ok: boolean;
49
+ /**
50
+ * `''` when `ok` AND the capture verified; a code when the outcome is a
51
+ * failure, and also when a write landed but could not be read back — an
52
+ * `ok: true` with `bytes > 0` is not the same thing as a usable profile.
53
+ */
54
+ readonly code: string;
55
+ readonly message: string;
56
+ readonly profile: string;
57
+ readonly storageStatePath: string;
58
+ /** Size of the persisted file. 0 whenever nothing was written. */
59
+ readonly bytes: number;
60
+ /** Cookie/origin COUNTS only — never the values (see `readStateCounts`). */
61
+ readonly cookies: number;
62
+ readonly origins: number;
63
+ /** Never silent: a teardown or read-back problem lands here (S1's F1). */
64
+ readonly warnings: readonly string[];
65
+ }
66
+ export interface WebLoginOptions {
67
+ /** A name that already passed `resolveProfileName`; re-validated here. */
68
+ readonly profile: string;
69
+ /**
70
+ * Called once, with the headed browser already open and before the wait
71
+ * begins. IO belongs to the CLI layer, so the human instruction ("log in,
72
+ * then close the window") is composed there.
73
+ */
74
+ readonly announce: () => void;
75
+ /** Defaults to `LOGIN_TIMEOUT_MS`. A test shortens it; nothing else does. */
76
+ readonly timeoutMs?: number;
77
+ }
78
+ /**
79
+ * Open the headed browser, read the session into memory while the user works,
80
+ * and persist it when the user closes the window.
81
+ *
82
+ * Everything that happens once the browser is up comes back as `ok: false` with
83
+ * a code rather than a throw. A failure BEFORE that point — an invalid name, an
84
+ * unresolvable Playwright, a launch that will not start — does throw, because
85
+ * there is no browser to tear down and no outcome to describe; the CLI renders
86
+ * it with the same code and the same envelope shape, so the caller sees one
87
+ * contract either way.
88
+ */
89
+ export declare function runHeadedLogin(options: WebLoginOptions): Promise<WebLoginOutcome>;