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 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.5",
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
@@ -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 = (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
  }
@@ -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: "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.`,
121
122
  });
122
123
  }
123
124
  const trimmed = apiKey.trim();
124
125
  if (trimmed === "") {
125
126
  throw authError("No access key configured.", {
126
- 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.`,
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: "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.`,
307
321
  });
308
322
  }
309
323
  if (res.status === 403) {
@@ -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
  */
@@ -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
+ });