peaks-loop 4.0.35 → 4.0.37

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 (128) hide show
  1. package/CHANGELOG.md +40 -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.d.ts +5 -2
  10. package/dist/cli/commands/code-runtime-commands.js +78 -7
  11. package/dist/cli/commands/core/doctor-command.d.ts +8 -0
  12. package/dist/cli/commands/core/doctor-command.js +44 -2
  13. package/dist/cli/commands/core/memory-command.js +5 -1
  14. package/dist/cli/commands/dispatch-commands.js +15 -3
  15. package/dist/cli/commands/dispatch-from-dag.js +17 -0
  16. package/dist/cli/commands/hooks-commands.js +10 -1
  17. package/dist/cli/commands/job-commands.js +107 -25
  18. package/dist/cli/commands/memory-commands.d.ts +24 -0
  19. package/dist/cli/commands/memory-commands.js +77 -10
  20. package/dist/cli/commands/request-commands.d.ts +8 -0
  21. package/dist/cli/commands/request-commands.js +23 -2
  22. package/dist/cli/commands/scan-commands.js +1 -1
  23. package/dist/cli/commands/sub-agent-commands.js +2 -0
  24. package/dist/cli/commands/wave-plan-commands.d.ts +24 -0
  25. package/dist/cli/commands/wave-plan-commands.js +93 -0
  26. package/dist/cli/commands/web-commands.d.ts +28 -0
  27. package/dist/cli/commands/web-commands.js +327 -0
  28. package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
  29. package/dist/cli/commands/web-lifecycle-commands.js +321 -0
  30. package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
  31. package/dist/services/best-practice/scan-orchestrator.js +14 -5
  32. package/dist/services/code/orchestrator-can-do.js +27 -4
  33. package/dist/services/context/build-dispatch-system-prompt.d.ts +66 -9
  34. package/dist/services/context/build-dispatch-system-prompt.js +132 -17
  35. package/dist/services/context/context-audit-hint.d.ts +79 -0
  36. package/dist/services/context/context-audit-hint.js +150 -0
  37. package/dist/services/context/context-audit.d.ts +100 -0
  38. package/dist/services/context/context-audit.js +322 -0
  39. package/dist/services/context/summary-view.d.ts +54 -0
  40. package/dist/services/context/summary-view.js +114 -0
  41. package/dist/services/dispatch/file-overlap-wave-planner.d.ts +70 -0
  42. package/dist/services/dispatch/file-overlap-wave-planner.js +119 -0
  43. package/dist/services/dispatch/session-capsule.d.ts +23 -0
  44. package/dist/services/dispatch/session-capsule.js +56 -0
  45. package/dist/services/dispatch/slice-dag.d.ts +9 -0
  46. package/dist/services/dispatch/slice-dag.js +9 -1
  47. package/dist/services/dispatch/test-tool-detection.d.ts +12 -1
  48. package/dist/services/dispatch/test-tool-detection.js +14 -13
  49. package/dist/services/hooks/auto-compact-hook-install.js +10 -1
  50. package/dist/services/hooks/write-gate.js +88 -0
  51. package/dist/services/ide/adapters/claude-code-adapter.d.ts +10 -0
  52. package/dist/services/ide/adapters/claude-code-adapter.js +20 -1
  53. package/dist/services/ide/ide-types.d.ts +15 -0
  54. package/dist/services/lint/detect-eslint.d.ts +2 -0
  55. package/dist/services/lint/detect-eslint.js +23 -9
  56. package/dist/services/lint/npx-resolver.d.ts +6 -0
  57. package/dist/services/lint/npx-resolver.js +38 -14
  58. package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +5 -0
  59. package/dist/services/memory/project-memory-service/parsers/frontmatter.js +55 -5
  60. package/dist/services/release/version-precheck-service.js +9 -2
  61. package/dist/services/scan/file-size-scan.d.ts +29 -0
  62. package/dist/services/scan/file-size-scan.js +63 -0
  63. package/dist/services/session/caller-binding-service.d.ts +24 -0
  64. package/dist/services/session/caller-binding-service.js +34 -0
  65. package/dist/services/session/getSessionDir.js +15 -10
  66. package/dist/services/skills/hooks-codegate-superpowers.d.ts +33 -0
  67. package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
  68. package/dist/services/skills/hooks-settings-service.d.ts +10 -0
  69. package/dist/services/skills/hooks-settings-service.js +152 -61
  70. package/dist/services/slice/slice-check-service.d.ts +14 -0
  71. package/dist/services/slice/slice-check-service.js +110 -50
  72. package/dist/services/slice/slice-check-types.d.ts +12 -7
  73. package/dist/services/slice/slice-check-types.js +8 -3
  74. package/dist/services/slice/slice-decompose-runners.js +24 -21
  75. package/dist/services/sop/sop-check-service.js +12 -1
  76. package/dist/services/web/bounded-output.d.ts +34 -0
  77. package/dist/services/web/bounded-output.js +68 -0
  78. package/dist/services/web/browser-acquire.d.ts +14 -0
  79. package/dist/services/web/browser-acquire.js +84 -0
  80. package/dist/services/web/browser-session-manager.d.ts +111 -0
  81. package/dist/services/web/browser-session-manager.js +413 -0
  82. package/dist/services/web/daemon-entry.d.ts +1 -0
  83. package/dist/services/web/daemon-entry.js +65 -0
  84. package/dist/services/web/daemon-registry.d.ts +42 -0
  85. package/dist/services/web/daemon-registry.js +164 -0
  86. package/dist/services/web/daemon-supervisor.d.ts +144 -0
  87. package/dist/services/web/daemon-supervisor.js +455 -0
  88. package/dist/services/web/playwright-loader.d.ts +89 -0
  89. package/dist/services/web/playwright-loader.js +253 -0
  90. package/dist/services/web/snapshot-pruner.d.ts +48 -0
  91. package/dist/services/web/snapshot-pruner.js +241 -0
  92. package/dist/services/web/untrusted-envelope.d.ts +27 -0
  93. package/dist/services/web/untrusted-envelope.js +44 -0
  94. package/dist/services/web/web-artifact-paths.d.ts +79 -0
  95. package/dist/services/web/web-artifact-paths.js +163 -0
  96. package/dist/services/web/web-client.d.ts +19 -0
  97. package/dist/services/web/web-client.js +55 -0
  98. package/dist/services/web/web-daemon-service.d.ts +38 -0
  99. package/dist/services/web/web-daemon-service.js +416 -0
  100. package/dist/services/web/web-fallback.d.ts +70 -0
  101. package/dist/services/web/web-fallback.js +121 -0
  102. package/dist/services/web/web-install-service.d.ts +91 -0
  103. package/dist/services/web/web-install-service.js +346 -0
  104. package/dist/services/web/web-login-profile.d.ts +89 -0
  105. package/dist/services/web/web-login-profile.js +612 -0
  106. package/dist/services/web/web-login-staging.d.ts +27 -0
  107. package/dist/services/web/web-login-staging.js +173 -0
  108. package/dist/services/web/web-protocol.d.ts +58 -0
  109. package/dist/services/web/web-protocol.js +58 -0
  110. package/dist/services/web/web-status-report.d.ts +33 -0
  111. package/dist/services/web/web-status-report.js +47 -0
  112. package/dist/services/workspace/claude-settings-template.d.ts +41 -5
  113. package/dist/services/workspace/claude-settings-template.js +116 -64
  114. package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
  115. package/dist/services/workspace/workspace-service.js +33 -0
  116. package/package.json +5 -5
  117. package/scripts/copy-templates.mjs +12 -0
  118. package/scripts/sync-version.mjs +20 -0
  119. package/skills/bee/peaks-qa/SKILL.md +2 -0
  120. package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +12 -0
  121. package/skills/bee/peaks-rd/SKILL.md +2 -0
  122. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +14 -0
  123. package/skills/bee/peaks-txt/SKILL.md +2 -0
  124. package/skills/bee/peaks-ui/SKILL.md +2 -0
  125. package/skills/peaks-code/SKILL.md +18 -0
  126. package/skills/peaks-code/references/browser-workflow.md +10 -1
  127. package/skills/peaks-code/references/context-governance.md +29 -0
  128. package/skills/peaks-doctor/SKILL.md +2 -0
