@commonlyai/cli 0.1.26 → 0.1.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -6,8 +6,15 @@
6
6
  */
7
7
 
8
8
  import { createInterface } from 'readline';
9
- import { login as apiLogin } from '../lib/api.js';
9
+ import { hostname } from 'os';
10
+ import { createClient, login as apiLogin } from '../lib/api.js';
10
11
  import { saveInstance } from '../lib/config.js';
12
+ import {
13
+ DeviceLoginCancelledError,
14
+ DeviceLoginDeniedError,
15
+ DeviceLoginExpiredError,
16
+ waitForDeviceAuthorization,
17
+ } from '../lib/device-login.js';
11
18
 
12
19
  const prompt = (rl, question) => new Promise((resolve) => rl.question(question, resolve));
13
20
 
@@ -41,6 +48,7 @@ export const registerLogin = (program) => {
41
48
  .description('Authenticate to a Commonly instance')
42
49
  .option('--instance <url>', 'Instance URL (default: https://api.commonly.me)')
43
50
  .option('--key <name>', 'Config key to save as (default: "default" or "local")')
51
+ .option('--password', 'Use the legacy email/password prompt instead of device authorization')
44
52
  .addHelpText('after', `
45
53
  Examples:
46
54
  $ commonly login # production (default key)
@@ -58,26 +66,64 @@ Tokens are stored in ~/.commonly/config.json. Other commands take
58
66
  const isLocal = instanceUrl.includes('localhost') || instanceUrl.includes('127.0.0.1');
59
67
  const configKey = opts.key || (isLocal ? 'local' : 'default');
60
68
 
61
- console.log(`Logging in to ${instanceUrl}`);
62
-
63
- const rl = createInterface({ input: process.stdin, output: process.stdout });
64
- const email = await prompt(rl, 'Email: ');
65
- rl.close();
66
-
67
- const password = await promptSecret('Password: ');
68
-
69
69
  try {
70
+ if (!opts.password) {
71
+ const client = createClient({ instance: instanceUrl, token: null });
72
+ const started = await client.post('/api/auth/device/start', {
73
+ clientName: 'commonly-cli',
74
+ clientVersion: program.version(),
75
+ hostname: hostname(),
76
+ });
77
+ const minutes = Math.ceil(started.expiresIn / 60);
78
+ console.log(`Logging in to ${instanceUrl} as a new device.\n`);
79
+ console.log(` Open ${started.verifyUrl}`);
80
+ console.log(` Code ${started.userCode}`);
81
+ console.log(`\nWaiting for approval… (expires in ${minutes}:00) press o to open the browser, q to cancel`);
82
+ const data = await waitForDeviceAuthorization({
83
+ client,
84
+ deviceCode: started.deviceCode,
85
+ userCode: started.userCode,
86
+ verifyUrl: started.verifyUrl,
87
+ interval: started.interval,
88
+ expiresIn: started.expiresIn,
89
+ onStatus: (message) => console.log(message),
90
+ });
91
+
92
+ saveInstance({
93
+ key: configKey,
94
+ url: instanceUrl,
95
+ token: data.token,
96
+ userId: data.userId,
97
+ username: data.username,
98
+ tokenType: 'device',
99
+ });
100
+ const devicesUrl = new URL('/settings/devices', started.verifyUrl).toString();
101
+ console.log(`\n✓ Authorized as @${data.username} on ${configKey} (${instanceUrl})`);
102
+ console.log(` Token saved to ~/.commonly/config.json · manage devices at ${devicesUrl}`);
103
+ return;
104
+ }
105
+
106
+ console.log(`Logging in to ${instanceUrl}`);
107
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
108
+ const email = await prompt(rl, 'Email: ');
109
+ rl.close();
110
+ const password = await promptSecret('Password: ');
70
111
  const data = await apiLogin(instanceUrl, email.trim(), password);
71
112
  const token = data.token;
72
113
  const userId = data.user?._id || data.user?.id;
73
114
  const username = data.user?.username;
74
115
 
75
- saveInstance({ key: configKey, url: instanceUrl, token, userId, username });
116
+ saveInstance({ key: configKey, url: instanceUrl, token, userId, username, tokenType: 'jwt' });
76
117
 
77
118
  console.log(`\nLogged in as ${username} (${configKey})`);
78
119
  console.log(`Token saved to ~/.commonly/config.json`);
79
120
  } catch (err) {
80
- console.error(`Login failed: ${err.message}`);
121
+ const message = err instanceof DeviceLoginExpiredError
122
+ ? `Code expired after 10 minutes. Run commonly login --instance ${configKey} for a new code.`
123
+ : err instanceof DeviceLoginCancelledError || err instanceof DeviceLoginDeniedError
124
+ ? err.message
125
+ : `Login failed: ${err.message}`;
126
+ console.error(message);
81
127
  process.exit(1);
82
128
  }
83
129
  });
@@ -97,9 +143,29 @@ export const registerWhoami = (program) => {
97
143
  return;
98
144
  }
99
145
 
100
- instances.forEach(({ key, url, username, active, savedAt }) => {
146
+ instances.forEach(({ key, url, username, active, token, tokenType }) => {
101
147
  const marker = active ? '→' : ' ';
102
- console.log(`${marker} ${key} ${username || '?'}@${url} (saved ${new Date(savedAt).toLocaleDateString()})`);
148
+ console.log(`${marker} ${key} ${username || '?'}@${url} (${formatTokenStatus(token, tokenType, Date.now(), key)})`);
103
149
  });
104
150
  });
105
151
  };
152
+
153
+ const decodeJwtExpiry = (token) => {
154
+ if (typeof token !== 'string' || token.split('.').length !== 3) return null;
155
+ try {
156
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
157
+ return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ };
162
+
163
+ export const formatTokenStatus = (token, tokenType, now = Date.now(), instanceKey = 'default') => {
164
+ if (tokenType === 'device' || String(token || '').startsWith('cm_')) return 'device token · no expiry';
165
+ const expiresAt = decodeJwtExpiry(token);
166
+ if (!expiresAt) return 'session token · expiry unknown';
167
+ const diff = expiresAt - now;
168
+ if (diff <= 0) return `expired — commonly login --instance ${instanceKey}`;
169
+ const hours = Math.max(1, Math.ceil(diff / (60 * 60 * 1000)));
170
+ return `expires in ${hours}h`;
171
+ };
package/src/lib/api.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * unless overridden. This is the only place that makes HTTP calls.
6
6
  */
7
7
 
8
- import { resolveInstanceUrl, getToken } from './config.js';
8
+ import { resolveInstanceUrl, getToken, resolveInstance } from './config.js';
9
9
 
10
10
  const headers = (token, extra = {}) => ({
11
11
  'Content-Type': 'application/json',
@@ -13,12 +13,21 @@ const headers = (token, extra = {}) => ({
13
13
  ...extra,
14
14
  });
15
15
 
16
- const handleResponse = async (res) => {
16
+ const knownSessionFailure = (body) => [body?.msg, body?.error, body?.message]
17
+ .some((value) => ['Token is not valid', 'Invalid API token', 'Account no longer exists'].includes(value));
18
+
19
+ export const sessionExpiredMessage = ({ instanceKey, baseUrl }) => (
20
+ `Session for ${instanceKey} (${baseUrl}) has expired.\nRun: commonly login --instance ${instanceKey}`
21
+ );
22
+
23
+ const handleResponse = async (res, session = null) => {
17
24
  const text = await res.text();
18
25
  let body;
19
26
  try { body = JSON.parse(text); } catch { body = { message: text }; }
20
27
  if (!res.ok) {
21
- const msg = body?.error || body?.message || `HTTP ${res.status}`;
28
+ const msg = res.status === 401 && session && knownSessionFailure(body)
29
+ ? sessionExpiredMessage(session)
30
+ : body?.error || body?.message || body?.msg || `HTTP ${res.status}`;
22
31
  const err = new Error(msg);
23
32
  err.status = res.status;
24
33
  err.body = body;
@@ -27,32 +36,37 @@ const handleResponse = async (res) => {
27
36
  return body;
28
37
  };
29
38
 
30
- export const createClient = ({ instance = null, token = null } = {}) => {
39
+ export const createClient = ({ instance = null, token = undefined } = {}) => {
31
40
  const baseUrl = resolveInstanceUrl(instance);
32
- const authToken = token || getToken(instance);
41
+ const resolved = resolveInstance(instance);
42
+ const authToken = token === undefined ? getToken(instance) : token;
43
+ const session = {
44
+ instanceKey: resolved?.key || (typeof instance === 'string' && instance && !/^https?:\/\//i.test(instance) ? instance : 'default'),
45
+ baseUrl,
46
+ };
33
47
 
34
48
  const get = (path, params = {}) => {
35
49
  const url = new URL(`${baseUrl}${path}`);
36
50
  Object.entries(params).forEach(([k, v]) => v != null && url.searchParams.set(k, v));
37
- return fetch(url.toString(), { headers: headers(authToken) }).then(handleResponse);
51
+ return fetch(url.toString(), { headers: headers(authToken) }).then((res) => handleResponse(res, session));
38
52
  };
39
53
 
40
54
  const post = (path, body = {}) => fetch(`${baseUrl}${path}`, {
41
55
  method: 'POST',
42
56
  headers: headers(authToken),
43
57
  body: JSON.stringify(body),
44
- }).then(handleResponse);
58
+ }).then((res) => handleResponse(res, session));
45
59
 
46
60
  const patch = (path, body = {}) => fetch(`${baseUrl}${path}`, {
47
61
  method: 'PATCH',
48
62
  headers: headers(authToken),
49
63
  body: JSON.stringify(body),
50
- }).then(handleResponse);
64
+ }).then((res) => handleResponse(res, session));
51
65
 
52
66
  const del = (path) => fetch(`${baseUrl}${path}`, {
53
67
  method: 'DELETE',
54
68
  headers: headers(authToken),
55
- }).then(handleResponse);
69
+ }).then((res) => handleResponse(res, session));
56
70
 
57
71
  // Multipart upload via native FormData/Blob (Node 18+) — no runtime deps.
58
72
  // Content-Type is deliberately NOT set: fetch writes the multipart boundary.
@@ -72,7 +86,7 @@ export const createClient = ({ instance = null, token = null } = {}) => {
72
86
  method: 'POST',
73
87
  headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
74
88
  body: form,
75
- }).then(handleResponse);
89
+ }).then((res) => handleResponse(res, session));
76
90
  };
77
91
 
78
92
  return {
package/src/lib/config.js CHANGED
@@ -107,13 +107,16 @@ export const resolveInstanceUrl = (instanceArg = null) => {
107
107
  return DEFAULT_INSTANCE_URL;
108
108
  };
109
109
 
110
- export const saveInstance = ({ key = 'default', url, token, userId, username }) => {
110
+ export const saveInstance = ({ key = 'default', url, token, userId, username, tokenType = null }) => {
111
111
  const config = read();
112
112
  config.instances[key] = {
113
113
  url: url.replace(/\/$/, ''),
114
114
  token,
115
115
  userId,
116
116
  username,
117
+ // Device bearers are long-lived until revoked. Preserve the explicit kind
118
+ // so `whoami` can say that without guessing from a future token format.
119
+ ...(tokenType ? { tokenType } : {}),
117
120
  savedAt: new Date().toISOString(),
118
121
  };
119
122
  config.active = key;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * RFC 8628-shaped device-code interaction for `commonly login`.
3
+ *
4
+ * The browser owns password/OAuth; the terminal only ever receives the
5
+ * resulting per-device bearer once from /device/poll. This module keeps the
6
+ * timing and terminal-key behaviour testable without a live TTY.
7
+ */
8
+ import { execFile as nodeExecFile } from 'child_process';
9
+ import { platform } from 'os';
10
+ import { emitKeypressEvents } from 'readline';
11
+
12
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
+
14
+ export class DeviceLoginCancelledError extends Error {
15
+ constructor() {
16
+ super('Login cancelled.');
17
+ this.name = 'DeviceLoginCancelledError';
18
+ }
19
+ }
20
+
21
+ export class DeviceLoginDeniedError extends Error {
22
+ constructor() {
23
+ super('Denied in the browser. Nothing was saved.');
24
+ this.name = 'DeviceLoginDeniedError';
25
+ }
26
+ }
27
+
28
+ export class DeviceLoginExpiredError extends Error {
29
+ constructor() {
30
+ super('Device authorization code expired.');
31
+ this.name = 'DeviceLoginExpiredError';
32
+ }
33
+ }
34
+
35
+ const safeBrowserUrl = (value) => {
36
+ const url = new URL(value);
37
+ if (!['http:', 'https:'].includes(url.protocol)) {
38
+ throw new Error('Device authorization URL must use HTTP or HTTPS.');
39
+ }
40
+ return url.toString();
41
+ };
42
+
43
+ export const openBrowser = (url, execFile = nodeExecFile, currentPlatform = platform()) => {
44
+ const browserUrl = safeBrowserUrl(url);
45
+ // `cmd /c start <url>` routes a server-supplied URL through a shell. Use a
46
+ // direct executable on Windows just as we do on macOS/Linux, so `o` cannot
47
+ // turn a malicious verifyUrl into a second command.
48
+ const command = currentPlatform === 'darwin' ? 'open' : currentPlatform === 'win32' ? 'rundll32' : 'xdg-open';
49
+ const args = currentPlatform === 'win32'
50
+ ? ['url.dll,FileProtocolHandler', browserUrl]
51
+ : [browserUrl];
52
+ return new Promise((resolve) => {
53
+ execFile(command, args, () => resolve());
54
+ });
55
+ };
56
+
57
+ export const waitForDeviceAuthorization = async ({
58
+ client,
59
+ deviceCode,
60
+ userCode,
61
+ verifyUrl,
62
+ interval = 5,
63
+ expiresIn = 600,
64
+ stdin = process.stdin,
65
+ wait = sleep,
66
+ onOpen = openBrowser,
67
+ onStatus = () => undefined,
68
+ now = () => Date.now(),
69
+ }) => {
70
+ const authorizeUrl = `${verifyUrl}?code=${encodeURIComponent(userCode)}`;
71
+ let cancelled = false;
72
+ let signalCancellation = () => {};
73
+ const cancellation = new Promise((resolve) => {
74
+ signalCancellation = resolve;
75
+ });
76
+ let currentInterval = Math.max(Number(interval) || 5, 1);
77
+ const deadline = now() + Math.max(Number(expiresIn) || 600, 1) * 1000;
78
+ const isTty = Boolean(stdin?.isTTY && typeof stdin.setRawMode === 'function');
79
+
80
+ const onKeypress = (value, key = {}) => {
81
+ if (key?.ctrl && key.name === 'c') {
82
+ cancelled = true;
83
+ signalCancellation();
84
+ }
85
+ if (value === 'q') {
86
+ cancelled = true;
87
+ signalCancellation();
88
+ }
89
+ if (value === 'o') void onOpen(authorizeUrl);
90
+ };
91
+
92
+ if (stdin?.on) {
93
+ emitKeypressEvents(stdin);
94
+ if (isTty) stdin.setRawMode(true);
95
+ stdin.on('keypress', onKeypress);
96
+ }
97
+
98
+ try {
99
+ while (now() < deadline) {
100
+ if (cancelled) throw new DeviceLoginCancelledError();
101
+ let result;
102
+ try {
103
+ result = await client.post('/api/auth/device/poll', { deviceCode });
104
+ } catch {
105
+ throw new Error('Unable to complete device authorization. Try again.');
106
+ }
107
+ if (result?.status === 'authorized' && result.token) return result;
108
+ if (result?.status === 'denied') throw new DeviceLoginDeniedError();
109
+ if (result?.status === 'expired') throw new DeviceLoginExpiredError();
110
+ if (result?.status === 'slow_down') {
111
+ currentInterval *= 2;
112
+ onStatus('Waiting for browser approval (slowing down)…');
113
+ } else if (result?.status !== 'authorization_pending') {
114
+ throw new DeviceLoginExpiredError();
115
+ }
116
+ await Promise.race([
117
+ wait(currentInterval * 1000),
118
+ cancellation,
119
+ ]);
120
+ }
121
+ throw new DeviceLoginExpiredError();
122
+ } finally {
123
+ if (stdin?.removeListener) stdin.removeListener('keypress', onKeypress);
124
+ if (isTty) stdin.setRawMode(false);
125
+ }
126
+ };