skillrepo 3.1.4 → 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 CHANGED
@@ -24,19 +24,26 @@ Requires Node.js 18 or later.
24
24
 
25
25
  ## Quick start
26
26
 
27
- 1. **Create an access key** at [skillrepo.dev/app/settings](https://skillrepo.dev/app/settings)
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
- 3. **Your library is now on disk.** The CLI wrote skills into
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
- 4. **From now on**, use `skillrepo update` to pull new versions,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "3.1.4",
3
+ "version": "3.2.0",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 promptSecret("Enter your access key (sk_live_...)");
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
@@ -227,7 +232,7 @@ export async function runInit(argv, io = {}, deps = {}) {
227
232
  p.step(2, 7, "Validating key");
228
233
  let accountCtx;
229
234
  try {
230
- accountCtx = await validateAccessKey(serverUrl, apiKey);
235
+ accountCtx = await validateAccessKey(serverUrl, apiKey, "init");
231
236
  } catch (err) {
232
237
  // If the existing config had a stale/revoked key, fall back to
233
238
  // prompt (unless --yes, which is meant to be non-interactive).
@@ -241,11 +246,16 @@ 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 = (await promptSecret("Enter your access key (sk_live_...)")).trim();
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
  }
248
- accountCtx = await validateAccessKey(serverUrl, apiKey);
258
+ accountCtx = await validateAccessKey(serverUrl, apiKey, "init");
249
259
  } else {
250
260
  throw err;
251
261
  }
@@ -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
 
@@ -82,6 +83,22 @@ async function userAgent() {
82
83
  return `skillrepo-cli/${_cachedVersion}`;
83
84
  }
84
85
 
86
+ /**
87
+ * Valid values for the `X-SkillRepo-Source` header sent on auth-validate
88
+ * requests. `init` is used by `skillrepo init`; every other command
89
+ * sends `validate`. The server-side logging middleware (atxpace/skill-repo#732)
90
+ * records one of `cli.bootstrap` or `cli.validate` in `server_events`
91
+ * based on this header. Older CLI versions that predate this header
92
+ * cause the server to default to `validate` — tile counts are a floor,
93
+ * never inflated.
94
+ */
95
+ export const CLI_SOURCE_VALUES = ["init", "validate"];
96
+
97
+ /** Type guard for the discriminated union — exported for tests. */
98
+ export function isCliSource(value) {
99
+ return CLI_SOURCE_VALUES.includes(value);
100
+ }
101
+
85
102
  /**
86
103
  * Build the canonical headers for every authenticated request.
87
104
  */
@@ -101,13 +118,13 @@ async function authHeaders(apiKey) {
101
118
  // path through authHeaders produces a clean Bearer header.
102
119
  if (typeof apiKey !== "string") {
103
120
  throw authError("No access key configured.", {
104
- hint: "Run `skillrepo init` or pass --key <sk_live_...>.",
121
+ hint: `Run \`skillrepo init\`, pass --key <sk_live_...>, or open ${DEFAULT_CLI_AUTH_URL} to mint a new access key.`,
105
122
  });
106
123
  }
107
124
  const trimmed = apiKey.trim();
108
125
  if (trimmed === "") {
109
126
  throw authError("No access key configured.", {
110
- hint: "Run `skillrepo init` or pass --key <sk_live_...>.",
127
+ hint: `Run \`skillrepo init\`, pass --key <sk_live_...>, or open ${DEFAULT_CLI_AUTH_URL} to mint a new access key.`,
111
128
  });
112
129
  }
113
130
  return {
@@ -286,8 +303,21 @@ async function mapErrorResponse(res, url) {
286
303
  const code = body.code;
287
304
 
288
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
+ }
289
319
  return authError(message, {
290
- hint: "Run `skillrepo init` to refresh your access key.",
320
+ hint: `Run \`skillrepo init\` or open ${authUrl} in your browser to get a new access key.`,
291
321
  });
292
322
  }
293
323
  if (res.status === 403) {
@@ -368,15 +398,38 @@ async function mapErrorResponse(res, url) {
368
398
  * authenticated account context. Replaces the deprecated
369
399
  * `/api/v1/setup` endpoint for credential validation.
370
400
  *
401
+ * The `source` parameter surfaces the CLI's intent to the server-side
402
+ * logging middleware (atxpace/skill-repo#732) via the
403
+ * `X-SkillRepo-Source` header:
404
+ *
405
+ * - `"init"` → server records `cli.bootstrap` in `server_events`.
406
+ * Used by `skillrepo init` — a first-install event that drives
407
+ * the Feed "CLI bootstraps 7d/30d" tile counter.
408
+ * - `"validate"` (default) → server records `cli.validate`. Used by
409
+ * every other authenticated command that revalidates the key.
410
+ *
411
+ * An unrecognized `source` is silently coerced to `"validate"` rather
412
+ * than thrown. The coercion guards against a caller typo or a future
413
+ * value the current client-side taxonomy doesn't know yet — we prefer
414
+ * under-counting bootstraps to fabricating a category the caller
415
+ * didn't mean to use. Servers ignore unknown HTTP header values, so
416
+ * there is no wire-level risk to sending whatever a caller passes;
417
+ * the coercion is purely a safety net for CLI-side typos.
418
+ *
371
419
  * @param {string} serverUrl
372
420
  * @param {string} apiKey
421
+ * @param {string} [source] - `"init"` or `"validate"` (default).
373
422
  * @returns {Promise<AuthValidateResult>}
374
423
  */
375
- export async function validateAccessKey(serverUrl, apiKey) {
424
+ export async function validateAccessKey(serverUrl, apiKey, source = "validate") {
376
425
  const url = `${normalizeUrl(serverUrl)}/api/v1/auth/validate`;
426
+ const normalizedSource = isCliSource(source) ? source : "validate";
377
427
  const res = await safeFetch(url, {
378
428
  method: "POST",
379
- headers: await authHeaders(apiKey),
429
+ headers: {
430
+ ...(await authHeaders(apiKey)),
431
+ "X-SkillRepo-Source": normalizedSource,
432
+ },
380
433
  // POST /auth/validate is side-effect-free on the server (it
381
434
  // only reads the key and returns account context), so retrying
382
435
  // a transient 5xx is safe. 4xx auth errors are NOT retried
@@ -1,6 +1,11 @@
1
1
  /**
2
- * Interactive prompts using Node's built-in readline.
3
- * Zero dependencies. Supports TTY detection and NO_COLOR.
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 now only exports the
13
- * three interactive primitives (`promptText`, `promptSecret`,
14
- * `confirm`) that still need direct stdin/stdout access.
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
  */
@@ -112,6 +112,21 @@ describe("runInit — happy path", () => {
112
112
  assert.match(stdout.text(), /SkillRepo is ready/);
113
113
  });
114
114
 
115
+ it("sends X-SkillRepo-Source: init to /api/v1/auth/validate (Epic 12 #905)", async () => {
116
+ // The init command is the ONLY call path that should report
117
+ // `init` to the server. Every other CLI command reports
118
+ // `validate` by default. This test pins the contract so a silent
119
+ // regression (e.g. removing the source arg in init.mjs, or the
120
+ // http.mjs default drifting) surfaces here.
121
+ await runInit(
122
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
123
+ { stdout, stderr },
124
+ );
125
+ const validateHeaders = server.getLastValidateHeaders();
126
+ assert.ok(validateHeaders, "mock server should have seen a validate call");
127
+ assert.equal(validateHeaders["x-skillrepo-source"], "init");
128
+ });
129
+
115
130
  it("--json outputs structured summary", async () => {
116
131
  await runInit(
117
132
  ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
@@ -100,6 +100,13 @@ export function createMockServer(initialPayload, options = {}) {
100
100
  // practice this is fine — but a future parallel test runner
101
101
  // would need per-request capture.
102
102
  let lastPostBody = null;
103
+ /**
104
+ * Captures the headers from the most recent POST /api/v1/auth/validate
105
+ * request. Used by tests (e.g. init.test.mjs) to assert the CLI sent
106
+ * the expected `X-SkillRepo-Source` value. Reset to null on each
107
+ * validate-route hit so tests see only the headers they triggered.
108
+ */
109
+ let lastValidateHeaders = null;
103
110
 
104
111
  /**
105
112
  * Validate the Authorization header.
@@ -170,6 +177,12 @@ export function createMockServer(initialPayload, options = {}) {
170
177
 
171
178
  // ── PR2: POST /api/v1/auth/validate ─────────────────────────────
172
179
  if (url.pathname === "/api/v1/auth/validate" && req.method === "POST") {
180
+ // Snapshot request headers before the auth check so tests can
181
+ // assert on the CLI's `X-SkillRepo-Source` header even when the
182
+ // auth check fails (future-proofing — today we only call this
183
+ // after auth succeeds, but the snapshot is ordering-safe either
184
+ // way).
185
+ lastValidateHeaders = { ...req.headers };
173
186
  if (!checkAuth(req, res)) return;
174
187
  res.writeHead(200, { "Content-Type": "application/json" });
175
188
  res.end(JSON.stringify(validateResponse));
@@ -484,5 +497,15 @@ export function createMockServer(initialPayload, options = {}) {
484
497
  getLastPostBody() {
485
498
  return lastPostBody;
486
499
  },
500
+
501
+ /**
502
+ * Return the headers from the most recent POST /api/v1/auth/validate
503
+ * request, or null if none has been made yet. Used by init-command
504
+ * tests to assert the CLI sent the documented
505
+ * `X-SkillRepo-Source` value (Epic 12 #905).
506
+ */
507
+ getLastValidateHeaders() {
508
+ return lastValidateHeaders;
509
+ },
487
510
  };
488
511
  }
@@ -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
+ });
@@ -39,6 +39,8 @@ import {
39
39
  searchSkills,
40
40
  addSkillToLibrary,
41
41
  removeSkillFromLibrary,
42
+ isCliSource,
43
+ CLI_SOURCE_VALUES,
42
44
  } from "../../lib/http.mjs";
43
45
  import {
44
46
  CliError,
@@ -91,6 +93,32 @@ const VALID_KEY = "sk_live_test_abc123";
91
93
 
92
94
  // ── validateAccessKey ──────────────────────────────────────────────────
93
95
 
96
+ // ── CLI source header exports ──────────────────────────────────────────
97
+
98
+ describe("CLI_SOURCE_VALUES / isCliSource", () => {
99
+ it("CLI_SOURCE_VALUES declares exactly the documented values", () => {
100
+ // Any new source value requires a server-side taxonomy update
101
+ // (atxpace/skill-repo#732). Pin the declared values so a silent
102
+ // extension without the corresponding server work surfaces here.
103
+ assert.deepEqual([...CLI_SOURCE_VALUES].sort(), ["init", "validate"]);
104
+ });
105
+
106
+ it("isCliSource accepts only the documented values", () => {
107
+ assert.equal(isCliSource("init"), true);
108
+ assert.equal(isCliSource("validate"), true);
109
+ });
110
+
111
+ it("isCliSource rejects everything else", () => {
112
+ assert.equal(isCliSource(""), false);
113
+ assert.equal(isCliSource("INIT"), false);
114
+ assert.equal(isCliSource("validate "), false);
115
+ assert.equal(isCliSource("bootstrap"), false);
116
+ assert.equal(isCliSource(undefined), false);
117
+ assert.equal(isCliSource(null), false);
118
+ assert.equal(isCliSource(42), false);
119
+ });
120
+ });
121
+
94
122
  describe("validateAccessKey", () => {
95
123
  it("returns the account context on 200", async () => {
96
124
  const srv = await startServer((req, res) => {
@@ -118,6 +146,81 @@ describe("validateAccessKey", () => {
118
146
  }
119
147
  });
120
148
 
149
+ it("sends X-SkillRepo-Source: validate by default", async () => {
150
+ // Epic 12 #905: the server's CLI-logging middleware (#732)
151
+ // distinguishes `cli.bootstrap` from `cli.validate` based on this
152
+ // header. The default path MUST be `validate` so only the explicit
153
+ // init caller lands on the bootstrap bucket.
154
+ const srv = await startServer((req, res) => {
155
+ jsonRes(res, 200, {
156
+ userId: "user-1",
157
+ accountId: "acc-1",
158
+ accountSlug: "alice",
159
+ accountName: "Alice",
160
+ scopes: [],
161
+ keyId: "key-1",
162
+ tier: "free",
163
+ });
164
+ });
165
+ try {
166
+ await validateAccessKey(srv.url, VALID_KEY);
167
+ assert.equal(
168
+ srv.lastRequest.headers["x-skillrepo-source"],
169
+ "validate",
170
+ );
171
+ } finally {
172
+ await srv.close();
173
+ }
174
+ });
175
+
176
+ it("sends X-SkillRepo-Source: init when source='init' is passed", async () => {
177
+ const srv = await startServer((req, res) => {
178
+ jsonRes(res, 200, {
179
+ userId: "user-1",
180
+ accountId: "acc-1",
181
+ accountSlug: "alice",
182
+ accountName: "Alice",
183
+ scopes: [],
184
+ keyId: "key-1",
185
+ tier: "free",
186
+ });
187
+ });
188
+ try {
189
+ await validateAccessKey(srv.url, VALID_KEY, "init");
190
+ assert.equal(srv.lastRequest.headers["x-skillrepo-source"], "init");
191
+ } finally {
192
+ await srv.close();
193
+ }
194
+ });
195
+
196
+ it("coerces an unknown source to 'validate' (forward-compat safety)", async () => {
197
+ // A future CLI version may pass a source value the CURRENT server
198
+ // doesn't know about. Rather than fabricating a bogus category,
199
+ // the CLI silently falls back to `validate`. Server-side CLI-
200
+ // bootstrap counts are a floor, never inflated.
201
+ const srv = await startServer((req, res) => {
202
+ jsonRes(res, 200, {
203
+ userId: "user-1",
204
+ accountId: "acc-1",
205
+ accountSlug: "alice",
206
+ accountName: "Alice",
207
+ scopes: [],
208
+ keyId: "key-1",
209
+ tier: "free",
210
+ });
211
+ });
212
+ try {
213
+ // @ts-expect-error — intentional unknown source.
214
+ await validateAccessKey(srv.url, VALID_KEY, "experimental");
215
+ assert.equal(
216
+ srv.lastRequest.headers["x-skillrepo-source"],
217
+ "validate",
218
+ );
219
+ } finally {
220
+ await srv.close();
221
+ }
222
+ });
223
+
121
224
  it("throws authError on 401", async () => {
122
225
  const srv = await startServer((req, res) => {
123
226
  jsonRes(res, 401, { error: "Invalid access key" });
@@ -272,6 +375,69 @@ describe("validateAccessKey", () => {
272
375
  );
273
376
  });
274
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
+
275
441
  it("rejects empty serverUrl with validationError", async () => {
276
442
  await assert.rejects(
277
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
+ });