skillrepo 3.1.5 → 3.2.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/README.md +18 -6
- package/package.json +1 -1
- package/src/commands/init.mjs +12 -2
- package/src/lib/browser-open.mjs +136 -0
- package/src/lib/constants.mjs +69 -0
- package/src/lib/http.mjs +17 -3
- package/src/lib/prompt.mjs +81 -5
- package/src/test/lib/browser-open.test.mjs +187 -0
- package/src/test/lib/constants.test.mjs +93 -0
- package/src/test/lib/http.test.mjs +63 -0
- package/src/test/lib/prompt.test.mjs +154 -0
package/README.md
CHANGED
|
@@ -24,19 +24,26 @@ Requires Node.js 18 or later.
|
|
|
24
24
|
|
|
25
25
|
## Quick start
|
|
26
26
|
|
|
27
|
-
1. **
|
|
28
|
-
(Settings → Access Keys).
|
|
29
|
-
2. **Run `init`** inside your project directory:
|
|
27
|
+
1. **Run `init`** inside your project directory:
|
|
30
28
|
```sh
|
|
31
29
|
npx skillrepo init
|
|
32
30
|
```
|
|
33
|
-
|
|
31
|
+
When no key is configured, `init` opens
|
|
32
|
+
[skillrepo.dev/cli/auth](https://skillrepo.dev/cli/auth) in your default
|
|
33
|
+
browser. Sign in, click **Issue CLI key**, and paste the key back into
|
|
34
|
+
the terminal. The page works headless too — copy the key from any
|
|
35
|
+
browser if your terminal can't auto-launch one.
|
|
36
|
+
2. **Your library is now on disk.** The CLI wrote skills into
|
|
34
37
|
`.claude/skills/` (project) and registered the MCP server with every IDE
|
|
35
38
|
it detected (`.claude/`, `.cursor/`, `.vscode/`, `~/.codeium/windsurf/`).
|
|
36
|
-
|
|
39
|
+
3. **From now on**, use `skillrepo update` to pull new versions,
|
|
37
40
|
`skillrepo add` to add skills to your library, and `skillrepo list` to
|
|
38
41
|
see what you have.
|
|
39
42
|
|
|
43
|
+
> If you'd rather provision the key yourself, create one at
|
|
44
|
+
> [skillrepo.dev/app/settings](https://skillrepo.dev/app/settings)
|
|
45
|
+
> (Settings → Access Keys) and pass `--key <sk_live_...>` to `init`.
|
|
46
|
+
|
|
40
47
|
## Commands
|
|
41
48
|
|
|
42
49
|
### `init` — first-run setup
|
|
@@ -63,7 +70,12 @@ below), and runs the first library sync.
|
|
|
63
70
|
`init` is idempotent: re-running with a valid existing config re-runs
|
|
64
71
|
detection + MCP merge + first sync without re-prompting for a key. If the
|
|
65
72
|
stored key has been revoked (401 from `/auth/validate`), the CLI falls back
|
|
66
|
-
to the interactive prompt automatically
|
|
73
|
+
to the interactive prompt automatically — including opening
|
|
74
|
+
[skillrepo.dev/cli/auth](https://skillrepo.dev/cli/auth) so you can mint a
|
|
75
|
+
fresh key without leaving the terminal.
|
|
76
|
+
|
|
77
|
+
When stdin is not a TTY (CI, piped input), `init` skips the browser launch
|
|
78
|
+
and just prints the URL — paste the key via the upstream pipe.
|
|
67
79
|
|
|
68
80
|
**Headless / CI:** if you run from a directory with no IDE markers, init
|
|
69
81
|
will refuse and print a copy-pasteable MCP config for manual wiring. To
|
package/package.json
CHANGED
package/src/commands/init.mjs
CHANGED
|
@@ -56,6 +56,7 @@ import { installSessionSyncHook } from "./init-session-sync.mjs";
|
|
|
56
56
|
import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
|
|
57
57
|
import {
|
|
58
58
|
promptSecret,
|
|
59
|
+
promptWithBrowserOpen,
|
|
59
60
|
confirm,
|
|
60
61
|
} from "../lib/prompt.mjs";
|
|
61
62
|
import {
|
|
@@ -64,6 +65,7 @@ import {
|
|
|
64
65
|
validationError,
|
|
65
66
|
EXIT_AUTH,
|
|
66
67
|
} from "../lib/errors.mjs";
|
|
68
|
+
import { cliAuthUrl } from "../lib/constants.mjs";
|
|
67
69
|
|
|
68
70
|
// Local print helpers that use the INJECTED stdout/stderr streams.
|
|
69
71
|
// The prompt.mjs `print*` helpers write to process.stdout directly,
|
|
@@ -204,7 +206,10 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
204
206
|
hint: "Pass --key sk_live_... or set SKILLREPO_ACCESS_KEY.",
|
|
205
207
|
});
|
|
206
208
|
}
|
|
207
|
-
apiKey = await
|
|
209
|
+
apiKey = await promptWithBrowserOpen(
|
|
210
|
+
cliAuthUrl(serverUrl),
|
|
211
|
+
"Enter your access key (sk_live_...)",
|
|
212
|
+
);
|
|
208
213
|
}
|
|
209
214
|
|
|
210
215
|
// Trim whitespace from the key before validating. Pasting an API
|
|
@@ -241,7 +246,12 @@ export async function runInit(argv, io = {}, deps = {}) {
|
|
|
241
246
|
!yes
|
|
242
247
|
) {
|
|
243
248
|
p.warning("Existing config has an invalid key. Re-prompting for a new one.");
|
|
244
|
-
apiKey = (
|
|
249
|
+
apiKey = (
|
|
250
|
+
await promptWithBrowserOpen(
|
|
251
|
+
cliAuthUrl(serverUrl),
|
|
252
|
+
"Enter your access key (sk_live_...)",
|
|
253
|
+
)
|
|
254
|
+
).trim();
|
|
245
255
|
if (!apiKey || !apiKey.startsWith("sk_live_")) {
|
|
246
256
|
throw validationError("Invalid access key format.");
|
|
247
257
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform "open URL in the user's default browser" helper
|
|
3
|
+
* (epic #1214, Phase 2 #1217).
|
|
4
|
+
*
|
|
5
|
+
* Used by the inline auth flow (`promptWithBrowserOpen` in
|
|
6
|
+
* `prompt.mjs`) so any command that needs an access key can take the
|
|
7
|
+
* user to `https://skillrepo.dev/cli/auth` and have them paste the
|
|
8
|
+
* issued key back into the terminal.
|
|
9
|
+
*
|
|
10
|
+
* Design rules:
|
|
11
|
+
*
|
|
12
|
+
* • Zero non-Node dependencies. Uses `child_process.spawn` with
|
|
13
|
+
* `detached: true` + `unref()` so the spawned browser does not
|
|
14
|
+
* keep the CLI process alive.
|
|
15
|
+
* • Platform-specific commands are explicit, not shelled out.
|
|
16
|
+
* `spawn(command, [url])` passes the URL as argv directly with no
|
|
17
|
+
* shell interpolation, so URL contents (e.g., query strings with
|
|
18
|
+
* `&`) cannot become shell metacharacters.
|
|
19
|
+
* • Returns `true` when the spawn call succeeded synchronously,
|
|
20
|
+
* `false` for synchronous failures (unsupported platform, URL
|
|
21
|
+
* rejected, spawn throws). Note: when the OS reports the binary
|
|
22
|
+
* is missing asynchronously (ENOENT via the child's `error` event
|
|
23
|
+
* — common on minimal Linux containers without `xdg-open`), this
|
|
24
|
+
* function still returns `true`. The `error` handler swallows the
|
|
25
|
+
* event so it doesn't crash the CLI, but the browser window never
|
|
26
|
+
* actually opens. Callers must always print the URL alongside the
|
|
27
|
+
* spawn so the user has a copy/paste path that works regardless
|
|
28
|
+
* of the async outcome.
|
|
29
|
+
* • The URL is validated to be http(s) before launch. The constant
|
|
30
|
+
* in `constants.mjs` is trusted, but this gate stops a future
|
|
31
|
+
* caller from accidentally launching a `file://` or `javascript:`
|
|
32
|
+
* URI through an OS-level handler.
|
|
33
|
+
*
|
|
34
|
+
* The `platform` and `spawnFn` parameters are injection points for
|
|
35
|
+
* tests — production callers omit them and the defaults
|
|
36
|
+
* (`process.platform` and `child_process.spawn`) take effect.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Attempt to open `url` in the user's default browser.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} url - HTTP(S) URL to open.
|
|
45
|
+
* @param {object} [options]
|
|
46
|
+
* @param {NodeJS.Platform} [options.platform] - Override for tests.
|
|
47
|
+
* @param {typeof nodeSpawn} [options.spawnFn] - Override for tests.
|
|
48
|
+
* @returns {boolean} `true` if a browser process was spawned,
|
|
49
|
+
* `false` if the platform is unsupported, the URL is rejected,
|
|
50
|
+
* or the spawn failed.
|
|
51
|
+
*/
|
|
52
|
+
export function openBrowser(url, { platform, spawnFn } = {}) {
|
|
53
|
+
if (!isLaunchableUrl(url)) return false;
|
|
54
|
+
|
|
55
|
+
const plat = platform ?? process.platform;
|
|
56
|
+
const spawnImpl = spawnFn ?? nodeSpawn;
|
|
57
|
+
|
|
58
|
+
const launch = resolveLaunchCommand(plat, url);
|
|
59
|
+
if (!launch) return false;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const child = spawnImpl(launch.command, launch.args, {
|
|
63
|
+
detached: true,
|
|
64
|
+
stdio: "ignore",
|
|
65
|
+
});
|
|
66
|
+
// If the command is missing entirely (ENOENT), Node emits an
|
|
67
|
+
// `error` event asynchronously. Swallow it so it doesn't bubble
|
|
68
|
+
// up as an unhandled exception — the caller already chose to
|
|
69
|
+
// treat openBrowser failure as a soft signal.
|
|
70
|
+
child.on("error", () => {});
|
|
71
|
+
child.unref();
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reject anything that isn't a normal http(s) URL. The auth flow
|
|
80
|
+
* only ever passes the SkillRepo cloud URL, but defensive validation
|
|
81
|
+
* here means a future regression that points this helper at a
|
|
82
|
+
* `javascript:` or `file:` URL doesn't silently launch through the
|
|
83
|
+
* OS handler.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} url
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
function isLaunchableUrl(url) {
|
|
89
|
+
if (typeof url !== "string" || url.length === 0) return false;
|
|
90
|
+
try {
|
|
91
|
+
const parsed = new URL(url);
|
|
92
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Map a Node platform string to the OS command that opens a URL.
|
|
100
|
+
* Returns `null` for unsupported platforms (the caller falls back to
|
|
101
|
+
* printing the URL).
|
|
102
|
+
*
|
|
103
|
+
* @param {NodeJS.Platform} platform
|
|
104
|
+
* @param {string} url
|
|
105
|
+
* @returns {{ command: string, args: string[] } | null}
|
|
106
|
+
*/
|
|
107
|
+
function resolveLaunchCommand(platform, url) {
|
|
108
|
+
if (platform === "darwin") {
|
|
109
|
+
return { command: "open", args: [url] };
|
|
110
|
+
}
|
|
111
|
+
if (platform === "win32") {
|
|
112
|
+
// `start` is a cmd.exe builtin, not a real binary. The empty-string
|
|
113
|
+
// first argument becomes the window title — without it, a URL
|
|
114
|
+
// wrapped in quotes would be consumed as the title and never
|
|
115
|
+
// launched. We pass an EMPTY JS string `""` (zero characters)
|
|
116
|
+
// rather than the literal two-character `'""'`: with `shell: false`,
|
|
117
|
+
// Node's argv→command-line conversion emits an empty string as the
|
|
118
|
+
// four-char sequence `""` on the wire, which is what `start`
|
|
119
|
+
// expects. Passing the literal two-quote string round-trips through
|
|
120
|
+
// CRT escaping as `\"\"` and confuses `start`'s argument parser.
|
|
121
|
+
// This matches the canonical pattern used by the `open` npm package.
|
|
122
|
+
return { command: "cmd.exe", args: ["/c", "start", "", url] };
|
|
123
|
+
}
|
|
124
|
+
// linux, freebsd, openbsd, sunos, ... → xdg-open is the freedesktop
|
|
125
|
+
// standard. If it's missing, the spawn-error path returns false.
|
|
126
|
+
if (
|
|
127
|
+
platform === "linux" ||
|
|
128
|
+
platform === "freebsd" ||
|
|
129
|
+
platform === "openbsd" ||
|
|
130
|
+
platform === "sunos" ||
|
|
131
|
+
platform === "aix"
|
|
132
|
+
) {
|
|
133
|
+
return { command: "xdg-open", args: [url] };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-cutting CLI constants. Single source of truth for URLs and
|
|
3
|
+
* other values that would otherwise drift across modules.
|
|
4
|
+
*
|
|
5
|
+
* Keep this file dependency-free — modules at every layer import from
|
|
6
|
+
* here, so a transitive dependency would risk import cycles.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The browser-assisted CLI auth page (epic #1214, Phases 1 #1216 +
|
|
11
|
+
* 3 #1218).
|
|
12
|
+
*
|
|
13
|
+
* The CLI is cloud-only today, so the production default URL is
|
|
14
|
+
* baked in. But users running against staging (or any non-default
|
|
15
|
+
* --url) should see a hint URL pointing at the matching host —
|
|
16
|
+
* otherwise a 401 hint that says "open https://skillrepo.dev/cli/auth"
|
|
17
|
+
* is wrong when they're actually authed against staging.skillrepo.dev.
|
|
18
|
+
*
|
|
19
|
+
* `cliAuthUrl(serverUrl)` derives the auth URL from a server URL,
|
|
20
|
+
* preserving the host. Callers that already have the active
|
|
21
|
+
* serverUrl in scope (every public http.mjs function does) should
|
|
22
|
+
* use the helper. Callers without context fall back to
|
|
23
|
+
* `DEFAULT_CLI_AUTH_URL`.
|
|
24
|
+
*
|
|
25
|
+
* Query params (Phase 3, #1218):
|
|
26
|
+
*
|
|
27
|
+
* • `source=cli` — distinguishes CLI-driven traffic from manual
|
|
28
|
+
* browser visits. The page uses this to gate the auto-mint
|
|
29
|
+
* behavior; without it, the page renders the deliberate
|
|
30
|
+
* "Issue CLI key" button.
|
|
31
|
+
* • `new=1` — the CLI's signal that it has no working key locally.
|
|
32
|
+
* The page reads this to immediately attempt a mint on load
|
|
33
|
+
* (rather than waiting for a click). On `quota_exceeded` the
|
|
34
|
+
* page falls into the rotate flow.
|
|
35
|
+
*
|
|
36
|
+
* Both params are advisory — the page is server-side authenticated
|
|
37
|
+
* and authorized regardless. Spoofing the params just affects UX
|
|
38
|
+
* immediacy, not authorization.
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_CLI_AUTH_HOST = "https://skillrepo.dev";
|
|
41
|
+
const CLI_AUTH_PATH = "/cli/auth";
|
|
42
|
+
const CLI_AUTH_QUERY = "?source=cli&new=1";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Derive the auth URL for the given server URL. Preserves the
|
|
46
|
+
* server's host so the hint tells users "open <the server you're
|
|
47
|
+
* actually using>/cli/auth" rather than always pointing at prod.
|
|
48
|
+
*
|
|
49
|
+
* @param {string | null | undefined} serverUrl
|
|
50
|
+
* Server URL the CLI is authenticated against. Trailing slashes
|
|
51
|
+
* are stripped. `null` / `undefined` / empty string fall back to
|
|
52
|
+
* the production host.
|
|
53
|
+
* @returns {string} Full auth URL with the auto-mint hint params.
|
|
54
|
+
*/
|
|
55
|
+
export function cliAuthUrl(serverUrl) {
|
|
56
|
+
const base =
|
|
57
|
+
typeof serverUrl === "string" && serverUrl.trim() !== ""
|
|
58
|
+
? serverUrl.replace(/\/+$/, "")
|
|
59
|
+
: DEFAULT_CLI_AUTH_HOST;
|
|
60
|
+
return `${base}${CLI_AUTH_PATH}${CLI_AUTH_QUERY}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Default auth URL pointing at production. Used in error paths that
|
|
65
|
+
* have no serverUrl context (e.g. the `authHeaders` no-key guard
|
|
66
|
+
* before any flag has been resolved). Prefer `cliAuthUrl(serverUrl)`
|
|
67
|
+
* everywhere a serverUrl is in scope.
|
|
68
|
+
*/
|
|
69
|
+
export const DEFAULT_CLI_AUTH_URL = cliAuthUrl(DEFAULT_CLI_AUTH_HOST);
|
package/src/lib/http.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
validationError,
|
|
30
30
|
withRetry,
|
|
31
31
|
} from "./errors.mjs";
|
|
32
|
+
import { cliAuthUrl, DEFAULT_CLI_AUTH_URL } from "./constants.mjs";
|
|
32
33
|
|
|
33
34
|
const DEFAULT_URL = "https://skillrepo.dev";
|
|
34
35
|
|
|
@@ -117,13 +118,13 @@ async function authHeaders(apiKey) {
|
|
|
117
118
|
// path through authHeaders produces a clean Bearer header.
|
|
118
119
|
if (typeof apiKey !== "string") {
|
|
119
120
|
throw authError("No access key configured.", {
|
|
120
|
-
hint:
|
|
121
|
+
hint: `Run \`skillrepo init\`, pass --key <sk_live_...>, or open ${DEFAULT_CLI_AUTH_URL} to mint a new access key.`,
|
|
121
122
|
});
|
|
122
123
|
}
|
|
123
124
|
const trimmed = apiKey.trim();
|
|
124
125
|
if (trimmed === "") {
|
|
125
126
|
throw authError("No access key configured.", {
|
|
126
|
-
hint:
|
|
127
|
+
hint: `Run \`skillrepo init\`, pass --key <sk_live_...>, or open ${DEFAULT_CLI_AUTH_URL} to mint a new access key.`,
|
|
127
128
|
});
|
|
128
129
|
}
|
|
129
130
|
return {
|
|
@@ -302,8 +303,21 @@ async function mapErrorResponse(res, url) {
|
|
|
302
303
|
const code = body.code;
|
|
303
304
|
|
|
304
305
|
if (res.status === 401) {
|
|
306
|
+
// Derive the auth URL from the request URL's origin so users
|
|
307
|
+
// running against staging see a staging hint, not a prod hint.
|
|
308
|
+
// `url` here is the full request URL (e.g.
|
|
309
|
+
// https://staging.skillrepo.dev/api/v1/library); `new URL(...).origin`
|
|
310
|
+
// gives us "https://staging.skillrepo.dev" which is what
|
|
311
|
+
// `cliAuthUrl` expects.
|
|
312
|
+
let authUrl = DEFAULT_CLI_AUTH_URL;
|
|
313
|
+
try {
|
|
314
|
+
authUrl = cliAuthUrl(new URL(url).origin);
|
|
315
|
+
} catch {
|
|
316
|
+
// URL parse failure shouldn't happen — `safeFetch` already
|
|
317
|
+
// validated. Fall back to the prod default.
|
|
318
|
+
}
|
|
305
319
|
return authError(message, {
|
|
306
|
-
hint:
|
|
320
|
+
hint: `Run \`skillrepo init\` or open ${authUrl} in your browser to get a new access key.`,
|
|
307
321
|
});
|
|
308
322
|
}
|
|
309
323
|
if (res.status === 403) {
|
package/src/lib/prompt.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Interactive prompts using Node's built-in readline.
|
|
3
|
-
*
|
|
2
|
+
* Interactive prompts using Node's built-in readline. Supports TTY
|
|
3
|
+
* detection and NO_COLOR.
|
|
4
|
+
*
|
|
5
|
+
* Dependencies: `node:readline` and `./browser-open.mjs` (used by
|
|
6
|
+
* `promptWithBrowserOpen` to spawn the system browser).
|
|
7
|
+
* `browser-open.mjs` itself depends only on `node:child_process`, so
|
|
8
|
+
* the full transitive surface remains node-builtins-only.
|
|
4
9
|
*
|
|
5
10
|
* v3.0.0 cleanup (PR4 cross-review): the old v2.0.0 print helpers
|
|
6
11
|
* (printHeader, printStep, printSuccess, printWarning, printError,
|
|
@@ -9,13 +14,16 @@
|
|
|
9
14
|
* the stream-injection pattern every v3.0.0 command uses for
|
|
10
15
|
* testability. `init.mjs` defines its own `makePrinter` helper that
|
|
11
16
|
* ties into the injected io.stdout/io.stderr streams; every other
|
|
12
|
-
* command uses the same pattern. This module
|
|
13
|
-
*
|
|
14
|
-
*
|
|
17
|
+
* command uses the same pattern. This module exports the interactive
|
|
18
|
+
* primitives (`promptText`, `promptSecret`, `confirm`) that need
|
|
19
|
+
* direct stdin/stdout access, plus `promptWithBrowserOpen` (epic
|
|
20
|
+
* #1214 Phase 2 #1217) for the browser-assisted auth flow.
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
23
|
import { createInterface } from "node:readline";
|
|
18
24
|
|
|
25
|
+
import { openBrowser } from "./browser-open.mjs";
|
|
26
|
+
|
|
19
27
|
const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
20
28
|
const dim = (s) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s);
|
|
21
29
|
|
|
@@ -89,6 +97,74 @@ export function promptSecret(question) {
|
|
|
89
97
|
});
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Browser-assisted variant of `promptSecret` (epic #1214 Phase 2,
|
|
102
|
+
* issue #1217). Opens the user's default browser to `url` so they can
|
|
103
|
+
* sign in, mint a CLI key, and paste it back into the terminal.
|
|
104
|
+
*
|
|
105
|
+
* Behavior:
|
|
106
|
+
*
|
|
107
|
+
* • Always prints the URL on its own line first. The browser-open
|
|
108
|
+
* attempt is a convenience — the URL print is the contract, so the
|
|
109
|
+
* user can copy/paste even when the spawn silently fails (sandboxed
|
|
110
|
+
* containers, SSH sessions without `xdg-open`, etc.).
|
|
111
|
+
* • Skips the browser launch when `process.stdin.isTTY` is false.
|
|
112
|
+
* A pipe/CI invocation that hits this prompt has no human to read
|
|
113
|
+
* the spawned browser, and `start` / `xdg-open` would fail or
|
|
114
|
+
* produce noise. The pasted-token capture below still works for
|
|
115
|
+
* piped stdin via `promptSecret`'s non-TTY branch.
|
|
116
|
+
* • Delegates to `promptSecret(label)` for capture so the existing
|
|
117
|
+
* no-echo / Ctrl-C / backspace UX is preserved.
|
|
118
|
+
*
|
|
119
|
+
* The `openBrowserFn`, `isInteractive`, `stdout`, and `promptSecretFn`
|
|
120
|
+
* parameters are injection points for tests — production callers omit
|
|
121
|
+
* them.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} url - HTTP(S) URL to open in the browser.
|
|
124
|
+
* @param {string} label - Prompt label for `promptSecret`.
|
|
125
|
+
* @param {object} [options]
|
|
126
|
+
* @param {(url: string) => boolean} [options.openBrowserFn] - Override for tests.
|
|
127
|
+
* @param {() => boolean} [options.isInteractive] - Override for tests.
|
|
128
|
+
* @param {NodeJS.WritableStream} [options.stdout] - Output stream
|
|
129
|
+
* (defaults to `process.stdout`). Tests inject a buffer here.
|
|
130
|
+
* @param {(label: string) => Promise<string>} [options.promptSecretFn] -
|
|
131
|
+
* Override for tests so the secret-capture path can be exercised
|
|
132
|
+
* without driving `process.stdin`.
|
|
133
|
+
* @returns {Promise<string>} The pasted access key.
|
|
134
|
+
*/
|
|
135
|
+
export async function promptWithBrowserOpen(
|
|
136
|
+
url,
|
|
137
|
+
label,
|
|
138
|
+
{
|
|
139
|
+
openBrowserFn = openBrowser,
|
|
140
|
+
isInteractive = () => Boolean(process.stdin.isTTY),
|
|
141
|
+
stdout = process.stdout,
|
|
142
|
+
promptSecretFn = promptSecret,
|
|
143
|
+
} = {},
|
|
144
|
+
) {
|
|
145
|
+
// Fail loudly on a bad URL rather than silently rendering "undefined"
|
|
146
|
+
// to the terminal. The only production caller passes the constant
|
|
147
|
+
// from `constants.mjs`, but a future caller (or a typo) shouldn't
|
|
148
|
+
// produce a confusing UI line.
|
|
149
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
150
|
+
throw new TypeError("promptWithBrowserOpen: `url` must be a non-empty string");
|
|
151
|
+
}
|
|
152
|
+
stdout.write(` Opening browser for authentication:\n ${url}\n`);
|
|
153
|
+
if (isInteractive()) {
|
|
154
|
+
const opened = openBrowserFn(url);
|
|
155
|
+
if (!opened) {
|
|
156
|
+
stdout.write(
|
|
157
|
+
" Could not open the browser automatically — open the URL above manually.\n",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
stdout.write(
|
|
162
|
+
" Non-interactive session — open the URL above in a browser to mint a key.\n",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return promptSecretFn(label);
|
|
166
|
+
}
|
|
167
|
+
|
|
92
168
|
/**
|
|
93
169
|
* y/n confirmation.
|
|
94
170
|
*/
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/browser-open.mjs (epic #1214 Phase 2 #1217).
|
|
3
|
+
*
|
|
4
|
+
* No real browsers spawn here — every test injects a `spawnFn` stub
|
|
5
|
+
* and asserts on the recorded command/args. The injected `platform`
|
|
6
|
+
* argument lets a single host (CI runs on macOS) cover the win32 and
|
|
7
|
+
* linux branches deterministically.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
|
|
13
|
+
import { openBrowser } from "../../lib/browser-open.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a stub spawnFn that records every call and returns a fake
|
|
17
|
+
* child-process handle. The handle implements only the surface
|
|
18
|
+
* `openBrowser` actually uses: `on(event, handler)` and `unref()`.
|
|
19
|
+
*/
|
|
20
|
+
function makeSpawnStub({ throwOnSpawn = false, emitError = false } = {}) {
|
|
21
|
+
const calls = [];
|
|
22
|
+
const handlers = [];
|
|
23
|
+
function spawnFn(command, args, options) {
|
|
24
|
+
calls.push({ command, args, options });
|
|
25
|
+
if (throwOnSpawn) {
|
|
26
|
+
throw new Error("spawn ENOENT");
|
|
27
|
+
}
|
|
28
|
+
const child = {
|
|
29
|
+
on(event, handler) {
|
|
30
|
+
handlers.push({ event, handler });
|
|
31
|
+
if (emitError && event === "error") {
|
|
32
|
+
// emit synchronously so the test sees it before the
|
|
33
|
+
// openBrowser call returns
|
|
34
|
+
handler(new Error("ENOENT"));
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
unref() {},
|
|
38
|
+
};
|
|
39
|
+
return child;
|
|
40
|
+
}
|
|
41
|
+
return { spawnFn, calls, handlers };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("openBrowser", () => {
|
|
45
|
+
it("uses `open` on darwin", () => {
|
|
46
|
+
const { spawnFn, calls } = makeSpawnStub();
|
|
47
|
+
const result = openBrowser("https://skillrepo.dev/cli/auth", {
|
|
48
|
+
platform: "darwin",
|
|
49
|
+
spawnFn,
|
|
50
|
+
});
|
|
51
|
+
assert.equal(result, true);
|
|
52
|
+
assert.equal(calls.length, 1);
|
|
53
|
+
assert.equal(calls[0].command, "open");
|
|
54
|
+
assert.deepEqual(calls[0].args, ["https://skillrepo.dev/cli/auth"]);
|
|
55
|
+
assert.equal(calls[0].options.detached, true);
|
|
56
|
+
assert.equal(calls[0].options.stdio, "ignore");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("uses `xdg-open` on linux", () => {
|
|
60
|
+
const { spawnFn, calls } = makeSpawnStub();
|
|
61
|
+
const result = openBrowser("https://skillrepo.dev/cli/auth", {
|
|
62
|
+
platform: "linux",
|
|
63
|
+
spawnFn,
|
|
64
|
+
});
|
|
65
|
+
assert.equal(result, true);
|
|
66
|
+
assert.equal(calls[0].command, "xdg-open");
|
|
67
|
+
assert.deepEqual(calls[0].args, ["https://skillrepo.dev/cli/auth"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("uses `cmd.exe /c start` on win32 with EMPTY-string title arg", () => {
|
|
71
|
+
// The third arg MUST be the empty JS string `""` (zero chars), NOT
|
|
72
|
+
// the literal two-quote `'""'`. Node's argv→cmdline CRT escaping
|
|
73
|
+
// emits an empty string as `""` on the wire, which is what cmd.exe
|
|
74
|
+
// `start` expects. Passing `'""'` would round-trip as `\"\"` and
|
|
75
|
+
// break `start`'s argument parser. Pin the exact zero-length char
|
|
76
|
+
// so a future regression is caught.
|
|
77
|
+
const { spawnFn, calls } = makeSpawnStub();
|
|
78
|
+
const result = openBrowser("https://skillrepo.dev/cli/auth", {
|
|
79
|
+
platform: "win32",
|
|
80
|
+
spawnFn,
|
|
81
|
+
});
|
|
82
|
+
assert.equal(result, true);
|
|
83
|
+
assert.equal(calls[0].command, "cmd.exe");
|
|
84
|
+
assert.deepEqual(calls[0].args, [
|
|
85
|
+
"/c",
|
|
86
|
+
"start",
|
|
87
|
+
"",
|
|
88
|
+
"https://skillrepo.dev/cli/auth",
|
|
89
|
+
]);
|
|
90
|
+
assert.equal(calls[0].args[2].length, 0, "title arg must be zero-length");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("uses xdg-open on freebsd / openbsd / sunos / aix", () => {
|
|
94
|
+
for (const platform of ["freebsd", "openbsd", "sunos", "aix"]) {
|
|
95
|
+
const { spawnFn, calls } = makeSpawnStub();
|
|
96
|
+
const result = openBrowser("https://skillrepo.dev/cli/auth", {
|
|
97
|
+
platform,
|
|
98
|
+
spawnFn,
|
|
99
|
+
});
|
|
100
|
+
assert.equal(result, true, `${platform} should spawn`);
|
|
101
|
+
assert.equal(calls[0].command, "xdg-open");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns false on unsupported platforms without spawning", () => {
|
|
106
|
+
const { spawnFn, calls } = makeSpawnStub();
|
|
107
|
+
const result = openBrowser("https://skillrepo.dev/cli/auth", {
|
|
108
|
+
platform: "haiku",
|
|
109
|
+
spawnFn,
|
|
110
|
+
});
|
|
111
|
+
assert.equal(result, false);
|
|
112
|
+
assert.equal(calls.length, 0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns false when spawn throws", () => {
|
|
116
|
+
const { spawnFn, calls } = makeSpawnStub({ throwOnSpawn: true });
|
|
117
|
+
const result = openBrowser("https://skillrepo.dev/cli/auth", {
|
|
118
|
+
platform: "darwin",
|
|
119
|
+
spawnFn,
|
|
120
|
+
});
|
|
121
|
+
assert.equal(result, false);
|
|
122
|
+
assert.equal(calls.length, 1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("registers an error handler so post-spawn ENOENT is swallowed", () => {
|
|
126
|
+
// The spawn returns a child but the OS reports the binary is
|
|
127
|
+
// missing asynchronously via an `error` event. Without a
|
|
128
|
+
// registered handler this would crash the CLI.
|
|
129
|
+
const { spawnFn, handlers } = makeSpawnStub({ emitError: true });
|
|
130
|
+
const result = openBrowser("https://skillrepo.dev/cli/auth", {
|
|
131
|
+
platform: "linux",
|
|
132
|
+
spawnFn,
|
|
133
|
+
});
|
|
134
|
+
// Spawn succeeded → returns true; the error event was registered
|
|
135
|
+
// and silently absorbed.
|
|
136
|
+
assert.equal(result, true);
|
|
137
|
+
const errorHandlers = handlers.filter((h) => h.event === "error");
|
|
138
|
+
assert.equal(errorHandlers.length, 1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rejects non-string url", () => {
|
|
142
|
+
const { spawnFn, calls } = makeSpawnStub();
|
|
143
|
+
for (const bogus of [undefined, null, 42, {}, ""]) {
|
|
144
|
+
const result = openBrowser(bogus, { platform: "darwin", spawnFn });
|
|
145
|
+
assert.equal(result, false, `should reject ${JSON.stringify(bogus)}`);
|
|
146
|
+
}
|
|
147
|
+
assert.equal(calls.length, 0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("rejects javascript: and file: URIs to prevent OS-handler abuse", () => {
|
|
151
|
+
const { spawnFn, calls } = makeSpawnStub();
|
|
152
|
+
for (const url of [
|
|
153
|
+
"javascript:alert(1)",
|
|
154
|
+
"file:///etc/passwd",
|
|
155
|
+
"data:text/html,<script>alert(1)</script>",
|
|
156
|
+
"ftp://example.com",
|
|
157
|
+
"not-a-url",
|
|
158
|
+
]) {
|
|
159
|
+
const result = openBrowser(url, { platform: "darwin", spawnFn });
|
|
160
|
+
assert.equal(result, false, `should reject ${url}`);
|
|
161
|
+
}
|
|
162
|
+
assert.equal(calls.length, 0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("accepts http:// URLs", () => {
|
|
166
|
+
const { spawnFn, calls } = makeSpawnStub();
|
|
167
|
+
const result = openBrowser("http://localhost:3000/cli/auth", {
|
|
168
|
+
platform: "darwin",
|
|
169
|
+
spawnFn,
|
|
170
|
+
});
|
|
171
|
+
assert.equal(result, true);
|
|
172
|
+
assert.equal(calls[0].args[0], "http://localhost:3000/cli/auth");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("does not set `shell` (URL must not be shell-interpreted)", () => {
|
|
176
|
+
// Stronger than `notEqual(shell, true)`: `shell` must be absent.
|
|
177
|
+
// If a future refactor sets `shell: false` (still safe) the test
|
|
178
|
+
// passes; if it sets `shell: "/bin/bash"` (unsafe — re-parses URL)
|
|
179
|
+
// this catches it.
|
|
180
|
+
const { spawnFn, calls } = makeSpawnStub();
|
|
181
|
+
openBrowser("https://skillrepo.dev/cli/auth?a=1&b=2", {
|
|
182
|
+
platform: "darwin",
|
|
183
|
+
spawnFn,
|
|
184
|
+
});
|
|
185
|
+
assert.equal(calls[0].options.shell, undefined);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/constants.mjs (epic #1214 Phase 3 #1218
|
|
3
|
+
* follow-up).
|
|
4
|
+
*
|
|
5
|
+
* The `cliAuthUrl(serverUrl)` helper has three distinct branches —
|
|
6
|
+
* production fallback, normal-host pass-through, and trailing-slash
|
|
7
|
+
* stripping. Coverage was previously indirect via http.mjs tests;
|
|
8
|
+
* this file pins each branch directly so a refactor of `cliAuthUrl`
|
|
9
|
+
* can't silently regress without surfacing here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
cliAuthUrl,
|
|
17
|
+
DEFAULT_CLI_AUTH_URL,
|
|
18
|
+
} from "../../lib/constants.mjs";
|
|
19
|
+
|
|
20
|
+
describe("cliAuthUrl", () => {
|
|
21
|
+
it("returns the production-default URL when serverUrl is null", () => {
|
|
22
|
+
assert.equal(cliAuthUrl(null), DEFAULT_CLI_AUTH_URL);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns the production-default URL when serverUrl is undefined", () => {
|
|
26
|
+
assert.equal(cliAuthUrl(undefined), DEFAULT_CLI_AUTH_URL);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns the production-default URL when serverUrl is empty string", () => {
|
|
30
|
+
assert.equal(cliAuthUrl(""), DEFAULT_CLI_AUTH_URL);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns the production-default URL when serverUrl is whitespace-only", () => {
|
|
34
|
+
// Whitespace-only is not a meaningful server URL; the helper
|
|
35
|
+
// should fall back to prod rather than producing
|
|
36
|
+
// " /cli/auth?source=cli&new=1".
|
|
37
|
+
assert.equal(cliAuthUrl(" "), DEFAULT_CLI_AUTH_URL);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("preserves the host when serverUrl is a normal URL", () => {
|
|
41
|
+
assert.equal(
|
|
42
|
+
cliAuthUrl("https://staging.skillrepo.dev"),
|
|
43
|
+
"https://staging.skillrepo.dev/cli/auth?source=cli&new=1",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("strips a single trailing slash", () => {
|
|
48
|
+
assert.equal(
|
|
49
|
+
cliAuthUrl("https://staging.skillrepo.dev/"),
|
|
50
|
+
"https://staging.skillrepo.dev/cli/auth?source=cli&new=1",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("strips multiple trailing slashes", () => {
|
|
55
|
+
assert.equal(
|
|
56
|
+
cliAuthUrl("https://staging.skillrepo.dev///"),
|
|
57
|
+
"https://staging.skillrepo.dev/cli/auth?source=cli&new=1",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("preserves an explicit port", () => {
|
|
62
|
+
assert.equal(
|
|
63
|
+
cliAuthUrl("http://localhost:3000"),
|
|
64
|
+
"http://localhost:3000/cli/auth?source=cli&new=1",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("includes the auto-mint hint params (source=cli&new=1)", () => {
|
|
69
|
+
// Pin the exact param shape — both the page and the CLI
|
|
70
|
+
// depend on this contract. If either side changes, both must
|
|
71
|
+
// agree. This test catches drift if `cliAuthUrl` ever stops
|
|
72
|
+
// emitting one of the params.
|
|
73
|
+
const url = cliAuthUrl("https://example.com");
|
|
74
|
+
const parsed = new URL(url);
|
|
75
|
+
assert.equal(parsed.searchParams.get("source"), "cli");
|
|
76
|
+
assert.equal(parsed.searchParams.get("new"), "1");
|
|
77
|
+
assert.equal(parsed.pathname, "/cli/auth");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("DEFAULT_CLI_AUTH_URL", () => {
|
|
82
|
+
it("points at the production host", () => {
|
|
83
|
+
const parsed = new URL(DEFAULT_CLI_AUTH_URL);
|
|
84
|
+
assert.equal(parsed.host, "skillrepo.dev");
|
|
85
|
+
assert.equal(parsed.protocol, "https:");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("includes the auto-mint hint params", () => {
|
|
89
|
+
const parsed = new URL(DEFAULT_CLI_AUTH_URL);
|
|
90
|
+
assert.equal(parsed.searchParams.get("source"), "cli");
|
|
91
|
+
assert.equal(parsed.searchParams.get("new"), "1");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -375,6 +375,69 @@ describe("validateAccessKey", () => {
|
|
|
375
375
|
);
|
|
376
376
|
});
|
|
377
377
|
|
|
378
|
+
it("hint on missing apiKey points users at the /cli/auth browser flow (#1217)", async () => {
|
|
379
|
+
// Phase 2 of the API Access Integrity v2 epic added an inline
|
|
380
|
+
// browser-assisted auth path. The upfront-no-key error must
|
|
381
|
+
// surface that path so users get unstuck without running
|
|
382
|
+
// `skillrepo init` first.
|
|
383
|
+
await assert.rejects(
|
|
384
|
+
() => validateAccessKey("http://127.0.0.1:1", ""),
|
|
385
|
+
(err) =>
|
|
386
|
+
err instanceof CliError &&
|
|
387
|
+
err.exitCode === EXIT_AUTH &&
|
|
388
|
+
typeof err.hint === "string" &&
|
|
389
|
+
err.hint.includes("https://skillrepo.dev/cli/auth"),
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("401 hint points users at the /cli/auth browser flow (#1217)", async () => {
|
|
394
|
+
const srv = await startServer((req, res) =>
|
|
395
|
+
jsonRes(res, 401, { error: "Invalid access key" }),
|
|
396
|
+
);
|
|
397
|
+
try {
|
|
398
|
+
await assert.rejects(
|
|
399
|
+
() => validateAccessKey(srv.url, VALID_KEY),
|
|
400
|
+
(err) =>
|
|
401
|
+
err instanceof CliError &&
|
|
402
|
+
err.exitCode === EXIT_AUTH &&
|
|
403
|
+
typeof err.hint === "string" &&
|
|
404
|
+
err.hint.includes("/cli/auth?source=cli&new=1"),
|
|
405
|
+
);
|
|
406
|
+
} finally {
|
|
407
|
+
await srv.close();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Phase 3 follow-up (verification on staging): the 401 hint URL
|
|
412
|
+
// should reflect the SERVER the CLI is talking to, not always the
|
|
413
|
+
// production host. A user authed against staging.skillrepo.dev who
|
|
414
|
+
// hits 401 should see "open https://staging.skillrepo.dev/cli/auth"
|
|
415
|
+
// — pointing them to the prod auth page would make them mint a
|
|
416
|
+
// prod key against the wrong account.
|
|
417
|
+
it("401 hint URL host matches the request URL host (not always prod)", async () => {
|
|
418
|
+
const srv = await startServer((req, res) =>
|
|
419
|
+
jsonRes(res, 401, { error: "x" }),
|
|
420
|
+
);
|
|
421
|
+
try {
|
|
422
|
+
await assert.rejects(
|
|
423
|
+
() => validateAccessKey(srv.url, VALID_KEY),
|
|
424
|
+
(err) => {
|
|
425
|
+
if (!(err instanceof CliError) || err.exitCode !== EXIT_AUTH) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
if (typeof err.hint !== "string") return false;
|
|
429
|
+
// Test server is at http://127.0.0.1:<port>; the hint
|
|
430
|
+
// should derive its auth URL from THAT host, not from the
|
|
431
|
+
// hardcoded prod default.
|
|
432
|
+
const expectedAuthUrl = `${srv.url}/cli/auth?source=cli&new=1`;
|
|
433
|
+
return err.hint.includes(expectedAuthUrl);
|
|
434
|
+
},
|
|
435
|
+
);
|
|
436
|
+
} finally {
|
|
437
|
+
await srv.close();
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
378
441
|
it("rejects empty serverUrl with validationError", async () => {
|
|
379
442
|
await assert.rejects(
|
|
380
443
|
() => validateAccessKey("", VALID_KEY),
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `promptWithBrowserOpen` in src/lib/prompt.mjs
|
|
3
|
+
* (epic #1214 Phase 2 #1217).
|
|
4
|
+
*
|
|
5
|
+
* The pre-existing `promptSecret`/`promptText`/`confirm` helpers are
|
|
6
|
+
* not covered here — they drive `process.stdin` in raw mode and
|
|
7
|
+
* already have indirect coverage via the dispatcher tests. This file
|
|
8
|
+
* is scoped to the new browser-assisted helper, which is fully
|
|
9
|
+
* testable through dependency injection.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
|
|
15
|
+
import { promptWithBrowserOpen } from "../../lib/prompt.mjs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a writable-stream stub that captures every chunk as a string.
|
|
19
|
+
*/
|
|
20
|
+
function makeStdoutStub() {
|
|
21
|
+
const chunks = [];
|
|
22
|
+
return {
|
|
23
|
+
stream: {
|
|
24
|
+
write(chunk) {
|
|
25
|
+
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
|
26
|
+
return true;
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
get text() {
|
|
30
|
+
return chunks.join("");
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("promptWithBrowserOpen", () => {
|
|
36
|
+
it("prints the URL on its own line before prompting", async () => {
|
|
37
|
+
const stdout = makeStdoutStub();
|
|
38
|
+
await promptWithBrowserOpen("https://skillrepo.dev/cli/auth", "Key", {
|
|
39
|
+
openBrowserFn: () => true,
|
|
40
|
+
isInteractive: () => true,
|
|
41
|
+
stdout: stdout.stream,
|
|
42
|
+
promptSecretFn: () => Promise.resolve("sk_live_token"),
|
|
43
|
+
});
|
|
44
|
+
assert.match(stdout.text, /Opening browser for authentication:/);
|
|
45
|
+
assert.match(stdout.text, /https:\/\/skillrepo\.dev\/cli\/auth/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns the pasted token from promptSecretFn", async () => {
|
|
49
|
+
const stdout = makeStdoutStub();
|
|
50
|
+
const token = await promptWithBrowserOpen(
|
|
51
|
+
"https://skillrepo.dev/cli/auth",
|
|
52
|
+
"Enter key",
|
|
53
|
+
{
|
|
54
|
+
openBrowserFn: () => true,
|
|
55
|
+
isInteractive: () => true,
|
|
56
|
+
stdout: stdout.stream,
|
|
57
|
+
promptSecretFn: () => Promise.resolve("sk_live_pasted_value"),
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
assert.equal(token, "sk_live_pasted_value");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("forwards the label to promptSecretFn", async () => {
|
|
64
|
+
const stdout = makeStdoutStub();
|
|
65
|
+
let receivedLabel = null;
|
|
66
|
+
await promptWithBrowserOpen("https://skillrepo.dev/cli/auth", "MY-LABEL", {
|
|
67
|
+
openBrowserFn: () => true,
|
|
68
|
+
isInteractive: () => true,
|
|
69
|
+
stdout: stdout.stream,
|
|
70
|
+
promptSecretFn: (label) => {
|
|
71
|
+
receivedLabel = label;
|
|
72
|
+
return Promise.resolve("");
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
assert.equal(receivedLabel, "MY-LABEL");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("calls openBrowserFn when stdin is a TTY", async () => {
|
|
79
|
+
const stdout = makeStdoutStub();
|
|
80
|
+
let openCalls = 0;
|
|
81
|
+
let receivedUrl = null;
|
|
82
|
+
await promptWithBrowserOpen("https://skillrepo.dev/cli/auth", "Key", {
|
|
83
|
+
openBrowserFn: (url) => {
|
|
84
|
+
openCalls++;
|
|
85
|
+
receivedUrl = url;
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
88
|
+
isInteractive: () => true,
|
|
89
|
+
stdout: stdout.stream,
|
|
90
|
+
promptSecretFn: () => Promise.resolve(""),
|
|
91
|
+
});
|
|
92
|
+
assert.equal(openCalls, 1);
|
|
93
|
+
assert.equal(receivedUrl, "https://skillrepo.dev/cli/auth");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("skips openBrowserFn entirely when stdin is NOT a TTY (CI/pipe)", async () => {
|
|
97
|
+
const stdout = makeStdoutStub();
|
|
98
|
+
let openCalls = 0;
|
|
99
|
+
await promptWithBrowserOpen("https://skillrepo.dev/cli/auth", "Key", {
|
|
100
|
+
openBrowserFn: () => {
|
|
101
|
+
openCalls++;
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
isInteractive: () => false,
|
|
105
|
+
stdout: stdout.stream,
|
|
106
|
+
promptSecretFn: () => Promise.resolve(""),
|
|
107
|
+
});
|
|
108
|
+
assert.equal(openCalls, 0, "openBrowser must not be called in non-TTY");
|
|
109
|
+
assert.match(stdout.text, /Non-interactive session/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("prints a graceful fallback when openBrowserFn returns false", async () => {
|
|
113
|
+
const stdout = makeStdoutStub();
|
|
114
|
+
await promptWithBrowserOpen("https://skillrepo.dev/cli/auth", "Key", {
|
|
115
|
+
openBrowserFn: () => false,
|
|
116
|
+
isInteractive: () => true,
|
|
117
|
+
stdout: stdout.stream,
|
|
118
|
+
promptSecretFn: () => Promise.resolve(""),
|
|
119
|
+
});
|
|
120
|
+
assert.match(stdout.text, /Could not open the browser automatically/);
|
|
121
|
+
// The URL is still printed first, so the user has something to copy.
|
|
122
|
+
assert.match(stdout.text, /https:\/\/skillrepo\.dev\/cli\/auth/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("rejects with TypeError on a bogus url (fails loudly, no `undefined` in UI)", async () => {
|
|
126
|
+
const stdout = makeStdoutStub();
|
|
127
|
+
for (const bogus of [undefined, null, 0, {}, "", false]) {
|
|
128
|
+
await assert.rejects(
|
|
129
|
+
() =>
|
|
130
|
+
promptWithBrowserOpen(bogus, "Key", {
|
|
131
|
+
openBrowserFn: () => true,
|
|
132
|
+
isInteractive: () => true,
|
|
133
|
+
stdout: stdout.stream,
|
|
134
|
+
promptSecretFn: () => Promise.resolve(""),
|
|
135
|
+
}),
|
|
136
|
+
TypeError,
|
|
137
|
+
`should reject ${JSON.stringify(bogus)}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
// Nothing should have been written — the guard fires before any I/O.
|
|
141
|
+
assert.equal(stdout.text, "");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("does NOT print the fallback message when openBrowser succeeds", async () => {
|
|
145
|
+
const stdout = makeStdoutStub();
|
|
146
|
+
await promptWithBrowserOpen("https://skillrepo.dev/cli/auth", "Key", {
|
|
147
|
+
openBrowserFn: () => true,
|
|
148
|
+
isInteractive: () => true,
|
|
149
|
+
stdout: stdout.stream,
|
|
150
|
+
promptSecretFn: () => Promise.resolve(""),
|
|
151
|
+
});
|
|
152
|
+
assert.doesNotMatch(stdout.text, /Could not open the browser automatically/);
|
|
153
|
+
});
|
|
154
|
+
});
|