swarmiq 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tal Wayn / SwarmIQ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # SwarmIQ Init CLI
2
+
3
+ <div dir="rtl">
4
+
5
+ ## סקירה
6
+
7
+ `swarmiq-init` הוא כלי שורת פקודה לחיבור ראשוני של המשתמש לפרוקסי SwarmIQ.
8
+ הוא מוביל את המשתמש דרך GitHub OAuth (דרך Clerk), מקבל `api_key`, שומר אותו
9
+ בקובץ קונפיגורציה מקומי, ומדפיס את משתני הסביבה שיש להגדיר ב-Claude Code / VS Code
10
+ כדי שכל הבקשות יעברו דרך הפרוקסי.
11
+
12
+ **פרסום ל-npm** הוא פעולה ידנית של Tal (מוגדרת למועד מאוחר יותר, כמו רכישת Contabo).
13
+ עד אז יש להשתמש בהפעלה מקומית (ראה להלן).
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## Onboarding flow
20
+
21
+ ```
22
+ User → npx swarmiq-init → GitHub OAuth (Clerk) → JWT
23
+
24
+
25
+ POST /v1/auth/callback
26
+ (proxy creates tenant + api_key)
27
+
28
+
29
+ ~/.swarmiq/config.json ← key written
30
+
31
+
32
+ env-var instructions printed
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Usage (after npm publish — deferred)
38
+
39
+ ```sh
40
+ npx swarmiq-init
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Local dev invocation (no npm publish required)
46
+
47
+ ```sh
48
+ # From the repo root:
49
+ node tools/cli/src/index.mjs
50
+
51
+ # Or from within tools/cli/:
52
+ node src/index.mjs
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Options
58
+
59
+ | Flag | Effect |
60
+ |------|--------|
61
+ | `--help` / `-h` | Show help and exit |
62
+ | `--version` / `-v` | Print version and exit |
63
+
64
+ ---
65
+
66
+ ## Environment variables
67
+
68
+ | Variable | Default | Description |
69
+ |----------|---------|-------------|
70
+ | `SWARMIQ_CONFIG_HOME` | `~/.swarmiq` | Directory where `config.json` is written |
71
+ | `SWARMIQ_PROXY_URL` | `http://127.0.0.1:8000` | SwarmIQ proxy base URL |
72
+
73
+ ---
74
+
75
+ ## Output — config.json
76
+
77
+ After a successful run, `$SWARMIQ_CONFIG_HOME/config.json` contains:
78
+
79
+ ```json
80
+ {
81
+ "api_key": "<your-swarmiq-api-key>",
82
+ "tenant_id": "tnt_abc123",
83
+ "tier": "free",
84
+ "proxy_base_url": "http://127.0.0.1:8000",
85
+ "updated_at": "2026-05-31T12:00:00.000Z"
86
+ }
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Output — env vars printed to terminal
92
+
93
+ ```sh
94
+ export ANTHROPIC_BASE_URL="http://127.0.0.1:8000"
95
+ export ANTHROPIC_API_KEY="<your-swarmiq-api-key>"
96
+ ```
97
+
98
+ Add these to your `~/.zshrc` / `~/.bashrc` (or Windows equivalent), or configure
99
+ Claude Code with:
100
+
101
+ ```sh
102
+ claude config set apiBaseUrl "http://127.0.0.1:8000"
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Running tests
108
+
109
+ No dependencies required. Uses Node's built-in test runner.
110
+
111
+ ```sh
112
+ cd tools/cli
113
+ node --test test/
114
+ ```
115
+
116
+ Expected output: all 11 tests pass, `fail 0`.
117
+
118
+ ---
119
+
120
+ ## Architecture
121
+
122
+ ```
123
+ tools/cli/
124
+ ├── src/
125
+ │ ├── index.mjs ← CLI entry point (bin: swarmiq-init)
126
+ │ ├── init.mjs ← Core flow, fully injectable (no network in tests)
127
+ │ ├── oauth.mjs ← URL builder + local callback HTTP server
128
+ │ ├── api.mjs ← POST /v1/auth/callback exchange
129
+ │ └── config.mjs ← ~/.swarmiq/config.json read/write
130
+ └── test/
131
+ └── init.test.mjs ← 11 tests, node --test
132
+ ```
133
+
134
+ **Runtime dependencies: zero.** Only Node built-ins (`node:fs`, `node:http`,
135
+ `node:crypto`, `node:os`, `node:path`, `node:url`, `node:child_process`).
136
+
137
+ **Design choice — plain ESM `.mjs` (not TypeScript):** The project has no
138
+ existing Node build pipeline. TypeScript would require `npm install` + `npx tsc`
139
+ on every change, creating offline-fragile CI. Plain ESM runs with zero build
140
+ steps: `node src/index.mjs` and `node --test test/` both work immediately on
141
+ Node 18+.
142
+
143
+ ---
144
+
145
+ ## Auth contract
146
+
147
+ The CLI implements the onboarding leg of
148
+ `infra/contracts/auth.contract.md` (C3, Wave-0B):
149
+
150
+ - OAuth entry point: `https://clerk.swarmiq.dev/oauth/github`
151
+ - Exchange endpoint: `POST /v1/auth/callback`
152
+ - Response: `{ tenant_id, api_key, tier }`
153
+ - Config written: `{ api_key, tenant_id, tier, proxy_base_url }`
154
+
155
+ ---
156
+
157
+ <div dir="rtl">
158
+
159
+ ## פרסום ל-npm (פעולה ידנית)
160
+
161
+ כאשר מגיע הזמן לפרסם:
162
+
163
+ ```sh
164
+ cd tools/cli
165
+ npm publish --access public
166
+ ```
167
+
168
+ ברגע שהחבילה פורסמה, המשתמשים יוכלו להריץ:
169
+
170
+ ```sh
171
+ npx swarmiq-init
172
+ ```
173
+
174
+ עד אז — השתמשו בהפעלה מקומית.
175
+
176
+ </div>
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "swarmiq",
3
+ "version": "0.2.0",
4
+ "description": "Route Claude Code through SwarmIQ's free-LLM cascade and save your Anthropic Max quota.",
5
+ "type": "module",
6
+ "main": "src/index.mjs",
7
+ "bin": {
8
+ "swarmiq": "src/index.mjs"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test test/init.test.mjs test/github_auth.test.mjs"
17
+ },
18
+ "engines": {
19
+ "node": ">=18.0.0"
20
+ },
21
+ "keywords": [
22
+ "claude",
23
+ "claude-code",
24
+ "anthropic",
25
+ "llm",
26
+ "proxy",
27
+ "router",
28
+ "cascade",
29
+ "swarmiq"
30
+ ],
31
+ "license": "MIT",
32
+ "homepage": "https://swarmiq.dev",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/TalWayn72/swarmiq-os.git",
36
+ "directory": "tools/cli"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/TalWayn72/swarmiq-os/issues"
40
+ }
41
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * api.mjs — HTTP helpers for talking to the SwarmIQ proxy.
3
+ *
4
+ * All functions accept an injectable `fetch` parameter so tests can replace
5
+ * network calls with fakes without ever hitting the wire.
6
+ *
7
+ * IMPORTANT: Provider API keys are accepted as parameters but NEVER written
8
+ * to disk and NEVER logged. They are sent once over HTTPS and then discarded.
9
+ */
10
+
11
+ import { PROXY_BASE_URL_DEFAULT } from './config.mjs';
12
+
13
+ // ─── BYOK: store a provider key in the Vault ─────────────────────────────────
14
+
15
+ /**
16
+ * POST /v1/vault/provider-key
17
+ * Stores a provider API key in the SwarmIQ Vault under the caller's tenant.
18
+ * The key parameter is used exactly once — never cached, never written to disk.
19
+ *
20
+ * @param {object} opts
21
+ * @param {string} opts.provider - e.g. "anthropic", "openai"
22
+ * @param {string} opts.key - the raw API key (not persisted locally)
23
+ * @param {string} opts.accessToken - Bearer token from device flow
24
+ * @param {string} [opts.proxyBaseUrl]
25
+ * @param {Function}[opts.fetch]
26
+ * @returns {Promise<{status:string, provider:string}>}
27
+ */
28
+ export async function storeProviderKey({
29
+ provider,
30
+ key,
31
+ accessToken,
32
+ proxyBaseUrl = PROXY_BASE_URL_DEFAULT,
33
+ fetch: fetchFn = globalThis.fetch,
34
+ }) {
35
+ const url = `${proxyBaseUrl}/v1/vault/provider-key`;
36
+ const res = await fetchFn(url, {
37
+ method: 'POST',
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ Authorization: `Bearer ${accessToken}`,
41
+ },
42
+ body: JSON.stringify({ provider, key }),
43
+ });
44
+
45
+ if (!res.ok) {
46
+ const body = await res.text().catch(() => '');
47
+ throw new Error(
48
+ `POST /v1/vault/provider-key failed: HTTP ${res.status} — ${body}`
49
+ );
50
+ }
51
+
52
+ return res.json();
53
+ }
54
+
55
+ // ─── Health + Savings summary ─────────────────────────────────────────────────
56
+
57
+ /**
58
+ * GET /health
59
+ * Returns raw JSON from the health endpoint or null on failure.
60
+ *
61
+ * @param {object} opts
62
+ * @param {string} [opts.proxyBaseUrl]
63
+ * @param {Function}[opts.fetch]
64
+ * @returns {Promise<Record<string,unknown>|null>}
65
+ */
66
+ export async function fetchHealth({
67
+ proxyBaseUrl = PROXY_BASE_URL_DEFAULT,
68
+ fetch: fetchFn = globalThis.fetch,
69
+ } = {}) {
70
+ try {
71
+ const res = await fetchFn(`${proxyBaseUrl}/health`);
72
+ if (!res.ok) return null;
73
+ return res.json();
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * GET /v1/savings/summary
81
+ * Returns the savings summary or null on failure.
82
+ *
83
+ * @param {object} opts
84
+ * @param {string} [opts.proxyBaseUrl]
85
+ * @param {Function}[opts.fetch]
86
+ * @returns {Promise<Record<string,unknown>|null>}
87
+ */
88
+ export async function fetchSavings({
89
+ proxyBaseUrl = PROXY_BASE_URL_DEFAULT,
90
+ fetch: fetchFn = globalThis.fetch,
91
+ } = {}) {
92
+ try {
93
+ const res = await fetchFn(`${proxyBaseUrl}/v1/savings/summary`);
94
+ if (!res.ok) return null;
95
+ return res.json();
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * config.mjs — Config file read/write for SwarmIQ CLI.
3
+ *
4
+ * Config location: $SWARMIQ_CONFIG_HOME/config.json (default: ~/.swarmiq/config.json)
5
+ * Overridable via env SWARMIQ_CONFIG_HOME for tests.
6
+ *
7
+ * Also handles read-modify-write of ~/.claude/settings.json to inject the
8
+ * SwarmIQ env block (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY).
9
+ * The "SwarmIQ block" is identified by the presence of ANTHROPIC_BASE_URL under
10
+ * the settings.env key so --logout can cleanly remove exactly those keys.
11
+ */
12
+
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+
17
+ // ─── proxy / OAuth base URLs ─────────────────────────────────────────────────
18
+
19
+ /** Default proxy base — overridable via SWARMIQ_PROXY_URL env var. */
20
+ export const PROXY_BASE_URL_DEFAULT =
21
+ process.env.SWARMIQ_PROXY_URL ?? 'https://api.swarmiq.dev';
22
+
23
+ // ─── SwarmIQ config.json helpers ─────────────────────────────────────────────
24
+
25
+ /**
26
+ * Resolve the directory that holds config.json.
27
+ * @returns {string} absolute path to config directory
28
+ */
29
+ export function configDir() {
30
+ if (process.env.SWARMIQ_CONFIG_HOME) {
31
+ return process.env.SWARMIQ_CONFIG_HOME;
32
+ }
33
+ return join(homedir(), '.swarmiq');
34
+ }
35
+
36
+ /**
37
+ * Resolve the config file path.
38
+ * @returns {string} absolute path to config.json
39
+ */
40
+ export function configPath() {
41
+ return join(configDir(), 'config.json');
42
+ }
43
+
44
+ /**
45
+ * Read config.json; returns {} if it doesn't exist.
46
+ * @returns {Record<string, unknown>}
47
+ */
48
+ export function readConfig() {
49
+ const p = configPath();
50
+ if (!existsSync(p)) return {};
51
+ try {
52
+ return JSON.parse(readFileSync(p, 'utf8'));
53
+ } catch {
54
+ return {};
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Write (merge) fields into config.json, creating the directory if needed.
60
+ * Idempotent: merges over existing keys.
61
+ * Never writes provider keys — callers must strip sensitive fields before
62
+ * passing to this function.
63
+ * @param {Record<string, unknown>} fields
64
+ */
65
+ export function writeConfig(fields) {
66
+ const dir = configDir();
67
+ if (!existsSync(dir)) {
68
+ mkdirSync(dir, { recursive: true });
69
+ }
70
+ const existing = readConfig();
71
+ const merged = { ...existing, ...fields, updated_at: new Date().toISOString() };
72
+ writeFileSync(configPath(), JSON.stringify(merged, null, 2) + '\n', 'utf8');
73
+ }
74
+
75
+ // ─── ~/.claude/settings.json helpers ─────────────────────────────────────────
76
+
77
+ /**
78
+ * Resolve the Claude Code settings.json path.
79
+ * Overridable via SWARMIQ_CLAUDE_SETTINGS_PATH for tests.
80
+ * @returns {string}
81
+ */
82
+ export function claudeSettingsPath() {
83
+ if (process.env.SWARMIQ_CLAUDE_SETTINGS_PATH) {
84
+ return process.env.SWARMIQ_CLAUDE_SETTINGS_PATH;
85
+ }
86
+ return join(homedir(), '.claude', 'settings.json');
87
+ }
88
+
89
+ /**
90
+ * Read ~/.claude/settings.json; returns {} if absent or unparseable.
91
+ * @returns {Record<string, unknown>}
92
+ */
93
+ export function readClaudeSettings() {
94
+ const p = claudeSettingsPath();
95
+ if (!existsSync(p)) return {};
96
+ try {
97
+ return JSON.parse(readFileSync(p, 'utf8'));
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Write the SwarmIQ env block into ~/.claude/settings.json.
105
+ * Merges carefully: existing keys outside of `env` are preserved;
106
+ * within `env`, only the three SwarmIQ keys are added/updated.
107
+ *
108
+ * @param {object} opts
109
+ * @param {string} opts.proxyBaseUrl - written as ANTHROPIC_BASE_URL
110
+ * @param {string} opts.accessToken - written as ANTHROPIC_AUTH_TOKEN
111
+ */
112
+ export function writeClaudeSettings({ proxyBaseUrl, accessToken }) {
113
+ const p = claudeSettingsPath();
114
+ const dir = dirname(p);
115
+
116
+ if (!existsSync(dir)) {
117
+ mkdirSync(dir, { recursive: true });
118
+ }
119
+
120
+ const existing = readClaudeSettings();
121
+ const existingEnv = (existing.env && typeof existing.env === 'object')
122
+ ? existing.env
123
+ : {};
124
+
125
+ const merged = {
126
+ ...existing,
127
+ env: {
128
+ ...existingEnv,
129
+ ANTHROPIC_BASE_URL: proxyBaseUrl,
130
+ ANTHROPIC_AUTH_TOKEN: accessToken,
131
+ ANTHROPIC_API_KEY: '',
132
+ },
133
+ };
134
+
135
+ writeFileSync(p, JSON.stringify(merged, null, 2) + '\n', 'utf8');
136
+ }
137
+
138
+ /**
139
+ * Remove the SwarmIQ env block from ~/.claude/settings.json.
140
+ * Removes ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY.
141
+ * If `env` becomes empty after removal it is deleted from the settings object.
142
+ * If the file doesn't exist, this is a no-op.
143
+ */
144
+ export function removeClaudeSettings() {
145
+ const p = claudeSettingsPath();
146
+ if (!existsSync(p)) return;
147
+
148
+ const existing = readClaudeSettings();
149
+ if (!existing.env || typeof existing.env !== 'object') return;
150
+
151
+ const SWARMIQ_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'];
152
+ const newEnv = { ...existing.env };
153
+ for (const k of SWARMIQ_KEYS) {
154
+ delete newEnv[k];
155
+ }
156
+
157
+ const updated = { ...existing };
158
+ if (Object.keys(newEnv).length === 0) {
159
+ delete updated.env;
160
+ } else {
161
+ updated.env = newEnv;
162
+ }
163
+
164
+ writeFileSync(p, JSON.stringify(updated, null, 2) + '\n', 'utf8');
165
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * github_auth.mjs — GitHub OAuth Device Flow for SwarmIQ CLI.
3
+ *
4
+ * Implements the GitHub Device Flow (https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow):
5
+ * 1. POST https://github.com/login/device/code
6
+ * body: client_id + scope=read:user user:email
7
+ * → {device_code, user_code, verification_uri, expires_in, interval}
8
+ * 2. Display user_code + verification_uri to user; open browser (caller handles this).
9
+ * 3. Poll POST https://github.com/login/oauth/access_token
10
+ * body: client_id + device_code + grant_type=urn:ietf:params:oauth:grant-type:device_code
11
+ * → {access_token, ...} or {error: "authorization_pending"|"slow_down"|"expired_token"|"access_denied"}
12
+ *
13
+ * All network calls and the sleep function are injectable for testing.
14
+ * The GitHub access token is NEVER written to disk — callers must keep it in
15
+ * memory only and discard it after exchanging for a SwarmIQ token.
16
+ */
17
+
18
+ const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code';
19
+ const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
20
+
21
+ /**
22
+ * Start the GitHub device flow: POST to GitHub's device/code endpoint.
23
+ *
24
+ * @param {string} clientId - The GitHub OAuth App client_id
25
+ * @param {Function} [fetchFn] - Injectable fetch (defaults to globalThis.fetch)
26
+ * @returns {Promise<{
27
+ * device_code: string,
28
+ * user_code: string,
29
+ * verification_uri: string,
30
+ * expires_in: number,
31
+ * interval: number,
32
+ * }>}
33
+ */
34
+ export async function requestGitHubDeviceCode(clientId, fetchFn = globalThis.fetch) {
35
+ const res = await fetchFn(GITHUB_DEVICE_CODE_URL, {
36
+ method: 'POST',
37
+ headers: {
38
+ 'Content-Type': 'application/x-www-form-urlencoded',
39
+ Accept: 'application/json',
40
+ },
41
+ body: new URLSearchParams({
42
+ client_id: clientId,
43
+ scope: 'read:user user:email',
44
+ }).toString(),
45
+ });
46
+
47
+ if (!res.ok) {
48
+ const body = await res.text().catch(() => '');
49
+ throw new Error(
50
+ `GitHub device code request failed: HTTP ${res.status} — ${body}`
51
+ );
52
+ }
53
+
54
+ const data = await res.json();
55
+
56
+ if (data.error) {
57
+ throw new Error(`GitHub device code error: ${data.error} — ${data.error_description ?? ''}`);
58
+ }
59
+
60
+ if (!data.device_code || !data.user_code || !data.verification_uri) {
61
+ throw new Error(
62
+ 'Unexpected response from GitHub device/code: missing required fields'
63
+ );
64
+ }
65
+
66
+ return {
67
+ device_code: data.device_code,
68
+ user_code: data.user_code,
69
+ verification_uri: data.verification_uri,
70
+ expires_in: data.expires_in ?? 900,
71
+ interval: data.interval ?? 5,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Poll GitHub's token endpoint until the user completes authorisation.
77
+ *
78
+ * SECURITY: The returned access_token is a GitHub token and must NEVER be
79
+ * written to disk. Callers must exchange it immediately via POST /v1/auth/github
80
+ * and then discard it.
81
+ *
82
+ * @param {string} clientId - The GitHub OAuth App client_id
83
+ * @param {string} deviceCode - device_code from requestGitHubDeviceCode
84
+ * @param {number} interval - Initial polling interval in seconds
85
+ * @param {object} [opts]
86
+ * @param {Function}[opts.fetch] - Injectable fetch (defaults to globalThis.fetch)
87
+ * @param {Function}[opts.sleep] - Injectable sleep (ms:number)=>Promise<void>
88
+ * @param {number} [opts.expiresIn] - Total expiry window in seconds (default 900)
89
+ * @returns {Promise<string>} The GitHub access token (transient — do not persist)
90
+ */
91
+ export async function pollGitHubToken(
92
+ clientId,
93
+ deviceCode,
94
+ interval,
95
+ { fetch: fetchFn = globalThis.fetch, sleep = defaultSleep, expiresIn = 900 } = {}
96
+ ) {
97
+ const deadline = Date.now() + expiresIn * 1000;
98
+ let currentInterval = interval;
99
+
100
+ while (Date.now() < deadline) {
101
+ await sleep(currentInterval * 1000);
102
+
103
+ const res = await fetchFn(GITHUB_TOKEN_URL, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': 'application/x-www-form-urlencoded',
107
+ Accept: 'application/json',
108
+ },
109
+ body: new URLSearchParams({
110
+ client_id: clientId,
111
+ device_code: deviceCode,
112
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
113
+ }).toString(),
114
+ });
115
+
116
+ if (!res.ok) {
117
+ const body = await res.text().catch(() => '');
118
+ throw new Error(
119
+ `GitHub token poll failed: HTTP ${res.status} — ${body}`
120
+ );
121
+ }
122
+
123
+ const data = await res.json();
124
+
125
+ if (data.access_token) {
126
+ // Success — return token immediately (do NOT persist it)
127
+ return data.access_token;
128
+ }
129
+
130
+ const error = data.error ?? 'unknown_error';
131
+
132
+ switch (error) {
133
+ case 'authorization_pending':
134
+ // User hasn't approved yet — keep polling
135
+ break;
136
+
137
+ case 'slow_down':
138
+ // GitHub asked us to back off — increase interval by 5s
139
+ currentInterval += 5;
140
+ break;
141
+
142
+ case 'expired_token':
143
+ throw new Error(
144
+ 'GitHub device authorisation expired. Please run `swarmiq` again to restart.'
145
+ );
146
+
147
+ case 'access_denied':
148
+ throw new Error(
149
+ 'GitHub authorisation was denied. Please run `swarmiq` again and approve access.'
150
+ );
151
+
152
+ default:
153
+ throw new Error(
154
+ `Unexpected error from GitHub token endpoint: ${error} — ${data.error_description ?? ''}`
155
+ );
156
+ }
157
+ }
158
+
159
+ throw new Error(
160
+ 'GitHub device authorisation timed out. Please run `swarmiq` again to restart.'
161
+ );
162
+ }
163
+
164
+ // ─── internal helpers ─────────────────────────────────────────────────────────
165
+
166
+ function defaultSleep(ms) {
167
+ return new Promise((resolve) => setTimeout(resolve, ms));
168
+ }