@ahood/cli 0.1.0 → 0.2.1

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
@@ -5,26 +5,28 @@ CLI for [ahood](https://ahood.vercel.app), a registry for installing and publish
5
5
  ## Quick start
6
6
 
7
7
  ```
8
- npx @alexkay/ahood-cli@latest login
9
- npx @alexkay/ahood-cli@latest search <something>
10
- npx @alexkay/ahood-cli@latest add <owner>/<skill>
8
+ npx @ahood/cli@latest login
9
+ npx @ahood/cli@latest search <something>
10
+ npx @ahood/cli@latest add <owner>/<skill>
11
11
  ```
12
12
 
13
13
  ## Install
14
14
 
15
15
  ```
16
- npx @alexkay/ahood-cli@latest <command>
16
+ npx @ahood/cli@latest <command>
17
17
  ```
18
18
 
19
19
  or install it globally, so the plain `ahood` command works without `npx`:
20
20
 
21
21
  ```
22
- npm i -g @alexkay/ahood-cli
22
+ npm i -g @ahood/cli
23
23
  ahood <command>
24
24
  ```
25
25
 
26
26
  For CI or any non-interactive environment, set `AHOOD_TOKEN` instead of running `login` -- every command checks it first.
27
27
 
28
+ `AHOOD_API_URL` overrides the registry endpoint (defaults to `https://ahood.vercel.app`). It must be `https://`, except for `localhost`/`127.0.0.1` or an `.test`/`.invalid`/`.example`/`.localhost` host, which may use plain `http://` for local development.
29
+
28
30
  ## Commands
29
31
 
30
32
  <!-- Kept in sync by hand with cli/src/help.ts's COMMANDS_HELP (the terminal
@@ -33,21 +35,34 @@ For CI or any non-interactive environment, set `AHOOD_TOKEN` instead of running
33
35
 
34
36
  | Command | What it does |
35
37
  | --- | --- |
36
- | `ahood login` | Device-code browser login, stores a token at `~/.config/ahood/credentials.json`. |
38
+ | `ahood login` | Device-code browser login, stores a token at `~/.config/ahood/credentials.json` (or `$XDG_CONFIG_HOME/ahood/`). |
37
39
  | `ahood logout` | Removes the stored token. |
38
- | `ahood whoami` | Reports whether your stored token still authenticates. |
39
- | `ahood search <query>` | Search published skills. |
40
- | `ahood list-mine` | List your own skills, public and private, with their visibility and download/star counts. |
40
+ | `ahood whoami [--json]` | Reports whether your stored token still authenticates. |
41
+ | `ahood search <query> [--json] [--limit <n>]` | Search published skills. |
42
+ | `ahood view <owner>/<skill> [--json] [--web]` (alias: `show`) | Show a single skill's details -- tags, license, homepage, repository, dates, etc. -- without installing it, or open its page in a browser. |
43
+ | `ahood list-mine [--json]` | List your own skills, public and private, with their visibility and download/star counts. |
41
44
  | `ahood add <owner>/<skill>[@version]` | Install into `.claude/skills/<owner>/<skill>/`, pinned in `.claude/skills.lock.json`. |
42
- | `ahood update [<owner>/<skill>]` | Move the lockfile pin forward to the latest version. |
45
+ | `ahood update [<owner>/<skill> ...]` | Move the lockfile pin(s) forward to the latest version. One skill failing to update doesn't stop the rest. |
43
46
  | `ahood remove <owner>/<skill>` | Uninstall and unpin (local only -- does not affect the published skill). |
44
- | `ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility]` | Update a skill you own. Only the flags you pass are changed; requires the `publish` scope. |
45
- | `ahood unpublish <owner>/<skill>` | Deletes the skill from the registry for every consumer, not just your local install -- prompts for a typed "yes" confirmation first. |
47
+ | `ahood edit <owner>/<skill> [--tagline] [--tags] [--license] [--visibility] [--homepage] [--repository]` | Update a skill you own. Only the flags you pass are changed; requires the `publish` scope. |
48
+ | `ahood unpublish <owner>/<skill> [--yes]` | Deletes the skill from the registry for every consumer, not just your local install -- prompts for a typed "yes" confirmation, or pass `--yes` for scripts/CI. |
46
49
  | `ahood star <owner>/<skill>` | Star a skill. |
47
50
  | `ahood unstar <owner>/<skill>` | Remove your star from a skill. |
48
- | `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` | Publish a new version of an existing skill from a folder containing `SKILL.md`. The skill itself is created from the web UI first. |
49
- | `ahood token create\|list\|revoke` | Manage personal API tokens. Creating a token requires an existing logged-in session -- see the docs. |
51
+ | `ahood publish <owner>/<skill>@<version> [--path <dir>] [--name] [--tagline] [--tags] [--license] [--homepage] [--repository]` | Publish a new version of a skill from a folder containing `SKILL.md` (defaults to the current directory). If the skill doesn't exist yet, creates it first -- `--name` is required in that case; `--tagline`/`--tags`/`--license`/`--homepage`/`--repository` are optional and only used at creation. Processing (validation, checksumming, secret scanning) happens server-side after upload; this command polls and reports the final published/failed status. The legacy form `ahood publish <path> --owner <owner> --slug <skill> --version <x.y.z>` is still accepted. |
52
+ | `ahood token create <name>\|list [--json]\|revoke <id>` | Manage personal API tokens. Creating a token requires an existing logged-in session -- see the docs. |
53
+ | `ahood completion <bash\|zsh\|fish>` | Print a shell completion script for the command names. |
54
+
55
+ Run `ahood help`, `ahood help <command>`, or `ahood <command> --help` for the same reference directly in your terminal. `ahood --version` prints the installed CLI version.
50
56
 
51
- Run `ahood help` or `ahood <command> --help` for the same reference directly in your terminal.
57
+ ### Exit codes
58
+
59
+ | Code | Meaning |
60
+ | --- | --- |
61
+ | `0` | Success |
62
+ | `1` | General error |
63
+ | `2` | Usage or validation error (bad arguments, or the server rejected the request as invalid) |
64
+ | `4` | Authentication required or rejected (not logged in, or the token was refused) |
65
+ | `5` | Not found |
66
+ | `6` | Network/transport error |
52
67
 
53
68
  Full reference, including personal API tokens, CI usage, the public REST API, and the MCP server: **https://ahood.vercel.app/docs**
@@ -1,10 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdirSync, writeFileSync } from "node:fs";
3
- import { join, resolve, sep } from "node:path";
2
+ import { mkdirSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, resolve, sep } from "node:path";
4
4
  import * as tarStream from "tar-stream";
