@jevitate/cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-cli.d.ts +3 -1
- package/dist/ai-cli.js +60 -16
- package/dist/ai-cli.js.map +1 -1
- package/dist/approval-provenance.d.ts +136 -0
- package/dist/approval-provenance.js +317 -0
- package/dist/approval-provenance.js.map +1 -0
- package/dist/author-journey-cli.js +4 -2
- package/dist/author-journey-cli.js.map +1 -1
- package/dist/bin.js +30544 -20851
- package/dist/browser-cli.d.ts +10 -0
- package/dist/browser-cli.js +50 -0
- package/dist/browser-cli.js.map +1 -0
- package/dist/build-info.generated.js +2 -2
- package/dist/builtin-analyzers.d.ts +7 -0
- package/dist/builtin-analyzers.js +19 -0
- package/dist/builtin-analyzers.js.map +1 -0
- package/dist/campaign-cli.d.ts +6 -0
- package/dist/campaign-cli.js +9 -4
- package/dist/campaign-cli.js.map +1 -1
- package/dist/catalog-analysis.d.ts +70 -0
- package/dist/catalog-analysis.js +507 -0
- package/dist/catalog-analysis.js.map +1 -0
- package/dist/catalog-api.d.ts +73 -0
- package/dist/catalog-api.js +97 -0
- package/dist/catalog-api.js.map +1 -0
- package/dist/catalog-cli.d.ts +12 -0
- package/dist/catalog-cli.js +224 -0
- package/dist/catalog-cli.js.map +1 -0
- package/dist/catalog-review.d.ts +29 -0
- package/dist/catalog-review.js +290 -0
- package/dist/catalog-review.js.map +1 -0
- package/dist/catalog.d.ts +133 -0
- package/dist/catalog.js +352 -0
- package/dist/catalog.js.map +1 -0
- package/dist/change-context.d.ts +44 -0
- package/dist/change-context.js +203 -0
- package/dist/change-context.js.map +1 -0
- package/dist/check-api.d.ts +1 -1
- package/dist/check-api.js +68 -9
- package/dist/check-api.js.map +1 -1
- package/dist/check-cli.d.ts +5 -1
- package/dist/check-cli.js +64 -10
- package/dist/check-cli.js.map +1 -1
- package/dist/check-execute.d.ts +16 -1
- package/dist/check-execute.js +78 -31
- package/dist/check-execute.js.map +1 -1
- package/dist/check-plan.d.ts +14 -0
- package/dist/check-plan.js +28 -2
- package/dist/check-plan.js.map +1 -1
- package/dist/check-suite.js +6 -3
- package/dist/check-suite.js.map +1 -1
- package/dist/check-types.d.ts +51 -6
- package/dist/check-types.js +8 -0
- package/dist/check-types.js.map +1 -1
- package/dist/cli-output.js +85 -5
- package/dist/cli-output.js.map +1 -1
- package/dist/cli-shared.d.ts +22 -2
- package/dist/cli-shared.js +34 -5
- package/dist/cli-shared.js.map +1 -1
- package/dist/demo-aspect-api.d.ts +15 -0
- package/dist/demo-aspect-api.js +26 -1
- package/dist/demo-aspect-api.js.map +1 -1
- package/dist/demo-aspect-cli.js +64 -5
- package/dist/demo-aspect-cli.js.map +1 -1
- package/dist/exit-codes.d.ts +6 -3
- package/dist/exit-codes.js +13 -3
- package/dist/exit-codes.js.map +1 -1
- package/dist/explore-adversarial.d.ts +3 -1
- package/dist/explore-adversarial.js +4 -4
- package/dist/explore-adversarial.js.map +1 -1
- package/dist/explore-author.js +5 -2
- package/dist/explore-author.js.map +1 -1
- package/dist/explore-cli.js +145 -9
- package/dist/explore-cli.js.map +1 -1
- package/dist/explore-coverage.d.ts +6 -2
- package/dist/explore-coverage.js +7 -6
- package/dist/explore-coverage.js.map +1 -1
- package/dist/explore-feature.d.ts +3 -1
- package/dist/explore-feature.js +5 -5
- package/dist/explore-feature.js.map +1 -1
- package/dist/explore-goal.d.ts +37 -6
- package/dist/explore-goal.js +69 -50
- package/dist/explore-goal.js.map +1 -1
- package/dist/explore-shared.d.ts +12 -7
- package/dist/explore-shared.js +9 -1
- package/dist/explore-shared.js.map +1 -1
- package/dist/findings-filing.d.ts +6 -0
- package/dist/findings-filing.js +15 -1
- package/dist/findings-filing.js.map +1 -1
- package/dist/goal-run-flags.d.ts +2 -0
- package/dist/goal-run-flags.js +8 -1
- package/dist/goal-run-flags.js.map +1 -1
- package/dist/heal-probe-guard.d.ts +9 -0
- package/dist/heal-probe-guard.js +113 -0
- package/dist/heal-probe-guard.js.map +1 -0
- package/dist/host-health-run.d.ts +32 -1
- package/dist/host-health-run.js +38 -1
- package/dist/host-health-run.js.map +1 -1
- package/dist/http-5xx-outcome.d.ts +8 -3
- package/dist/http-5xx-outcome.js +5 -15
- package/dist/http-5xx-outcome.js.map +1 -1
- package/dist/init-cli.d.ts +7 -0
- package/dist/init-cli.js +79 -9
- package/dist/init-cli.js.map +1 -1
- package/dist/init-codeowners.d.ts +39 -0
- package/dist/init-codeowners.js +91 -0
- package/dist/init-codeowners.js.map +1 -0
- package/dist/init-keys.d.ts +3 -1
- package/dist/init-keys.js +20 -8
- package/dist/init-keys.js.map +1 -1
- package/dist/init-skills.d.ts +85 -7
- package/dist/init-skills.js +303 -36
- package/dist/init-skills.js.map +1 -1
- package/dist/jev-advisor.d.ts +72 -0
- package/dist/jev-advisor.js +172 -0
- package/dist/jev-advisor.js.map +1 -0
- package/dist/journey-api.d.ts +76 -5
- package/dist/journey-api.js +238 -29
- package/dist/journey-api.js.map +1 -1
- package/dist/journey-cli.js +257 -23
- package/dist/journey-cli.js.map +1 -1
- package/dist/journey-heal.d.ts +74 -0
- package/dist/journey-heal.js +180 -0
- package/dist/journey-heal.js.map +1 -0
- package/dist/journey-network-checks.d.ts +5 -1
- package/dist/journey-network-checks.js +15 -4
- package/dist/journey-network-checks.js.map +1 -1
- package/dist/journey-prefix.js +2 -2
- package/dist/journey-prefix.js.map +1 -1
- package/dist/journey-proposal-store.d.ts +100 -0
- package/dist/journey-proposal-store.js +342 -0
- package/dist/journey-proposal-store.js.map +1 -0
- package/dist/journey-result-record.d.ts +40 -0
- package/dist/journey-result-record.js +28 -0
- package/dist/journey-result-record.js.map +1 -0
- package/dist/journey-review-api.d.ts +19 -0
- package/dist/journey-review-api.js +53 -0
- package/dist/journey-review-api.js.map +1 -0
- package/dist/journey-review-store.d.ts +16 -0
- package/dist/journey-review-store.js +95 -0
- package/dist/journey-review-store.js.map +1 -0
- package/dist/journey-review.d.ts +54 -0
- package/dist/journey-review.js +538 -0
- package/dist/journey-review.js.map +1 -0
- package/dist/journey-verify.js +14 -1
- package/dist/journey-verify.js.map +1 -1
- package/dist/key-report.d.ts +20 -5
- package/dist/key-report.js +32 -9
- package/dist/key-report.js.map +1 -1
- package/dist/kill-signal.js +10 -2
- package/dist/kill-signal.js.map +1 -1
- package/dist/launch-armed.js +5 -1
- package/dist/launch-armed.js.map +1 -1
- package/dist/load-cli.js +4 -2
- package/dist/load-cli.js.map +1 -1
- package/dist/log-classes.d.ts +73 -0
- package/dist/log-classes.js +167 -0
- package/dist/log-classes.js.map +1 -0
- package/dist/log-correlation.d.ts +29 -10
- package/dist/log-correlation.js +44 -21
- package/dist/log-correlation.js.map +1 -1
- package/dist/login-cli.d.ts +17 -0
- package/dist/login-cli.js +161 -0
- package/dist/login-cli.js.map +1 -0
- package/dist/logs-cli.js +3 -1
- package/dist/logs-cli.js.map +1 -1
- package/dist/mcp-api.d.ts +6 -1
- package/dist/mcp-api.js +110 -16
- package/dist/mcp-api.js.map +1 -1
- package/dist/mcp-cli-runner.d.ts +2 -0
- package/dist/mcp-cli-runner.js +3 -1
- package/dist/mcp-cli-runner.js.map +1 -1
- package/dist/mcp-cli-tools.d.ts +5 -0
- package/dist/mcp-cli-tools.js +178 -23
- package/dist/mcp-cli-tools.js.map +1 -1
- package/dist/mission-cli.js +8 -4
- package/dist/mission-cli.js.map +1 -1
- package/dist/mission-journal.d.ts +4 -1
- package/dist/mission-journal.js +9 -3
- package/dist/mission-journal.js.map +1 -1
- package/dist/mission-queue-cli.js +21 -2
- package/dist/mission-queue-cli.js.map +1 -1
- package/dist/mission-queue-runner.d.ts +6 -0
- package/dist/mission-queue-runner.js +33 -3
- package/dist/mission-queue-runner.js.map +1 -1
- package/dist/multi-run-cli.d.ts +7 -1
- package/dist/multi-run-cli.js +10 -3
- package/dist/multi-run-cli.js.map +1 -1
- package/dist/multi-run.d.ts +44 -8
- package/dist/multi-run.js +118 -25
- package/dist/multi-run.js.map +1 -1
- package/dist/persona-login.d.ts +256 -0
- package/dist/persona-login.js +884 -0
- package/dist/persona-login.js.map +1 -0
- package/dist/pre-approval.d.ts +98 -0
- package/dist/pre-approval.js +175 -0
- package/dist/pre-approval.js.map +1 -0
- package/dist/program.js +12 -0
- package/dist/program.js.map +1 -1
- package/dist/project-dir.d.ts +2 -1
- package/dist/project-dir.js +3 -1
- package/dist/project-dir.js.map +1 -1
- package/dist/readiness.d.ts +29 -0
- package/dist/readiness.js +291 -0
- package/dist/readiness.js.map +1 -0
- package/dist/regression-cli.js +4 -2
- package/dist/regression-cli.js.map +1 -1
- package/dist/report-api.d.ts +25 -2
- package/dist/report-api.js +34 -5
- package/dist/report-api.js.map +1 -1
- package/dist/report-cli.js +8 -1
- package/dist/report-cli.js.map +1 -1
- package/dist/resource-preflight.js +5 -3
- package/dist/resource-preflight.js.map +1 -1
- package/dist/result-schema.d.ts +12 -0
- package/dist/result-schema.js +8 -0
- package/dist/result-schema.js.map +1 -1
- package/dist/run-index.d.ts +4 -2
- package/dist/run-index.js +3 -1
- package/dist/run-index.js.map +1 -1
- package/dist/run-key-preflight.d.ts +2 -1
- package/dist/run-key-preflight.js +3 -2
- package/dist/run-key-preflight.js.map +1 -1
- package/dist/run-metadata.d.ts +29 -0
- package/dist/run-metadata.js +48 -0
- package/dist/run-metadata.js.map +1 -0
- package/dist/run-tags-cli.d.ts +14 -0
- package/dist/run-tags-cli.js +41 -0
- package/dist/run-tags-cli.js.map +1 -0
- package/dist/self-heal-adapter.d.ts +43 -27
- package/dist/self-heal-adapter.js +196 -62
- package/dist/self-heal-adapter.js.map +1 -1
- package/dist/site-cli.d.ts +1 -1
- package/dist/site-cli.js +24 -1
- package/dist/site-cli.js.map +1 -1
- package/dist/source-cli.js +9 -5
- package/dist/source-cli.js.map +1 -1
- package/dist/suite-explore-options.d.ts +14 -0
- package/dist/suite-explore-options.js +8 -0
- package/dist/suite-explore-options.js.map +1 -1
- package/dist/sweep-api.d.ts +289 -0
- package/dist/sweep-api.js +859 -0
- package/dist/sweep-api.js.map +1 -0
- package/dist/sweep-cli.d.ts +9 -0
- package/dist/sweep-cli.js +117 -0
- package/dist/sweep-cli.js.map +1 -0
- package/dist/target-config.d.ts +5 -2
- package/dist/target-config.js +15 -0
- package/dist/target-config.js.map +1 -1
- package/dist/ux-cli.js +3 -1
- package/dist/ux-cli.js.map +1 -1
- package/dist/ux-usability.d.ts +9 -3
- package/dist/ux-usability.js +8 -5
- package/dist/ux-usability.js.map +1 -1
- package/dist/verify-fix-cli.js +4 -2
- package/dist/verify-fix-cli.js.map +1 -1
- package/package.json +9 -9
- package/skills/jevitate-ci-check/SKILL.md +9 -1
- package/skills/jevitate-demo/SKILL.md +5 -2
- package/skills/jevitate-getting-started/SKILL.md +14 -1
- package/skills/jevitate-mission-scope/SKILL.md +2 -2
- package/skills/jevitate-run-journey/SKILL.md +42 -9
- package/skills/jevitate-test-campaign/SKILL.md +26 -5
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { redactText, redactUrl } from "@jevitate/ai-core";
|
|
6
|
+
import { clock } from "@jevitate/domain";
|
|
7
|
+
import { assertAuthorizedExploreTarget, isLoginLikeUrl, UnauthorizedExploreTargetError } from "@jevitate/explore";
|
|
8
|
+
import { sessionLaunchOptions } from "./browser-run-options.js";
|
|
9
|
+
import { findGitRoot, sessionFileInProjectRefusal } from "./project-dir.js";
|
|
10
|
+
export class LoginArgsError extends Error {
|
|
11
|
+
code = "E_LOGIN_ARGS";
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "LoginArgsError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** The login did not succeed (credentials rejected, a field not found, the success check never held). */
|
|
18
|
+
export class LoginFailedError extends Error {
|
|
19
|
+
code = "E_LOGIN_FAILED";
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "LoginFailedError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
26
|
+
/** jevitate's own credentials are never typed into a page (a login config cannot name them). */
|
|
27
|
+
const RESERVED_VARS = new Set(["OPENROUTER_API_KEY", "TYPESAFE_API_KEY", "GITHUB_TOKEN"]);
|
|
28
|
+
/** Checks an environment variable NAME (a value pasted in its place is never echoed). */
|
|
29
|
+
export function assertEnvVarName(name, what) {
|
|
30
|
+
if (!VAR_NAME.test(name)) {
|
|
31
|
+
throw new LoginArgsError(`${what} expects an environment variable NAME (letters, digits, _; not starting with a digit) — the credential itself is read from the environment, never passed as a value`);
|
|
32
|
+
}
|
|
33
|
+
if (RESERVED_VARS.has(name) || name.startsWith("JEVITATE_")) {
|
|
34
|
+
throw new LoginArgsError(`${what} ${name}: jevitate's own credentials and settings are never typed into a page`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function httpUrl(v, what) {
|
|
38
|
+
let u;
|
|
39
|
+
try {
|
|
40
|
+
u = new URL(v);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new LoginArgsError(`${what} must be an absolute http(s) URL`);
|
|
44
|
+
}
|
|
45
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
46
|
+
throw new LoginArgsError(`${what} must be an absolute http(s) URL`);
|
|
47
|
+
if (u.username !== "" || u.password !== "")
|
|
48
|
+
throw new LoginArgsError(`${what} must not carry credentials in the URL`);
|
|
49
|
+
return u.href;
|
|
50
|
+
}
|
|
51
|
+
const LOGIN_KEYS = ["url", "api", "userEnv", "passwordEnv", "userField", "passwordField", "submit", "success"];
|
|
52
|
+
/** The keys only a form sign-in uses (refused beside `api`). */
|
|
53
|
+
const FORM_ONLY_KEYS = ["url", "userField", "passwordField", "submit", "success"];
|
|
54
|
+
const API_KEYS = ["url", "userKey", "passwordKey", "tokenPath", "storageKey", "storage", "verifyUrl"];
|
|
55
|
+
/** A JSON body key: a plain identifier (letters, digits, `_`, `-`). */
|
|
56
|
+
const BODY_KEY = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
|
|
57
|
+
/** One segment of a token path: an identifier or an array index. */
|
|
58
|
+
const PATH_SEGMENT = /^(?:[A-Za-z_][A-Za-z0-9_-]{0,63}|0|[1-9][0-9]{0,5})$/;
|
|
59
|
+
const UNSAFE_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
|
60
|
+
/** A storage key: a safe identifier (letters, digits, `_`, `-`, `.`, `:`, `$`). */
|
|
61
|
+
const STORAGE_KEY = /^[A-Za-z_$][A-Za-z0-9_$.:-]{0,127}$/;
|
|
62
|
+
const FLAG_LABELS = {
|
|
63
|
+
url: "--api",
|
|
64
|
+
userKey: "--api-user-key",
|
|
65
|
+
passwordKey: "--api-password-key",
|
|
66
|
+
tokenPath: "--token-path",
|
|
67
|
+
storageKey: "--storage-key",
|
|
68
|
+
storage: "--storage",
|
|
69
|
+
verifyUrl: "--verify-url",
|
|
70
|
+
};
|
|
71
|
+
/** `a.b.0.c` → its segments (strict: identifiers or indexes, no prototype keys). */
|
|
72
|
+
export function parseTokenPath(path, what) {
|
|
73
|
+
const segments = path.split(".");
|
|
74
|
+
if (segments.length > 8 || segments.some((s) => !PATH_SEGMENT.test(s) || UNSAFE_SEGMENTS.has(s))) {
|
|
75
|
+
throw new LoginArgsError(`${what} must be a simple dotted path of identifiers or array indexes (e.g. token, data.accessToken; at most 8 segments), got ${JSON.stringify(path)}`);
|
|
76
|
+
}
|
|
77
|
+
return segments;
|
|
78
|
+
}
|
|
79
|
+
/** Validates an API login's settings (fail closed before any network). */
|
|
80
|
+
export function parseApiLogin(raw, labels = FLAG_LABELS) {
|
|
81
|
+
const url = httpUrl(raw.url, labels.url);
|
|
82
|
+
for (const [k, label] of [["userKey", labels.userKey], ["passwordKey", labels.passwordKey]]) {
|
|
83
|
+
const v = raw[k];
|
|
84
|
+
if (v !== undefined && !BODY_KEY.test(v))
|
|
85
|
+
throw new LoginArgsError(`${label} must be a JSON key of letters, digits, _ or - (starting with a letter or _), got ${JSON.stringify(v)}`);
|
|
86
|
+
}
|
|
87
|
+
const userKey = raw.userKey ?? "username";
|
|
88
|
+
const passwordKey = raw.passwordKey ?? "password";
|
|
89
|
+
if (userKey === passwordKey)
|
|
90
|
+
throw new LoginArgsError(`${labels.userKey} and ${labels.passwordKey} must differ (both are ${JSON.stringify(userKey)})`);
|
|
91
|
+
if (raw.storage !== undefined && raw.storage !== "local") {
|
|
92
|
+
if (raw.storage === "session") {
|
|
93
|
+
throw new LoginArgsError(`${labels.storage} session is not supported: a Playwright storage state holds cookies and localStorage only, so a sessionStorage token could not be saved or restored — use ${labels.storage} local, or a cookie session`);
|
|
94
|
+
}
|
|
95
|
+
throw new LoginArgsError(`${labels.storage} must be local, got ${JSON.stringify(raw.storage)}`);
|
|
96
|
+
}
|
|
97
|
+
let segments;
|
|
98
|
+
if (raw.tokenPath !== undefined)
|
|
99
|
+
segments = parseTokenPath(raw.tokenPath, labels.tokenPath);
|
|
100
|
+
else if (raw.storageKey !== undefined || raw.storage !== undefined)
|
|
101
|
+
throw new LoginArgsError(`${labels.storageKey}/${labels.storage} place the token ${labels.tokenPath} reads: pass ${labels.tokenPath} too`);
|
|
102
|
+
if (raw.storageKey !== undefined && !STORAGE_KEY.test(raw.storageKey)) {
|
|
103
|
+
throw new LoginArgsError(`${labels.storageKey} must be a safe identifier (letters, digits, _ $ . : -; starting with a letter, _ or $), got ${JSON.stringify(raw.storageKey)}`);
|
|
104
|
+
}
|
|
105
|
+
const storageKey = raw.storageKey ?? segments?.at(-1);
|
|
106
|
+
if (storageKey !== undefined && !STORAGE_KEY.test(storageKey)) {
|
|
107
|
+
throw new LoginArgsError(`${labels.tokenPath} ends in ${JSON.stringify(storageKey)}, which is not a storage key: name one with ${labels.storageKey}`);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
url,
|
|
111
|
+
userKey,
|
|
112
|
+
passwordKey,
|
|
113
|
+
...optional("tokenPath", raw.tokenPath),
|
|
114
|
+
...optional("storageKey", storageKey),
|
|
115
|
+
storage: "local",
|
|
116
|
+
...optional("verifyUrl", raw.verifyUrl === undefined ? undefined : httpUrl(raw.verifyUrl, labels.verifyUrl)),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Validates a personas file entry's `login` object (`where` names it in errors). */
|
|
120
|
+
export function parsePersonaLogin(v, where) {
|
|
121
|
+
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
122
|
+
throw new LoginArgsError(`${where} must be an object {url | api, userEnv, passwordEnv, …}`);
|
|
123
|
+
const o = v;
|
|
124
|
+
for (const k of Object.keys(o)) {
|
|
125
|
+
if (!LOGIN_KEYS.includes(k)) {
|
|
126
|
+
throw new LoginArgsError(`${where}.${k}: unknown key (allowed: ${LOGIN_KEYS.join(", ")}; credentials are read from the environment variables userEnv/passwordEnv name)`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const str = (from, k, label, required) => {
|
|
130
|
+
const x = from[k];
|
|
131
|
+
if (x === undefined && !required)
|
|
132
|
+
return undefined;
|
|
133
|
+
if (typeof x !== "string" || x.trim() === "")
|
|
134
|
+
throw new LoginArgsError(`${label} must be a non-empty string`);
|
|
135
|
+
return x;
|
|
136
|
+
};
|
|
137
|
+
const userEnv = str(o, "userEnv", `${where}.userEnv`, true);
|
|
138
|
+
const passwordEnv = str(o, "passwordEnv", `${where}.passwordEnv`, true);
|
|
139
|
+
let login;
|
|
140
|
+
if (o.api !== undefined) {
|
|
141
|
+
const formKeys = FORM_ONLY_KEYS.filter((k) => o[k] !== undefined);
|
|
142
|
+
if (formKeys.length > 0)
|
|
143
|
+
throw new LoginArgsError(`${where}: api signs in through an HTTP endpoint — the form keys ${formKeys.join(", ")} do not apply beside it`);
|
|
144
|
+
const a = typeof o.api === "string" ? { url: o.api } : o.api;
|
|
145
|
+
if (a === null || typeof a !== "object" || Array.isArray(a))
|
|
146
|
+
throw new LoginArgsError(`${where}.api must be the endpoint URL or an object {url, userKey?, passwordKey?, tokenPath?, storageKey?, storage?, verifyUrl?}`);
|
|
147
|
+
const ao = a;
|
|
148
|
+
for (const k of Object.keys(ao)) {
|
|
149
|
+
if (!API_KEYS.includes(k))
|
|
150
|
+
throw new LoginArgsError(`${where}.api.${k}: unknown key (allowed: ${API_KEYS.join(", ")})`);
|
|
151
|
+
}
|
|
152
|
+
const label = (k) => `${where}.api${typeof o.api === "string" && k === "url" ? "" : `.${k}`}`;
|
|
153
|
+
const api = parseApiLogin({
|
|
154
|
+
url: str(ao, "url", label("url"), true),
|
|
155
|
+
userKey: str(ao, "userKey", label("userKey"), false),
|
|
156
|
+
passwordKey: str(ao, "passwordKey", label("passwordKey"), false),
|
|
157
|
+
tokenPath: str(ao, "tokenPath", label("tokenPath"), false),
|
|
158
|
+
storageKey: str(ao, "storageKey", label("storageKey"), false),
|
|
159
|
+
storage: str(ao, "storage", label("storage"), false),
|
|
160
|
+
verifyUrl: str(ao, "verifyUrl", label("verifyUrl"), false),
|
|
161
|
+
}, { url: label("url"), userKey: label("userKey"), passwordKey: label("passwordKey"), tokenPath: label("tokenPath"), storageKey: label("storageKey"), storage: label("storage"), verifyUrl: label("verifyUrl") });
|
|
162
|
+
login = { api, userEnv, passwordEnv };
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
login = {
|
|
166
|
+
url: httpUrl(str(o, "url", `${where}.url`, true), `${where}.url`),
|
|
167
|
+
userEnv,
|
|
168
|
+
passwordEnv,
|
|
169
|
+
...optional("userField", str(o, "userField", `${where}.userField`, false)),
|
|
170
|
+
...optional("passwordField", str(o, "passwordField", `${where}.passwordField`, false)),
|
|
171
|
+
...optional("submit", str(o, "submit", `${where}.submit`, false)),
|
|
172
|
+
...optional("success", str(o, "success", `${where}.success`, false)),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
assertEnvVarName(login.userEnv, `${where}.userEnv`);
|
|
176
|
+
assertEnvVarName(login.passwordEnv, `${where}.passwordEnv`);
|
|
177
|
+
if (login.success !== undefined)
|
|
178
|
+
parseLoginSuccess(login.success);
|
|
179
|
+
return login;
|
|
180
|
+
}
|
|
181
|
+
function optional(k, v) {
|
|
182
|
+
return (v === undefined ? {} : { [k]: v });
|
|
183
|
+
}
|
|
184
|
+
/** `jevitate login`'s flags → a validated `PersonaLogin` (fail closed before any browser opens). */
|
|
185
|
+
export function loginFromFlags(f) {
|
|
186
|
+
if (f.api !== undefined) {
|
|
187
|
+
if (f.url !== undefined)
|
|
188
|
+
throw new LoginArgsError("--api and --url are two ways to sign in: pass one");
|
|
189
|
+
const formFlags = [
|
|
190
|
+
["--user-field", f.userField],
|
|
191
|
+
["--password-field", f.passwordField],
|
|
192
|
+
["--submit", f.submit],
|
|
193
|
+
["--success", f.success],
|
|
194
|
+
]
|
|
195
|
+
.filter(([, v]) => v !== undefined)
|
|
196
|
+
.map(([k]) => k);
|
|
197
|
+
if (formFlags.length > 0)
|
|
198
|
+
throw new LoginArgsError(`--api signs in through an HTTP endpoint, not a form: ${formFlags.join(", ")} ${formFlags.length === 1 ? "does" : "do"} not apply`);
|
|
199
|
+
const login = { api: parseApiLogin(f.api), userEnv: f.userEnv, passwordEnv: f.passwordEnv };
|
|
200
|
+
assertEnvVarName(login.userEnv, "--user-env");
|
|
201
|
+
assertEnvVarName(login.passwordEnv, "--password-env");
|
|
202
|
+
return login;
|
|
203
|
+
}
|
|
204
|
+
if (f.url === undefined)
|
|
205
|
+
throw new LoginArgsError("missing --url (or --api)");
|
|
206
|
+
const login = {
|
|
207
|
+
url: httpUrl(f.url, "--url"),
|
|
208
|
+
userEnv: f.userEnv,
|
|
209
|
+
passwordEnv: f.passwordEnv,
|
|
210
|
+
...optional("userField", f.userField),
|
|
211
|
+
...optional("passwordField", f.passwordField),
|
|
212
|
+
...optional("submit", f.submit),
|
|
213
|
+
...optional("success", f.success),
|
|
214
|
+
};
|
|
215
|
+
assertEnvVarName(login.userEnv, "--user-env");
|
|
216
|
+
assertEnvVarName(login.passwordEnv, "--password-env");
|
|
217
|
+
if (login.success !== undefined)
|
|
218
|
+
parseLoginSuccess(login.success);
|
|
219
|
+
return login;
|
|
220
|
+
}
|
|
221
|
+
/** `urlIncludes:<text>` | `selector:<css>` | `text:<text>`. */
|
|
222
|
+
export function parseLoginSuccess(spec) {
|
|
223
|
+
const ci = spec.indexOf(":");
|
|
224
|
+
const kind = ci === -1 ? "" : spec.slice(0, ci);
|
|
225
|
+
const rest = ci === -1 ? "" : spec.slice(ci + 1);
|
|
226
|
+
if ((kind === "urlIncludes" || kind === "selector" || kind === "text") && rest.trim() !== "") {
|
|
227
|
+
return kind === "selector" ? { kind, selector: rest } : { kind, text: rest };
|
|
228
|
+
}
|
|
229
|
+
throw new LoginArgsError(`--success must be urlIncludes:<text>, selector:<css> or text:<text>, got ${JSON.stringify(spec)}`);
|
|
230
|
+
}
|
|
231
|
+
/** `--auth-check <off|auto|urlExcludes:<text>|selector:<css>>` (default `auto`). */
|
|
232
|
+
export function parseAuthCheck(spec) {
|
|
233
|
+
if (spec === undefined || spec === "auto")
|
|
234
|
+
return { mode: "auto" };
|
|
235
|
+
if (spec === "off")
|
|
236
|
+
return { mode: "off" };
|
|
237
|
+
const ci = spec.indexOf(":");
|
|
238
|
+
const kind = ci === -1 ? "" : spec.slice(0, ci);
|
|
239
|
+
const rest = ci === -1 ? "" : spec.slice(ci + 1);
|
|
240
|
+
if (kind === "urlExcludes" && rest !== "")
|
|
241
|
+
return { mode: "urlExcludes", text: rest };
|
|
242
|
+
if (kind === "selector" && rest !== "")
|
|
243
|
+
return { mode: "selector", selector: rest };
|
|
244
|
+
throw new LoginArgsError(`--auth-check must be off, auto, urlExcludes:<text> or selector:<css>, got ${JSON.stringify(spec)}`);
|
|
245
|
+
}
|
|
246
|
+
/** Reads the two credentials from the environment (fail closed: unset/empty refuses, naming the variable only). */
|
|
247
|
+
export function resolveLoginSecrets(login, env) {
|
|
248
|
+
const read = (name, what) => {
|
|
249
|
+
const v = env[name];
|
|
250
|
+
if (v === undefined || v === "")
|
|
251
|
+
throw new LoginArgsError(`${what}: environment variable ${name} is not set`);
|
|
252
|
+
return v;
|
|
253
|
+
};
|
|
254
|
+
return { username: read(login.userEnv, "--user-env"), password: read(login.passwordEnv, "--password-env") };
|
|
255
|
+
}
|
|
256
|
+
/** Removes both credentials, and any session token read (#449), (and URL credentials/tokens) from a text before it leaves this module. */
|
|
257
|
+
function scrub(text, secrets, token) {
|
|
258
|
+
return redactText(text, token === undefined ? [secrets.password, secrets.username] : [token, secrets.password, secrets.username]);
|
|
259
|
+
}
|
|
260
|
+
// ── the login ─────────────────────────────────────────────────────────────────────────────────
|
|
261
|
+
const VISIBLE_PASSWORD = 'input[type="password"]';
|
|
262
|
+
const TEXT_INPUT = 'input:not([type="password"]):not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]):not([type="image"]):not([type="file"])';
|
|
263
|
+
/** The first VISIBLE element over the candidates, in order (an invalid selector is skipped, never thrown). */
|
|
264
|
+
async function firstVisible(candidates) {
|
|
265
|
+
for (const c of candidates) {
|
|
266
|
+
try {
|
|
267
|
+
const v = c.filter({ visible: true });
|
|
268
|
+
if ((await v.count()) > 0)
|
|
269
|
+
return v.first();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// an unparseable selector (a label given where CSS was tried) matches nothing
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
/** A field named by the operator: a label first (exact, then loose), else CSS. */
|
|
278
|
+
function fieldBySpec(page, spec) {
|
|
279
|
+
return [page.getByLabel(spec, { exact: true }), page.getByLabel(spec), page.locator(spec)];
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Waits (bounded by `timeoutMs`) until a login field is visible: any candidate from the user list or
|
|
283
|
+
* the password list. A client-rendered form (an SPA that mounts after load) therefore gets its chance
|
|
284
|
+
* before the "no username field" verdict. A timeout (or a candidate selector that is not CSS) is
|
|
285
|
+
* swallowed, so the existing field-not-found errors are still what the operator sees.
|
|
286
|
+
*/
|
|
287
|
+
async function waitForLoginField(page, login, timeoutMs) {
|
|
288
|
+
const candidates = [
|
|
289
|
+
...(login.userField === undefined ? userCandidates(page) : fieldBySpec(page, login.userField)),
|
|
290
|
+
...(login.passwordField === undefined ? passwordCandidates(page) : fieldBySpec(page, login.passwordField)),
|
|
291
|
+
];
|
|
292
|
+
try {
|
|
293
|
+
await candidates
|
|
294
|
+
.map((c) => c.filter({ visible: true }))
|
|
295
|
+
.reduce((a, b) => a.or(b))
|
|
296
|
+
.first()
|
|
297
|
+
.waitFor({ state: "visible", timeout: timeoutMs });
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// No field appeared within the bound (or a candidate was not a valid selector): the lookups below decide.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function userCandidates(page) {
|
|
304
|
+
return [
|
|
305
|
+
page.locator(`${TEXT_INPUT}[autocomplete~="username"]`),
|
|
306
|
+
page.locator('input[type="email"]'),
|
|
307
|
+
page.locator(`${TEXT_INPUT}[autocomplete~="email"]`),
|
|
308
|
+
page.locator(`${TEXT_INPUT}[name*="user" i], ${TEXT_INPUT}[id*="user" i]`),
|
|
309
|
+
page.locator(`${TEXT_INPUT}[name*="email" i], ${TEXT_INPUT}[id*="email" i]`),
|
|
310
|
+
page.locator(`${TEXT_INPUT}[name*="login" i], ${TEXT_INPUT}[id*="login" i]`),
|
|
311
|
+
page.getByLabel(/e-?mail|user\s*name|user\s*id|login|account/i),
|
|
312
|
+
page.locator(`form:has(${VISIBLE_PASSWORD}) ${TEXT_INPUT}`),
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
function passwordCandidates(page) {
|
|
316
|
+
return [page.locator('input[type="password"][autocomplete~="current-password"]'), page.locator(VISIBLE_PASSWORD)];
|
|
317
|
+
}
|
|
318
|
+
async function submitLocator(page, spec) {
|
|
319
|
+
if (spec !== undefined) {
|
|
320
|
+
const named = await firstVisible([page.getByRole("button", { name: spec, exact: true }), page.getByRole("button", { name: spec })]);
|
|
321
|
+
if (named === undefined)
|
|
322
|
+
throw new LoginFailedError(`no visible button named ${JSON.stringify(spec)} on the sign-in page`);
|
|
323
|
+
return named;
|
|
324
|
+
}
|
|
325
|
+
return firstVisible([
|
|
326
|
+
page.locator(`form:has(${VISIBLE_PASSWORD}) [type="submit"]`),
|
|
327
|
+
page.locator(`form:has(${TEXT_INPUT}) [type="submit"]`),
|
|
328
|
+
page.getByRole("button", { name: /^\s*(log\s*-?\s*in|sign\s*-?\s*in|continue|next|submit)\b/i }),
|
|
329
|
+
]);
|
|
330
|
+
}
|
|
331
|
+
/** Refuses to type anything into a page whose origin the run does not authorize. */
|
|
332
|
+
function assertPageAuthorized(page, allowlist) {
|
|
333
|
+
try {
|
|
334
|
+
assertAuthorizedExploreTarget(page.url(), allowlist);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
if (!(err instanceof UnauthorizedExploreTargetError))
|
|
338
|
+
throw err;
|
|
339
|
+
throw new LoginFailedError(`the sign-in page is on ${originOf(page.url())}, which is not an authorized origin (allowed: ${allowlist.join(", ") || "<none>"}) — credentials are never typed there; pass --allow <origin> to authorize it`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function originOf(url) {
|
|
343
|
+
try {
|
|
344
|
+
return new URL(url).origin;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return "?";
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function pathOf(url) {
|
|
351
|
+
try {
|
|
352
|
+
return new URL(url).pathname;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return "?";
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** How often {@link waitForNoSignInForm} re-checks the page for sign-in-form evidence (clock time). */
|
|
359
|
+
const SIGN_IN_FORM_POLL_MS = 100;
|
|
360
|
+
/** The submit controls whose accessible name marks a form as a sign-in form. */
|
|
361
|
+
const SIGN_IN_BUTTON = /sign\s*in|log\s*in|login|continue/i;
|
|
362
|
+
/**
|
|
363
|
+
* True when the page shows evidence of a sign-in form (#442): a login-like URL, OR a visible password
|
|
364
|
+
* field together with a visible username/email field ({@link userCandidates}) or a visible button whose
|
|
365
|
+
* accessible name matches {@link SIGN_IN_BUTTON}. A page with only a password field (an API-credentials
|
|
366
|
+
* or settings form) is NOT a sign-in form — it is not evidence of an expired session.
|
|
367
|
+
*/
|
|
368
|
+
async function showsSignInForm(page) {
|
|
369
|
+
if (isLoginLikeUrl(page.url()))
|
|
370
|
+
return true;
|
|
371
|
+
if ((await page.locator(VISIBLE_PASSWORD).filter({ visible: true }).count().catch(() => 0)) === 0)
|
|
372
|
+
return false;
|
|
373
|
+
if ((await firstVisible(userCandidates(page))) !== undefined)
|
|
374
|
+
return true;
|
|
375
|
+
return (await firstVisible([page.getByRole("button", { name: SIGN_IN_BUTTON })])) !== undefined;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Waits until {@link showsSignInForm} is false, bounded by `timeoutMs` of clock time (polled on the
|
|
379
|
+
* injectable clock, never a real-time sleep). Throws a Playwright-shaped `TimeoutError` at the bound,
|
|
380
|
+
* so the caller's existing timeout handling reports the login as failed.
|
|
381
|
+
*/
|
|
382
|
+
async function waitForNoSignInForm(page, timeoutMs) {
|
|
383
|
+
const deadline = clock.now() + Math.max(0, timeoutMs);
|
|
384
|
+
for (;;) {
|
|
385
|
+
if (!(await showsSignInForm(page)))
|
|
386
|
+
return;
|
|
387
|
+
const left = deadline - clock.now();
|
|
388
|
+
if (left <= 0) {
|
|
389
|
+
const err = new Error(`Timeout ${Math.round(timeoutMs)}ms exceeded waiting for the page to leave its sign-in form`);
|
|
390
|
+
err.name = "TimeoutError";
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
393
|
+
await clock.sleep(Math.min(SIGN_IN_FORM_POLL_MS, left));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async function waitForSuccess(page, success, timeoutMs) {
|
|
397
|
+
if (success === undefined) {
|
|
398
|
+
// Default: the app moved off its sign-in page — no login-like URL, and no sign-in-form evidence.
|
|
399
|
+
await page.waitForURL((u) => !isLoginLikeUrl(u.href), { timeout: timeoutMs, waitUntil: "load" });
|
|
400
|
+
await waitForNoSignInForm(page, timeoutMs);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (success.kind === "urlIncludes") {
|
|
404
|
+
await page.waitForURL((u) => u.href.includes(success.text), { timeout: timeoutMs, waitUntil: "load" });
|
|
405
|
+
}
|
|
406
|
+
else if (success.kind === "selector") {
|
|
407
|
+
await page.locator(success.selector).first().waitFor({ state: "visible", timeout: timeoutMs });
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
await page.getByText(success.text).first().waitFor({ state: "visible", timeout: timeoutMs });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** A sign-in form's error message (an alert, or a field the page marked invalid). */
|
|
414
|
+
const FORM_ERROR = '[role="alert"], [aria-invalid="true"]';
|
|
415
|
+
/**
|
|
416
|
+
* Resolves "rejected" when, after the submit, the page shows a form error while a password field is
|
|
417
|
+
* still visible (the credentials were refused) — so a wrong password fails at once, not at the
|
|
418
|
+
* timeout. Never resolves otherwise (the success wait decides).
|
|
419
|
+
*/
|
|
420
|
+
function formRejected(page, timeoutMs) {
|
|
421
|
+
const never = new Promise(() => undefined);
|
|
422
|
+
return page
|
|
423
|
+
.locator(FORM_ERROR)
|
|
424
|
+
.filter({ visible: true })
|
|
425
|
+
.first()
|
|
426
|
+
.waitFor({ state: "visible", timeout: timeoutMs })
|
|
427
|
+
.then(async () => ((await page.locator(VISIBLE_PASSWORD).filter({ visible: true }).count().catch(() => 0)) > 0 ? "rejected" : never), () => never);
|
|
428
|
+
}
|
|
429
|
+
function describeSuccess(success) {
|
|
430
|
+
if (success === undefined)
|
|
431
|
+
return "the page to leave the sign-in form (no login-like URL, no password field)";
|
|
432
|
+
if (success.kind === "urlIncludes")
|
|
433
|
+
return `the URL to include ${JSON.stringify(success.text)}`;
|
|
434
|
+
if (success.kind === "selector")
|
|
435
|
+
return `${JSON.stringify(success.selector)} to be visible`;
|
|
436
|
+
return `the text ${JSON.stringify(success.text)} to be visible`;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Drives the sign-in form on `page` (already open, no storage state): navigates to `login.url`, fills
|
|
440
|
+
* the fields, submits, and waits for the success check. Throws `LoginFailedError` (scrubbed).
|
|
441
|
+
* Supports a two-step form (username, then a password page): when no password field is visible yet,
|
|
442
|
+
* the username is submitted first.
|
|
443
|
+
*/
|
|
444
|
+
export async function performLogin(page, login, secrets, opts) {
|
|
445
|
+
const success = login.success === undefined ? undefined : parseLoginSuccess(login.success);
|
|
446
|
+
const t = opts.timeoutMs;
|
|
447
|
+
try {
|
|
448
|
+
if (login.url === undefined)
|
|
449
|
+
throw new LoginFailedError("no sign-in page: the login parameters name an API endpoint, not a form");
|
|
450
|
+
await page.goto(login.url, { waitUntil: "load", timeout: t });
|
|
451
|
+
assertPageAuthorized(page, opts.allowlist);
|
|
452
|
+
await waitForLoginField(page, login, t);
|
|
453
|
+
const findUser = async () => firstVisible(login.userField === undefined ? userCandidates(page) : fieldBySpec(page, login.userField));
|
|
454
|
+
const findPassword = async () => firstVisible(login.passwordField === undefined ? passwordCandidates(page) : fieldBySpec(page, login.passwordField));
|
|
455
|
+
let password = await findPassword();
|
|
456
|
+
const user = await findUser();
|
|
457
|
+
if (user === undefined) {
|
|
458
|
+
throw new LoginFailedError(`no username field found on ${pathOf(page.url())}${login.userField === undefined ? " — name it with --user-field <label or CSS selector>" : ` matching ${JSON.stringify(login.userField)}`}`);
|
|
459
|
+
}
|
|
460
|
+
await user.fill(secrets.username, { timeout: t });
|
|
461
|
+
if (password === undefined) {
|
|
462
|
+
// Two-step sign-in: submit the username, then the password page.
|
|
463
|
+
const next = await submitLocator(page, login.submit);
|
|
464
|
+
if (next === undefined)
|
|
465
|
+
await user.press("Enter", { timeout: t });
|
|
466
|
+
else
|
|
467
|
+
await next.click({ timeout: t });
|
|
468
|
+
if (login.passwordField === undefined)
|
|
469
|
+
await page.locator(VISIBLE_PASSWORD).filter({ visible: true }).first().waitFor({ state: "visible", timeout: t });
|
|
470
|
+
else
|
|
471
|
+
await page.waitForLoadState("load", { timeout: t });
|
|
472
|
+
assertPageAuthorized(page, opts.allowlist);
|
|
473
|
+
password = await findPassword();
|
|
474
|
+
}
|
|
475
|
+
if (password === undefined) {
|
|
476
|
+
throw new LoginFailedError(`no password field found on ${pathOf(page.url())}${login.passwordField === undefined ? " — name it with --password-field <label or CSS selector>" : ` matching ${JSON.stringify(login.passwordField)}`}`);
|
|
477
|
+
}
|
|
478
|
+
assertPageAuthorized(page, opts.allowlist);
|
|
479
|
+
await password.fill(secrets.password, { timeout: t });
|
|
480
|
+
const submit = await submitLocator(page, login.submit);
|
|
481
|
+
// An error message already on the form is not this attempt's: early failure detection is then off.
|
|
482
|
+
const alertsBefore = await page.locator(FORM_ERROR).filter({ visible: true }).count().catch(() => 0);
|
|
483
|
+
if (submit === undefined)
|
|
484
|
+
await password.press("Enter", { timeout: t });
|
|
485
|
+
else
|
|
486
|
+
await submit.click({ timeout: t });
|
|
487
|
+
try {
|
|
488
|
+
const succeeded = waitForSuccess(page, success, t).then(() => "succeeded");
|
|
489
|
+
succeeded.catch(() => undefined); // awaited below, or abandoned once the form reports an error
|
|
490
|
+
const outcome = alertsBefore > 0 ? await succeeded : await Promise.race([succeeded, formRejected(page, t)]);
|
|
491
|
+
if (outcome === "rejected") {
|
|
492
|
+
throw new LoginFailedError(`the sign-in form reported an error on ${pathOf(page.url())} — the credentials in ${login.userEnv}/${login.passwordEnv} were not accepted`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
497
|
+
throw new LoginFailedError(`the sign-in did not succeed within ${Math.round(t / 1000)}s: waited for ${describeSuccess(success)}; the page is ${pathOf(page.url())} (check the credentials in ${login.userEnv}/${login.passwordEnv}, or set --success)`);
|
|
498
|
+
}
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
501
|
+
const landed = page.url();
|
|
502
|
+
assertPageAuthorized(page, opts.allowlist);
|
|
503
|
+
return landed;
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
if (err instanceof LoginFailedError)
|
|
507
|
+
throw new LoginFailedError(scrub(err.message, secrets));
|
|
508
|
+
// A Playwright error never carries a typed value, but every message is scrubbed regardless.
|
|
509
|
+
const message = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
510
|
+
throw new LoginFailedError(scrub(`the sign-in failed: ${message}`, secrets));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// ── saving the state ──────────────────────────────────────────────────────────────────────────
|
|
514
|
+
/** Where a storage state goes: refused inside a repo's `.jevitate/`; a warning when it is in a repo and not ignored by git. */
|
|
515
|
+
export function checkSaveTarget(file, what) {
|
|
516
|
+
const path = resolve(file);
|
|
517
|
+
const refusal = sessionFileInProjectRefusal(path, what);
|
|
518
|
+
if (refusal !== undefined)
|
|
519
|
+
throw new LoginArgsError(refusal);
|
|
520
|
+
const warnings = [];
|
|
521
|
+
const root = findGitRoot(dirname(path));
|
|
522
|
+
if (root !== null) {
|
|
523
|
+
const rel = relative(root, path);
|
|
524
|
+
if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) {
|
|
525
|
+
let ignored;
|
|
526
|
+
try {
|
|
527
|
+
execFileSync("git", ["check-ignore", "-q", "--no-index", "--", rel], { cwd: root, stdio: "ignore" });
|
|
528
|
+
ignored = true;
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
const status = err.status;
|
|
532
|
+
ignored = status === 1 ? false : undefined;
|
|
533
|
+
}
|
|
534
|
+
if (ignored !== true) {
|
|
535
|
+
warnings.push(`${what} ${path} is inside the git repository ${root} and ${ignored === false ? "is not gitignored" : "could not be checked against .gitignore"}: it holds live session tokens — add it to .gitignore or save it outside the repo`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return { path, warnings };
|
|
540
|
+
}
|
|
541
|
+
/** Writes the storage state JSON atomically with mode 0600 (a temp file in the same directory, then rename). */
|
|
542
|
+
export function writeStorageStateFile(path, json) {
|
|
543
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
544
|
+
const tmp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
545
|
+
try {
|
|
546
|
+
writeFileSync(tmp, json, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
547
|
+
chmodSync(tmp, 0o600);
|
|
548
|
+
renameSync(tmp, path);
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
rmSync(tmp, { force: true });
|
|
552
|
+
throw err;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function assertLoginOriginAuthorized(url, allowlist, what) {
|
|
556
|
+
try {
|
|
557
|
+
assertAuthorizedExploreTarget(url, allowlist);
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
if (err instanceof UnauthorizedExploreTargetError)
|
|
561
|
+
throw new LoginArgsError(`${what} ${JSON.stringify(redactUrl(url))} is not an authorized origin (allowed: ${allowlist.join(", ") || "<none>"}) — refused`);
|
|
562
|
+
throw err;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/** Signs in on a fresh, unrecorded session and saves its storage state (mode 0600). */
|
|
566
|
+
export async function mintStorageState(o) {
|
|
567
|
+
if (o.login.api !== undefined)
|
|
568
|
+
return mintApiStorageState(o, o.login.api);
|
|
569
|
+
if (o.login.url === undefined)
|
|
570
|
+
throw new LoginArgsError("the login parameters name neither a sign-in page (url) nor an endpoint (api)");
|
|
571
|
+
const save = resolve(o.save);
|
|
572
|
+
const refusal = sessionFileInProjectRefusal(save, "the storage state");
|
|
573
|
+
if (refusal !== undefined)
|
|
574
|
+
throw new LoginArgsError(refusal);
|
|
575
|
+
assertLoginOriginAuthorized(o.login.url, o.allowlist, "login URL");
|
|
576
|
+
const secrets = resolveLoginSecrets(o.login, o.env);
|
|
577
|
+
// Never recorded: no video directory, no tracing, no screenshots — whatever the run's own flags say.
|
|
578
|
+
const { recordVideo: _video, ...launch } = o.browser ?? {};
|
|
579
|
+
let session;
|
|
580
|
+
try {
|
|
581
|
+
session = await o.port.open({ ...sessionLaunchOptions(launch), allowedOrigins: [...o.allowlist], baseUrl: o.login.url });
|
|
582
|
+
const landed = await performLogin(session.page, o.login, secrets, { allowlist: o.allowlist, timeoutMs: o.timeoutMs });
|
|
583
|
+
const state = session.captureStorageState !== undefined ? await session.captureStorageState() : JSON.stringify(await session.page.context().storageState());
|
|
584
|
+
writeStorageStateFile(save, state);
|
|
585
|
+
return { saved: save, landedUrl: scrub(redactUrl(landed), secrets) };
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
if (err instanceof LoginFailedError || err instanceof LoginArgsError)
|
|
589
|
+
throw err;
|
|
590
|
+
throw new LoginFailedError(scrub(`the sign-in failed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`, secrets));
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
await session?.close().catch(() => undefined);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/** The value at a dotted path in a parsed JSON body (own properties only), or undefined. */
|
|
597
|
+
function valueAtPath(body, segments) {
|
|
598
|
+
let cur = body;
|
|
599
|
+
for (const s of segments) {
|
|
600
|
+
if (cur === null || typeof cur !== "object" || !Object.prototype.hasOwnProperty.call(cur, s))
|
|
601
|
+
return undefined;
|
|
602
|
+
cur = cur[s];
|
|
603
|
+
}
|
|
604
|
+
return cur;
|
|
605
|
+
}
|
|
606
|
+
/** The app page an API login's session is proven on (and whose origin receives the token). */
|
|
607
|
+
export function apiVerifyUrl(api) {
|
|
608
|
+
return api.verifyUrl ?? `${new URL(api.url).origin}/`;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* #449: signs in through an HTTP endpoint. The credentials are POSTed as JSON (from a fresh,
|
|
612
|
+
* unrecorded browser context, so the response's `Set-Cookie` lands in its cookie jar); redirects are
|
|
613
|
+
* never followed. The storage state is the context's cookies plus, with a token path, the token
|
|
614
|
+
* written into the verify URL origin's localStorage. Every message is scrubbed of both credentials
|
|
615
|
+
* and the token; a failed sign-in or verification writes nothing.
|
|
616
|
+
*/
|
|
617
|
+
async function mintApiStorageState(o, api) {
|
|
618
|
+
const save = resolve(o.save);
|
|
619
|
+
const refusal = sessionFileInProjectRefusal(save, "the storage state");
|
|
620
|
+
if (refusal !== undefined)
|
|
621
|
+
throw new LoginArgsError(refusal);
|
|
622
|
+
const appUrl = apiVerifyUrl(api);
|
|
623
|
+
assertLoginOriginAuthorized(api.url, o.allowlist, "login endpoint");
|
|
624
|
+
assertLoginOriginAuthorized(appUrl, o.allowlist, "the app page (--verify-url)");
|
|
625
|
+
if (o.verify !== undefined)
|
|
626
|
+
assertLoginOriginAuthorized(o.verify.url, o.allowlist, "the app page (--verify-url)");
|
|
627
|
+
const secrets = resolveLoginSecrets(o.login, o.env);
|
|
628
|
+
const segments = api.tokenPath === undefined ? undefined : parseTokenPath(api.tokenPath, "--token-path");
|
|
629
|
+
const { recordVideo: _video, ...launch } = o.browser ?? {};
|
|
630
|
+
let session;
|
|
631
|
+
let token;
|
|
632
|
+
let tmp;
|
|
633
|
+
try {
|
|
634
|
+
session = await o.port.open({ ...sessionLaunchOptions(launch), allowedOrigins: [...o.allowlist], baseUrl: appUrl });
|
|
635
|
+
const context = session.page.context();
|
|
636
|
+
const response = await context.request.post(api.url, {
|
|
637
|
+
data: { [api.userKey]: secrets.username, [api.passwordKey]: secrets.password },
|
|
638
|
+
headers: { accept: "application/json" },
|
|
639
|
+
maxRedirects: 0,
|
|
640
|
+
failOnStatusCode: false,
|
|
641
|
+
timeout: o.timeoutMs,
|
|
642
|
+
});
|
|
643
|
+
const status = response.status();
|
|
644
|
+
const where = pathOf(api.url);
|
|
645
|
+
if (status >= 300 && status < 400) {
|
|
646
|
+
const location = response.headers()["location"];
|
|
647
|
+
const target = location === undefined ? undefined : originOf(new URL(location, api.url).href);
|
|
648
|
+
if (target !== undefined && target !== new URL(api.url).origin) {
|
|
649
|
+
throw new LoginFailedError(`the sign-in endpoint ${where} redirected (HTTP ${status}) to another origin, ${target} — redirects are never followed`);
|
|
650
|
+
}
|
|
651
|
+
if (segments !== undefined)
|
|
652
|
+
throw new LoginFailedError(`the sign-in endpoint ${where} answered HTTP ${status} (a redirect, not followed), not a JSON body holding --token-path ${api.tokenPath}`);
|
|
653
|
+
}
|
|
654
|
+
else if (status < 200 || status >= 300) {
|
|
655
|
+
throw new LoginFailedError(`the sign-in endpoint ${where} answered HTTP ${status} — the credentials in ${o.login.userEnv}/${o.login.passwordEnv} were not accepted`);
|
|
656
|
+
}
|
|
657
|
+
if (segments !== undefined) {
|
|
658
|
+
let body;
|
|
659
|
+
try {
|
|
660
|
+
body = JSON.parse(await response.text());
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
throw new LoginFailedError(`the sign-in endpoint ${where} answered HTTP ${status} with a body that is not JSON — --token-path ${api.tokenPath} cannot be read`);
|
|
664
|
+
}
|
|
665
|
+
const v = valueAtPath(body, segments);
|
|
666
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
667
|
+
token = String(v);
|
|
668
|
+
else if (typeof v === "string" && v !== "")
|
|
669
|
+
token = v;
|
|
670
|
+
else
|
|
671
|
+
throw new LoginFailedError(`the sign-in endpoint's JSON response has no ${v === undefined || v === null ? "value" : "string value"} at --token-path ${api.tokenPath}`);
|
|
672
|
+
}
|
|
673
|
+
const raw = session.captureStorageState !== undefined ? await session.captureStorageState() : JSON.stringify(await context.storageState());
|
|
674
|
+
const state = JSON.parse(raw);
|
|
675
|
+
if (token === undefined && (state.cookies ?? []).length === 0) {
|
|
676
|
+
throw new LoginFailedError(`the sign-in endpoint ${where} answered HTTP ${status} but set no cookie — name the token in its JSON response with --token-path`);
|
|
677
|
+
}
|
|
678
|
+
if (token !== undefined) {
|
|
679
|
+
const origin = new URL(appUrl).origin;
|
|
680
|
+
const origins = (state.origins ?? []).filter((x) => x.origin !== origin);
|
|
681
|
+
const existing = (state.origins ?? []).find((x) => x.origin === origin)?.localStorage ?? [];
|
|
682
|
+
origins.push({ origin, localStorage: [...existing.filter((e) => e.name !== api.storageKey), { name: api.storageKey, value: token }] });
|
|
683
|
+
state.origins = origins;
|
|
684
|
+
}
|
|
685
|
+
await session.close().catch(() => undefined);
|
|
686
|
+
session = undefined;
|
|
687
|
+
let landed = appUrl;
|
|
688
|
+
const json = JSON.stringify(state);
|
|
689
|
+
if (o.verify === undefined) {
|
|
690
|
+
writeStorageStateFile(save, json);
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
// Proven before it replaces anything: the check runs on a 0600 temp file beside the target.
|
|
694
|
+
tmp = join(dirname(save), `.${basename(save)}.${randomUUID()}.verify.json`);
|
|
695
|
+
writeStorageStateFile(tmp, json);
|
|
696
|
+
const verdict = await checkSession({ storageState: tmp, url: o.verify.url, allowlist: o.allowlist, check: o.verify.check, port: o.port, ...(o.browser === undefined ? {} : { browser: o.browser }), timeoutMs: o.timeoutMs });
|
|
697
|
+
if (!verdict.ok)
|
|
698
|
+
throw new LoginFailedError(`signed in through ${where}, but ${verdict.reason.replace(basename(tmp), "the new storage state")} — nothing saved`);
|
|
699
|
+
if (verdict.landedUrl !== undefined)
|
|
700
|
+
landed = verdict.landedUrl;
|
|
701
|
+
renameSync(tmp, save);
|
|
702
|
+
tmp = undefined;
|
|
703
|
+
}
|
|
704
|
+
return { saved: save, landedUrl: scrub(redactUrl(landed), secrets, token) };
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
if (err instanceof LoginFailedError)
|
|
708
|
+
throw new LoginFailedError(scrub(err.message, secrets, token));
|
|
709
|
+
if (err instanceof LoginArgsError)
|
|
710
|
+
throw new LoginArgsError(scrub(err.message, secrets, token));
|
|
711
|
+
throw new LoginFailedError(scrub(`the sign-in failed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`, secrets, token));
|
|
712
|
+
}
|
|
713
|
+
finally {
|
|
714
|
+
if (tmp !== undefined)
|
|
715
|
+
rmSync(tmp, { force: true });
|
|
716
|
+
await session?.close().catch(() => undefined);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/** How long the check waits for a client-side redirect (an SPA that asks its API, then routes to /login). */
|
|
720
|
+
const SETTLE_MS = 3_000;
|
|
721
|
+
/**
|
|
722
|
+
* Opens `url` with the storage state and decides by code whether the session is alive. A start URL
|
|
723
|
+
* that is itself a sign-in route is never judged (`auto`). An unreachable app is NOT an expired
|
|
724
|
+
* session: the check passes it on and the mission reports the app as unreachable, as before.
|
|
725
|
+
*/
|
|
726
|
+
export async function checkSession(o) {
|
|
727
|
+
if (o.check.mode === "off")
|
|
728
|
+
return { ok: true };
|
|
729
|
+
if (o.check.mode === "auto" && isLoginLikeUrl(o.url))
|
|
730
|
+
return { ok: true };
|
|
731
|
+
assertAuthorizedExploreTarget(o.url, o.allowlist);
|
|
732
|
+
const { recordVideo: _video, ...launch } = o.browser ?? {};
|
|
733
|
+
const session = await o.port.open({ ...sessionLaunchOptions(launch), storageState: o.storageState, allowedOrigins: [...o.allowlist], baseUrl: o.url });
|
|
734
|
+
try {
|
|
735
|
+
if ((await session.probeReachable?.(o.url)) != null)
|
|
736
|
+
return { ok: true };
|
|
737
|
+
const page = session.page;
|
|
738
|
+
try {
|
|
739
|
+
await page.goto(o.url, { waitUntil: "load", timeout: o.timeoutMs });
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
return { ok: true }; // a navigation failure is the mission's to report, not an auth verdict
|
|
743
|
+
}
|
|
744
|
+
await page.waitForLoadState("networkidle", { timeout: SETTLE_MS }).catch(() => undefined);
|
|
745
|
+
const verdict = await judgeLanding(page, o);
|
|
746
|
+
// A rotating session (a refresh cookie replaced on every use) was just used by the check: the
|
|
747
|
+
// live one is written back, so the mission starts from it rather than from a consumed token.
|
|
748
|
+
if (verdict.ok)
|
|
749
|
+
await keepRotatedSession(session, o.storageState);
|
|
750
|
+
return verdict;
|
|
751
|
+
}
|
|
752
|
+
finally {
|
|
753
|
+
await session.close().catch(() => undefined);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/** The check's verdict on the page the start URL landed on. */
|
|
757
|
+
async function judgeLanding(page, o) {
|
|
758
|
+
const landed = page.url();
|
|
759
|
+
const where = redactUrl(landed);
|
|
760
|
+
const state = basename(o.storageState);
|
|
761
|
+
switch (o.check.mode) {
|
|
762
|
+
case "auto": {
|
|
763
|
+
if (isLoginLikeUrl(landed)) {
|
|
764
|
+
return { ok: false, landedUrl: where, reason: `the session in ${state} is not signed in: ${pathOf(o.url)} redirected to the sign-in page ${pathOf(landed)}` };
|
|
765
|
+
}
|
|
766
|
+
if (await showsSignInForm(page)) {
|
|
767
|
+
return { ok: false, landedUrl: where, reason: `the session in ${state} is not signed in: ${pathOf(landed)} shows a sign-in form (a password field with a username field or a sign-in button)` };
|
|
768
|
+
}
|
|
769
|
+
return { ok: true, landedUrl: where };
|
|
770
|
+
}
|
|
771
|
+
case "urlExcludes":
|
|
772
|
+
return landed.includes(o.check.text)
|
|
773
|
+
? { ok: false, landedUrl: where, reason: `the session in ${state} is not signed in: the start page landed on ${pathOf(landed)}, which includes ${JSON.stringify(o.check.text)} (--auth-check)` }
|
|
774
|
+
: { ok: true, landedUrl: where };
|
|
775
|
+
case "selector": {
|
|
776
|
+
const visible = await page
|
|
777
|
+
.locator(o.check.selector)
|
|
778
|
+
.first()
|
|
779
|
+
.waitFor({ state: "visible", timeout: Math.min(o.timeoutMs, 5_000) })
|
|
780
|
+
.then(() => true)
|
|
781
|
+
.catch(() => false);
|
|
782
|
+
return visible
|
|
783
|
+
? { ok: true, landedUrl: where }
|
|
784
|
+
: { ok: false, landedUrl: where, reason: `the session in ${state} is not signed in: the signed-in marker ${JSON.stringify(o.check.selector)} is not visible on ${pathOf(landed)} (--auth-check)` };
|
|
785
|
+
}
|
|
786
|
+
case "off":
|
|
787
|
+
return { ok: true, landedUrl: where };
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/** `name@domain/path` → value for a storage state's cookies, and `origin key` → value for its localStorage. */
|
|
791
|
+
function sessionValues(json) {
|
|
792
|
+
let raw;
|
|
793
|
+
try {
|
|
794
|
+
raw = JSON.parse(json);
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return undefined;
|
|
798
|
+
}
|
|
799
|
+
const o = (raw ?? {});
|
|
800
|
+
const out = new Map();
|
|
801
|
+
for (const c of Array.isArray(o.cookies) ? o.cookies : []) {
|
|
802
|
+
const k = c;
|
|
803
|
+
if (typeof k.name === "string" && typeof k.value === "string")
|
|
804
|
+
out.set(`cookie ${k.name}@${String(k.domain)}${String(k.path ?? "/")}`, k.value);
|
|
805
|
+
}
|
|
806
|
+
for (const x of Array.isArray(o.origins) ? o.origins : []) {
|
|
807
|
+
const origin = x;
|
|
808
|
+
for (const e of Array.isArray(origin.localStorage) ? origin.localStorage : []) {
|
|
809
|
+
const kv = e;
|
|
810
|
+
if (typeof kv.name === "string" && typeof kv.value === "string")
|
|
811
|
+
out.set(`local ${String(origin.origin)} ${kv.name}`, kv.value);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return out;
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Writes the check's live session back over `file` when a value the file held changed or was
|
|
818
|
+
* removed during the check (a rotated refresh token). Untouched otherwise; never inside a repo's
|
|
819
|
+
* `.jevitate/`; best effort (the mission's own `sessionLost` warning still catches a lost session).
|
|
820
|
+
*/
|
|
821
|
+
async function keepRotatedSession(session, file) {
|
|
822
|
+
if (session.captureStorageState === undefined || sessionFileInProjectRefusal(file, "the storage state") !== undefined)
|
|
823
|
+
return;
|
|
824
|
+
try {
|
|
825
|
+
const before = sessionValues(readFileSync(file, "utf8"));
|
|
826
|
+
const live = await session.captureStorageState();
|
|
827
|
+
const after = sessionValues(live);
|
|
828
|
+
if (before === undefined || after === undefined)
|
|
829
|
+
return;
|
|
830
|
+
const rotated = [...before].some(([k, v]) => after.get(k) !== v);
|
|
831
|
+
if (rotated)
|
|
832
|
+
writeStorageStateFile(file, live);
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
// best effort: the state file is left as it was
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/** The pre-flight: check the session; when it is expired and login parameters exist, re-mint once and re-check. */
|
|
839
|
+
export async function ensurePersonaSession(o) {
|
|
840
|
+
if (o.check.mode === "off")
|
|
841
|
+
return { ok: true, refreshed: false };
|
|
842
|
+
const who = o.persona === undefined ? "the session" : `persona ${o.persona}`;
|
|
843
|
+
let first;
|
|
844
|
+
if (existsSync(o.storageState)) {
|
|
845
|
+
first = await checkSession(o);
|
|
846
|
+
if (first.ok)
|
|
847
|
+
return { ok: true, refreshed: false };
|
|
848
|
+
}
|
|
849
|
+
const expired = first === undefined || first.ok ? `the storage state ${basename(o.storageState)} does not exist yet` : first.reason;
|
|
850
|
+
const landedUrl = first !== undefined && !first.ok ? first.landedUrl : undefined;
|
|
851
|
+
if (o.login === undefined)
|
|
852
|
+
return { ok: false, reason: expired, ...(landedUrl === undefined ? {} : { landedUrl }) };
|
|
853
|
+
try {
|
|
854
|
+
await mintStorageState({ login: o.login, save: o.storageState, allowlist: o.allowlist, env: o.env, port: o.port, ...(o.browser === undefined ? {} : { browser: o.browser }), timeoutMs: o.timeoutMs });
|
|
855
|
+
}
|
|
856
|
+
catch (err) {
|
|
857
|
+
if (!(err instanceof LoginFailedError) && !(err instanceof LoginArgsError))
|
|
858
|
+
throw err;
|
|
859
|
+
return { ok: false, reason: `${expired}; re-signing in ${who} failed: ${err.message}`, ...(landedUrl === undefined ? {} : { landedUrl }) };
|
|
860
|
+
}
|
|
861
|
+
o.note?.(`jevitate: ${who}: ${expired} — signed in again from its login parameters and saved ${basename(o.storageState)}\n`);
|
|
862
|
+
const second = await checkSession(o);
|
|
863
|
+
if (second.ok)
|
|
864
|
+
return { ok: true, refreshed: true };
|
|
865
|
+
return { ok: false, reason: `${second.reason} (even right after signing in again)`, landedUrl: second.landedUrl };
|
|
866
|
+
}
|
|
867
|
+
export function authExpiredResult(strategy, startUrl, failed, persona) {
|
|
868
|
+
const reason = `${persona === undefined ? "" : `persona ${persona}: `}${failed.reason} — the mission did not start (re-save the storage state with \`jevitate login\`, or give the persona login parameters to refresh it automatically)`;
|
|
869
|
+
return {
|
|
870
|
+
strategy,
|
|
871
|
+
outcome: "inconclusive",
|
|
872
|
+
missionOutcome: "inconclusive",
|
|
873
|
+
reason,
|
|
874
|
+
failure: { kind: "auth-expired", message: reason, ...(persona === undefined ? {} : { persona }) },
|
|
875
|
+
...(persona === undefined ? {} : { persona }),
|
|
876
|
+
startUrl: redactUrl(startUrl),
|
|
877
|
+
...(failed.landedUrl === undefined ? {} : { landedUrl: failed.landedUrl }),
|
|
878
|
+
exitCode: 2,
|
|
879
|
+
...(strategy === "goal" ? { goalOutcome: "inconclusive", goalReason: "broken-run" } : {}),
|
|
880
|
+
defects: [],
|
|
881
|
+
defectOutcome: { status: "none", byKind: {} },
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
//# sourceMappingURL=persona-login.js.map
|