@doow/cli 0.1.2 → 0.1.5

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/dist/cli.cjs CHANGED
@@ -231,7 +231,7 @@ function getApiUrl(profile) {
231
231
  return profile.apiUrl;
232
232
  if (process.env['DOOW_API_URL'])
233
233
  return process.env['DOOW_API_URL'];
234
- return 'https://api.doow.com';
234
+ return 'https://dev-api.doow.co';
235
235
  }
236
236
 
237
237
  // ---------------------------------------------------------------------------
@@ -287,26 +287,55 @@ function createFileStore() {
287
287
  // Keyring-backed store
288
288
  // ---------------------------------------------------------------------------
289
289
  function createKeyringStore(keytar) {
290
+ const fileStore = createFileStore();
291
+ let currentBackend = 'keyring';
290
292
  return {
291
293
  async get(profileName) {
292
294
  try {
293
295
  const raw = await withTimeout(keytar.getPassword(SERVICE_NAME, profileName), OP_TIMEOUT_MS);
294
- if (raw == null)
295
- return undefined;
296
- return JSON.parse(raw);
296
+ if (raw != null) {
297
+ currentBackend = 'keyring';
298
+ return JSON.parse(raw);
299
+ }
300
+ currentBackend = 'keyring';
297
301
  }
298
302
  catch {
299
- return undefined;
303
+ printFallbackWarning();
304
+ currentBackend = 'file';
300
305
  }
306
+ const fallback = await fileStore.get(profileName);
307
+ if (fallback !== undefined) {
308
+ currentBackend = 'file';
309
+ return fallback;
310
+ }
311
+ return undefined;
301
312
  },
302
313
  async set(profileName, creds) {
303
- await withTimeout(keytar.setPassword(SERVICE_NAME, profileName, JSON.stringify(creds)), OP_TIMEOUT_MS);
314
+ try {
315
+ await withTimeout(keytar.setPassword(SERVICE_NAME, profileName, JSON.stringify(creds)), OP_TIMEOUT_MS);
316
+ currentBackend = 'keyring';
317
+ // Remove stale fallback data once the keyring becomes the source of truth.
318
+ await fileStore.clear(profileName);
319
+ }
320
+ catch {
321
+ printFallbackWarning();
322
+ currentBackend = 'file';
323
+ await fileStore.set(profileName, creds);
324
+ }
304
325
  },
305
326
  async clear(profileName) {
306
- await withTimeout(keytar.deletePassword(SERVICE_NAME, profileName), OP_TIMEOUT_MS);
327
+ try {
328
+ await withTimeout(keytar.deletePassword(SERVICE_NAME, profileName), OP_TIMEOUT_MS);
329
+ currentBackend = 'keyring';
330
+ }
331
+ catch {
332
+ printFallbackWarning();
333
+ currentBackend = 'file';
334
+ }
335
+ await fileStore.clear(profileName);
307
336
  },
308
337
  backend() {
309
- return 'keyring';
338
+ return currentBackend;
310
339
  },
311
340
  };
312
341
  }
@@ -325,7 +354,6 @@ async function createCredentialStore() {
325
354
  let keytar;
326
355
  try {
327
356
  // Dynamic import so missing keytar never crashes the module at load time.
328
- // @ts-expect-error — keytar is an optional peer dep and may not be installed.
329
357
  const mod = await import('keytar');
330
358
  // Support both default-export and named-export module shapes.
331
359
  keytar = (mod.default ?? mod);
@@ -469,7 +497,8 @@ async function resolveAuth(options = {}) {
469
497
  }
470
498
  if (creds?.accessToken) {
471
499
  const token = creds.accessToken;
472
- const needsRefresh = isTokenExpiredOrExpiring(creds.expiresAt);
500
+ const canRefresh = typeof creds.refreshToken === 'string' && creds.refreshToken.trim().length > 0;
501
+ const needsRefresh = canRefresh ? isTokenExpiredOrExpiring(creds.expiresAt) : false;
473
502
  return {
474
503
  type: 'oauth-token',
475
504
  token,
@@ -626,13 +655,12 @@ function startCallbackServer(expectedState, timeoutMs) {
626
655
  // ---------------------------------------------------------------------------
627
656
  // Token exchange
628
657
  // ---------------------------------------------------------------------------
629
- async function exchangeCodeForTokens(apiUrl, code, codeVerifier, state) {
658
+ async function exchangeCodeForTokens(apiUrl, codeVerifier, state) {
630
659
  const response = await fetch(`${apiUrl}/v1/auth/cli/token`, {
631
660
  method: 'POST',
632
661
  headers: { 'Content-Type': 'application/json' },
633
662
  body: JSON.stringify({
634
663
  grant_type: 'authorization_code',
635
- code,
636
664
  code_verifier: codeVerifier,
637
665
  state,
638
666
  }),
@@ -643,6 +671,31 @@ async function exchangeCodeForTokens(apiUrl, code, codeVerifier, state) {
643
671
  }
644
672
  return response.json();
645
673
  }
674
+ async function requestAuthorizationUrl(apiUrl, codeChallenge, state, redirectUri) {
675
+ const response = await fetch(`${apiUrl}/v1/auth/cli/authorize`, {
676
+ method: 'POST',
677
+ headers: { 'Content-Type': 'application/json' },
678
+ body: JSON.stringify({
679
+ client_id: 'doow-cli',
680
+ code_challenge: codeChallenge,
681
+ code_challenge_method: 'S256',
682
+ redirect_uri: redirectUri,
683
+ state,
684
+ }),
685
+ });
686
+ if (!response.ok) {
687
+ const body = await response.text().catch(() => '(no body)');
688
+ throw new Error(`Authorize request failed: HTTP ${response.status} — ${body}`);
689
+ }
690
+ const payload = (await response.json());
691
+ if (payload.state !== state) {
692
+ throw new Error('Authorize response state mismatch — possible server bug. Try again.');
693
+ }
694
+ if (!payload.authorization_url) {
695
+ throw new Error('Authorize response did not include authorization_url.');
696
+ }
697
+ return payload.authorization_url;
698
+ }
646
699
  // ---------------------------------------------------------------------------
647
700
  // Main flow
648
701
  // ---------------------------------------------------------------------------
@@ -666,14 +719,9 @@ async function executePkceFlow(options = {}) {
666
719
  // Step 3: Start callback server
667
720
  const { server, port, callbackPromise } = await startCallbackServer(state, timeout);
668
721
  try {
669
- // Step 4: Build authorize URL
722
+ // Step 4: Request an authorization URL from the API
670
723
  const redirectUri = `http://127.0.0.1:${port}/callback`;
671
- const authorizeUrl = `${apiUrl}/v1/auth/cli/authorize` +
672
- `?response_type=code` +
673
- `&code_challenge=${codeChallenge}` +
674
- `&code_challenge_method=S256` +
675
- `&state=${state}` +
676
- `&redirect_uri=${encodeURIComponent(redirectUri)}`;
724
+ const authorizeUrl = await requestAuthorizationUrl(apiUrl, codeChallenge, state, redirectUri);
677
725
  // Step 5: Open browser
678
726
  try {
679
727
  const { default: open } = await import('open');
@@ -684,9 +732,9 @@ async function executePkceFlow(options = {}) {
684
732
  throw new Error(`Failed to open browser: ${msg}. Try doow login --device for headless environments.`);
685
733
  }
686
734
  // Step 6: Wait for callback
687
- const { code } = await callbackPromise;
735
+ await callbackPromise;
688
736
  // Step 7: Exchange code for tokens
689
- const tokenResponse = await exchangeCodeForTokens(apiUrl, code, codeVerifier, state);
737
+ const tokenResponse = await exchangeCodeForTokens(apiUrl, codeVerifier, state);
690
738
  // Step 8: Store tokens
691
739
  const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
692
740
  const result = {
@@ -714,7 +762,7 @@ async function executePkceFlow(options = {}) {
714
762
  * environments where a browser cannot be opened on the same machine.
715
763
  *
716
764
  * Steps:
717
- * 1. POST /v1/auth/device/authorize → get device_code + user_code
765
+ * 1. POST /v1/auth/cli/device → get device_code + user_code
718
766
  * 2. Display verification URI + user_code on stderr
719
767
  * 3. Optionally open the browser (best-effort)
720
768
  * 4. Poll POST /v1/auth/device/token until granted or expired
@@ -734,6 +782,15 @@ function sleep$1(ms) {
734
782
  function expiresAtFromSecondsIn(secondsIn) {
735
783
  return new Date(Date.now() + secondsIn * 1000).toISOString();
736
784
  }
785
+ function isLegacyPendingPollResponse(status, body) {
786
+ const legacyBody = body;
787
+ return (status === 400 &&
788
+ body != null &&
789
+ typeof body === 'object' &&
790
+ legacyBody.name === 'HttpException' &&
791
+ legacyBody.message === 'Bad Request Exception' &&
792
+ legacyBody.response === 'Bad Request Exception');
793
+ }
737
794
  // ---------------------------------------------------------------------------
738
795
  // Core function
739
796
  // ---------------------------------------------------------------------------
@@ -756,7 +813,7 @@ async function executeDeviceFlow(options = {}) {
756
813
  // -------------------------------------------------------------------------
757
814
  // Step 1: Request device authorization
758
815
  // -------------------------------------------------------------------------
759
- const authorizeRes = await fetch(`${apiUrl}/v1/auth/device/authorize`, {
816
+ const authorizeRes = await fetch(`${apiUrl}/v1/auth/cli/device`, {
760
817
  method: 'POST',
761
818
  headers: { 'Content-Type': 'application/json' },
762
819
  body: JSON.stringify({ client_id: 'doow-cli' }),
@@ -766,6 +823,7 @@ async function executeDeviceFlow(options = {}) {
766
823
  throw new Error(`Device authorization request failed: HTTP ${authorizeRes.status}${body ? ` — ${body}` : ''}`);
767
824
  }
768
825
  const auth = (await authorizeRes.json());
826
+ const deviceCodeExpiresAt = Date.now() + auth.expires_in * 1000;
769
827
  // -------------------------------------------------------------------------
770
828
  // Step 2: Display instructions on stderr
771
829
  // -------------------------------------------------------------------------
@@ -785,6 +843,9 @@ async function executeDeviceFlow(options = {}) {
785
843
  let pollInterval = auth.interval; // seconds; RFC 8628 §3.5
786
844
  while (true) {
787
845
  await sleep$1(pollInterval * 1000);
846
+ if (Date.now() > deviceCodeExpiresAt) {
847
+ throw new Error('Device code expired. Run doow login --device again.');
848
+ }
788
849
  const tokenRes = await fetch(`${apiUrl}/v1/auth/device/token`, {
789
850
  method: 'POST',
790
851
  headers: { 'Content-Type': 'application/json' },
@@ -829,6 +890,9 @@ async function executeDeviceFlow(options = {}) {
829
890
  case 'access_denied':
830
891
  throw new Error('Authorization denied by user.');
831
892
  default:
893
+ if (isLegacyPendingPollResponse(tokenRes.status, errBody)) {
894
+ continue;
895
+ }
832
896
  throw new Error(`Token polling failed: ${errBody.error}${errBody.error_description ? ` — ${errBody.error_description}` : ''}`);
833
897
  }
834
898
  }
@@ -9137,29 +9201,45 @@ async function checkNetwork(apiUrl) {
9137
9201
  }
9138
9202
  }
9139
9203
  async function checkApiVersion(apiUrl) {
9204
+ const healthPaths = ['/health', '/v1/health'];
9140
9205
  try {
9141
9206
  const controller = new AbortController();
9142
9207
  const timeoutId = setTimeout(() => controller.abort(), 5000);
9143
- const res = await fetch(`${apiUrl}/v1/health`, { signal: controller.signal });
9144
- clearTimeout(timeoutId);
9145
- if (!res.ok) {
9146
- return { name: 'api_version', status: 'warn', detail: `HTTP ${res.status} from /v1/health` };
9147
- }
9148
- let version;
9149
- try {
9150
- const body = await res.json();
9151
- if (typeof body['version'] === 'string')
9152
- version = body['version'];
9153
- else if (typeof body['server_version'] === 'string')
9154
- version = body['server_version'];
9155
- }
9156
- catch {
9157
- // non-JSON body is acceptable
9208
+ let lastPath = healthPaths[0];
9209
+ let lastStatus;
9210
+ for (const path of healthPaths) {
9211
+ lastPath = path;
9212
+ const res = await fetch(`${apiUrl}${path}`, { signal: controller.signal });
9213
+ if (res.ok) {
9214
+ clearTimeout(timeoutId);
9215
+ let version;
9216
+ try {
9217
+ const body = await res.json();
9218
+ if (typeof body['version'] === 'string')
9219
+ version = body['version'];
9220
+ else if (typeof body['server_version'] === 'string')
9221
+ version = body['server_version'];
9222
+ }
9223
+ catch {
9224
+ // non-JSON body is acceptable
9225
+ }
9226
+ return {
9227
+ name: 'api_version',
9228
+ status: 'pass',
9229
+ detail: version ? `server ${version}` : 'reachable (no version in response)',
9230
+ };
9231
+ }
9232
+ lastStatus = res.status;
9233
+ if (res.status !== 404 && res.status !== 410) {
9234
+ clearTimeout(timeoutId);
9235
+ return { name: 'api_version', status: 'warn', detail: `HTTP ${res.status} from ${path}` };
9236
+ }
9158
9237
  }
9238
+ clearTimeout(timeoutId);
9159
9239
  return {
9160
9240
  name: 'api_version',
9161
- status: 'pass',
9162
- detail: version ? `server ${version}` : 'reachable (no version in response)',
9241
+ status: 'warn',
9242
+ detail: `HTTP ${lastStatus ?? 404} from ${lastPath}`,
9163
9243
  };
9164
9244
  }
9165
9245
  catch (err) {
@@ -33863,7 +33943,7 @@ async function startStdioServer(options) {
33863
33943
  await server.connect(transport);
33864
33944
  }
33865
33945
 
33866
- const VERSION = "0.1.2" ;
33946
+ const VERSION = "0.1.5" ;
33867
33947
  const ASCII_LOGO = `
33868
33948
  _
33869
33949
  __| | ___ ___ __ __
@@ -33880,6 +33960,10 @@ function printLogo() {
33880
33960
  process.stdout.write(' Not logged in.\n\n');
33881
33961
  process.stdout.write(' Run \x1b[36mdoow login\x1b[0m to get started.\n\n');
33882
33962
  }
33963
+ function readString(data, key) {
33964
+ const value = data[key];
33965
+ return typeof value === 'string' ? value : undefined;
33966
+ }
33883
33967
  // ---------------------------------------------------------------------------
33884
33968
  // loginHandler — exported for testability
33885
33969
  // ---------------------------------------------------------------------------
@@ -33989,14 +34073,28 @@ async function whoamiHandler(globalOpts, credentialStore) {
33989
34073
  process.exit(1);
33990
34074
  }
33991
34075
  const data = (await res.json());
33992
- const email = data['email'] ?? '(unknown)';
33993
- const org = data['organization'] ?? '(unknown)';
34076
+ const email = readString(data, 'email') ?? '(unknown)';
34077
+ const organizationId = readString(data, 'organization_id');
34078
+ const org = readString(data, 'organization') ?? organizationId ?? '(unknown)';
34079
+ const role = readString(data, 'role');
34080
+ const capabilities = Array.isArray(data['capabilities'])
34081
+ ? data['capabilities'].filter((value) => typeof value === 'string')
34082
+ : undefined;
33994
34083
  if (isJson) {
33995
- process.stdout.write(JSON.stringify({ email, organization: org, profile: profileName }) + '\n');
34084
+ process.stdout.write(JSON.stringify({
34085
+ email,
34086
+ organization: org,
34087
+ organizationId,
34088
+ role,
34089
+ capabilities,
34090
+ profile: profileName,
34091
+ }) + '\n');
33996
34092
  }
33997
34093
  else {
33998
34094
  process.stderr.write(`Logged in as ${email}\n`);
33999
34095
  process.stderr.write(`Organization: ${org}\n`);
34096
+ if (role)
34097
+ process.stderr.write(`Role: ${role}\n`);
34000
34098
  process.stderr.write(`Auth: ${resolved.source}\n`);
34001
34099
  process.stderr.write(`Profile: ${profileName}\n`);
34002
34100
  }
@@ -34071,8 +34169,12 @@ program
34071
34169
  .option('--token-stdin', 'Read raw access token from stdin')
34072
34170
  .action(async (opts) => {
34073
34171
  const globalOpts = program.opts();
34172
+ const mergedOpts = {
34173
+ ...opts,
34174
+ apiKey: opts.apiKey ?? globalOpts.apiKey,
34175
+ };
34074
34176
  try {
34075
- await loginHandler(opts, globalOpts);
34177
+ await loginHandler(mergedOpts, globalOpts);
34076
34178
  }
34077
34179
  catch (err) {
34078
34180
  process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);