5
5
  import { gunzipSync } from "node:zlib";
6
6
  import { apiFetch, apiJson } from "../http.js";
7
- import { writeLockfileEntry } from "../lockfile.js";
7
+ import { LOCKFILE_PATH, parseOwnerSkillVersion, skillDir } from "../spec.js";
8
+ import { readLockfile, writeLockfileEntry } from "../lockfile.js";
9
+ const USAGE = "Usage: ahood add <owner>/<skill>[@version]";
10
+ const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB compressed
11
+ const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024; // 200 MB decompressed
12
+ const MAX_ENTRY_COUNT = 10_000;
8
13
  // GET /api/v1/skills/{owner}/{skill}/versions/{version} matches the version
9
14
  // string with an exact .eq() (verified live against this branch's route) --
10
15
  // there is no "latest" literal in skill_versions.version, so requesting
@@ -19,28 +24,37 @@ import { writeLockfileEntry } from "../lockfile.js";
19
24
  // rather than assumed.
20
25
  async function fetchVersionMeta(owner, skill, version) {
21
26
  if (version === "latest") {
22
- const { skill_versions } = await apiJson(`/api/v1/skills/${owner}/${skill}`);
27
+ const { skill_versions } = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`);
23
28
  if (!skill_versions)
24
29
  throw new Error(`${owner}/${skill} has no published version`);
25
30
  return { ...skill_versions, yanked_at: null };
26
31
  }
27
- return apiJson(`/api/v1/skills/${owner}/${skill}/versions/${version}`);
28
- }
29
- function parseSpec(spec) {
30
- const atIndex = spec.lastIndexOf("@");
31
- const ownerSkill = atIndex > 0 ? spec.slice(0, atIndex) : spec;
32
- const version = atIndex > 0 ? spec.slice(atIndex + 1) : "latest";
33
- const [owner, skill] = ownerSkill.split("/");
34
- if (!owner || !skill)
35
- throw new Error("Usage: ahood add <owner>/<skill>[@version]");
36
- return { owner, skill, version };
32
+ return apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/versions/${encodeURIComponent(version)}`);
37
33
  }
