@mechasm/cli 0.0.0-stage → 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.
package/src/frp.js ADDED
@@ -0,0 +1,169 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { access, chmod, copyFile, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+
7
+ const version = '0.71.0';
8
+ const archives = {
9
+ 'linux-x64': ['linux_amd64.tar.gz', '84f27e39f11169f7adcef8e8b70c9329de17747b1f14dad9fb95eef5682ea716'],
10
+ 'linux-arm64': ['linux_arm64.tar.gz', 'f33c293c275d8fc68c654b6fba8f10b2551d6463d09a9fc9cffb7227eae82266'],
11
+ 'darwin-x64': ['darwin_amd64.tar.gz', '1b1b4e2f1836e21e8733f1dddaacd4ed9ae67d7dbee39046b9d7b7eda6253637'],
12
+ 'darwin-arm64': ['darwin_arm64.tar.gz', '45be02b186860d375ed49a8941ae9569628a54bf14e67fc36b29c98c99dabcc6'],
13
+ 'win32-x64': ['windows_amd64.zip', '9e5062e3e5cf07e67144a3a4acf175ef6a2486f3605dd6cf288bae34ab39819f'],
14
+ 'win32-arm64': ['windows_arm64.zip', 'b56a5c2a1a2a55d11bc27aeef6edabd39f3d194360ea66660cc27281b502cb1c'],
15
+ };
16
+
17
+ function run(binary, args) {
18
+ return new Promise((resolve, reject) => {
19
+ const child = spawn(binary, args, { stdio: 'inherit' });
20
+ child.once('error', reject);
21
+ child.once('exit', (code) => code === 0 ? resolve() : reject(new Error(`${binary} exited with ${code}`)));
22
+ });
23
+ }
24
+
25
+ export async function ensureFrp(role) {
26
+ if (process.env.MECHASM_FRP_DIR) {
27
+ return join(process.env.MECHASM_FRP_DIR, `${role === 'relay' ? 'frps' : 'frpc'}${process.platform === 'win32' ? '.exe' : ''}`);
28
+ }
29
+ const archive = archives[`${process.platform}-${process.arch}`];
30
+ if (!archive) throw new Error(`No frp build for ${process.platform}-${process.arch}. Set MECHASM_FRP_DIR to your frp binaries.`);
31
+
32
+ const destination = join(homedir(), '.cache', 'mechasm-local', `frp-${version}`);
33
+ const binary = join(destination, `${role === 'relay' ? 'frps' : 'frpc'}${process.platform === 'win32' ? '.exe' : ''}`);
34
+ try {
35
+ await access(binary);
36
+ return binary;
37
+ } catch (error) {
38
+ if (error.code !== 'ENOENT') throw error;
39
+ }
40
+
41
+ const [suffix, expectedHash] = archive;
42
+ const name = `frp_${version}_${suffix}`;
43
+ const temp = await mkdtemp(join(tmpdir(), 'mechasm-frp-'));
44
+ try {
45
+ const response = await fetch(`https://github.com/fatedier/frp/releases/download/v${version}/${name}`);
46
+ if (!response.ok) throw new Error(`frp download failed: HTTP ${response.status}`);
47
+ const bytes = Buffer.from(await response.arrayBuffer());
48
+ if (createHash('sha256').update(bytes).digest('hex') !== expectedHash) {
49
+ throw new Error('frp download checksum did not match.');
50
+ }
51
+ const archivePath = join(temp, name);
52
+ await writeFile(archivePath, bytes, { mode: 0o600 });
53
+ await run('tar', ['-xf', archivePath, '-C', temp]);
54
+ await mkdir(destination, { recursive: true, mode: 0o700 });
55
+ for (const executable of ['frpc', 'frps']) {
56
+ const filename = `${executable}${process.platform === 'win32' ? '.exe' : ''}`;
57
+ const target = join(destination, filename);
58
+ await copyFile(join(temp, `frp_${version}_${suffix.replace(/\.tar\.gz$|\.zip$/, '')}`, filename), target);
59
+ if (process.platform !== 'win32') await chmod(target, 0o700);
60
+ }
61
+ return binary;
62
+ } finally {
63
+ await rm(temp, { recursive: true, force: true });
64
+ }
65
+ }
66
+
67
+ export async function runFrp(role, config) {
68
+ const binary = await ensureFrp(role);
69
+ const temp = await mkdtemp(join(tmpdir(), 'mechasm-tunnel-'));
70
+ const configPath = join(temp, 'frp.json');
71
+ await writeFile(configPath, JSON.stringify(config), { mode: 0o600 });
72
+ try {
73
+ await run(binary, ['verify', '-c', configPath]);
74
+ const child = spawn(binary, ['-c', configPath], { stdio: 'inherit' });
75
+ const stop = () => child.kill('SIGTERM');
76
+ process.on('SIGINT', stop);
77
+ process.on('SIGTERM', stop);
78
+ try {
79
+ await new Promise((resolve, reject) => {
80
+ child.once('error', reject);
81
+ child.once('exit', (code, signal) => code === 0 || signal === 'SIGTERM' || signal === 'SIGINT' ? resolve() : reject(new Error(`frp exited with ${code}`)));
82
+ });
83
+ } finally {
84
+ process.off('SIGINT', stop);
85
+ process.off('SIGTERM', stop);
86
+ }
87
+ } finally {
88
+ await rm(temp, { recursive: true, force: true });
89
+ }
90
+ }
91
+
92
+ export async function startFrpClient(config, caPem, signal) {
93
+ const binary = await ensureFrp('client');
94
+ if (signal?.aborted) throw Object.assign(new Error('Tunnel start cancelled.'), { name: 'AbortError' });
95
+ const temp = await mkdtemp(join(tmpdir(), 'mechasm-lease-'));
96
+ const caPath = join(temp, 'relay-ca.pem');
97
+ const configPath = join(temp, 'frpc.json');
98
+ let child;
99
+ try {
100
+ await chmod(temp, 0o700);
101
+ await writeFile(caPath, caPem, { mode: 0o600, flag: 'wx' });
102
+ config.transport ??= {};
103
+ config.transport.tls = { ...(config.transport.tls || {}), enable: true, trustedCaFile: caPath };
104
+ await writeFile(configPath, JSON.stringify(config), { mode: 0o600, flag: 'wx' });
105
+ await new Promise((resolve, reject) => {
106
+ const verify = spawn(binary, ['verify', '-c', configPath], { stdio: 'ignore' });
107
+ let settled = false;
108
+ const finish = (error) => {
109
+ if (settled) return;
110
+ settled = true;
111
+ clearTimeout(timer);
112
+ signal?.removeEventListener('abort', abort);
113
+ error ? reject(error) : resolve();
114
+ };
115
+ const abort = () => { verify.kill('SIGTERM'); finish(Object.assign(new Error('Tunnel start cancelled.'), { name: 'AbortError' })); };
116
+ const timer = setTimeout(() => {
117
+ verify.kill('SIGTERM');
118
+ finish(new Error('frpc config verification timed out.'));
119
+ }, 10_000);
120
+ if (signal?.aborted) abort();
121
+ else signal?.addEventListener('abort', abort, { once: true });
122
+ verify.once('error', (error) => finish(error));
123
+ verify.once('exit', (code) => code === 0 ? finish() : finish(new Error('frpc rejected the local tunnel configuration.')));
124
+ });
125
+ if (signal?.aborted) throw Object.assign(new Error('Tunnel start cancelled.'), { name: 'AbortError' });
126
+ child = spawn(binary, ['-c', configPath], { stdio: ['ignore', 'pipe', 'pipe'] });
127
+ let recent = '';
128
+ const waitForProxy = new Promise((resolve, reject) => {
129
+ const timer = setTimeout(() => reject(new Error(`frpc did not connect to the relay in time: ${recent.slice(-2000)}`)), 15_000);
130
+ const abort = () => { child.kill('SIGTERM'); reject(Object.assign(new Error('Tunnel start cancelled.'), { name: 'AbortError' })); };
131
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
132
+ const inspect = (chunk) => {
133
+ recent = (recent + chunk.toString()).slice(-8192);
134
+ const connected = config.proxies.every((proxy) => {
135
+ const name = String(proxy.name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
136
+ return new RegExp(`\\[${name}\\].{0,300}start proxy success`).test(recent);
137
+ });
138
+ if (connected) { cleanup(); resolve(); }
139
+ };
140
+ child.stdout.on('data', inspect);
141
+ child.stderr.on('data', inspect);
142
+ if (signal?.aborted) abort();
143
+ else signal?.addEventListener('abort', abort, { once: true });
144
+ child.once('error', (error) => { cleanup(); reject(error); });
145
+ child.once('exit', (code) => { cleanup(); reject(new Error(`frpc exited before connecting (${code}): ${recent.slice(-2000)}`)); });
146
+ });
147
+ await waitForProxy;
148
+ let stopped = false;
149
+ return {
150
+ child,
151
+ async stop() {
152
+ if (stopped) return;
153
+ stopped = true;
154
+ if (child.exitCode !== null || child.signalCode) { await rm(temp, { recursive: true, force: true }); return; }
155
+ child.kill('SIGTERM');
156
+ await Promise.race([
157
+ new Promise((resolve) => child.once('exit', resolve)),
158
+ new Promise((resolve) => setTimeout(resolve, 3000)),
159
+ ]);
160
+ if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
161
+ await rm(temp, { recursive: true, force: true });
162
+ },
163
+ };
164
+ } catch (error) {
165
+ child?.kill('SIGTERM');
166
+ await rm(temp, { recursive: true, force: true });
167
+ throw error;
168
+ }
169
+ }
@@ -0,0 +1,125 @@
1
+ import { constants } from 'node:fs';
2
+ import { mkdir, open, rename, rm, chmod, readFile, stat } from 'node:fs/promises';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ import { cliConfigDirectory, CliError } from './api-client.js';
6
+
7
+ function approvalPath(origin) {
8
+ const key = createHash('sha256').update(origin).digest('hex').slice(0, 20);
9
+ return join(cliConfigDirectory(), `local-approvals-${key}.json`);
10
+ }
11
+
12
+ async function readApprovals(path) {
13
+ let file;
14
+ try {
15
+ file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
16
+ const info = await file.stat();
17
+ if (!info.isFile() || (process.platform !== 'win32' && (info.uid !== process.getuid?.() || (info.mode & 0o077)))) {
18
+ throw new CliError('Local approval file permissions are unsafe.');
19
+ }
20
+ const data = JSON.parse(await file.readFile('utf8'));
21
+ if (data?.version !== 1 || !data.approvals || typeof data.approvals !== 'object' || Array.isArray(data.approvals)) {
22
+ throw new CliError('Local approval file is invalid.');
23
+ }
24
+ return data;
25
+ } catch (error) {
26
+ if (error.code === 'ENOENT') return { version: 1, approvals: {} };
27
+ if (error instanceof SyntaxError) throw new CliError('Local approval file is invalid.');
28
+ throw error;
29
+ } finally { await file?.close(); }
30
+ }
31
+
32
+ async function writeApprovals(path, data) {
33
+ await mkdir(cliConfigDirectory(), { recursive: true, mode: 0o700 });
34
+ if (process.platform !== 'win32') await chmod(cliConfigDirectory(), 0o700);
35
+ const temp = join(cliConfigDirectory(), `.local-approvals-${randomBytes(8).toString('hex')}.tmp`);
36
+ let file;
37
+ try {
38
+ file = await open(temp, 'wx', 0o600);
39
+ await file.writeFile(`${JSON.stringify(data)}\n`, 'utf8');
40
+ await file.sync();
41
+ await file.close();
42
+ file = null;
43
+ await rename(temp, path);
44
+ } finally {
45
+ await file?.close();
46
+ await rm(temp, { force: true });
47
+ }
48
+ }
49
+
50
+ async function processStartTime(pid) {
51
+ if (process.platform !== 'linux') return null;
52
+ try {
53
+ const stat = await readFile(`/proc/${pid}/stat`, 'utf8');
54
+ return stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/)[19] || null;
55
+ } catch { return null; }
56
+ }
57
+
58
+ async function withApprovalLock(action) {
59
+ await mkdir(cliConfigDirectory(), { recursive: true, mode: 0o700 });
60
+ if (process.platform !== 'win32') await chmod(cliConfigDirectory(), 0o700);
61
+ const lockPath = `${approvalPath('lock')}.lock`;
62
+ const deadline = Date.now() + 10_000;
63
+ while (true) {
64
+ try {
65
+ const lock = await open(lockPath, 'wx', 0o600);
66
+ try { await lock.writeFile(JSON.stringify({ pid: process.pid, processStartTime: await processStartTime(process.pid) })); }
67
+ finally { await lock.close(); }
68
+ try { return await action(); }
69
+ finally { await rm(lockPath, { force: true }); }
70
+ } catch (error) {
71
+ if (error.code !== 'EEXIST') throw error;
72
+ try {
73
+ const info = await stat(lockPath);
74
+ let owner;
75
+ try { owner = JSON.parse(await readFile(lockPath, 'utf8')); } catch { /* writer is still creating lock */ }
76
+ let alive = false;
77
+ if (Number.isInteger(owner?.pid) && owner.pid > 0) {
78
+ try { process.kill(owner.pid, 0); alive = true; } catch (checkError) { alive = checkError.code === 'EPERM'; }
79
+ if (alive && typeof owner.processStartTime === 'string') {
80
+ const currentStartTime = await processStartTime(owner.pid);
81
+ if (currentStartTime && currentStartTime !== owner.processStartTime) alive = false;
82
+ }
83
+ }
84
+ if (!alive && Date.now() - info.mtimeMs > 30_000) await rm(lockPath, { force: true });
85
+ } catch (lockError) { if (lockError.code !== 'ENOENT') throw lockError; }
86
+ if (Date.now() >= deadline) throw new CliError('Timed out waiting to update local port approvals.');
87
+ await new Promise((resolve) => setTimeout(resolve, 50));
88
+ }
89
+ }
90
+ }
91
+
92
+ export async function setLocalApproval(origin, projectId, deviceId, ports, preferredLocalIP = '127.0.0.1') {
93
+ if (typeof projectId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(projectId) || !deviceId ||
94
+ !['127.0.0.1', '::1'].includes(preferredLocalIP) || !Array.isArray(ports) || ports.length < 1 || ports.length > 16 ||
95
+ ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
96
+ throw new CliError('Invalid local runner approval.');
97
+ }
98
+ const path = approvalPath(origin);
99
+ await withApprovalLock(async () => {
100
+ const data = await readApprovals(path);
101
+ data.approvals[projectId] = { deviceId, ports: [...new Set(ports)], preferredLocalIP, updatedAt: new Date().toISOString() };
102
+ await writeApprovals(path, data);
103
+ });
104
+ }
105
+
106
+ export async function clearLocalApproval(origin, projectId) {
107
+ if (typeof projectId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(projectId)) throw new CliError('Invalid project ID.');
108
+ const path = approvalPath(origin);
109
+ await withApprovalLock(async () => {
110
+ const data = await readApprovals(path);
111
+ delete data.approvals[projectId];
112
+ await writeApprovals(path, data);
113
+ });
114
+ }
115
+
116
+ export async function approvedLocalPorts(origin, projectId, deviceId) {
117
+ if (typeof projectId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(projectId)) return [];
118
+ const approvals = (await readApprovals(approvalPath(origin))).approvals;
119
+ const record = Object.hasOwn(approvals, projectId) ? approvals[projectId] : null;
120
+ if (!record || record.deviceId !== deviceId || !Array.isArray(record.ports)) return null;
121
+ return {
122
+ ports: record.ports.filter((port) => Number.isInteger(port) && port >= 1 && port <= 65535),
123
+ preferredLocalIP: ['127.0.0.1', '::1'].includes(record.preferredLocalIP) ? record.preferredLocalIP : '127.0.0.1',
124
+ };
125
+ }
@@ -0,0 +1,35 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { cliConfigDirectory } from './api-client.js';
4
+ import { CLI_VERSION } from './version.js';
5
+
6
+ const REGISTRY_URL = 'https://registry.npmjs.org/@mechasm%2fcli/latest';
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+
9
+ export async function notifyUpdate() {
10
+ if (process.env.CI || process.env.MECHASM_NO_UPDATE_NOTIFIER || !process.stderr.isTTY || process.argv.includes('--json')) return;
11
+ const cachePath = join(cliConfigDirectory(), 'update-notice.json');
12
+ try {
13
+ const cache = JSON.parse(await readFile(cachePath, 'utf8'));
14
+ const age = Date.now() - cache.checkedAt;
15
+ if (Number.isFinite(cache.checkedAt) && age >= 0 && age < DAY_MS) return;
16
+ } catch { /* missing or invalid cache: check registry */ }
17
+ try {
18
+ const response = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(1500), redirect: 'error', headers: { accept: 'application/json' } });
19
+ if (!response.ok) return;
20
+ const data = await response.json();
21
+ if (typeof data.version === 'string' && /^\d+\.\d+\.\d+$/.test(data.version)) {
22
+ const current = CLI_VERSION.split('.').map(Number);
23
+ const latest = data.version.split('.').map(Number);
24
+ if (latest.some((part, index) => part > current[index] && latest.slice(0, index).every((n, i) => n === current[i]))) {
25
+ console.error(`Mechasm CLI ${data.version} available. Upgrade: npm install -g @mechasm/cli@latest`);
26
+ }
27
+ }
28
+ } catch { /* update checks are optional */ }
29
+ finally {
30
+ try {
31
+ await mkdir(cliConfigDirectory(), { recursive: true, mode: 0o700 });
32
+ await writeFile(cachePath, JSON.stringify({ checkedAt: Date.now() }), { mode: 0o600 });
33
+ } catch { /* cache is best-effort */ }
34
+ }
35
+ }