@@ -0,0 +1,612 @@
1
+ /**
2
+ * The persistent login profile — its name guard, and the headed login that
3
+ * produces it (slice S4, file 20; design §2/§5/§10.2, orchestrator decision C1).
4
+ *
5
+ * Two halves, one responsibility ("a profile the user explicitly asked to keep"):
6
+ *
7
+ * 1. The PATH GUARD. `~/.peaks/web-profiles/<name>/storageState.json`, where
8
+ * `<name>` must match `PROFILE_NAME_RE` after lower-case folding, must not
9
+ * be a dot segment, must not BEGIN with a dot (`.con` has an empty stem, so
10
+ * the device-name test cannot see it, and `..foo` is a dot segment the
11
+ * charset lets through), must not end in a dot or a space (see
12
+ * `hasTrailingDotOrSpace`), must not be a Windows device name, and must
13
+ * resolve under the profile root. Re-derived from `resolveUserDataDir`
14
+ * (`src/cli/commands/playwright-commands.ts:462`), whose prefix-plus-separator
15
+ * containment test is what stops `/home/A/prof2` passing for `/home/A/prof`.
16
+ * The charset test alone is NOT the guard: `..` matches
17
+ * `^[a-z0-9._-]{1,64}$` — the dot-segment rejection is what catches it.
18
+ *
19
+ * 2. The HEADED LOGIN. C1 is binding: never `launchPersistentContext`, which
20
+ * would create one browser PROCESS per dispatch and violate Q8. One
21
+ * `chromium.launch({ headless: false })` the user drives, and the profile is
22
+ * captured as a Playwright storage state — not a Chromium `userDataDir`.
23
+ *
24
+ * This is the ONLY path in the feature that persists a login (PRD R7), so it is
25
+ * reached only from an explicit `peaks web login --profile <name>`. The daemon's
26
+ * browser is headless and its own; `login` neither needs a session binding nor
27
+ * goes through `routeOp`.
28
+ *
29
+ * THE INTERACTION PROTOCOL (user decision, 2026-09-10 — it REPLACED an explicit
30
+ * `login.confirmed` file):
31
+ *
32
+ * - "Done" is the user CLOSING THE HEADED WINDOW. It is the only signal that
33
+ * does not ask the user to do something they would not otherwise do, and
34
+ * `browser.on('disconnected')` is what the wait ends on.
35
+ * - The capture is IN MEMORY, and that is a hard constraint, not a preference:
36
+ * with a non-persistent context — which C1 mandates — the context is gone
37
+ * the moment the browser disconnects. Verified on the pinned
38
+ * playwright@1.63.0: `context.storageState()` after disconnect throws
39
+ * "Target page, context or browser has been closed". So the state is read
40
+ * INTO THIS PROCESS every `LOGIN_SNAPSHOT_MS` while the window is open
41
+ * (`storageState()` with no `path`, which returns the object), and written to
42
+ * `storageState.json` EXACTLY ONCE, at the moment of disconnect.
43
+ * - The publish is ATOMIC (user decision UD-7, 2026-09-10); it lives in
44
+ * `web-login-staging.ts`. The bytes land in a staging sibling IN THE SAME
45
+ * PROFILE DIRECTORY and a `renameSync` linearizes them onto
46
+ * `storageState.json`, so the previous profile is untouched unless that
47
+ * rename succeeds. That is the fix for what a single `writeFileSync` did:
48
+ * its default flag `w` is `O_TRUNC`, so it emptied the artifact AT OPEN —
49
+ * before the first byte — and a mid-write `ENOSPC`/`EIO` destroyed a working
50
+ * login while the failure message called it "unchanged". The staging name is
51
+ * PER-RUN (`storageState.json.<pid>.staging`, S4 R5) because a fixed one let
52
+ * two concurrent logins on one profile install each other's bytes.
53
+ * `browser-workflow.md` names the staging file as a carve-out with three
54
+ * conditions, all kept: it lives in the profile directory, it is deleted on
55
+ * every failure path, and nothing ever reads it as a profile. An unclosed or
56
+ * never-captured login publishes NOTHING at all.
57
+ * - An EMPTY capture is not a session. A run that captured NOTHING — no
58
+ * cookies AND no origins — writes nothing and fails, rather than replacing a
59
+ * working profile with an empty one and reporting a login that did not
60
+ * happen as a success. A state that holds only `origins` (a session kept in
61
+ * localStorage) IS a session and is published (S4 R5).
62
+ * - The persisted state can be up to one snapshot interval STALE: a cookie set
63
+ * in the last second before the window is closed may be missing. The capture
64
+ * is never exact and is never described as exact.
65
+ * - The wait is bounded by a MONOTONIC deadline (`LOGIN_TIMEOUT_MS`) taken
66
+ * BEFORE the launch — so launch, context and page creation SPEND that budget
67
+ * rather than sitting outside it — and the teardown is bounded too, so
68
+ * neither an abandoned login nor a `browser.close()` that never settles can
69
+ * hold the process (or a browser process) open for the rest of the machine's
70
+ * uptime. The residual, stated rather than implied: those setup steps are
71
+ * inside the budget but are NOT individually raced, so a `launch()` that
72
+ * never settles would still hang. Bounding it would mean abandoning a
73
+ * browser this code has no handle on yet.
74
+ */
75
+ import { mkdirSync, readFileSync, statSync } from 'node:fs';
76
+ import { join } from 'node:path';
77
+ import { performance } from 'node:perf_hooks';
78
+ import { getErrorMessage } from 'peaks-loop-shared/result';
79
+ import { assertUnder, userWebProfilesDir } from './web-artifact-paths.js';
80
+ import { loadPlaywright } from './playwright-loader.js';
81
+ import { discardStaging, publishState } from './web-login-staging.js';
82
+ /**
83
+ * A profile name is ONE path component: LOWER-case letters, digits, dot,
84
+ * underscore and hyphen, 1–64 of them. `..` matches this charset (it is two
85
+ * dots and nothing else), which is exactly why `resolveProfileName`'s
86
+ * dot-segment rejection is not redundant.
87
+ *
88
+ * This describes the CANONICAL form. NTFS and default APFS fold case, so `Work`
89
+ * and `work` are ONE directory on those filesystems; `resolveProfileName`
90
+ * therefore lower-cases what the caller typed and uses the folded result, rather
91
+ * than rejecting a name the caller means perfectly well. The fold is reported
92
+ * back so it is never silent.
93
+ */
94
+ export const PROFILE_NAME_RE = /^[a-z0-9._-]{1,64}$/;
95
+ /** `.` would collide with the profile ROOT; `..` would climb out of it. */
96
+ const DOT_SEGMENTS = new Set(['.', '..']);
97
+ /**
98
+ * Windows device names, reserved as a path component even with an extension
99
+ * (`con.json` opens the device). A profile that lands on one writes nothing, so
100
+ * a "successful" login would silently persist no session at all.
101
+ */
102
+ const RESERVED_DEVICE_NAMES = new Set([
103
+ 'con',
104
+ 'prn',
105
+ 'aux',
106
+ 'nul',
107
+ ...Array.from({ length: 9 }, (_unused, index) => `com${String(index + 1)}`),
108
+ ...Array.from({ length: 9 }, (_unused, index) => `lpt${String(index + 1)}`)
109
+ ]);
110
+ /**
111
+ * How much of an unvalidated or caller-typed name a message may echo back. A
112
+ * refusal is not a place to reproduce a 1 MB `--profile` (S1's bounded-output
113
+ * invariant), so both the guard here and the CLI's gate refusal cap it with the
114
+ * SAME function.
115
+ */
116
+ const MAX_ECHOED_NAME_CHARS = 64;
117
+ /** Bound a caller-supplied name before it is embedded in a message. */
118
+ export function cappedEcho(raw) {
119
+ return raw.length > MAX_ECHOED_NAME_CHARS ? `${raw.slice(0, MAX_ECHOED_NAME_CHARS)}…` : raw;
120
+ }
121
+ /**
122
+ * Whether `raw` ends in a dot or a space.
123
+ *
124
+ * Windows strips both, so `work.` has no stable name of its own there — it is
125
+ * the `work` directory. Unlike case, which folds into a name the caller plainly
126
+ * meant, trimming would silently rewrite the name, so this is REJECTED rather
127
+ * than folded.
128
+ */
129
+ function hasTrailingDotOrSpace(raw) {
130
+ return raw !== raw.replace(/[. ]+$/, '');
131
+ }
132
+ /** `<homedir>/.peaks/web-profiles/<name>/`. The name must be resolved first. */
133
+ export function webProfileDir(name) {
134
+ return join(userWebProfilesDir(), name);
135
+ }
136
+ /**
137
+ * Validate a caller-supplied `--profile` and return the CANONICAL name — the
138
+ * input lower-cased, which is the one name the filesystem will actually store.
139
+ * `--profile Work` resolves to `work`.
140
+ *
141
+ * The fold is deliberate (user decision, 2026-09-10) and is never silent: the
142
+ * call site compares what it typed with what came back and reports the
143
+ * difference. What has no sensible canonical form is still refused — see
144
+ * `hasTrailingDotOrSpace`.
145
+ *
146
+ * Two refusals are DELIBERATE rather than incidental (S4 repair, security S6):
147
+ *
148
+ * - a LEADING dot is refused, because it makes `folded.split('.')[0]` empty.
149
+ * `.con` is the Windows device under a name the stem test cannot see, and
150
+ * `..foo` is a dot segment that `PROFILE_NAME_RE` accepts — it used to be
151
+ * stopped by `assertUnder` instead, which surfaced `WEB_PATH_ESCAPE` under
152
+ * the wrong code. Both now carry this function's own message.
153
+ * - the caller's input is echoed CAPPED, like the CLI's gate refusal, so a
154
+ * huge `--profile` cannot be reproduced whole in a message.
155
+ *
156
+ * Throws `WEB_PROFILE_NAME_INVALID`, and returns the NAME rather than a path so
157
+ * a call site cannot mistake one for the other.
158
+ */
159
+ export function resolveProfileName(raw) {
160
+ const folded = raw.toLowerCase();
161
+ const deviceName = folded.split('.')[0] ?? '';
162
+ if (!PROFILE_NAME_RE.test(folded) ||
163
+ DOT_SEGMENTS.has(folded) ||
164
+ deviceName === '' ||
165
+ hasTrailingDotOrSpace(raw) ||
166
+ RESERVED_DEVICE_NAMES.has(deviceName)) {
167
+ throw new Error(`WEB_PROFILE_NAME_INVALID: ${JSON.stringify(cappedEcho(raw))} must match ` +
168
+ `${PROFILE_NAME_RE.source} after lower-casing and must not begin with a dot, be a dot ` +
169
+ 'segment, be a Windows device name, or end in a dot or a space');
170
+ }
171
+ assertUnder(webProfileDir(folded), userWebProfilesDir());
172
+ return folded;
173
+ }
174
+ /**
175
+ * `<homedir>/.peaks/web-profiles/<name>/storageState.json` — the persisted
176
+ * artifact, and the only thing `login` writes.
177
+ */
178
+ export function loginStorageStatePath(name) {
179
+ return join(webProfileDir(resolveProfileName(name)), 'storageState.json');
180
+ }
181
+ /**
182
+ * How long one headed login may stay open. Bounded so an abandoned `login`
183
+ * cannot leave a browser process behind indefinitely; ten minutes is generous
184
+ * for a password plus an MFA round trip.
185
+ */
186
+ const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
187
+ /**
188
+ * How often the session is read into memory while the window is open. This IS
189
+ * the staleness bound of what gets persisted (see the module docstring): a
190
+ * cookie set after the last read is not in the saved profile.
191
+ */
192
+ const LOGIN_SNAPSHOT_MS = 1_000;
193
+ /**
194
+ * How long teardown may take before it is REPORTED instead of waited on. The
195
+ * docstring promises an abandoned login cannot hold a browser open forever; an
196
+ * unbounded `browser.close()` in the `finally` is exactly how that promise would
197
+ * be broken (S4 R3). A close that loses this race is not silent — the caller is
198
+ * told the browser may still be running.
199
+ */
200
+ const TEARDOWN_TIMEOUT_MS = 5_000;
201
+ /**
202
+ * The profile DIRECTORY's mode. The tree holds live session cookies, so it gets
203
+ * the same owner-only treatment the daemon token does
204
+ * (`daemon-registry.ts:24-33`), for a strictly more valuable file. The
205
+ * ARTIFACT's mode — and the plain statement of what these calls are worth on
206
+ * Windows, where they are inert — lives with the write, in
207
+ * `web-login-staging.ts`.
208
+ *
209
+ * On POSIX this one is what it looks like: `mkdirSync(…, {mode: 0o700})` yields
210
+ * 0700 for any umask.
211
+ */
212
+ const PROFILE_DIR_MODE = 0o700;
213
+ /** The code a completed-but-unreadable capture carries on an `ok` outcome. */
214
+ const STATE_UNREADABLE_CODE = 'WEB_LOGIN_STATE_UNREADABLE';
215
+ /**
216
+ * Open the headed browser, read the session into memory while the user works,
217
+ * and persist it when the user closes the window.
218
+ *
219
+ * Everything that happens once the browser is up comes back as `ok: false` with
220
+ * a code rather than a throw. A failure BEFORE that point — an invalid name, an
221
+ * unresolvable Playwright, a launch that will not start — does throw, because
222
+ * there is no browser to tear down and no outcome to describe; the CLI renders
223
+ * it with the same code and the same envelope shape, so the caller sees one
224
+ * contract either way.
225
+ */
226
+ export async function runHeadedLogin(options) {
227
+ const profile = resolveProfileName(options.profile);
228
+ const dir = webProfileDir(profile);
229
+ const statePath = join(dir, 'storageState.json');
230
+ const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
231
+ // Taken BEFORE the launch (S4 R3): the bound covers the whole command, not
232
+ // just the wait. Launch, context and page creation are time the user is not
233
+ // getting back, and a docstring claim about not holding a browser open forever
234
+ // has to include them.
235
+ const deadline = performance.now() + timeoutMs;
236
+ const warnings = [];
237
+ mkdirSync(dir, { recursive: true, mode: PROFILE_DIR_MODE });
238
+ const playwright = await loadPlaywright();
239
+ const browser = await playwright.chromium.launch({ headless: false });
240
+ let closed = false;
241
+ let captured = false;
242
+ let emptyCapture = false;
243
+ let failure = null;
244
+ let state = null;
245
+ let readErrorWhileOpen = null;
246
+ try {
247
+ // The disconnect listener is attached BEFORE the first await (S4 repair,
248
+ // code review F3): `disconnected` is not replayed, so a browser that dies
249
+ // while the context or the page is being created would otherwise be missed,
250
+ // `closed` would stay false, and the wait would spin out the full ten
251
+ // minutes on a browser that is already gone. `isConnected()` is the same
252
+ // defence for a browser that was already dead when the launch returned.
253
+ const disconnect = watchDisconnect(browser);
254
+ const context = await browser.newContext({ acceptDownloads: false });
255
+ await context.newPage();
256
+ options.announce();
257
+ const result = await snapshotUntilClosed(context, disconnect, deadline);
258
+ closed = result.closed;
259
+ captured = result.captured;
260
+ readErrorWhileOpen = result.readErrorWhileOpen;
261
+ // What is in memory IS what was captured: a session never read is never
262
+ // written, whatever else happened.
263
+ if (result.closed && result.captured) {
264
+ // The write site's own guard (tech-doc §7.2 rule 2), on top of the one
265
+ // `resolveProfileName` already ran on the directory.
266
+ assertUnder(statePath, userWebProfilesDir());
267
+ // Nothing captured is not a session (S4 R3, both lenses): publishing it
268
+ // would replace a working profile with an empty one AND report a login
269
+ // that did not happen as a success. Nothing is written, and the outcome
270
+ // below is a failure — so this cannot overwrite an existing profile
271
+ // either. What counts as nothing is BOTH arrays empty (S4 R5): an
272
+ // origins-only state is a localStorage session, not an empty capture.
273
+ if (isEmptyCapture(result.snapshot)) {
274
+ emptyCapture = true;
275
+ }
276
+ else {
277
+ publishState(statePath, result.snapshot, warnings);
278
+ state = readStateCounts(statePath);
279
+ if (state.failure !== null) {
280
+ warnings.push(`the storage state at ${statePath} could not be read back: ${state.failure}`);
281
+ }
282
+ }
283
+ }
284
+ }
285
+ catch (error) {
286
+ failure = { code: 'WEB_LOGIN_FAILED', message: getErrorMessage(error) };
287
+ if (captured) {
288
+ // The publish is what can fail here (a lock on the file, no space), and
289
+ // the freshly captured session then lives only in this process's memory
290
+ // and is dropped.
291
+ //
292
+ // The message claims only what this run can verify (S4 R5): our ONLY
293
+ // artifact-touching operation is the rename, and it threw, so THIS RUN did
294
+ // not modify the artifact. The older wording ("is unchanged") asserted
295
+ // something about the file that a concurrent login on the same profile can
296
+ // make false — and, before the staging publish, was false for a single
297
+ // `writeFileSync`, which truncated the artifact at open (S4 R3).
298
+ warnings.push(`the session captured for profile "${profile}" was NOT written; this run did not ` +
299
+ `modify ${statePath}`);
300
+ }
301
+ }
302
+ finally {
303
+ // Teardown stays non-blocking but never silent (S1's F1): a browser that
304
+ // will not close — or will not confirm that it did — is reported rather than
305
+ // swallowed, and it is BOUNDED so a wedged close cannot pin the process.
306
+ await closeBounded(browser, timeoutMs, warnings);
307
+ // Every path out: published, refused, empty, timed out, never captured, or
308
+ // thrown. A staging file holds live cookies and must never outlive this run.
309
+ discardStaging(statePath, warnings);
310
+ }
311
+ const stats = state ?? { bytes: 0, cookies: 0, origins: 0, failure: null };
312
+ if (failure !== null) {
313
+ return {
314
+ ok: false,
315
+ code: failure.code,
316
+ message: failure.message,
317
+ profile,
318
+ storageStatePath: statePath,
319
+ ...zeroStats(),
320
+ warnings
321
+ };
322
+ }
323
+ if (!closed) {
324
+ return {
325
+ ok: false,
326
+ code: 'WEB_LOGIN_NOT_CLOSED',
327
+ message: `the headed browser for profile "${profile}" was not closed within ` +
328
+ `${String(Math.round(timeoutMs / 1000))} s, so no storage state was written`,
329
+ profile,
330
+ storageStatePath: statePath,
331
+ ...zeroStats(),
332
+ warnings
333
+ };
334
+ }
335
+ if (!captured) {
336
+ // The window closed, but no snapshot was ever taken — nothing was captured,
337
+ // so there is nothing to save and nothing that would make an empty file
338
+ // look like a session.
339
+ //
340
+ // WHY, though, is not always "it closed" (S4 R3). A `storageState()` that
341
+ // keeps failing while the browser is still up — a protocol error, a wedged
342
+ // context — used to be swallowed by a bare `catch` and reported as a close,
343
+ // forever, with the real cause never surfaced. `readErrorWhileOpen` is only
344
+ // set for a read that failed with the browser STILL CONNECTED and with an
345
+ // error that is not the close's own "Target … has been closed" (S4 R5), so a
346
+ // close cannot be mistaken for it — in either ordering.
347
+ const why = readErrorWhileOpen === null
348
+ ? 'closed before its session could be read'
349
+ : `never returned a readable session (every attempt failed while it was open: ${readErrorWhileOpen})`;
350
+ return {
351
+ ok: false,
352
+ code: 'WEB_LOGIN_NO_SNAPSHOT',
353
+ message: `the headed browser for profile "${profile}" ${why}, so no storage state was written`,
354
+ profile,
355
+ storageStatePath: statePath,
356
+ ...zeroStats(),
357
+ warnings
358
+ };
359
+ }
360
+ if (emptyCapture) {
361
+ // The window closed and a state WAS read, but it holds NOTHING — neither
362
+ // cookies nor origins. For a verb whose whole purpose is persisting a
363
+ // session that is not a success, and writing it would replace a working
364
+ // profile with a useless one (S4 R3). Nothing was published: nothing was
365
+ // written by this run.
366
+ return {
367
+ ok: false,
368
+ code: 'WEB_LOGIN_EMPTY_SNAPSHOT',
369
+ message: `the headed browser for profile "${profile}" was closed but the captured session holds ` +
370
+ 'neither cookies nor origins, so no storage state was written — that is not a login',
371
+ profile,
372
+ storageStatePath: statePath,
373
+ ...zeroStats(),
374
+ warnings
375
+ };
376
+ }
377
+ return {
378
+ ok: true,
379
+ // The write landed, so this is not a failure — but an unreadable capture is
380
+ // not a clean success either, and `ok: true, cookies: 0` alone reads as one
381
+ // (R5). The code is what an ok-only consumer cannot miss.
382
+ code: stats.failure === null ? '' : STATE_UNREADABLE_CODE,
383
+ message: '',
384
+ profile,
385
+ storageStatePath: statePath,
386
+ bytes: stats.bytes,
387
+ cookies: stats.cookies,
388
+ origins: stats.origins,
389
+ warnings
390
+ };
391
+ }
392
+ function zeroStats() {
393
+ return { bytes: 0, cookies: 0, origins: 0 };
394
+ }
395
+ /**
396
+ * An empty capture — a parseable storage state that holds NOTHING: no cookies
397
+ * AND no origins. It is not a session, so it is never published and never
398
+ * reported as a success (S4 R3, both lenses).
399
+ *
400
+ * BOTH, not just cookies (S4 R5). A state that keeps its session in `origins`
401
+ * (a localStorage token) is a real, valid storage state, and refusing it made
402
+ * the persistent-login verb unable to persist a non-cookie session. The hazard
403
+ * this refusal exists for is publishing *nothing* over a working profile.
404
+ *
405
+ * A raw-string capture (`stateRaw`) is not an empty one: `typeof` excludes it,
406
+ * so the corrupt-artifact path still reaches the read-back check that reports
407
+ * it. Nor is a MALFORMED one (`{"cookies": 1}`) — the read-back check is what
408
+ * reports that, which is why this reads strictly for two empty arrays.
409
+ */
410
+ function isEmptyCapture(snapshot) {
411
+ if (snapshot === null || typeof snapshot !== 'object') {
412
+ return false;
413
+ }
414
+ const cookies = snapshot.cookies;
415
+ const origins = snapshot.origins;
416
+ return (Array.isArray(cookies) &&
417
+ cookies.length === 0 &&
418
+ Array.isArray(origins) &&
419
+ origins.length === 0);
420
+ }
421
+ /**
422
+ * Watch for the user closing the window (UD-4), from the moment the browser is
423
+ * up — before any other await, because `disconnected` is not replayed.
424
+ *
425
+ * `isConnected()` is consulted FIRST: a browser that is already gone must fail
426
+ * fast rather than run out the timeout, and there is no event left to wait for.
427
+ * Both members are optional on `PwBrowser`, so a browser that cannot report
428
+ * either is treated as never disconnecting — the pre-existing behaviour.
429
+ */
430
+ function watchDisconnect(browser) {
431
+ let closed = browser.isConnected?.() === false;
432
+ let settleClosed = () => undefined;
433
+ const whenClosed = new Promise((settle) => {
434
+ settleClosed = settle;
435
+ });
436
+ if (closed) {
437
+ settleClosed();
438
+ }
439
+ else {
440
+ browser.on?.('disconnected', () => {
441
+ closed = true;
442
+ settleClosed();
443
+ });
444
+ }
445
+ return { closed: () => closed, whenClosed };
446
+ }
447
+ /**
448
+ * Read the session into memory every `LOGIN_SNAPSHOT_MS` until the user closes
449
+ * the window, and report how it ended plus the last successful read.
450
+ *
451
+ * The read has to happen WHILE the window is open: once the browser disconnects
452
+ * the non-persistent context (C1) cannot be read at all — `storageState()` then
453
+ * throws "Target page, context or browser has been closed", verified on the
454
+ * pinned playwright@1.63.0. So the state in memory at the moment of disconnect
455
+ * is the LAST read, up to one interval old, and that is the honest staleness
456
+ * bound of the saved profile.
457
+ *
458
+ * MONOTONIC deadline (R8): on wall-clock time a backward NTP/DST/VM-resume step
459
+ * moves the deadline away faster than `now()` advances, `now() >= deadline`
460
+ * never holds, and an abandoned login holds a headed browser open for the rest
461
+ * of the machine's uptime — the opposite of what the bound is for.
462
+ */
463
+ async function snapshotUntilClosed(context, disconnect, deadline) {
464
+ let captured = false;
465
+ let snapshot = null;
466
+ let readErrorWhileOpen = null;
467
+ for (;;) {
468
+ const read = await readSession(context);
469
+ if (read.ok) {
470
+ captured = true;
471
+ snapshot = read.state;
472
+ }
473
+ if (disconnect.closed()) {
474
+ return { closed: true, captured, snapshot, readErrorWhileOpen };
475
+ }
476
+ // A failed read the CLOSE cannot explain: the browser is still connected
477
+ // (S4 R3), and the error is not Playwright's own "it is gone" (S4 R5).
478
+ //
479
+ // The check is AFTER the read, not before it, because the rejection can land
480
+ // before the `disconnected` event does — and with the check up front, that
481
+ // ordering recorded a close as a read failure and the outcome blamed the
482
+ // read. The wording check covers what the event still cannot: the error
483
+ // itself says the target is closed (F3).
484
+ if (!read.ok && !read.closedTarget) {
485
+ readErrorWhileOpen = read.error;
486
+ }
487
+ if (performance.now() >= deadline) {
488
+ return { closed: false, captured, snapshot, readErrorWhileOpen };
489
+ }
490
+ // Wakes on the disconnect rather than after the full interval, so closing
491
+ // the window ends the wait (and publishes) without an extra second's delay.
492
+ await Promise.race([sleep(LOGIN_SNAPSHOT_MS), disconnect.whenClosed]);
493
+ }
494
+ }
495
+ async function readSession(context) {
496
+ try {
497
+ return { ok: true, state: await context.storageState(), error: null, closedTarget: false };
498
+ }
499
+ catch (error) {
500
+ const raw = getErrorMessage(error);
501
+ return { ok: false, state: null, error: cappedEcho(raw), closedTarget: isClosedTargetError(raw) };
502
+ }
503
+ }
504
+ /**
505
+ * Whether a failed read is EXPLAINED BY THE CLOSE — Playwright's own wording for
506
+ * "the target you asked about is gone" — rather than by a read that genuinely
507
+ * failed while the browser was up.
508
+ *
509
+ * `disconnect.closed()` alone cannot separate the two: the `disconnected` event
510
+ * can still be in flight when the rejection lands, which is how a close came to
511
+ * be reported as "every attempt failed while it was open: Target … has been
512
+ * closed" (S4 R5 / F3). The error's own text is the only other signal a rejected
513
+ * `storageState()` gives, and this is what it says.
514
+ */
515
+ function isClosedTargetError(raw) {
516
+ return /has been closed|Target closed/i.test(raw);
517
+ }
518
+ /**
519
+ * Size plus cookie/origin COUNTS of the state just written.
520
+ *
521
+ * Counts only: the values are exactly what `browser-workflow.md` forbids putting
522
+ * in an artifact, and they would be printed to a terminal here. A read-back that
523
+ * fails is REPORTED rather than swallowed — the write already happened, so the
524
+ * caller must not be told the profile is fine when nothing could be read (S1's
525
+ * F1). It returns zero counts instead of throwing for the same reason: an
526
+ * unreadable file is news, not a reason to lose the path.
527
+ *
528
+ * "Unreadable" includes JSON that parses but is NOT a storage state: a file
529
+ * holding `null`, a string or `{"cookies": 1}` is not a capture, and reporting
530
+ * it as `cookies: 0` with no failure would make a corrupt artifact read exactly
531
+ * like a clean one that merely has no cookies.
532
+ *
533
+ * The failure text is OURS, never the parser's: V8 quotes the head of its input
534
+ * into `JSON.parse`'s message, and that input is a live session cookie value —
535
+ * so the message would carry a fragment of it to the terminal and the
536
+ * transcript. The byte count is the diagnostic instead (S4-3).
537
+ */
538
+ function readStateCounts(path) {
539
+ let bytes = 0;
540
+ try {
541
+ // Kept outside the parse so an unparseable-but-present file still reports
542
+ // the size it really has.
543
+ bytes = statSync(path).size;
544
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
545
+ if (parsed === null ||
546
+ typeof parsed !== 'object' ||
547
+ !Array.isArray(parsed.cookies) ||
548
+ !Array.isArray(parsed.origins)) {
549
+ return { bytes, ...unreadable(bytes) };
550
+ }
551
+ return { bytes, cookies: parsed.cookies.length, origins: parsed.origins.length, failure: null };
552
+ }
553
+ catch {
554
+ return { bytes, ...unreadable(bytes) };
555
+ }
556
+ }
557
+ /** The one refusal shape, so both unreadable branches read the same. */
558
+ function unreadable(bytes) {
559
+ return {
560
+ cookies: 0,
561
+ origins: 0,
562
+ failure: `it is not a readable storage state (${String(bytes)} bytes on disk)`
563
+ };
564
+ }
565
+ /**
566
+ * Close the headed browser, but never wait on it forever.
567
+ *
568
+ * `browser.close()` is a promise the browser can fail to settle — a wedged
569
+ * renderer, a process that was killed but not reaped — and an unbounded `await`
570
+ * in the `finally` is exactly how the module docstring's "cannot hold a browser
571
+ * open forever" would stop being true (S4 R3). So it is raced against a timer:
572
+ * `min(timeoutMs, TEARDOWN_TIMEOUT_MS)` — a test that shortens the login bound
573
+ * shortens teardown with it, a production run gets the constant.
574
+ *
575
+ * Losing the race is REPORTED, never silent. The caller has to know a browser
576
+ * process may still be on the machine, and the close is not retried or waited
577
+ * on: this command is over.
578
+ */
579
+ async function closeBounded(browser, timeoutMs, warnings) {
580
+ const boundMs = Math.min(timeoutMs, TEARDOWN_TIMEOUT_MS);
581
+ let expired = false;
582
+ const bound = sleep(boundMs).then(() => {
583
+ expired = true;
584
+ });
585
+ try {
586
+ // The call is INSIDE the try: a synchronous throw from `close()` must be
587
+ // reported as a teardown warning, not escape and replace the outcome.
588
+ await Promise.race([browser.close(), bound]);
589
+ }
590
+ catch (error) {
591
+ warnings.push(`the headed browser did not close cleanly: ${getErrorMessage(error)}`);
592
+ return;
593
+ }
594
+ if (expired) {
595
+ warnings.push(`the headed browser did not report closed within ${String(boundMs)} ms; it may still be running`);
596
+ }
597
+ }
598
+ /**
599
+ * The interval timer is `unref`'d: whenever the disconnect wins the race (the
600
+ * common case) the timeout is orphaned, and an orphan that holds the event loop
601
+ * would keep the `peaks web login` process alive up to one interval past the
602
+ * command having logically finished (code review F4). It still fires normally
603
+ * while the process is alive, which is all the race needs.
604
+ */
605
+ function sleep(ms) {
606
+ return new Promise((settle) => {
607
+ const timer = setTimeout(() => {
608
+ settle();
609
+ }, ms);
610
+ timer.unref?.();
611
+ });
612
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Publish the captured session ATOMICALLY (UD-7): stage the bytes beside the
3
+ * artifact, then `renameSync` them onto it.
4
+ *
5
+ * A rename within one directory is one filesystem operation, so
6
+ * `storageState.json` is either the previous profile or the complete new one —
7
+ * never a truncated half of either. That is what makes the failure message
8
+ * honest: a thrown write or a thrown rename leaves the artifact as it was, so
9
+ * "this run did not modify it" is a fact rather than an assertion.
10
+ *
11
+ * The staging file never survives this call: the `catch` unlinks it before
12
+ * rethrowing, and `runHeadedLogin`'s `finally` sweeps it on every path out.
13
+ * Nothing may ever READ the staging path; it exists only between these two
14
+ * calls.
15
+ */
16
+ export declare function publishState(statePath: string, snapshot: unknown, warnings: string[]): void;
17
+ /**
18
+ * Delete this run's staging file, and sweep any a KILLED run left behind.
19
+ *
20
+ * Called on EVERY path out of `runHeadedLogin` — a thrown write, a failed
21
+ * rename, a timeout, a never-captured close, and after a successful publish
22
+ * (where the rename has already consumed the file, making this a no-op). The
23
+ * staging file holds live session cookies, and the carve-out allows it only
24
+ * while a publish is in flight: it must never outlive the command that created
25
+ * it.
26
+ */
27
+ export declare function discardStaging(statePath: string, warnings: string[]): void;