38
34
  export async function extractTarGz(buffer, destDir) {
39
- const tarBuffer = gunzipSync(buffer);
35
+ let decompressed;
36
+ try {
37
+ // maxOutputLength caps decompression itself, so a highly-compressed
38
+ // "bomb" (whose checksum otherwise matches, since that check runs on the
39
+ // compressed bytes) can't exhaust memory -- it throws before the
40
+ // oversized buffer is ever materialized.
41
+ decompressed = gunzipSync(buffer, { maxOutputLength: MAX_EXTRACTED_BYTES });
42
+ }
43
+ catch (error) {
44
+ throw new Error(`Archive decompresses to more than ${MAX_EXTRACTED_BYTES / (1024 * 1024)} MB, or is not valid gzip -- refusing to extract.`, { cause: error });
45
+ }
40
46
  const extract = tarStream.extract();
41
47
  const resolvedDest = resolve(destDir);
48
+ let entryCount = 0;
42
49
  await new Promise((resolvePromise, reject) => {
43
50
  extract.on("entry", (header, stream, next) => {
51
+ entryCount++;
52
+ if (entryCount > MAX_ENTRY_COUNT) {
53
+ stream.resume();
54
+ reject(new Error(`Archive has more than ${MAX_ENTRY_COUNT} entries -- refusing to extract.`));
55
+ extract.destroy();
56
+ return;
57
+ }
44
58
  // Path containment, checked BEFORE anything is written. A tar entry
45
59
  // name is attacker-controlled data in an archive we merely downloaded
46
60
  // -- the server's publish-time validateEntries is supposed to have
@@ -68,25 +82,72 @@ export async function extractTarGz(buffer, destDir) {
68
82
  const chunks = [];
69
83
  stream.on("data", (chunk) => chunks.push(chunk));
70
84
  stream.on("end", () => {
71
- mkdirSync(join(fullPath, ".."), { recursive: true });
72
- writeFileSync(fullPath, Buffer.concat(chunks));
73
- next();
85
+ try {
86
+ mkdirSync(join(fullPath, ".."), { recursive: true });
87
+ // Preserve the tar entry's mode (e.g. a scripts/*.sh published with
88
+ // its executable bit set) instead of always writing with the
89
+ // default mode.
90
+ writeFileSync(fullPath, Buffer.concat(chunks), header.mode ? { mode: header.mode } : undefined);
91
+ next();
92
+ }
93
+ catch (error) {
94
+ // Previously uncaught here -- an ENOSPC/EACCES/EROFS mid-extract
95
+ // surfaced as a raw Node stack trace instead of the clean,
96
+ // single-line error every other failure in this CLI produces.
97
+ reject(error);
98
+ }
74
99
  });
75
100
  stream.on("error", reject);
76
101
  });
77
102
  extract.on("finish", () => resolvePromise());
78
103
  extract.on("error", reject);
79
- extract.end(tarBuffer);
104
+ extract.end(decompressed);
80
105
  });
81
106
  }
107
+ // Extracts into a fresh temp directory next to destDir, then swaps it in.
108
+ // Extracting straight into destDir (the old behavior) only ever ADDED files
109
+ // on an upgrade -- anything removed in a newer version, including a script
110
+ // pulled for a security reason, silently stuck around. The temp+rename swap
111
+ // also means a failed/interrupted extract never leaves destDir half-upgraded.
112
+ async function extractFreshVersion(buffer, destDir) {
113
+ const parentDir = dirname(destDir);
114
+ mkdirSync(parentDir, { recursive: true });
115
+ const tempDir = join(parentDir, `.${basename(destDir)}.tmp-${process.pid}-${Date.now()}`);
116
+ rmSync(tempDir, { recursive: true, force: true });
117
+ try {
118
+ await extractTarGz(buffer, tempDir);
119
+ }
120
+ catch (error) {
121
+ rmSync(tempDir, { recursive: true, force: true });
122
+ try {
123
+ rmdirSync(parentDir);
124
+ }
125
+ catch {
126
+ // parentDir wasn't empty (other skills from the same owner) -- fine.
127
+ }
128
+ throw error;
129
+ }
130
+ rmSync(destDir, { recursive: true, force: true });
131
+ renameSync(tempDir, destDir);
132
+ }
82
133
  export async function add(args) {
83
134
  const spec = args[0];
84
135
  if (!spec)
85
- throw new Error("Usage: ahood add <owner>/<skill>[@version]");
86
- const { owner, skill, version: requestedVersion } = parseSpec(spec);
136
+ throw new Error(USAGE);
137
+ const { owner, skill, version: requestedVersion } = parseOwnerSkillVersion(spec, USAGE);
138
+ const key = `${owner}/${skill}`;
87
139
  const meta = await fetchVersionMeta(owner, skill, requestedVersion);
140
+ // A lockfile entry records the checksum this exact version was installed
141
+ // with; if a later fetch of the "same" version disagrees, either the
142
+ // published artifact changed after being pinned or the registry response
143
+ // was tampered with in transit -- either way this is not something to
144
+ // silently accept and overwrite.
145
+ const existingEntry = readLockfile(LOCKFILE_PATH)[key];
146
+ if (existingEntry && existingEntry.version === meta.version && existingEntry.checksum_sha256 !== meta.checksum_sha256) {
147
+ throw new Error(`Refusing to install ${key}@${meta.version}: its checksum (${meta.checksum_sha256}) does not match the one already pinned in the lockfile (${existingEntry.checksum_sha256}) for this exact version. If you trust this change, remove ${key}'s lockfile entry first.`);
148
+ }
88
149
  if (meta.yanked_at) {
89
- console.warn(`WARNING: ${owner}/${skill}@${meta.version} has been yanked. Installing anyway.`);
150
+ console.warn(`WARNING: ${key}@${meta.version} has been yanked. Installing anyway.`);
90
151
  }
91
152
  // scripts/ warning: mirrors the web detail page's banner (platform ADR's
92
153
  // Open Risk #7) -- a CLI-only user installing via `add` would otherwise
@@ -94,27 +155,29 @@ export async function add(args) {
94
155
  if (meta.manifest.some((f) => f.path.startsWith("scripts/"))) {
95
156
  console.warn("WARNING: this skill includes a scripts/ directory. Review its contents before use.");
96
157
  }
97
- const downloadRes = await apiFetch(`/api/v1/skills/${owner}/${skill}/download?version=${meta.version}`, {
98
- headers: { "X-Ahood-Source": "cli" },
99
- redirect: "follow",
100
- });
101
- if (!downloadRes.ok)
102
- throw new Error(`Download failed with status ${downloadRes.status}`);
158
+ const downloadRes = await apiFetch(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}/download?version=${encodeURIComponent(meta.version)}`, { headers: { "X-Ahood-Source": "cli" }, redirect: "follow" });
159
+ if (!downloadRes.ok) {
160
+ const body = await downloadRes.text().catch(() => "");
161
+ throw new Error(`Download failed with status ${downloadRes.status}${body ? `: ${body}` : ""}`);
162
+ }
163
+ const contentLength = Number(downloadRes.headers.get("content-length") ?? NaN);
164
+ if (Number.isFinite(contentLength) && contentLength > MAX_DOWNLOAD_BYTES) {
165
+ throw new Error(`Download is ${contentLength} bytes, over the ${MAX_DOWNLOAD_BYTES / (1024 * 1024)} MB limit -- refusing to install.`);
166
+ }
103
167
  const buffer = Buffer.from(await downloadRes.arrayBuffer());
168
+ if (buffer.length > MAX_DOWNLOAD_BYTES) {
169
+ throw new Error(`Downloaded archive is ${buffer.length} bytes, over the ${MAX_DOWNLOAD_BYTES / (1024 * 1024)} MB limit -- refusing to install.`);
170
+ }
104
171
  const actualChecksum = createHash("sha256").update(buffer).digest("hex");
105
172
  if (actualChecksum !== meta.checksum_sha256) {
106
- throw new Error(`Checksum mismatch for ${owner}/${skill}@${meta.version}: expected ${meta.checksum_sha256}, got ${actualChecksum}. Refusing to install.`);
173
+ throw new Error(`Checksum mismatch for ${key}@${meta.version}: expected ${meta.checksum_sha256}, got ${actualChecksum}. Refusing to install.`);
107
174
  }
108
175
  // Owner-namespaced on disk, mirroring npm's node_modules/@scope/package.
109
- // Keyed on the slug alone, alice/utils and bob/utils were the same
110
- // directory: installing one silently overwrote the other's files, and
111
- // `remove` deleted whichever happened to be there. The lockfile key is
112
- // already "owner/skill" and needs no change.
113
- const destDir = join(".claude", "skills", owner, skill);
114
- await extractTarGz(buffer, destDir);
115
- writeLockfileEntry(join(".claude", "skills.lock.json"), `${owner}/${skill}`, {
176
+ const destDir = skillDir(owner, skill);
177
+ await extractFreshVersion(buffer, destDir);
178
+ writeLockfileEntry(LOCKFILE_PATH, key, {
116
179
  version: meta.version,
117
180
  checksum_sha256: meta.checksum_sha256,
118
181
  });
119
- console.log(`Installed ${owner}/${skill}@${meta.version} to ${destDir}`);
182
+ console.log(`Installed ${key}@${meta.version} to ${destDir}`);
120
183
  }
@@ -0,0 +1,46 @@
1
+ import { COMMANDS_HELP } from "../help.js";
2
+ const USAGE = "Usage: ahood completion <bash|zsh|fish>";
3
+ function commandNames() {
4
+ return COMMANDS_HELP.map((c) => c.usage.split(" ")[1]);
5
+ }
6
+ function bashCompletion() {
7
+ const names = commandNames().join(" ");
8
+ return [
9
+ "_ahood_completions() {",
10
+ ' local cur="${COMP_WORDS[COMP_CWORD]}"',
11
+ ` COMPREPLY=($(compgen -W "${names}" -- "$cur"))`,
12
+ "}",
13
+ "complete -F _ahood_completions ahood",
14
+ ].join("\n");
15
+ }
16
+ function zshCompletion() {
17
+ const names = commandNames().join(" ");
18
+ return [
19
+ "#compdef ahood",
20
+ `_arguments '1: :(${names})'`,
21
+ ].join("\n");
22
+ }
23
+ function fishCompletion() {
24
+ return commandNames()
25
+ .map((name) => `complete -c ahood -n "__fish_use_subcommand" -a "${name}"`)
26
+ .join("\n");
27
+ }
28
+ // Command-name completion only (not per-command flags) -- still the primary
29
+ // way users discover the available subcommands without reading docs, which
30
+ // this CLI otherwise has no mechanism for at all.
31
+ export async function completion(args) {
32
+ const shell = args[0];
33
+ switch (shell) {
34
+ case "bash":
35
+ console.log(bashCompletion());
36
+ return;
37
+ case "zsh":
38
+ console.log(zshCompletion());
39
+ return;
40
+ case "fish":
41
+ console.log(fishCompletion());
42
+ return;
43
+ default:
44
+ throw new Error(USAGE);
45
+ }
46
+ }
@@ -1,8 +1,7 @@
1
1
  import { apiJson } from "../http.js";
2
- function flagValue(args, flag) {
3
- const index = args.indexOf(flag);
4
- return index >= 0 ? args[index + 1] : undefined;
5
- }
2
+ import { flagValue } from "../flags.js";
3
+ import { parseOwnerSkill } from "../spec.js";
4
+ const USAGE = "Usage: ahood edit <owner>/<skill> [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--visibility public|private] [--homepage <url>] [--repository <url>]";
6
5
  // Mirrors PATCH /api/v1/skills/{owner}/{skill}'s allow-list exactly
7
6
  // (lib/skills/mutations.ts's UpdateSkillInput) -- only a field the caller
8
7
  // explicitly passed a flag for is included in the body, so an omitted flag
@@ -10,12 +9,9 @@ function flagValue(args, flag) {
10
9
  // that are present on the body at all).
11
10
  export async function edit(args) {
12
11
  const spec = args[0];
13
- if (!spec || spec.startsWith("--")) {
14
- throw new Error("Usage: ahood edit <owner>/<skill> [--tagline <text>] [--tags <comma,separated>] [--license <id>] [--visibility public|private]");
15
- }
16
- const [owner, skill] = spec.split("/");
17
- if (!owner || !skill)
18
- throw new Error("Usage: ahood edit <owner>/<skill> [--tagline ...] [--tags ...] [--license ...] [--visibility ...]");
12
+ if (!spec || spec.startsWith("--"))
13
+ throw new Error(USAGE);
14
+ const { owner, skill } = parseOwnerSkill(spec, USAGE);
19
15
  const body = {};
20
16
  const tagline = flagValue(args, "--tagline");
21
17
  if (tagline !== undefined)
@@ -27,12 +23,22 @@ export async function edit(args) {
27
23
  if (license !== undefined)
28
24
  body.license = license;
29
25
  const visibility = flagValue(args, "--visibility");
30
- if (visibility !== undefined)
26
+ if (visibility !== undefined) {
27
+ if (visibility !== "public" && visibility !== "private") {
28
+ throw new Error(`--visibility must be "public" or "private" (got "${visibility}").`);
29
+ }
31
30
  body.visibility = visibility;
31
+ }
32
+ const homepage = flagValue(args, "--homepage");
33
+ if (homepage !== undefined)
34
+ body.homepage = homepage;
35
+ const repository = flagValue(args, "--repository");
36
+ if (repository !== undefined)
37
+ body.repository = repository;
32
38
  if (Object.keys(body).length === 0) {
33
- throw new Error("Nothing to update -- pass at least one of --tagline, --tags, --license, --visibility.");
39
+ throw new Error("Nothing to update -- pass at least one of --tagline, --tags, --license, --visibility, --homepage, --repository.");
34
40
  }
35
- const updated = await apiJson(`/api/v1/skills/${owner}/${skill}`, {
41
+ const updated = await apiJson(`/api/v1/skills/${encodeURIComponent(owner)}/${encodeURIComponent(skill)}`, {
36
42
  method: "PATCH",
37
43
  headers: { "Content-Type": "application/json" },
38
44
  body: JSON.stringify(body),
@@ -1,11 +1,16 @@
1
1
  import { apiJson } from "../http.js";
2
- export async function listMine() {
2
+ export async function listMine(args = []) {
3
+ const jsonOutput = args.includes("--json");
3
4
  const { skills } = await apiJson("/api/v1/skills?mine=true");
5
+ if (jsonOutput) {
6
+ console.log(JSON.stringify(skills));
7
+ return;
8
+ }
4
9
  if (skills.length === 0) {
5
10
  console.log("You haven't published any skills yet.");
6
11
  return;
7
12
  }
8
13
  for (const skill of skills) {
9
- console.log(`${skill.slug} (${skill.visibility}) — ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads, ${skill.stars_count} stars)`);
14
+ console.log(`${skill.profiles.username}/${skill.slug} (${skill.visibility}) - ${skill.name}${skill.tagline ? `: ${skill.tagline}` : ""} (${skill.downloads_count} downloads, ${skill.stars_count} stars)`);
10
15
  }
11
16
  }
@@ -1,5 +1,8 @@
1
1
  import { apiJson } from "../http.js";
2
+ import { getApiUrl } from "../config.js";
2
3
  import { writeCredentials } from "../credentials.js";
4
+ const DEFAULT_EXPIRES_IN_SECONDS = 600; // 10 minutes, matching the previous timeout loop's real-world duration
5
+ const POLL_TIMEOUT_MS = 10_000;
3
6
  function sleep(ms) {
4
7
  return new Promise((resolve) => setTimeout(resolve, ms));
5
8
  }
@@ -9,14 +12,24 @@ export async function login() {
9
12
  });
10
13
  console.log(`First, confirm this code matches what you see in your browser: ${code}`);
11
14
  console.log(`Open ${verification_url} to approve.`);
12
- const deadline = Date.now() + expires_in * 1000;
15
+ // expires_in is server-supplied; a missing/non-finite value previously
16
+ // produced `Date.now() + undefined * 1000` = NaN, so `Date.now() < NaN` is
17
+ // always false and the poll loop's body never ran even once -- login
18
+ // failed instantly with "timed out" instead of ever actually polling.
19
+ const seconds = Number.isFinite(expires_in) && expires_in > 0 ? expires_in : DEFAULT_EXPIRES_IN_SECONDS;
20
+ const deadline = Date.now() + seconds * 1000;
13
21
  while (Date.now() < deadline) {
14
22
  await sleep(2000);
15
23
  let res;
16
24
  let body;
17
25
  try {
18
- res = await fetch(`${verification_url.split("?")[0].replace("/cli-auth", "")}/api/v1/auth/cli/device/${code}`);
19
- body = await res.json();
26
+ // Poll against the CONFIGURED API host (getApiUrl()), not a URL derived
27
+ // from the server-supplied verification_url -- that field is only ever
28
+ // shown to the human, never used to build a request, so a compromised
29
+ // or redirected registry response can't point this at a host that
30
+ // then harvests the device code and hands back an attacker's token.
31
+ res = await apiFetchWithTimeout(`/api/v1/auth/cli/device/${encodeURIComponent(code)}`);
32
+ body = (await res.json());
20
33
  }
21
34
  catch (error) {
22
35
  // A THROWN fetch (DNS blip, dropped socket, a body that isn't JSON) is
@@ -40,3 +53,10 @@ export async function login() {
40
53
  }
41
54
  throw new Error("Login timed out. Run `ahood login` again.");
42
55
  }
56
+ // Not apiJson: a non-2xx poll response ("pending", still-provisioning, etc.)
57
+ // is expected and must not throw -- the status code is inspected by the
58
+ // caller instead. Still routed through the same trusted host + a bounded
59
+ // timeout, unlike the raw, unbounded `fetch` this replaced.
60
+ async function apiFetchWithTimeout(path) {
61
+ return fetch(`${getApiUrl()}${path}`, { signal: AbortSignal.timeout(POLL_TIMEOUT_MS) });
62
+ }