@mechasm/cli 0.0.0-stage → 0.1.1
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/AGENT_GUIDE.md +15 -0
- package/LICENSE +11 -0
- package/README.md +107 -2
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/bin/mechasm.js +4 -0
- package/licenses/frp-LICENSE +202 -0
- package/package.json +42 -4
- package/src/api-client.js +424 -0
- package/src/cli.js +581 -0
- package/src/diagnostics.js +31 -0
- package/src/frp.js +169 -0
- package/src/local-approvals.js +125 -0
- package/src/notifier.js +35 -0
- package/src/service.js +460 -0
- package/src/update-guard.js +74 -0
- package/src/version.js +5 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { homedir, hostname } from 'node:os';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_API_URL = 'https://api.mechasm.ai';
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
11
|
+
const REFRESH_EARLY_MS = 60_000;
|
|
12
|
+
const MAX_TOKEN_TTL_SECONDS = 24 * 60 * 60;
|
|
13
|
+
|
|
14
|
+
export class CliError extends Error {
|
|
15
|
+
constructor(message, exitCode = 1) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.exitCode = exitCode;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function apiUrl(value = DEFAULT_API_URL) {
|
|
22
|
+
let url;
|
|
23
|
+
try { url = new URL(value); } catch { throw new CliError('Invalid --api-url.', 2); }
|
|
24
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
25
|
+
const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
26
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) ||
|
|
27
|
+
url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== '')) {
|
|
28
|
+
throw new CliError('--api-url must be an HTTPS origin (or HTTP loopback for local development).', 2);
|
|
29
|
+
}
|
|
30
|
+
return new URL(url.origin);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function cliConfigDirectory() {
|
|
34
|
+
const base = process.platform === 'win32'
|
|
35
|
+
? process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
|
|
36
|
+
: process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
37
|
+
return join(base, 'mechasm');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function credentialPath(origin) {
|
|
41
|
+
const key = createHash('sha256').update(origin).digest('hex').slice(0, 20);
|
|
42
|
+
return join(cliConfigDirectory(), `credentials-${key}.json`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function ensureConfigDirectory() {
|
|
46
|
+
const directory = cliConfigDirectory();
|
|
47
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
48
|
+
if (process.platform !== 'win32') await (await import('node:fs/promises')).chmod(directory, 0o700);
|
|
49
|
+
return directory;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readCredentials(path) {
|
|
53
|
+
let handle;
|
|
54
|
+
try {
|
|
55
|
+
const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
|
|
56
|
+
handle = await open(path, flags);
|
|
57
|
+
const info = await handle.stat();
|
|
58
|
+
if (!info.isFile()) throw new CliError('Credential file is not a regular file.');
|
|
59
|
+
if (process.platform !== 'win32' && (info.uid !== process.getuid?.() || (info.mode & 0o077))) {
|
|
60
|
+
throw new CliError('Credential file permissions are unsafe; remove it and sign in again.');
|
|
61
|
+
}
|
|
62
|
+
return JSON.parse(await handle.readFile('utf8'));
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code === 'ENOENT') return null;
|
|
65
|
+
if (error instanceof SyntaxError) throw new CliError('Credential file is invalid; remove it and sign in again.');
|
|
66
|
+
throw error;
|
|
67
|
+
} finally {
|
|
68
|
+
await handle?.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function writeCredentials(path, value) {
|
|
73
|
+
const directory = await ensureConfigDirectory();
|
|
74
|
+
const temporary = join(directory, `.credentials-${process.pid}-${randomBytes(6).toString('hex')}.tmp`);
|
|
75
|
+
let handle;
|
|
76
|
+
try {
|
|
77
|
+
handle = await open(temporary, 'wx', 0o600);
|
|
78
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8');
|
|
79
|
+
await handle.sync();
|
|
80
|
+
await handle.close();
|
|
81
|
+
handle = null;
|
|
82
|
+
await rename(temporary, path);
|
|
83
|
+
} finally {
|
|
84
|
+
await handle?.close();
|
|
85
|
+
await rm(temporary, { force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pidIsAlive(pid) {
|
|
90
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
91
|
+
try { process.kill(pid, 0); return true; }
|
|
92
|
+
catch (error) { return error.code === 'EPERM'; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function withCredentialLock(path, action) {
|
|
96
|
+
await ensureConfigDirectory();
|
|
97
|
+
const lockPath = `${path}.lock`;
|
|
98
|
+
const deadline = Date.now() + 30_000;
|
|
99
|
+
while (true) {
|
|
100
|
+
try {
|
|
101
|
+
const lock = await open(lockPath, 'wx', 0o600);
|
|
102
|
+
try { await lock.writeFile(JSON.stringify({ pid: process.pid, startedAt: Date.now() })); }
|
|
103
|
+
finally { await lock.close(); }
|
|
104
|
+
try { return await action(); }
|
|
105
|
+
finally { await rm(lockPath, { force: true }); }
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code !== 'EEXIST') throw error;
|
|
108
|
+
try {
|
|
109
|
+
const [info, content] = await Promise.all([stat(lockPath), readFile(lockPath, 'utf8')]);
|
|
110
|
+
let pid = 0;
|
|
111
|
+
try { pid = JSON.parse(content).pid; } catch { /* incomplete lock */ }
|
|
112
|
+
if (Date.now() - info.mtimeMs > 30_000 && !pidIsAlive(pid)) {
|
|
113
|
+
await rm(lockPath, { force: true });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
} catch (lockError) {
|
|
117
|
+
if (lockError.code === 'ENOENT') continue;
|
|
118
|
+
throw lockError;
|
|
119
|
+
}
|
|
120
|
+
if (Date.now() >= deadline) throw new CliError('Timed out waiting for another CLI command to refresh credentials.');
|
|
121
|
+
await delay(75);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function responseError(status, body) {
|
|
127
|
+
const message = typeof body?.error?.message === 'string' ? body.error.message : `API request failed (${status}).`;
|
|
128
|
+
const code = status === 401 ? 3 : status === 403 ? 4 : 1;
|
|
129
|
+
const error = new CliError(message.slice(0, 300), code);
|
|
130
|
+
error.apiCode = body?.error?.code ?? body?.code;
|
|
131
|
+
error.retryable = body?.error?.retryable === true || body?.retryable === true;
|
|
132
|
+
return error;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createCliClient(rawApiUrl) {
|
|
136
|
+
const base = apiUrl(rawApiUrl);
|
|
137
|
+
const origin = base.origin;
|
|
138
|
+
const path = credentialPath(origin);
|
|
139
|
+
|
|
140
|
+
async function request(route, { method = 'GET', body, token, responseType = 'json', signal, idempotencyKey, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
|
141
|
+
const headers = { accept: 'application/json' };
|
|
142
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
143
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
144
|
+
if (idempotencyKey) headers['idempotency-key'] = idempotencyKey;
|
|
145
|
+
let response;
|
|
146
|
+
try {
|
|
147
|
+
response = await fetch(new URL(`/cli/v1${route}`, base), {
|
|
148
|
+
method,
|
|
149
|
+
headers,
|
|
150
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
151
|
+
redirect: 'manual',
|
|
152
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
153
|
+
});
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (signal?.aborted) throw Object.assign(new Error('Request cancelled.'), { name: 'AbortError' });
|
|
156
|
+
throw new CliError(`Could not reach ${origin}: ${error.message}`);
|
|
157
|
+
}
|
|
158
|
+
if (response.status >= 300 && response.status < 400) {
|
|
159
|
+
throw new CliError('API redirect refused. Check --api-url.');
|
|
160
|
+
}
|
|
161
|
+
if (response.status === 204) return { status: response.status, data: null, headers: response.headers };
|
|
162
|
+
if (responseType === 'stream') {
|
|
163
|
+
if (!response.ok) {
|
|
164
|
+
let data = null;
|
|
165
|
+
try { data = await response.json(); } catch { /* handled below */ }
|
|
166
|
+
throw responseError(response.status, data);
|
|
167
|
+
}
|
|
168
|
+
return { status: response.status, data: response.body, headers: response.headers };
|
|
169
|
+
}
|
|
170
|
+
let data = null;
|
|
171
|
+
try { data = await response.json(); } catch { /* handled below */ }
|
|
172
|
+
if (!response.ok) throw responseError(response.status, data);
|
|
173
|
+
return { status: response.status, data, headers: response.headers };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readExternalToken(stdinToken) {
|
|
177
|
+
const token = stdinToken ?? process.env.MECHASM_ACCESS_TOKEN;
|
|
178
|
+
if (!token) return null;
|
|
179
|
+
if (!/^mca_[A-Za-z0-9_-]+$/.test(token)) throw new CliError('External access token has invalid format.', 2);
|
|
180
|
+
return token;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function accessToken(stdinToken) {
|
|
184
|
+
const external = readExternalToken(stdinToken);
|
|
185
|
+
if (external) return { token: external, external: true };
|
|
186
|
+
return withCredentialLock(path, async () => {
|
|
187
|
+
const current = await readCredentials(path);
|
|
188
|
+
if (!current?.accessToken || !current.refreshToken) throw new CliError('Not logged in. Run `mechasm login` first.', 3);
|
|
189
|
+
if (current.expiresAt > Date.now() + REFRESH_EARLY_MS) return { token: current.accessToken, external: false };
|
|
190
|
+
const next = await refreshCredentials(current);
|
|
191
|
+
return { token: next.accessToken, external: false };
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function authorized(route, options = {}) {
|
|
196
|
+
const auth = await accessToken(options.stdinToken);
|
|
197
|
+
const requestOptions = { method: options.method, body: options.body, responseType: options.responseType, signal: options.signal, idempotencyKey: options.idempotencyKey, timeoutMs: options.timeoutMs };
|
|
198
|
+
try {
|
|
199
|
+
return { ...(await request(route, { ...requestOptions, token: auth.token })), external: auth.external };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (auth.external || error.exitCode !== 3) throw error;
|
|
202
|
+
const latest = await withCredentialLock(path, async () => {
|
|
203
|
+
const current = await readCredentials(path);
|
|
204
|
+
if (!current?.accessToken || !current.refreshToken) throw new CliError('Session expired. Run `mechasm login` again.', 3);
|
|
205
|
+
if (current.accessToken !== auth.token && current.expiresAt > Date.now()) return current;
|
|
206
|
+
return refreshCredentials(current);
|
|
207
|
+
});
|
|
208
|
+
return { ...(await request(route, { ...requestOptions, token: latest.accessToken })), external: false };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function refreshCredentials(current) {
|
|
213
|
+
let refreshed;
|
|
214
|
+
try {
|
|
215
|
+
refreshed = (await request('/auth/refresh', { method: 'POST', body: { refreshToken: current.refreshToken } })).data;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error.exitCode === 3) throw new CliError('Session expired. Run `mechasm login` again.', 3);
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
const next = credentialRecord(refreshed);
|
|
221
|
+
await writeCredentials(path, next);
|
|
222
|
+
return next;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
origin,
|
|
227
|
+
request,
|
|
228
|
+
async login({ deviceName = hostname().slice(0, 120), noBrowser = false, json = false } = {}) {
|
|
229
|
+
deviceName = String(deviceName).trim();
|
|
230
|
+
if (!deviceName || deviceName.length > 120) throw new CliError('--device-name must be 1 to 120 characters.', 2);
|
|
231
|
+
const device = (await request('/auth/device', { method: 'POST', body: { deviceName } })).data;
|
|
232
|
+
if (typeof device?.deviceCode !== 'string' || !/^mcd_[A-Za-z0-9_-]{43}$/.test(device.deviceCode) ||
|
|
233
|
+
typeof device?.userCode !== 'string' || !device.userCode || typeof device?.verificationUrl !== 'string' ||
|
|
234
|
+
!Number.isInteger(device.expiresIn) || device.expiresIn < 1 || device.expiresIn > 1800) {
|
|
235
|
+
throw new CliError('API returned an invalid device authorization response.');
|
|
236
|
+
}
|
|
237
|
+
const approval = trustedApprovalUrl(device.verificationUrl, device.userCode, base);
|
|
238
|
+
if (json) {
|
|
239
|
+
console.error(JSON.stringify({ status: 'pending', verificationUrl: approval.toString(), expiresIn: device.expiresIn }));
|
|
240
|
+
} else {
|
|
241
|
+
if (noBrowser) {
|
|
242
|
+
console.log(`Open this link to allow this device:\n${approval}`);
|
|
243
|
+
} else {
|
|
244
|
+
if (!(await openBrowser(approval.toString()))) {
|
|
245
|
+
console.log(`Could not open your browser. Open this link to allow this device:\n${approval}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
console.log('Waiting for approval...');
|
|
249
|
+
}
|
|
250
|
+
if (!noBrowser && json) await openBrowser(approval.toString());
|
|
251
|
+
const expiresAt = Date.now() + device.expiresIn * 1000;
|
|
252
|
+
const interval = Math.max(1, Math.min(Number(device.interval) || 5, 30)) * 1000;
|
|
253
|
+
while (Date.now() < expiresAt) {
|
|
254
|
+
const result = await request('/auth/token', { method: 'POST', body: { deviceCode: device.deviceCode } });
|
|
255
|
+
if (result.status === 202) {
|
|
256
|
+
await delay(Math.min(interval, Math.max(0, expiresAt - Date.now())));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const saved = credentialRecord(result.data);
|
|
260
|
+
await withCredentialLock(path, () => writeCredentials(path, saved));
|
|
261
|
+
if (!json) console.log(`Logged in as ${deviceName}.`);
|
|
262
|
+
return { status: 'authorized', verificationUrl: approval.toString(), deviceId: saved.deviceId, expiresIn: result.data.expiresIn };
|
|
263
|
+
}
|
|
264
|
+
throw new CliError('Device authorization expired. Run `mechasm login` again.', 3);
|
|
265
|
+
},
|
|
266
|
+
async whoami(stdinToken, options = {}) {
|
|
267
|
+
const result = await authorized('/me', { stdinToken, timeoutMs: options.timeoutMs });
|
|
268
|
+
return { data: result.data, external: result.external };
|
|
269
|
+
},
|
|
270
|
+
async projects(stdinToken) {
|
|
271
|
+
const result = await authorized('/projects', { stdinToken });
|
|
272
|
+
return { data: result.data, external: result.external };
|
|
273
|
+
},
|
|
274
|
+
async listTeams(stdinToken) {
|
|
275
|
+
return (await authorized('/teams', { stdinToken })).data;
|
|
276
|
+
},
|
|
277
|
+
async createProject(body, stdinToken, idempotencyKey) {
|
|
278
|
+
return (await authorized('/projects', { method: 'POST', body, stdinToken, idempotencyKey })).data;
|
|
279
|
+
},
|
|
280
|
+
async projectVariables(projectId, stdinToken) {
|
|
281
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/variables`, { stdinToken })).data;
|
|
282
|
+
},
|
|
283
|
+
async setProjectVariable(projectId, key, value, stdinToken) {
|
|
284
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/variables`, { method: 'PUT', body: { key, value }, stdinToken })).data;
|
|
285
|
+
},
|
|
286
|
+
async unsetProjectVariable(projectId, key, stdinToken) {
|
|
287
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/variables`, { method: 'DELETE', body: { key }, stdinToken })).data;
|
|
288
|
+
},
|
|
289
|
+
async createTest(projectId, body, stdinToken, idempotencyKey) {
|
|
290
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/tests`, { method: 'POST', body, stdinToken, idempotencyKey })).data;
|
|
291
|
+
},
|
|
292
|
+
async editTest(projectId, testId, body, stdinToken, idempotencyKey) {
|
|
293
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/tests/${encodeURIComponent(testId)}`, { method: 'PATCH', body, stdinToken, idempotencyKey })).data;
|
|
294
|
+
},
|
|
295
|
+
async listTests(projectId, stdinToken, { offset, limit } = {}) {
|
|
296
|
+
const query = new URLSearchParams();
|
|
297
|
+
if (offset !== undefined) query.set('offset', String(offset));
|
|
298
|
+
if (limit !== undefined) query.set('limit', String(limit));
|
|
299
|
+
const suffix = query.size ? `?${query}` : '';
|
|
300
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/tests${suffix}`, { stdinToken })).data;
|
|
301
|
+
},
|
|
302
|
+
async getTest(projectId, testId, stdinToken) {
|
|
303
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/tests/${encodeURIComponent(testId)}`, { stdinToken })).data;
|
|
304
|
+
},
|
|
305
|
+
async runTest(projectId, testId, stdinToken, idempotencyKey) {
|
|
306
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/tests/${encodeURIComponent(testId)}/runs`, { method: 'POST', stdinToken, idempotencyKey })).data;
|
|
307
|
+
},
|
|
308
|
+
async stopRun(projectId, runId, stdinToken) {
|
|
309
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}/stop`, { method: 'POST', stdinToken })).data;
|
|
310
|
+
},
|
|
311
|
+
async getRun(projectId, runId, stdinToken) {
|
|
312
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}`, { stdinToken })).data;
|
|
313
|
+
},
|
|
314
|
+
async runLogs(projectId, runId, after, stdinToken, { wait = false, signal } = {}) {
|
|
315
|
+
const query = new URLSearchParams();
|
|
316
|
+
if (after != null) query.set('after', after);
|
|
317
|
+
if (wait) query.set('wait', '1');
|
|
318
|
+
const suffix = query.size ? `?${query}` : '';
|
|
319
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}/logs${suffix}`, { stdinToken, signal })).data;
|
|
320
|
+
},
|
|
321
|
+
async runVariables(projectId, runId, stdinToken) {
|
|
322
|
+
return (await authorized(`/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}/variables`, { stdinToken })).data;
|
|
323
|
+
},
|
|
324
|
+
async downloadRunArtifact(projectId, runId, artifact, stdinToken) {
|
|
325
|
+
const result = await authorized(`/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}/${artifact}`, { stdinToken, responseType: 'stream' });
|
|
326
|
+
return { body: result.data, contentType: result.headers.get('content-type'), contentDisposition: result.headers.get('content-disposition') };
|
|
327
|
+
},
|
|
328
|
+
async registerLocal(projectId, url, ports, stdinToken) {
|
|
329
|
+
return (await authorized('/local/register', { method: 'POST', body: { projectId, url, ports }, stdinToken })).data;
|
|
330
|
+
},
|
|
331
|
+
async unregisterLocal(projectId, stdinToken) {
|
|
332
|
+
return (await authorized(`/local/register?projectId=${encodeURIComponent(projectId)}`, { method: 'DELETE', stdinToken })).data;
|
|
333
|
+
},
|
|
334
|
+
async localDevices(projectId, stdinToken) {
|
|
335
|
+
return (await authorized(`/local/devices?projectId=${encodeURIComponent(projectId)}`, { stdinToken })).data;
|
|
336
|
+
},
|
|
337
|
+
async selectLocalRunner(projectId, deviceId, stdinToken) {
|
|
338
|
+
return (await authorized(`/local/projects/${encodeURIComponent(projectId)}/runner`, { method: 'PUT', body: { deviceId }, stdinToken })).data;
|
|
339
|
+
},
|
|
340
|
+
async localProject(projectId, stdinToken) {
|
|
341
|
+
return (await authorized(`/local/projects/${encodeURIComponent(projectId)}`, { stdinToken })).data;
|
|
342
|
+
},
|
|
343
|
+
async updateLocalProject(projectId, body, stdinToken) {
|
|
344
|
+
return (await authorized(`/local/projects/${encodeURIComponent(projectId)}`, { method: 'PUT', body, stdinToken })).data;
|
|
345
|
+
},
|
|
346
|
+
async localHeartbeat(stdinToken, signal) {
|
|
347
|
+
return await authorized('/local/heartbeat', { method: 'POST', stdinToken, signal });
|
|
348
|
+
},
|
|
349
|
+
async nextLocalLease(stdinToken, signal) {
|
|
350
|
+
return await authorized('/local/leases/next?wait=20', { stdinToken, signal });
|
|
351
|
+
},
|
|
352
|
+
async getLocalLease(leaseId, stdinToken, signal) {
|
|
353
|
+
return (await authorized(`/local/leases/${encodeURIComponent(leaseId)}`, { stdinToken, signal })).data;
|
|
354
|
+
},
|
|
355
|
+
async heartbeatLocalLease(leaseId, stdinToken, signal) {
|
|
356
|
+
return (await authorized(`/local/leases/${encodeURIComponent(leaseId)}/heartbeat`, { method: 'POST', stdinToken, signal })).data;
|
|
357
|
+
},
|
|
358
|
+
async readyLocalLease(leaseId, stdinToken, signal) {
|
|
359
|
+
return await authorized(`/local/leases/${encodeURIComponent(leaseId)}/ready`, { method: 'POST', stdinToken, signal });
|
|
360
|
+
},
|
|
361
|
+
async closeLocalLease(leaseId, stdinToken) {
|
|
362
|
+
return await authorized(`/local/leases/${encodeURIComponent(leaseId)}`, { method: 'DELETE', stdinToken });
|
|
363
|
+
},
|
|
364
|
+
async logout(stdinToken) {
|
|
365
|
+
const external = readExternalToken(stdinToken);
|
|
366
|
+
if (external) return request('/auth/logout', { method: 'POST', token: external });
|
|
367
|
+
return withCredentialLock(path, async () => {
|
|
368
|
+
let current = await readCredentials(path);
|
|
369
|
+
if (!current?.accessToken || !current.refreshToken) throw new CliError('Not logged in. Run `mechasm login` first.', 3);
|
|
370
|
+
if (current.expiresAt <= Date.now() + REFRESH_EARLY_MS) current = await refreshCredentials(current);
|
|
371
|
+
try {
|
|
372
|
+
await request('/auth/logout', { method: 'POST', token: current.accessToken });
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (error.exitCode !== 3) throw error;
|
|
375
|
+
current = await refreshCredentials(current);
|
|
376
|
+
await request('/auth/logout', { method: 'POST', token: current.accessToken });
|
|
377
|
+
}
|
|
378
|
+
await rm(path, { force: true });
|
|
379
|
+
});
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function credentialRecord(data) {
|
|
385
|
+
if (typeof data?.accessToken !== 'string' || !/^mca_[A-Za-z0-9_-]{43}$/.test(data.accessToken) ||
|
|
386
|
+
typeof data?.refreshToken !== 'string' || !/^mcr_[A-Za-z0-9_-]{64}$/.test(data.refreshToken) ||
|
|
387
|
+
!Number.isInteger(data.expiresIn) || data.expiresIn < 1 || data.expiresIn > MAX_TOKEN_TTL_SECONDS ||
|
|
388
|
+
typeof data?.deviceId !== 'string' || !data.deviceId.trim()) {
|
|
389
|
+
throw new CliError('API returned an invalid token response.');
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
accessToken: data.accessToken,
|
|
393
|
+
refreshToken: data.refreshToken,
|
|
394
|
+
expiresAt: Date.now() + data.expiresIn * 1000,
|
|
395
|
+
deviceId: data.deviceId,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function trustedApprovalUrl(value, userCode, api) {
|
|
400
|
+
let url;
|
|
401
|
+
try { url = new URL(value); } catch { throw new CliError('API returned an invalid approval URL.'); }
|
|
402
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
403
|
+
const localHost = (name) => ['localhost', '127.0.0.1', '::1'].includes(name.replace(/^\[|\]$/g, '').toLowerCase());
|
|
404
|
+
const trustedSite = host === 'mechasm.ai' || host.endsWith('.mechasm.ai');
|
|
405
|
+
const trustedLocal = api.protocol === 'http:' && localHost(host) && url.protocol === 'http:';
|
|
406
|
+
if (url.username || url.password || (url.protocol !== 'https:' && !trustedLocal) || (!trustedSite && !trustedLocal)) {
|
|
407
|
+
throw new CliError('API returned an untrusted approval URL; refusing to open it.');
|
|
408
|
+
}
|
|
409
|
+
url.searchParams.set('code', userCode);
|
|
410
|
+
return url;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function openBrowser(url) {
|
|
414
|
+
const [file, args] = process.platform === 'darwin'
|
|
415
|
+
? ['open', [url]]
|
|
416
|
+
: process.platform === 'win32'
|
|
417
|
+
? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]]
|
|
418
|
+
: ['xdg-open', [url]];
|
|
419
|
+
return new Promise((resolve) => {
|
|
420
|
+
const child = spawn(file, args, { detached: true, stdio: 'ignore' });
|
|
421
|
+
child.once('error', () => resolve(false));
|
|
422
|
+
child.once('spawn', () => { child.unref(); resolve(true); });
|
|
423
|
+
});
|
|
424
|
+
}
|