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 +18 -6
- package/package.json +1 -1
- package/src/commands/init.mjs +14 -4
- package/src/lib/browser-open.mjs +136 -0
- package/src/lib/constants.mjs +69 -0
- package/src/lib/http.mjs +58 -5
- package/src/lib/prompt.mjs +81 -5
- package/src/test/commands/init.test.mjs +15 -0
- package/src/test/e2e/mock-server.mjs +23 -0
- 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 +166 -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
|
|
@@ -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 = (
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
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
|
*/
|
|
@@ -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
|
+
});
|