package-registry-manager 0.21.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/CHANGELOG.md +60 -0
- package/LICENSE +24 -0
- package/README.md +21 -0
- package/package.json +53 -0
- package/src/approvals.mjs +72 -0
- package/src/auth-urls.mjs +149 -0
- package/src/automation.mjs +124 -0
- package/src/browser-options.mjs +175 -0
- package/src/browser.mjs +123 -0
- package/src/cli.mjs +431 -0
- package/src/containers.mjs +66 -0
- package/src/default-browser.mjs +149 -0
- package/src/discovery.mjs +461 -0
- package/src/flows.mjs +722 -0
- package/src/index.mjs +6 -0
- package/src/model.mjs +50 -0
- package/src/pages.mjs +91 -0
- package/src/plan.mjs +243 -0
- package/src/prerequisites.mjs +323 -0
- package/src/profile.mjs +104 -0
- package/src/publishers.mjs +495 -0
- package/src/registry-state.mjs +158 -0
- package/src/setup.mjs +820 -0
- package/src/skips.mjs +117 -0
- package/src/tokens.mjs +215 -0
- package/src/workflows.mjs +46 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the JavaScript package are documented here. The package
|
|
4
|
+
currently shares its release version with the Rust implementation.
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `setup --registry npm --execute` takes a never-published package to OIDC
|
|
11
|
+
trusted publishing in one run: web login, a confirmed first publish from a
|
|
12
|
+
temporary worktree of the default branch, `npm trust github`, and cleanup of
|
|
13
|
+
a leftover `NPM_TOKEN` secret. It never reads, creates, or asks for an npm
|
|
14
|
+
token (#3).
|
|
15
|
+
- `inspect` and `plan` look packages up on npm, crates.io, PyPI, and Docker Hub
|
|
16
|
+
and plan a `bootstrap`, `attach`, or `complete` mode. `--offline` skips the
|
|
17
|
+
lookups, `--dry-run` prints the whole flow, and `--verify-release` watches the
|
|
18
|
+
next release for provenance (#3).
|
|
19
|
+
- PyPI bootstraps through a pending publisher and a first OIDC publish from CI;
|
|
20
|
+
crates.io publishes once with a revocable token and then opens the trusted
|
|
21
|
+
publisher settings (#3).
|
|
22
|
+
- Docker Hub and GHCR images are detected from a `Dockerfile` and the release
|
|
23
|
+
workflow, with plans for the repository, a scoped token, `DOCKERHUB_*`
|
|
24
|
+
variables and secret, `packages: write`, and package linking (#5).
|
|
25
|
+
- `setup --browser default|automated` chooses where sign-in and approval URLs
|
|
26
|
+
open; `default`, the default, uses the user's default browser (#9).
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- npm web-auth URLs (`npm login`, `npm publish` 2FA, `npm trust`) open in the
|
|
31
|
+
user's default browser (`open`, `xdg-open`, or the Windows URL handler),
|
|
32
|
+
where they are usually already signed in. The automated browser profile is
|
|
33
|
+
only used to fill the trusted-publisher form (#9).
|
|
34
|
+
- The automation browser profile now defaults to a per-user state directory
|
|
35
|
+
outside any repository (`~/Library/Application Support` on macOS,
|
|
36
|
+
`$XDG_STATE_HOME` or `~/.local/state` on Linux, `%LOCALAPPDATA%` on Windows,
|
|
37
|
+
each followed by `package-registry-manager/browser-profile`) (#8).
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- A browser profile inside a Git work tree gets a `.gitignore` containing `*`
|
|
42
|
+
before the browser starts, and setup refuses to continue if Git still does
|
|
43
|
+
not ignore it or already tracks files in it. A profile left in
|
|
44
|
+
`.package-registry-manager/` by an earlier release is protected the same way,
|
|
45
|
+
with a warning to delete it (#8).
|
|
46
|
+
- When a web login is not completed and npm falls back to its legacy
|
|
47
|
+
`Username:` prompt, setup stops npm and says to re-run for a fresh login link
|
|
48
|
+
instead of failing with `npm exited with status 1` (#9).
|
|
49
|
+
|
|
50
|
+
- `plan` no longer emits setup steps or a trusted publisher for unpublishable
|
|
51
|
+
packages; they appear with `"steps": []` and a `skipped_reason`, and `setup`
|
|
52
|
+
picks the only publishable package when a registry also has private ones
|
|
53
|
+
(#4).
|
|
54
|
+
|
|
55
|
+
## [0.19.35] - 2026-09-21
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- Initial JavaScript implementation of repository inspection and registry
|
|
60
|
+
setup planning.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
This is free and unencumbered software released into the public domain.
|
|
2
|
+
|
|
3
|
+
Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
4
|
+
distribute this software, either in source code form or as a compiled
|
|
5
|
+
binary, for any purpose, commercial or non-commercial, and by any
|
|
6
|
+
means.
|
|
7
|
+
|
|
8
|
+
In jurisdictions that recognize copyright laws, the author or authors
|
|
9
|
+
of this software dedicate any and all copyright interest in the
|
|
10
|
+
software to the public domain. We make this dedication for the benefit
|
|
11
|
+
of the public at large and to the detriment of our heirs and
|
|
12
|
+
successors. We intend this dedication to be an overt act of
|
|
13
|
+
relinquishment in perpetuity of all present and future rights to this
|
|
14
|
+
software under copyright law.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
20
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
21
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
+
|
|
24
|
+
For more information, please refer to <https://unlicense.org>
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# package-registry-manager (JavaScript)
|
|
2
|
+
|
|
3
|
+
Node.js implementation of
|
|
4
|
+
[package-registry-manager](https://github.com/link-foundation/package-registry-manager).
|
|
5
|
+
It discovers package manifests, emits registry setup plans, validates packages
|
|
6
|
+
with exact argument vectors, and guides authenticated setup in the default
|
|
7
|
+
browser, filling forms in a dedicated visible browser profile.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx package-registry-manager inspect --repository /path/to/repository
|
|
11
|
+
npx package-registry-manager plan --repository /path/to/repository --format json
|
|
12
|
+
npx package-registry-manager setup --repository /path/to/repository --registry npm
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The package installs the command as both `package-registry-manager` and
|
|
16
|
+
`package-registry-manager-js`; use the second name when the Rust command of the
|
|
17
|
+
same name is also on your `PATH`.
|
|
18
|
+
|
|
19
|
+
Setup is a dry run unless `--execute` is present, and it never publishes an
|
|
20
|
+
artifact. See the repository README for all registry walkthroughs and safety
|
|
21
|
+
details.
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "package-registry-manager",
|
|
3
|
+
"version": "0.21.0",
|
|
4
|
+
"description": "Inspect repositories and guide secure package-registry setup",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"package-registry-manager": "src/cli.mjs",
|
|
8
|
+
"package-registry-manager-js": "src/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": "./src/index.mjs",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/link-foundation/package-registry-manager.git",
|
|
17
|
+
"directory": "js"
|
|
18
|
+
},
|
|
19
|
+
"license": "Unlicense",
|
|
20
|
+
"files": [
|
|
21
|
+
"src/",
|
|
22
|
+
"CHANGELOG.md",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"provenance": true
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"syntax": "node --check src/cli.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/automation.mjs && node --check src/profile.mjs && node --check src/setup.mjs && node --check src/auth-urls.mjs && node --check src/containers.mjs && node --check src/flows.mjs && node --check src/prerequisites.mjs && node --check src/registry-state.mjs && node --check src/workflows.mjs && node --check src/publishers.mjs && node --check src/skips.mjs && node --check src/tokens.mjs && node --check src/approvals.mjs && node --check src/default-browser.mjs && node --check src/pages.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs",
|
|
32
|
+
"lint": "eslint src test scripts --max-warnings 0",
|
|
33
|
+
"lint:fix": "eslint src test scripts --fix --max-warnings 0",
|
|
34
|
+
"format": "prettier --write .",
|
|
35
|
+
"format:check": "prettier --check .",
|
|
36
|
+
"check:duplication": "jscpd src",
|
|
37
|
+
"check": "npm run syntax && npm run lint && npm run format:check && npm run check:duplication",
|
|
38
|
+
"test": "node --test --test-timeout=30000",
|
|
39
|
+
"pack:check": "npm pack --dry-run"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"browser-commander": "^0.21.2",
|
|
43
|
+
"command-stream": "^1.3.0",
|
|
44
|
+
"playwright": "^1.63.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@eslint/js": "^10.0.1",
|
|
48
|
+
"eslint": "^10.11.0",
|
|
49
|
+
"eslint-config-prettier": "^10.1.8",
|
|
50
|
+
"jscpd": "^5.4.0",
|
|
51
|
+
"prettier": "^3.9.9"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm's browser sign-in and approval links: how long they stay valid, how the
|
|
3
|
+
* tool recognizes an expired one, and how often it asks npm for a fresh one.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** How long npm keeps a web login or approval session open. */
|
|
7
|
+
export const APPROVAL_WINDOW_MS = 5 * 60_000;
|
|
8
|
+
/** How many links the tool requests before it gives up on one step. */
|
|
9
|
+
export const APPROVAL_ATTEMPTS = 3;
|
|
10
|
+
/** npm's error once its approval session expired while it polled for the result. */
|
|
11
|
+
export const EXPIRED_APPROVAL = /Invalid response from web login endpoint/i;
|
|
12
|
+
|
|
13
|
+
/** Shown with every npm approval link. */
|
|
14
|
+
export const TWO_FACTOR_HINT =
|
|
15
|
+
"If npm's approval page offers to skip two-factor checks for the next 5 minutes, choose it so the approvals that follow need no new confirmation.";
|
|
16
|
+
|
|
17
|
+
const pad = (number) => String(number).padStart(2, "0");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The deadline printed with a sign-in or approval link, such as
|
|
21
|
+
* "Sign in within about 5 minutes (until 19:05).".
|
|
22
|
+
*/
|
|
23
|
+
export function approvalDeadline(kind, now = new Date()) {
|
|
24
|
+
const until = new Date(now.getTime() + APPROVAL_WINDOW_MS);
|
|
25
|
+
const action = kind === "login" ? "Sign in" : "Approve";
|
|
26
|
+
return `${action} within about 5 minutes (until ${pad(until.getHours())}:${pad(until.getMinutes())}).`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Why an npm run ended on an expired link, or undefined when it did not: a
|
|
31
|
+
* web login falls back to npm's legacy `Username:` prompt, and a publish or
|
|
32
|
+
* trust approval fails with npm's web-login error.
|
|
33
|
+
*/
|
|
34
|
+
export function expiredApproval(result) {
|
|
35
|
+
if (result.legacyLogin) {
|
|
36
|
+
return "npm fell back to its legacy username prompt";
|
|
37
|
+
}
|
|
38
|
+
if (result.code !== 0 && result.approvalExpired) {
|
|
39
|
+
return "npm's approval session ended";
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Runs `attempt()` until it does not end on an expired link, up to
|
|
46
|
+
* `attempts` times, printing why each fresh link is requested.
|
|
47
|
+
*/
|
|
48
|
+
export async function withFreshLinks(
|
|
49
|
+
attempt,
|
|
50
|
+
{ attempts = APPROVAL_ATTEMPTS, log = console.log } = {},
|
|
51
|
+
) {
|
|
52
|
+
for (let number = 1; ; number += 1) {
|
|
53
|
+
const result = await attempt();
|
|
54
|
+
const reason = expiredApproval(result);
|
|
55
|
+
if (!reason) {
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
if (number >= attempts) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`the browser link expired ${attempts} times (${reason}); re-run the command when you are ready to approve within 5 minutes`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
log(
|
|
64
|
+
`\nThe link expired before it was approved (${reason}); requesting a fresh one (attempt ${number + 1} of ${attempts}).`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The closing note once a package publishes through trusted publishing. */
|
|
70
|
+
export function oidcReleaseNote(workflow) {
|
|
71
|
+
return `Future releases publish from ${workflow} through trusted publishing; no login is needed.`;
|
|
72
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { spawn } from "command-stream";
|
|
2
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { EXPIRED_APPROVAL } from "./approvals.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Preloaded into npm so it treats piped stdout as a terminal. npm only offers
|
|
10
|
+
* web authentication (`Authenticate your account at:`) on a TTY; the tool
|
|
11
|
+
* pipes stdout to find those URLs and open them in a browser.
|
|
12
|
+
*/
|
|
13
|
+
export const TTY_SHIM = `for (const stream of [process.stdin, process.stdout]) {
|
|
14
|
+
if (!stream.isTTY) {
|
|
15
|
+
Object.defineProperty(stream, "isTTY", { value: true, configurable: true });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
const PROMPTS = [
|
|
21
|
+
[/^Login at:?$/i, "login"],
|
|
22
|
+
[/^Authenticate your account at:?$/i, "approve"],
|
|
23
|
+
];
|
|
24
|
+
const INLINE = /(Login at|Authenticate your account at):?\s+(https?:\/\/\S+)/i;
|
|
25
|
+
const linkKind = (label) => (/^login/i.test(label) ? "login" : "approve");
|
|
26
|
+
// npm falls back to this prompt when a web login is not completed in time.
|
|
27
|
+
const LEGACY_LOGIN = /^Username:/i;
|
|
28
|
+
/** Terminal color and cursor escape sequences. */
|
|
29
|
+
// eslint-disable-next-line no-control-regex
|
|
30
|
+
export const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
31
|
+
|
|
32
|
+
const clean = (raw) => raw.replace(ANSI, "").trim();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns a line scanner that reports web-authentication URLs printed by npm,
|
|
36
|
+
* with their kind (`login` or `approve`), and, once, npm's legacy `Username:`
|
|
37
|
+
* prompt, which is printed without a trailing newline.
|
|
38
|
+
*/
|
|
39
|
+
export function authUrlScanner(onUrl, onLegacyLogin) {
|
|
40
|
+
let pending = "";
|
|
41
|
+
let expectUrl = null;
|
|
42
|
+
let legacy = false;
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
const detectLegacy = (line) => {
|
|
45
|
+
if (onLegacyLogin && !legacy && LEGACY_LOGIN.test(line)) {
|
|
46
|
+
legacy = true;
|
|
47
|
+
onLegacyLogin();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const report = (url, kind) => {
|
|
51
|
+
if (!seen.has(url)) {
|
|
52
|
+
seen.add(url);
|
|
53
|
+
onUrl(url, kind);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const scanLine = (raw) => {
|
|
57
|
+
const line = clean(raw);
|
|
58
|
+
detectLegacy(line);
|
|
59
|
+
const inline = INLINE.exec(line);
|
|
60
|
+
const prompt = PROMPTS.find(([pattern]) => pattern.test(line));
|
|
61
|
+
if (inline) {
|
|
62
|
+
report(inline[2], linkKind(inline[1]));
|
|
63
|
+
expectUrl = null;
|
|
64
|
+
} else if (prompt) {
|
|
65
|
+
expectUrl = prompt[1];
|
|
66
|
+
} else if (expectUrl && /^https?:\/\/\S+$/.test(line)) {
|
|
67
|
+
report(line, expectUrl);
|
|
68
|
+
expectUrl = null;
|
|
69
|
+
} else if (line) {
|
|
70
|
+
expectUrl = null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
return (chunk) => {
|
|
74
|
+
pending += chunk;
|
|
75
|
+
const lines = pending.split(/\r?\n/);
|
|
76
|
+
pending = lines.pop();
|
|
77
|
+
lines.forEach(scanLine);
|
|
78
|
+
detectLegacy(clean(pending));
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Appends a `--require` of the TTY shim to existing NODE_OPTIONS. */
|
|
83
|
+
export function nodeOptionsWithShim(existing, shimPath) {
|
|
84
|
+
const quoted = shimPath.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
85
|
+
return [existing, `--require "${quoted}"`].filter(Boolean).join(" ");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Writes the TTY shim to a private temporary directory. */
|
|
89
|
+
export async function writeTtyShim() {
|
|
90
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "prm-shim-"));
|
|
91
|
+
const shim = path.join(directory, "tty-shim.cjs");
|
|
92
|
+
await writeFile(shim, TTY_SHIM, { mode: 0o600 });
|
|
93
|
+
return { directory, shim };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs an exact argument vector with the terminal's stdin and stderr, while
|
|
98
|
+
* mirroring and capturing stdout. stdin stays a real terminal so masked
|
|
99
|
+
* prompts (cargo login, gh secret set) keep working. With
|
|
100
|
+
* `options.stopOnLegacyLogin` the command is stopped at npm's legacy
|
|
101
|
+
* `Username:` prompt and the result has `legacyLogin: true`. With
|
|
102
|
+
* `options.watchStderr` stderr is mirrored too, and the result has
|
|
103
|
+
* `approvalExpired: true` once npm reports an expired approval session.
|
|
104
|
+
*/
|
|
105
|
+
export function runInteractive(command, options) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const child = spawn(command.program, command.args, {
|
|
108
|
+
cwd: options.cwd,
|
|
109
|
+
env: options.env ?? process.env,
|
|
110
|
+
stdio: ["inherit", "pipe", options.watchStderr ? "pipe" : "inherit"],
|
|
111
|
+
shell: false,
|
|
112
|
+
});
|
|
113
|
+
let legacyLogin = false;
|
|
114
|
+
const stop = options.stopOnLegacyLogin
|
|
115
|
+
? () => {
|
|
116
|
+
legacyLogin = true;
|
|
117
|
+
child.kill();
|
|
118
|
+
}
|
|
119
|
+
: undefined;
|
|
120
|
+
const scan =
|
|
121
|
+
options.onUrl || stop
|
|
122
|
+
? authUrlScanner(options.onUrl ?? (() => {}), stop)
|
|
123
|
+
: null;
|
|
124
|
+
let stdout = "";
|
|
125
|
+
child.stdout.setEncoding("utf8");
|
|
126
|
+
child.stdout.on("data", (chunk) => {
|
|
127
|
+
stdout += chunk;
|
|
128
|
+
if (options.mirror !== false) {
|
|
129
|
+
process.stdout.write(chunk);
|
|
130
|
+
}
|
|
131
|
+
scan?.(chunk);
|
|
132
|
+
});
|
|
133
|
+
let approvalExpired = false;
|
|
134
|
+
let errors = "";
|
|
135
|
+
if (options.watchStderr) {
|
|
136
|
+
child.stderr.setEncoding("utf8");
|
|
137
|
+
child.stderr.on("data", (chunk) => {
|
|
138
|
+
process.stderr.write(chunk);
|
|
139
|
+
errors = `${errors}${chunk}`.slice(-4096);
|
|
140
|
+
approvalExpired ||= EXPIRED_APPROVAL.test(errors.replace(ANSI, ""));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
child.on("error", reject);
|
|
144
|
+
child.on("close", (code) => {
|
|
145
|
+
scan?.("\n");
|
|
146
|
+
resolve({ code: code ?? 1, stdout, legacyLogin, approvalExpired });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { cp } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
attachViaExtension,
|
|
6
|
+
EXTENSION_DIRECTORY,
|
|
7
|
+
launchRealBrowser,
|
|
8
|
+
} from "browser-commander";
|
|
9
|
+
|
|
10
|
+
import { snapshotBrowser } from "./browser-options.mjs";
|
|
11
|
+
import { defaultBrowserProfile, ensureProfileIgnored } from "./profile.mjs";
|
|
12
|
+
|
|
13
|
+
/** How long `--browser-attach extension` waits for the extension to connect. */
|
|
14
|
+
export const EXTENSION_TIMEOUT_MS = 300_000;
|
|
15
|
+
|
|
16
|
+
/** Where the companion extension is written for Chrome's "Load unpacked". */
|
|
17
|
+
export function relayExtensionDirectory() {
|
|
18
|
+
return path.join(path.dirname(defaultBrowserProfile()), "relay-extension");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The `launchRealBrowser` options for the automated browser: the dedicated
|
|
23
|
+
* profile by default, optionally seeded from a real profile, or a temporary
|
|
24
|
+
* snapshot of the user's own profile with `--browser-attach snapshot`.
|
|
25
|
+
*/
|
|
26
|
+
export function launchOptions(browser, profile, verbose = false) {
|
|
27
|
+
const options = {
|
|
28
|
+
engine: "playwright",
|
|
29
|
+
channel: browser.channel,
|
|
30
|
+
headless: false,
|
|
31
|
+
verbose,
|
|
32
|
+
};
|
|
33
|
+
if (browser.executable) {
|
|
34
|
+
options.executablePath = browser.executable;
|
|
35
|
+
}
|
|
36
|
+
if (browser.restrictions.length > 0) {
|
|
37
|
+
options.restrictions = browser.restrictions;
|
|
38
|
+
}
|
|
39
|
+
if (Object.keys(browser.preferences).length > 0) {
|
|
40
|
+
options.preferences = browser.preferences;
|
|
41
|
+
}
|
|
42
|
+
if (browser.attach?.mode === "snapshot") {
|
|
43
|
+
options.attach = {
|
|
44
|
+
mode: "snapshot",
|
|
45
|
+
browser: snapshotBrowser(browser.channel),
|
|
46
|
+
profile: browser.attach.profile ?? "Default",
|
|
47
|
+
};
|
|
48
|
+
return options;
|
|
49
|
+
}
|
|
50
|
+
options.userDataDir = profile;
|
|
51
|
+
if (browser.import) {
|
|
52
|
+
options.migrateFrom = {
|
|
53
|
+
browser: browser.import.browser,
|
|
54
|
+
...(browser.import.profile ? { profile: browser.import.profile } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return options;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The instructions printed while waiting for the companion extension. */
|
|
61
|
+
export function extensionInstructions(directory) {
|
|
62
|
+
return [
|
|
63
|
+
`Waiting up to ${EXTENSION_TIMEOUT_MS / 60_000} minutes for the Browser Commander extension in your own browser.`,
|
|
64
|
+
'If it is not installed, open chrome://extensions, turn on Developer mode, click "Load unpacked", and choose:',
|
|
65
|
+
` ${directory}`,
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Connects the browser that fills forms and returns a page with `goto`,
|
|
71
|
+
* `evaluate`, and `close`.
|
|
72
|
+
*/
|
|
73
|
+
export async function connectAutomation({ browser, profile, verbose = false }) {
|
|
74
|
+
if (browser.attach?.mode === "extension") {
|
|
75
|
+
return connectExtension();
|
|
76
|
+
}
|
|
77
|
+
if (!browser.attach) {
|
|
78
|
+
await ensureProfileIgnored(profile, { verbose });
|
|
79
|
+
}
|
|
80
|
+
const connection = await launchRealBrowser(
|
|
81
|
+
launchOptions(browser, profile, verbose),
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
goto: (url) => connection.page.goto(url),
|
|
85
|
+
evaluate: (script) => connection.page.evaluate(script),
|
|
86
|
+
close: () => connection.browser.close(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function connectExtension() {
|
|
91
|
+
const directory = relayExtensionDirectory();
|
|
92
|
+
await cp(EXTENSION_DIRECTORY, directory, { recursive: true });
|
|
93
|
+
const relay = await attachViaExtension({
|
|
94
|
+
timeoutMs: EXTENSION_TIMEOUT_MS,
|
|
95
|
+
onListening: () => console.log(extensionInstructions(directory)),
|
|
96
|
+
});
|
|
97
|
+
let session = null;
|
|
98
|
+
return {
|
|
99
|
+
async goto(url) {
|
|
100
|
+
if (session) {
|
|
101
|
+
await session.send("Page.navigate", { url });
|
|
102
|
+
} else {
|
|
103
|
+
const { tabId } = await relay.newTab(url);
|
|
104
|
+
session = await relay.session(tabId);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
async evaluate(expression) {
|
|
108
|
+
const { result, exceptionDetails } = await session.send(
|
|
109
|
+
"Runtime.evaluate",
|
|
110
|
+
{ expression, returnByValue: true, awaitPromise: true },
|
|
111
|
+
);
|
|
112
|
+
if (exceptionDetails) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`the page script failed: ${exceptionDetails.exception?.description ?? exceptionDetails.text}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return result.value;
|
|
118
|
+
},
|
|
119
|
+
async close() {
|
|
120
|
+
await session?.detach().catch(() => {});
|
|
121
|
+
await relay.close();
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
LAUNCH_RESTRICTION_PRESETS,
|
|
5
|
+
LAUNCH_RESTRICTIONS,
|
|
6
|
+
} from "browser-commander";
|
|
7
|
+
|
|
8
|
+
/** Browsers whose profile `--browser-import` can migrate. */
|
|
9
|
+
export const IMPORT_BROWSERS = ["chrome", "edge", "brave", "firefox"];
|
|
10
|
+
/** How `--browser-attach` reaches the user's own browser. */
|
|
11
|
+
export const ATTACH_MODES = ["snapshot", "extension"];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every launch restriction and preset `--browser-restriction` accepts, from
|
|
15
|
+
* Browser Commander's shared catalogue.
|
|
16
|
+
*/
|
|
17
|
+
export function restrictionNames() {
|
|
18
|
+
return [
|
|
19
|
+
...LAUNCH_RESTRICTIONS.map((restriction) => restriction.id),
|
|
20
|
+
...Object.keys(LAUNCH_RESTRICTION_PRESETS),
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validates the browser options of the automated profile. Nothing changes by
|
|
26
|
+
* default: a fresh dedicated profile fills forms. `--browser-import` migrates
|
|
27
|
+
* a real profile into it, and `--browser-attach` uses the user's own browser
|
|
28
|
+
* instead, as a temporary snapshot or through the companion extension.
|
|
29
|
+
*/
|
|
30
|
+
export function parseBrowserOptions({
|
|
31
|
+
channel = "chrome",
|
|
32
|
+
executable,
|
|
33
|
+
importFrom,
|
|
34
|
+
attach,
|
|
35
|
+
preferences = [],
|
|
36
|
+
restrictions = [],
|
|
37
|
+
profileGiven = false,
|
|
38
|
+
} = {}) {
|
|
39
|
+
const options = {
|
|
40
|
+
channel,
|
|
41
|
+
executable: executable ? path.resolve(executable) : null,
|
|
42
|
+
import: importFrom ? parseImport(importFrom) : null,
|
|
43
|
+
attach: attach ? parseAttach(attach) : null,
|
|
44
|
+
preferences: parsePreferences(preferences),
|
|
45
|
+
restrictions: validateRestrictions(restrictions),
|
|
46
|
+
};
|
|
47
|
+
if (options.attach && options.import) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
"--browser-attach cannot be combined with --browser-import",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (options.attach && profileGiven) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"--browser-attach cannot be combined with --browser-profile",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
options.attach?.mode === "extension" &&
|
|
59
|
+
(options.executable ||
|
|
60
|
+
options.restrictions.length > 0 ||
|
|
61
|
+
Object.keys(options.preferences).length > 0)
|
|
62
|
+
) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"--browser-attach extension cannot be combined with --browser-executable, --browser-pref, or --browser-restriction",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return options;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Parses `<chrome|edge|brave|firefox>[:profile]`. */
|
|
71
|
+
export function parseImport(spec) {
|
|
72
|
+
const separator = spec.indexOf(":");
|
|
73
|
+
const browser = separator === -1 ? spec : spec.slice(0, separator);
|
|
74
|
+
const profile = separator === -1 ? null : spec.slice(separator + 1);
|
|
75
|
+
if (!IMPORT_BROWSERS.includes(browser) || profile === "") {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"--browser-import must be <chrome|edge|brave|firefox>[:profile]",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return { browser, profile };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Parses `snapshot`, `snapshot:<profile>`, or `extension`. */
|
|
84
|
+
export function parseAttach(spec) {
|
|
85
|
+
if (spec === "extension") {
|
|
86
|
+
return { mode: "extension" };
|
|
87
|
+
}
|
|
88
|
+
const match = /^snapshot(?::(.+))?$/.exec(spec);
|
|
89
|
+
if (!match) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"--browser-attach must be 'snapshot', 'snapshot:<profile>', or 'extension'",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return { mode: "snapshot", profile: match[1] ?? null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Builds the preferences object from `key=value` pairs. Dotted keys nest, and
|
|
99
|
+
* a value is JSON when it parses as JSON, otherwise a string.
|
|
100
|
+
*/
|
|
101
|
+
export function parsePreferences(pairs) {
|
|
102
|
+
const preferences = {};
|
|
103
|
+
for (const pair of pairs) {
|
|
104
|
+
const separator = pair.indexOf("=");
|
|
105
|
+
const key = separator === -1 ? "" : pair.slice(0, separator);
|
|
106
|
+
if (!/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(key)) {
|
|
107
|
+
throw new Error(`--browser-pref must be key=value, got '${pair}'`);
|
|
108
|
+
}
|
|
109
|
+
const parts = key.split(".");
|
|
110
|
+
const last = parts.pop();
|
|
111
|
+
let target = preferences;
|
|
112
|
+
for (const part of parts) {
|
|
113
|
+
if (!isObject(target[part])) {
|
|
114
|
+
target[part] = {};
|
|
115
|
+
}
|
|
116
|
+
target = target[part];
|
|
117
|
+
}
|
|
118
|
+
target[last] = preferenceValue(pair.slice(separator + 1));
|
|
119
|
+
}
|
|
120
|
+
return preferences;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function preferenceValue(text) {
|
|
124
|
+
try {
|
|
125
|
+
const value = JSON.parse(text);
|
|
126
|
+
return Number.isFinite(value) || typeof value !== "number" ? value : text;
|
|
127
|
+
} catch {
|
|
128
|
+
return text;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isObject(value) {
|
|
133
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Rejects a restriction or preset that is not in the shared catalogue. */
|
|
137
|
+
export function validateRestrictions(names) {
|
|
138
|
+
const known = restrictionNames();
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
if (!known.includes(name)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`unknown --browser-restriction '${name}'; choose from ${known.join(", ")}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return [...new Set(names)];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The installed browser a snapshot copies for a launch channel. */
|
|
150
|
+
export function snapshotBrowser(channel) {
|
|
151
|
+
if (channel.startsWith("msedge")) {
|
|
152
|
+
return "edge";
|
|
153
|
+
}
|
|
154
|
+
return ["brave", "chromium"].includes(channel) ? channel : "chrome";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Describes where forms are filled, for the browser prerequisite. */
|
|
158
|
+
export function automatedDescription({
|
|
159
|
+
channel = "chrome",
|
|
160
|
+
profile,
|
|
161
|
+
attach,
|
|
162
|
+
import: source,
|
|
163
|
+
} = {}) {
|
|
164
|
+
if (attach?.mode === "extension") {
|
|
165
|
+
return "your own browser through the Browser Commander extension";
|
|
166
|
+
}
|
|
167
|
+
if (attach?.mode === "snapshot") {
|
|
168
|
+
return `a temporary snapshot of your ${snapshotBrowser(channel)} profile ${attach.profile ?? "Default"}`;
|
|
169
|
+
}
|
|
170
|
+
const location = profile ? ` at ${profile}` : "";
|
|
171
|
+
const imported = source
|
|
172
|
+
? ` with data imported from ${source.browser}${source.profile ? `:${source.profile}` : ""}`
|
|
173
|
+
: "";
|
|
174
|
+
return `the automated ${channel} profile${location}${imported}`;
|
|
175
|
+
}
|