@commonlyai/cli 0.1.25 → 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 +1 -1
- package/src/commands/agent.js +5 -1
- package/src/commands/login.js +79 -13
- package/src/lib/api.js +24 -10
- package/src/lib/config.js +4 -1
- package/src/lib/device-login.js +126 -0
- package/src/lib/poller.js +2 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -1344,7 +1344,11 @@ export const performRun = ({
|
|
|
1344
1344
|
recordHandledEvent(agentName, event._id);
|
|
1345
1345
|
}
|
|
1346
1346
|
try {
|
|
1347
|
-
|
|
1347
|
+
const deliveryId = event.payload?.deliveryId;
|
|
1348
|
+
await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
|
|
1349
|
+
result,
|
|
1350
|
+
...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
|
|
1351
|
+
});
|
|
1348
1352
|
} catch (ackErr) {
|
|
1349
1353
|
onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`));
|
|
1350
1354
|
}
|
package/src/commands/login.js
CHANGED
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { createInterface } from 'readline';
|
|
9
|
-
import {
|
|
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
|
-
|
|
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,
|
|
146
|
+
instances.forEach(({ key, url, username, active, token, tokenType }) => {
|
|
101
147
|
const marker = active ? '→' : ' ';
|
|
102
|
-
console.log(`${marker} ${key} ${username || '?'}@${url} (
|
|
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
|
|
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 =
|
|
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 =
|
|
39
|
+
export const createClient = ({ instance = null, token = undefined } = {}) => {
|
|
31
40
|
const baseUrl = resolveInstanceUrl(instance);
|
|
32
|
-
const
|
|
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
|
+
};
|
package/src/lib/poller.js
CHANGED
|
@@ -47,8 +47,10 @@ export const startPoller = ({
|
|
|
47
47
|
|
|
48
48
|
// Acknowledge the event
|
|
49
49
|
try {
|
|
50
|
+
const deliveryId = event.payload?.deliveryId;
|
|
50
51
|
await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
|
|
51
52
|
result,
|
|
53
|
+
...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
|
|
52
54
|
});
|
|
53
55
|
} catch (ackErr) {
|
|
54
56
|
// Non-fatal — event will be retried
|