@doow/cli 0.1.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.
Files changed (43) hide show
  1. package/README.md +75 -0
  2. package/dist/cjs/auth/api-key.js +159 -0
  3. package/dist/cjs/auth/api-key.js.map +1 -0
  4. package/dist/cjs/auth/detect.js +173 -0
  5. package/dist/cjs/auth/detect.js.map +1 -0
  6. package/dist/cjs/auth/device-flow.js +135 -0
  7. package/dist/cjs/auth/device-flow.js.map +1 -0
  8. package/dist/cjs/auth/keyring.js +118 -0
  9. package/dist/cjs/auth/keyring.js.map +1 -0
  10. package/dist/cjs/auth/pkce.js +243 -0
  11. package/dist/cjs/auth/pkce.js.map +1 -0
  12. package/dist/cjs/auth/refresh.js +203 -0
  13. package/dist/cjs/auth/refresh.js.map +1 -0
  14. package/dist/cjs/config/env.js +44 -0
  15. package/dist/cjs/config/env.js.map +1 -0
  16. package/dist/cjs/config/store.js +178 -0
  17. package/dist/cjs/config/store.js.map +1 -0
  18. package/dist/cjs/index.js +48 -0
  19. package/dist/cjs/index.js.map +1 -0
  20. package/dist/cli.cjs +34372 -0
  21. package/dist/cli.cjs.map +1 -0
  22. package/dist/esm/auth/api-key.js +154 -0
  23. package/dist/esm/auth/api-key.js.map +1 -0
  24. package/dist/esm/auth/detect.js +150 -0
  25. package/dist/esm/auth/detect.js.map +1 -0
  26. package/dist/esm/auth/device-flow.js +132 -0
  27. package/dist/esm/auth/device-flow.js.map +1 -0
  28. package/dist/esm/auth/keyring.js +116 -0
  29. package/dist/esm/auth/keyring.js.map +1 -0
  30. package/dist/esm/auth/pkce.js +220 -0
  31. package/dist/esm/auth/pkce.js.map +1 -0
  32. package/dist/esm/auth/refresh.js +198 -0
  33. package/dist/esm/auth/refresh.js.map +1 -0
  34. package/dist/esm/config/env.js +38 -0
  35. package/dist/esm/config/env.js.map +1 -0
  36. package/dist/esm/config/store.js +166 -0
  37. package/dist/esm/config/store.js.map +1 -0
  38. package/dist/esm/index.js +15 -0
  39. package/dist/esm/index.js.map +1 -0
  40. package/dist/mcp.cjs +8 -0
  41. package/dist/mcp.cjs.map +1 -0
  42. package/dist/types/index.d.ts +369 -0
  43. package/package.json +62 -0
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @doow/cli
2
+
3
+ Doow CLI -- manage SaaS spend from your terminal and coding agents.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @doow/cli
9
+ ```
10
+
11
+ Or via Homebrew (once the tap is live):
12
+
13
+ ```bash
14
+ brew tap doow-co/doow
15
+ brew install doow
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ```bash
21
+ doow login # authenticate (PKCE or device flow)
22
+ doow whoami # verify identity
23
+ doow apps list # list managed applications
24
+ doow reports expense # generate expense report
25
+ doow chat # interactive chat with Derek or Mina
26
+ ```
27
+
28
+ ## MCP server
29
+
30
+ ```bash
31
+ doow mcp # start MCP server over stdio
32
+ doow mcp --list-tools # print available tools as JSON
33
+ ```
34
+
35
+ Configure in your MCP client:
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "doow": {
41
+ "command": "doow",
42
+ "args": ["mcp"]
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ ## Shell completion
49
+
50
+ ```bash
51
+ doow completion install # auto-detect shell and install
52
+ doow completion bash # output bash completion script
53
+ doow completion zsh # output zsh completion script
54
+ ```
55
+
56
+ ## Diagnostics
57
+
58
+ ```bash
59
+ doow doctor # check auth, config, network, API version
60
+ ```
61
+
62
+ ## Global flags
63
+
64
+ | Flag | Description |
65
+ |------|-------------|
66
+ | `--json` | JSON output (default when piped) |
67
+ | `--table` | Table output (default in TTY) |
68
+ | `--profile <name>` | Multi-org profile selection |
69
+ | `--yes` | Skip confirmation prompts |
70
+ | `--dry-run` | Preview mutations without executing |
71
+ | `--debug` | Verbose HTTP logging to stderr |
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ var env = require('../config/env.js');
4
+ var keyring = require('./keyring.js');
5
+
6
+ /**
7
+ * api-key.ts
8
+ *
9
+ * PAT (Personal Access Token) authentication for CI/scripting contexts.
10
+ *
11
+ * Provides:
12
+ * - validateApiKey — pure format check (dak_ prefix, length ≥ 20)
13
+ * - authenticateWithApiKey — store key + optionally verify against API
14
+ * - readTokenFromStdin — read a piped token from stdin
15
+ * - resolveAuth — precedence chain: flag > env > stdin > stored > none
16
+ */
17
+ // ---------------------------------------------------------------------------
18
+ // Constants
19
+ // ---------------------------------------------------------------------------
20
+ const DAK_PREFIX = 'dak_';
21
+ const DAK_MIN_LENGTH = 20;
22
+ const REFRESH_BUFFER_SECONDS = 60;
23
+ // ---------------------------------------------------------------------------
24
+ // validateApiKey
25
+ // ---------------------------------------------------------------------------
26
+ /**
27
+ * Returns true if key starts with 'dak_' (case-sensitive) and is at least
28
+ * 20 characters total. Pure function — no network call.
29
+ */
30
+ function validateApiKey(key) {
31
+ return key.startsWith(DAK_PREFIX) && key.length >= DAK_MIN_LENGTH;
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // authenticateWithApiKey
35
+ // ---------------------------------------------------------------------------
36
+ /**
37
+ * Validates the key format, stores it in the credential store, and optionally
38
+ * verifies it works by hitting GET /v1/auth/capabilities.
39
+ *
40
+ * @throws {Error} if the key does not have the dak_ prefix
41
+ * @throws {Error} if verify is true and the capabilities request fails
42
+ */
43
+ async function authenticateWithApiKey(options) {
44
+ const { key, profileName = 'default', verify = true } = options;
45
+ const apiUrl = options.apiUrl ?? env.getApiUrl();
46
+ const store = options.credentialStore ?? (await keyring.createCredentialStore());
47
+ // Validate format first
48
+ if (!validateApiKey(key)) {
49
+ throw new Error(`Invalid API key format. Keys must start with '${DAK_PREFIX}' and be at least ${DAK_MIN_LENGTH} characters long.`);
50
+ }
51
+ // Optionally verify before storing
52
+ if (verify) {
53
+ const res = await fetch(`${apiUrl}/v1/auth/capabilities`, {
54
+ headers: { Authorization: `Bearer ${key}` },
55
+ });
56
+ if (!res.ok) {
57
+ const body = await res.text().catch(() => '');
58
+ throw new Error(`API key verification failed: HTTP ${res.status}${body ? ` — ${body}` : ''}`);
59
+ }
60
+ await store.set(profileName, { apiKey: key });
61
+ return { apiKey: key, verified: true };
62
+ }
63
+ await store.set(profileName, { apiKey: key });
64
+ return { apiKey: key, verified: false };
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // readTokenFromStdin
68
+ // ---------------------------------------------------------------------------
69
+ /**
70
+ * Reads a token from piped stdin input.
71
+ *
72
+ * @throws {Error} if stdin is a TTY (not piped)
73
+ * @throws {Error} if the resulting string is empty after trim
74
+ */
75
+ async function readTokenFromStdin() {
76
+ if (process.stdin.isTTY) {
77
+ throw new Error('--token-stdin requires piped input (e.g., echo $TOKEN | doow login --token-stdin)');
78
+ }
79
+ const parts = [];
80
+ for await (const chunk of process.stdin) {
81
+ if (Buffer.isBuffer(chunk)) {
82
+ parts.push(chunk.toString('utf-8'));
83
+ }
84
+ else {
85
+ parts.push(String(chunk));
86
+ }
87
+ }
88
+ const token = parts.join('').trim();
89
+ if (!token) {
90
+ throw new Error('No token received on stdin');
91
+ }
92
+ return token;
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // resolveAuth
96
+ // ---------------------------------------------------------------------------
97
+ /**
98
+ * Resolves the active auth context by walking the precedence chain:
99
+ * --api-key flag > DOOW_API_KEY env > --token-stdin > stored profile > none
100
+ */
101
+ async function resolveAuth(options = {}) {
102
+ const { apiKeyFlag, tokenStdin = false, profileName = 'default' } = options;
103
+ const store = options.credentialStore ?? (await keyring.createCredentialStore());
104
+ // 1. --api-key flag
105
+ if (apiKeyFlag !== undefined && apiKeyFlag !== '') {
106
+ if (!validateApiKey(apiKeyFlag)) {
107
+ throw new Error(`Invalid API key format. Keys must start with '${DAK_PREFIX}' and be at least ${DAK_MIN_LENGTH} characters long.`);
108
+ }
109
+ return { type: 'api-key', token: apiKeyFlag, source: '--api-key flag' };
110
+ }
111
+ // 2. DOOW_API_KEY env var
112
+ const envKey = process.env['DOOW_API_KEY'];
113
+ if (envKey && validateApiKey(envKey)) {
114
+ return { type: 'api-key', token: envKey, source: 'DOOW_API_KEY env' };
115
+ }
116
+ // 3. --token-stdin
117
+ if (tokenStdin) {
118
+ const token = await readTokenFromStdin();
119
+ const type = validateApiKey(token) ? 'api-key' : 'oauth-token';
120
+ return { type, token, source: 'stdin' };
121
+ }
122
+ // 4. Stored credentials for the profile
123
+ const creds = await store.get(profileName);
124
+ if (creds?.apiKey) {
125
+ return { type: 'api-key', token: creds.apiKey, source: 'stored profile' };
126
+ }
127
+ if (creds?.accessToken) {
128
+ const token = creds.accessToken;
129
+ const needsRefresh = isTokenExpiredOrExpiring(creds.expiresAt);
130
+ return {
131
+ type: 'oauth-token',
132
+ token,
133
+ source: 'stored profile',
134
+ ...(needsRefresh ? { needsRefresh: true } : {}),
135
+ };
136
+ }
137
+ // 5. Nothing found
138
+ return { type: 'none', source: 'none' };
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // Private helpers
142
+ // ---------------------------------------------------------------------------
143
+ /**
144
+ * Returns true if the ISO-8601 expiresAt string is either absent, already
145
+ * past, or within 60 seconds of now.
146
+ */
147
+ function isTokenExpiredOrExpiring(expiresAt) {
148
+ if (!expiresAt)
149
+ return true;
150
+ const expiryMs = new Date(expiresAt).getTime();
151
+ const nowPlusBuffer = Date.now() + REFRESH_BUFFER_SECONDS * 1000;
152
+ return expiryMs <= nowPlusBuffer;
153
+ }
154
+
155
+ exports.authenticateWithApiKey = authenticateWithApiKey;
156
+ exports.readTokenFromStdin = readTokenFromStdin;
157
+ exports.resolveAuth = resolveAuth;
158
+ exports.validateApiKey = validateApiKey;
159
+ //# sourceMappingURL=api-key.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-key.js","sources":["../../../../src/auth/api-key.ts"],"sourcesContent":[null],"names":["getApiUrl","createCredentialStore"],"mappings":"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"}
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+
3
+ var http = require('node:http');
4
+ var env = require('../config/env.js');
5
+ var apiKey = require('./api-key.js');
6
+ var pkce = require('./pkce.js');
7
+ var deviceFlow = require('./device-flow.js');
8
+
9
+ function _interopNamespaceDefault(e) {
10
+ var n = Object.create(null);
11
+ if (e) {
12
+ Object.keys(e).forEach(function (k) {
13
+ if (k !== 'default') {
14
+ var d = Object.getOwnPropertyDescriptor(e, k);
15
+ Object.defineProperty(n, k, d.get ? d : {
16
+ enumerable: true,
17
+ get: function () { return e[k]; }
18
+ });
19
+ }
20
+ });
21
+ }
22
+ n.default = e;
23
+ return Object.freeze(n);
24
+ }
25
+
26
+ var http__namespace = /*#__PURE__*/_interopNamespaceDefault(http);
27
+
28
+ /**
29
+ * detect.ts
30
+ *
31
+ * S122 — Auth auto-detection for the Doow CLI.
32
+ *
33
+ * Determines whether to use PKCE, device, or API-key authentication based on
34
+ * the current runtime environment, then executes the appropriate flow.
35
+ *
36
+ * Detection order:
37
+ * 1. --api-key provided + valid format → api-key
38
+ * 2. --device flag → device
39
+ * 3. Running inside CI ($CI set) → device
40
+ * 4. Non-interactive stdout (!isTTY) → device
41
+ * 5. Can bind 127.0.0.1 on a free port → pkce
42
+ * 6. Fallback → device
43
+ */
44
+ // ---------------------------------------------------------------------------
45
+ // canBindLocalhost
46
+ // ---------------------------------------------------------------------------
47
+ /**
48
+ * Tests whether the process can bind a TCP server on 127.0.0.1 using an
49
+ * OS-assigned ephemeral port. The server is closed immediately on success.
50
+ *
51
+ * Returns true → PKCE callback server will work.
52
+ * Returns false → Network stack can't bind (containers with restricted network
53
+ * policies, permission denied, etc.) — use device flow instead.
54
+ */
55
+ function canBindLocalhost() {
56
+ return new Promise((resolve) => {
57
+ const server = http__namespace.createServer();
58
+ server.once('error', () => {
59
+ resolve(false);
60
+ });
61
+ server.listen(0, '127.0.0.1', () => {
62
+ server.close(() => resolve(true));
63
+ });
64
+ });
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // detectAuthMethod
68
+ // ---------------------------------------------------------------------------
69
+ /**
70
+ * Returns the best authentication method for the current environment.
71
+ */
72
+ async function detectAuthMethod(options = {}) {
73
+ const { forceDevice = false, apiKey: apiKey$1 } = options;
74
+ // 1. API key provided and format-valid → skip OAuth entirely
75
+ if (apiKey$1 !== undefined && apiKey$1 !== '' && apiKey.validateApiKey(apiKey$1)) {
76
+ return 'api-key';
77
+ }
78
+ // 2. Explicit --device flag
79
+ if (forceDevice) {
80
+ return 'device';
81
+ }
82
+ // 3. CI environment — browser is not available
83
+ if (env.isCI()) {
84
+ return 'device';
85
+ }
86
+ // 4. Non-interactive (stdout is not a TTY) — can't drive a browser login
87
+ if (!env.isTTY()) {
88
+ return 'device';
89
+ }
90
+ // 5. Try to bind a local port — if it works, PKCE callback server will too
91
+ const canBind = await canBindLocalhost();
92
+ if (canBind) {
93
+ return 'pkce';
94
+ }
95
+ // 6. Safe fallback
96
+ return 'device';
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // isServerBindError
100
+ // ---------------------------------------------------------------------------
101
+ /**
102
+ * Returns true when an error looks like a localhost server bind failure —
103
+ * the only case where PKCE should automatically fall back to device flow.
104
+ *
105
+ * We intentionally do NOT fall back on application-level errors such as
106
+ * CSRF mismatch, token exchange failures, or user cancellation.
107
+ */
108
+ function isServerBindError(err) {
109
+ if (!(err instanceof Error))
110
+ return false;
111
+ const msg = err.message.toLowerCase();
112
+ return (msg.includes('failed to start local callback server') ||
113
+ msg.includes('failed to determine callback server port') ||
114
+ msg.includes('failed to open browser') ||
115
+ msg.includes('eaddrinuse') ||
116
+ msg.includes('eacces') ||
117
+ msg.includes('permission denied'));
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // executeAutoLogin
121
+ // ---------------------------------------------------------------------------
122
+ /**
123
+ * The main login orchestrator.
124
+ *
125
+ * 1. Detects the best auth method.
126
+ * 2. Executes the corresponding flow.
127
+ * 3. If PKCE fails with a server bind error, automatically retries with device flow.
128
+ */
129
+ async function executeAutoLogin(options = {}) {
130
+ const { forceDevice, apiKey: apiKey$1, apiUrl, profileName, credentialStore, timeout } = options;
131
+ const method = await detectAuthMethod({ forceDevice, apiKey: apiKey$1 });
132
+ if (method === 'api-key') {
133
+ // apiKey is guaranteed non-empty here (detectAuthMethod validated it)
134
+ await apiKey.authenticateWithApiKey({
135
+ key: apiKey$1,
136
+ apiUrl,
137
+ profileName,
138
+ credentialStore,
139
+ });
140
+ return { method: 'api-key', apiKey: apiKey$1 };
141
+ }
142
+ if (method === 'pkce') {
143
+ try {
144
+ const result = await pkce.executePkceFlow({ apiUrl, profileName, credentialStore, timeout });
145
+ return {
146
+ method: 'pkce',
147
+ accessToken: result.accessToken,
148
+ refreshToken: result.refreshToken,
149
+ expiresAt: result.expiresAt,
150
+ };
151
+ }
152
+ catch (err) {
153
+ // Only fall back on server bind errors — not on CSRF or token exchange errors
154
+ if (!isServerBindError(err)) {
155
+ throw err;
156
+ }
157
+ // Fall through to device flow below
158
+ }
159
+ }
160
+ // device flow (either detected or PKCE fallback)
161
+ const result = await deviceFlow.executeDeviceFlow({ apiUrl, profileName, credentialStore });
162
+ return {
163
+ method: 'device',
164
+ accessToken: result.accessToken,
165
+ refreshToken: result.refreshToken,
166
+ expiresAt: result.expiresAt,
167
+ };
168
+ }
169
+
170
+ exports.canBindLocalhost = canBindLocalhost;
171
+ exports.detectAuthMethod = detectAuthMethod;
172
+ exports.executeAutoLogin = executeAutoLogin;
173
+ //# sourceMappingURL=detect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect.js","sources":["../../../../src/auth/detect.ts"],"sourcesContent":[null],"names":["http","apiKey","validateApiKey","isCI","isTTY","authenticateWithApiKey","executePkceFlow","executeDeviceFlow"],"mappings":"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"}
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ var env = require('../config/env.js');
4
+ var keyring = require('./keyring.js');
5
+
6
+ /**
7
+ * device-flow.ts
8
+ *
9
+ * RFC 8628 OAuth 2.0 Device Authorization flow for headless/SSH/container
10
+ * environments where a browser cannot be opened on the same machine.
11
+ *
12
+ * Steps:
13
+ * 1. POST /v1/auth/device/authorize → get device_code + user_code
14
+ * 2. Display verification URI + user_code on stderr
15
+ * 3. Optionally open the browser (best-effort)
16
+ * 4. Poll POST /v1/auth/device/token until granted or expired
17
+ * 5. Store tokens in the credential store
18
+ */
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+ /** Sleep for `ms` milliseconds — real timer in production, override in tests. */
23
+ function sleep(ms) {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
26
+ /**
27
+ * Build an ISO-8601 expiry timestamp from a seconds-from-now value.
28
+ * Exported for test visibility only.
29
+ */
30
+ function expiresAtFromSecondsIn(secondsIn) {
31
+ return new Date(Date.now() + secondsIn * 1000).toISOString();
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // Core function
35
+ // ---------------------------------------------------------------------------
36
+ /**
37
+ * Execute the RFC 8628 device authorization flow.
38
+ *
39
+ * All user-facing output goes to stderr so stdout stays clean for piping.
40
+ *
41
+ * @throws {Error} if authorization fails or the device code expires.
42
+ */
43
+ async function executeDeviceFlow(options = {}) {
44
+ const apiUrl = options.apiUrl ?? env.getApiUrl();
45
+ const profileName = options.profileName ?? 'default';
46
+ const store = options.credentialStore ?? (await keyring.createCredentialStore());
47
+ const openUrl = options.openUrl ??
48
+ (async (url) => {
49
+ const { default: open } = await import('open');
50
+ await open(url);
51
+ });
52
+ // -------------------------------------------------------------------------
53
+ // Step 1: Request device authorization
54
+ // -------------------------------------------------------------------------
55
+ const authorizeRes = await fetch(`${apiUrl}/v1/auth/device/authorize`, {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify({ client_id: 'doow-cli' }),
59
+ });
60
+ if (!authorizeRes.ok) {
61
+ const body = await authorizeRes.text().catch(() => '');
62
+ throw new Error(`Device authorization request failed: HTTP ${authorizeRes.status}${body ? ` — ${body}` : ''}`);
63
+ }
64
+ const auth = (await authorizeRes.json());
65
+ // -------------------------------------------------------------------------
66
+ // Step 2: Display instructions on stderr
67
+ // -------------------------------------------------------------------------
68
+ process.stderr.write(`\nTo sign in, open this URL in any browser:\n ${auth.verification_uri}\n\nThen enter code: ${auth.user_code}\n\n`);
69
+ // Step 2b: Best-effort browser open — never throw on failure
70
+ if (env.shouldShowUI()) {
71
+ try {
72
+ await openUrl(auth.verification_uri);
73
+ }
74
+ catch {
75
+ // Silently ignore — user can open manually
76
+ }
77
+ }
78
+ // -------------------------------------------------------------------------
79
+ // Step 3: Poll for token
80
+ // -------------------------------------------------------------------------
81
+ let pollInterval = auth.interval; // seconds; RFC 8628 §3.5
82
+ while (true) {
83
+ await sleep(pollInterval * 1000);
84
+ const tokenRes = await fetch(`${apiUrl}/v1/auth/device/token`, {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: JSON.stringify({
88
+ device_code: auth.device_code,
89
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
90
+ }),
91
+ });
92
+ if (tokenRes.ok) {
93
+ // Success — stop polling
94
+ const tokens = (await tokenRes.json());
95
+ // -----------------------------------------------------------------------
96
+ // Step 4: Store tokens
97
+ // -----------------------------------------------------------------------
98
+ const expiresAt = expiresAtFromSecondsIn(tokens.expires_in);
99
+ await store.set(profileName, {
100
+ accessToken: tokens.access_token,
101
+ refreshToken: tokens.refresh_token,
102
+ expiresAt,
103
+ });
104
+ // -----------------------------------------------------------------------
105
+ // Step 5: Return result
106
+ // -----------------------------------------------------------------------
107
+ return {
108
+ accessToken: tokens.access_token,
109
+ refreshToken: tokens.refresh_token,
110
+ expiresAt,
111
+ };
112
+ }
113
+ // Non-200 — parse RFC 8628 error body
114
+ const errBody = (await tokenRes.json().catch(() => ({ error: 'unknown' })));
115
+ switch (errBody.error) {
116
+ case 'authorization_pending':
117
+ // User hasn't approved yet — keep polling at current interval
118
+ continue;
119
+ case 'slow_down':
120
+ // RFC 8628 §3.5: increase interval by 5 seconds
121
+ pollInterval += 5;
122
+ continue;
123
+ case 'expired_token':
124
+ throw new Error('Device code expired. Run doow login --device again.');
125
+ case 'access_denied':
126
+ throw new Error('Authorization denied by user.');
127
+ default:
128
+ throw new Error(`Token polling failed: ${errBody.error}${errBody.error_description ? ` — ${errBody.error_description}` : ''}`);
129
+ }
130
+ }
131
+ }
132
+
133
+ exports.executeDeviceFlow = executeDeviceFlow;
134
+ exports.expiresAtFromSecondsIn = expiresAtFromSecondsIn;
135
+ //# sourceMappingURL=device-flow.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-flow.js","sources":["../../../../src/auth/device-flow.ts"],"sourcesContent":[null],"names":["getApiUrl","createCredentialStore","shouldShowUI"],"mappings":"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"}