@emptyos/client 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.
@@ -0,0 +1,406 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { ClientError } from './errors.js';
7
+ import { gitCaptured } from './git.js';
8
+ import { readThingMetadata } from './manifest.js';
9
+
10
+ const VCS_DIRECTORIES = new Set(['.git', '.hg', '.svn']);
11
+
12
+ const GITIGNORE = `# Dependencies and generated output
13
+ node_modules/
14
+ build/
15
+ .cache/
16
+
17
+ # Local configuration and secrets
18
+ .env
19
+ .env.*
20
+ !.env.example
21
+
22
+ # Durable or test data
23
+ *.sqlite
24
+ *.sqlite3
25
+ *.db
26
+
27
+ # Runtime state
28
+ *.log
29
+ *.pid
30
+ coverage/
31
+ `;
32
+
33
+ const GITATTRIBUTES = `# Preserve imported bytes on every workstation and computer.
34
+ * -text
35
+ `;
36
+
37
+ export function inspectGeneratedSource(sourcePath) {
38
+ const source = path.resolve(sourcePath);
39
+ let stat;
40
+ try {
41
+ stat = fs.lstatSync(source);
42
+ } catch (error) {
43
+ if (error.code === 'ENOENT') throw new ClientError(`Source does not exist: ${source}`, 'source-missing');
44
+ throw error;
45
+ }
46
+ if (stat.isSymbolicLink()) throw new ClientError(`Source must not be a symbolic link: ${source}`, 'source-symlink');
47
+ if (stat.isDirectory()) {
48
+ if (fs.existsSync(path.join(source, 'thing.yaml'))) return { source, stat, kind: 'manifested-thing' };
49
+ const index = path.join(source, 'index.html');
50
+ let indexStat;
51
+ try {
52
+ indexStat = fs.lstatSync(index);
53
+ } catch (error) {
54
+ if (error.code === 'ENOENT') {
55
+ throw new ClientError(
56
+ `${source} does not look like a ready static site: no root index.html; put its build output, or add thing.yaml to declare a Thing`,
57
+ 'ambiguous-directory',
58
+ );
59
+ }
60
+ throw error;
61
+ }
62
+ if (indexStat.isSymbolicLink() || !indexStat.isFile()) {
63
+ throw new ClientError(`Static site index.html must be a regular file, not a symlink: ${index}`, 'invalid-static-index');
64
+ }
65
+ return { source, stat, kind: 'static-directory' };
66
+ }
67
+ if (!stat.isFile()) throw new ClientError(`Source must be a regular file or directory: ${source}`, 'invalid-source-type');
68
+ const extension = path.extname(source).toLowerCase();
69
+ return { source, stat, kind: extension === '.html' || extension === '.htm' ? 'html-file' : 'declared-file' };
70
+ }
71
+
72
+ export function derivedThingId(source, kind) {
73
+ const basename = path.basename(source);
74
+ return kind === 'static-directory' || kind === 'manifested-thing'
75
+ ? basename
76
+ : basename.slice(0, basename.length - path.extname(basename).length);
77
+ }
78
+
79
+ export async function stageGeneratedThing(sourceInfo, id, name, {
80
+ env = process.env,
81
+ projectId = null,
82
+ visibility = 'private',
83
+ } = {}) {
84
+ const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-put-'));
85
+ const repo = path.join(stage, 'repo');
86
+ const publicDir = path.join(repo, 'public');
87
+ const bundle = path.join(stage, 'thing.bundle');
88
+ const stats = { fileCount: 0, byteCount: 0 };
89
+ try {
90
+ if (sourceInfo.kind === 'manifested-thing') {
91
+ await snapshotManifestedThing(sourceInfo.source, repo, stats, env, sourceInfo.manifested ?? null);
92
+ return await packageRepo(stage, repo, bundle, name, stats, sourceInfo.kind, env);
93
+ }
94
+ fs.mkdirSync(publicDir, { recursive: true });
95
+ let declaredFile = null;
96
+ if (sourceInfo.kind === 'static-directory') {
97
+ copyTree(sourceInfo.source, publicDir, stats, sourceInfo.stat);
98
+ } else if (sourceInfo.kind === 'html-file') {
99
+ copyRegularFile(sourceInfo.source, path.join(publicDir, 'index.html'), stats);
100
+ } else {
101
+ declaredFile = path.basename(sourceInfo.source);
102
+ validateFilename(declaredFile);
103
+ copyRegularFile(sourceInfo.source, path.join(publicDir, declaredFile), stats);
104
+ }
105
+
106
+ const projectLine = projectId === null ? '' : `project: ${JSON.stringify(projectId)}\n`;
107
+ const fileLine = declaredFile === null ? '' : `file: ${JSON.stringify(declaredFile)}\n`;
108
+ fs.writeFileSync(
109
+ path.join(repo, 'thing.yaml'),
110
+ `id: ${JSON.stringify(id)}\nname: ${JSON.stringify(name)}\ntype: static\ndirectory: public\n${fileLine}route: ${JSON.stringify(`/${id}`)}\nvisibility: ${visibility}\n${projectLine}`,
111
+ );
112
+ fs.writeFileSync(path.join(repo, '.gitignore'), GITIGNORE);
113
+ fs.writeFileSync(path.join(repo, '.gitattributes'), GITATTRIBUTES);
114
+ const required = declaredFile === null ? 'public/index.html' : `public/${declaredFile}`;
115
+ fs.writeFileSync(
116
+ path.join(repo, 'verify'),
117
+ `#!/bin/sh\nset -eu\nfile=${shellQuote(required)}\ntest -f thing.yaml\ntest -f "$file"\ntest ! -L "$file"\n`,
118
+ { mode: 0o755 },
119
+ );
120
+
121
+ return await packageRepo(stage, repo, bundle, name, stats, sourceInfo.kind, env);
122
+ } catch (error) {
123
+ cleanPutStage(stage);
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ export async function inspectManifestedSource(source, env = process.env) {
129
+ const root = (await gitMust(['-C', source, 'rev-parse', '--show-toplevel'], env, 'Putting a manifested Thing requires a Git repository')).trim();
130
+ if (fs.realpathSync(root) !== fs.realpathSync(source)) {
131
+ throw new ClientError(`thing.yaml must be at the root of the Git repository (repository root: ${root})`, 'manifest-not-repository-root');
132
+ }
133
+ const branch = await gitResult(
134
+ ['-C', source, 'symbolic-ref', '--quiet', '--short', 'HEAD'],
135
+ env,
136
+ 'cannot inspect the manifested Thing branch',
137
+ );
138
+ if (branch.code !== 0 || !branch.stdout.trim()) {
139
+ throw new ClientError('Putting a manifested Thing requires an attached Git branch', 'detached-head');
140
+ }
141
+ const status = await gitMust(['-C', source, 'status', '--porcelain', '--untracked-files=all'], env, 'Cannot inspect the manifested Thing');
142
+ if (status !== '') throw new ClientError('Putting a manifested Thing requires a clean worktree with no tracked or untracked changes', 'dirty-worktree');
143
+ const head = (await gitMust(['-C', source, 'rev-parse', 'HEAD'], env, 'Cannot resolve the manifested Thing commit')).trim();
144
+ const manifest = readThingMetadata(source);
145
+ for (const script of ['verify', 'prepare']) {
146
+ const tracked = await gitResult(
147
+ ['-C', source, 'ls-files', '--error-unmatch', '--', script],
148
+ env,
149
+ `cannot inspect manifested ./${script}`,
150
+ );
151
+ if (script === 'prepare' && tracked.code !== 0) continue;
152
+ if (tracked.code !== 0) throw new ClientError('Putting a manifested Thing requires ./verify to be tracked by Git', 'verify-untracked');
153
+ validateExecutable(path.join(source, script), script);
154
+ }
155
+ const finalHead = (await gitMust(['-C', source, 'rev-parse', 'HEAD'], env, 'Cannot recheck the manifested Thing commit')).trim();
156
+ const finalStatus = await gitMust(['-C', source, 'status', '--porcelain', '--untracked-files=all'], env, 'Cannot recheck the manifested Thing');
157
+ if (finalHead !== head || finalStatus !== '') {
158
+ throw new ClientError('Manifested Thing changed during preflight; finish the change and retry', 'source-changed');
159
+ }
160
+ return { ...manifest, head };
161
+ }
162
+
163
+ export function cleanPutStage(stage) {
164
+ try {
165
+ fs.rmSync(stage, { recursive: true, force: true });
166
+ } catch {
167
+ // The OS can reap this non-canonical workstation staging directory.
168
+ }
169
+ }
170
+
171
+ function copyTree(source, destination, stats, expected = lstatTreeEntry(source)) {
172
+ assertStableDirectory(source, expected, 'before');
173
+ let entries;
174
+ try {
175
+ entries = fs.readdirSync(source, { withFileTypes: true });
176
+ } catch (error) {
177
+ if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) {
178
+ throw new ClientError(`Source directory changed while EmptyOS copied it: ${source}`, 'source-changed');
179
+ }
180
+ throw error;
181
+ }
182
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
183
+ const from = path.join(source, entry.name);
184
+ const to = path.join(destination, entry.name);
185
+ const stat = lstatTreeEntry(from);
186
+ if (stat.isSymbolicLink()) throw new ClientError(`Static site must not contain symbolic links: ${from}`, 'source-symlink');
187
+ if (VCS_DIRECTORIES.has(entry.name) && stat.isDirectory()) continue;
188
+ if (stat.isDirectory()) {
189
+ fs.mkdirSync(to, { mode: stat.mode & 0o777 });
190
+ copyTree(from, to, stats, stat);
191
+ } else if (stat.isFile()) {
192
+ copyRegularFile(from, to, stats, stat);
193
+ } else {
194
+ throw new ClientError(`Static site contains an unsupported special file: ${from}`, 'invalid-source-type');
195
+ }
196
+ }
197
+ assertStableDirectory(source, expected, 'while');
198
+ }
199
+
200
+ async function snapshotManifestedThing(source, destination, stats, env, expected) {
201
+ const inspected = await inspectManifestedSource(source, env);
202
+ if (expected && inspected.head !== expected.head) {
203
+ throw new ClientError('Manifested Thing changed after preflight; retry the put', 'source-changed');
204
+ }
205
+ const head = expected?.head ?? inspected.head;
206
+ fs.mkdirSync(destination, { recursive: true });
207
+ const tree = await gitMust(['-C', source, 'ls-tree', '-r', '-z', '--full-tree', head], isolatedGitEnv(env), 'Cannot enumerate the manifested Thing');
208
+ for (const record of tree.split('\0')) {
209
+ if (!record) continue;
210
+ const tab = record.indexOf('\t');
211
+ const header = tab === -1 ? '' : record.slice(0, tab);
212
+ const relative = tab === -1 ? '' : record.slice(tab + 1);
213
+ const match = /^(100644|100755) blob ([0-9a-f]{40}|[0-9a-f]{64})$/.exec(header);
214
+ if (!match) throw new ClientError(`Manifested Thing contains an unsupported Git entry: ${relative || header}`, 'invalid-source-type');
215
+ if (!safeGitPath(relative)) throw new ClientError(`Manifested Thing contains an unsafe path: ${JSON.stringify(relative)}`, 'invalid-source-path');
216
+ const file = path.join(destination, relative);
217
+ fs.mkdirSync(path.dirname(file), { recursive: true });
218
+ await writeGitBlob(source, match[2], file, match[1] === '100755' ? 0o755 : 0o644, env);
219
+ }
220
+ inspectCopiedTree(destination, stats);
221
+ const final = await inspectManifestedSource(source, env);
222
+ if (final.head !== head) throw new ClientError('Manifested Thing changed while EmptyOS copied it; retry the put', 'source-changed');
223
+ }
224
+
225
+ function safeGitPath(relative) {
226
+ if (!relative || path.isAbsolute(relative) || relative.includes('\0')) return false;
227
+ const parts = relative.split('/');
228
+ return parts.every((part) => part !== '' && part !== '.' && part !== '..');
229
+ }
230
+
231
+ async function writeGitBlob(source, object, destination, mode, env) {
232
+ const gitEnv = isolatedGitEnv(env);
233
+ const command = gitEnv.EMPTYOS_GIT_COMMAND || 'git';
234
+ const child = spawn(command, ['-C', source, 'cat-file', 'blob', object], {
235
+ env: gitEnv,
236
+ stdio: ['ignore', 'pipe', 'pipe'],
237
+ });
238
+ let stderr = '';
239
+ child.stderr.setEncoding('utf8');
240
+ child.stderr.on('data', (chunk) => (stderr += chunk));
241
+ const exited = new Promise((resolve, reject) => {
242
+ child.once('error', reject);
243
+ child.once('close', (code, signal) => {
244
+ if (code === 0) resolve();
245
+ else reject(new ClientError(stderr.trim() || `cannot read Git object ${object}${signal ? ` (${signal})` : ''}`, 'git-failed'));
246
+ });
247
+ });
248
+ try {
249
+ await Promise.all([
250
+ pipeline(child.stdout, fs.createWriteStream(destination, { flags: 'wx', mode })),
251
+ exited,
252
+ ]);
253
+ fs.chmodSync(destination, mode);
254
+ } catch (error) {
255
+ child.kill();
256
+ fs.rmSync(destination, { force: true });
257
+ throw error;
258
+ }
259
+ }
260
+
261
+ function inspectCopiedTree(root, stats, current = root) {
262
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
263
+ const file = path.join(current, entry.name);
264
+ const stat = fs.lstatSync(file);
265
+ if (stat.isSymbolicLink()) throw new ClientError(`Manifested Thing must not contain symbolic links: ${file}`, 'source-symlink');
266
+ if (stat.isDirectory()) inspectCopiedTree(root, stats, file);
267
+ else if (stat.isFile()) {
268
+ stats.fileCount += 1;
269
+ stats.byteCount += stat.size;
270
+ } else throw new ClientError(`Manifested Thing contains an unsupported special file: ${file}`, 'invalid-source-type');
271
+ }
272
+ }
273
+
274
+ function validateExecutable(file, name) {
275
+ let stat;
276
+ try {
277
+ stat = fs.lstatSync(file);
278
+ } catch (error) {
279
+ if (error.code === 'ENOENT') throw new ClientError(`Putting a manifested Thing requires ./${name}`, `${name}-missing`);
280
+ throw error;
281
+ }
282
+ if (stat.isSymbolicLink() || !stat.isFile() || (stat.mode & 0o111) === 0) {
283
+ throw new ClientError(`./${name} must be an executable regular file, not a symlink`, `${name}-invalid`);
284
+ }
285
+ }
286
+
287
+ async function packageRepo(stage, repo, bundle, name, stats, sourceKind, env) {
288
+ const gitEnv = isolatedGitEnv(env);
289
+ const template = path.join(stage, 'git-template');
290
+ fs.mkdirSync(template);
291
+ await gitMust(['init', '--quiet', '--initial-branch=main', `--template=${template}`, repo], gitEnv, 'Cannot initialize the Thing');
292
+ for (const [key, value] of [
293
+ ['user.name', 'EmptyOS'],
294
+ ['user.email', 'emptyos@localhost'],
295
+ ['core.autocrlf', 'false'],
296
+ ['core.safecrlf', 'false'],
297
+ ['core.hooksPath', os.devNull],
298
+ ['commit.gpgSign', 'false'],
299
+ ]) {
300
+ await gitMust(['-C', repo, 'config', '--local', key, value], gitEnv, 'Cannot isolate the Thing repository');
301
+ }
302
+ await gitMust(['-C', repo, 'add', '-A', '-f'], gitEnv, 'Cannot stage the Thing');
303
+ await gitMust(['-C', repo, 'commit', '--quiet', '--no-gpg-sign', '-m', `Create ${name}`], gitEnv, 'Cannot commit the Thing');
304
+ const head = (await gitMust(['-C', repo, 'rev-parse', 'HEAD'], gitEnv, 'Cannot resolve the Thing commit')).trim();
305
+ await gitMust(['-C', repo, 'bundle', 'create', bundle, 'main'], gitEnv, 'Cannot package the Thing');
306
+ return { bundle, head, stage, ...stats, sourceKind };
307
+ }
308
+
309
+ function copyRegularFile(source, destination, stats, before = fs.lstatSync(source)) {
310
+ if (before.isSymbolicLink() || !before.isFile()) {
311
+ throw new ClientError(`Source must be a regular file, not a symlink: ${source}`, 'invalid-source-type');
312
+ }
313
+ let sourceFd;
314
+ let destinationFd;
315
+ try {
316
+ try {
317
+ sourceFd = fs.openSync(source, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
318
+ } catch (error) {
319
+ if (error.code === 'ELOOP') throw new ClientError(`Source changed before EmptyOS could copy it: ${source}`, 'source-changed');
320
+ throw error;
321
+ }
322
+ const opened = fs.fstatSync(sourceFd);
323
+ if (!sameFileSnapshot(before, opened)) throw new ClientError(`Source changed before EmptyOS could copy it: ${source}`, 'source-changed');
324
+ destinationFd = fs.openSync(destination, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, before.mode & 0o777);
325
+ const buffer = Buffer.allocUnsafe(64 * 1024);
326
+ for (;;) {
327
+ const read = fs.readSync(sourceFd, buffer, 0, buffer.length, null);
328
+ if (read === 0) break;
329
+ let written = 0;
330
+ while (written < read) written += fs.writeSync(destinationFd, buffer, written, read - written);
331
+ }
332
+ const after = fs.fstatSync(sourceFd);
333
+ if (!sameFileSnapshot(opened, after)) throw new ClientError(`Source changed while EmptyOS copied it: ${source}`, 'source-changed');
334
+ fs.fchmodSync(destinationFd, before.mode & 0o777);
335
+ } finally {
336
+ if (destinationFd !== undefined) fs.closeSync(destinationFd);
337
+ if (sourceFd !== undefined) fs.closeSync(sourceFd);
338
+ }
339
+ stats.fileCount += 1;
340
+ stats.byteCount += before.size;
341
+ }
342
+
343
+ function sameFileSnapshot(left, right) {
344
+ return left.isFile() && right.isFile() && left.dev === right.dev && left.ino === right.ino &&
345
+ left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
346
+ }
347
+
348
+ function lstatTreeEntry(file) {
349
+ try {
350
+ return fs.lstatSync(file);
351
+ } catch (error) {
352
+ if (error.code === 'ENOENT') throw new ClientError(`Source changed while EmptyOS copied it: ${file}`, 'source-changed');
353
+ throw error;
354
+ }
355
+ }
356
+
357
+ function assertStableDirectory(directory, expected, timing) {
358
+ const current = lstatTreeEntry(directory);
359
+ if (!current.isDirectory() || !sameDirectorySnapshot(expected, current)) {
360
+ throw new ClientError(`Source directory changed ${timing} EmptyOS copied it: ${directory}`, 'source-changed');
361
+ }
362
+ }
363
+
364
+ function sameDirectorySnapshot(left, right) {
365
+ return left.isDirectory() && right.isDirectory() && left.dev === right.dev && left.ino === right.ino &&
366
+ left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
367
+ }
368
+
369
+ function validateFilename(filename) {
370
+ if (!filename || filename === '.' || filename === '..' || /[\u0000-\u001f\u007f]/.test(filename)) {
371
+ throw new ClientError(`Unsupported file name: ${JSON.stringify(filename)}`, 'invalid-source-name');
372
+ }
373
+ }
374
+
375
+ async function gitMust(args, env, fallback) {
376
+ const result = await gitResult(args, env, fallback);
377
+ if (result.code !== 0) {
378
+ const detail = result.stderr.trim() || result.stdout.trim();
379
+ throw new ClientError(detail ? `${fallback}: ${detail}` : fallback, 'git-failed');
380
+ }
381
+ return result.stdout;
382
+ }
383
+
384
+ async function gitResult(args, env, fallback) {
385
+ try {
386
+ return await gitCaptured(args, { env });
387
+ } catch (error) {
388
+ throw new ClientError(`${fallback}: ${error.message}`, 'git-failed');
389
+ }
390
+ }
391
+
392
+ function shellQuote(value) {
393
+ return `'${value.replaceAll("'", "'\\''")}'`;
394
+ }
395
+
396
+ function isolatedGitEnv(env) {
397
+ const clean = {};
398
+ for (const [key, value] of Object.entries(env)) if (!key.startsWith('GIT_')) clean[key] = value;
399
+ return {
400
+ ...clean,
401
+ GIT_ATTR_NOSYSTEM: '1',
402
+ GIT_CONFIG_GLOBAL: os.devNull,
403
+ GIT_CONFIG_NOSYSTEM: '1',
404
+ GIT_CONFIG_SYSTEM: os.devNull,
405
+ };
406
+ }
@@ -0,0 +1,119 @@
1
+ import fs from 'node:fs';
2
+ import https from 'node:https';
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import { ClientError } from './errors.js';
5
+
6
+ const TUNNEL_PATH = '/_system/ssh';
7
+ const TUNNEL_PROTOCOL = 'emptyos-ssh';
8
+ // Shorter than the client's ssh ConnectTimeout=10 so this helper explains a
9
+ // silent host before ssh gives up on the banner exchange and kills it.
10
+ const TUNNEL_CONNECT_TIMEOUT_MS = 8_000;
11
+ const UNANSWERED_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE']);
12
+ const TLS_CODE_RE = /^(ERR_TLS_|ERR_SSL_|CERT_|UNABLE_TO_|SELF_SIGNED_|DEPTH_ZERO_)/u;
13
+
14
+ export function readTunnelToken(tokenPath) {
15
+ let fd;
16
+ try {
17
+ fd = fs.openSync(tokenPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
18
+ const stat = fs.fstatSync(fd);
19
+ if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) {
20
+ throw new ClientError('SSH tunnel token file must be a regular file with mode 0600');
21
+ }
22
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
23
+ throw new ClientError('SSH tunnel token file must be owned by the current user');
24
+ }
25
+ const raw = fs.readFileSync(fd, 'utf8');
26
+ const token = raw.trim();
27
+ if (!/^v1\.[A-Za-z0-9_-]{43}$/u.test(token) || (raw !== token && raw !== `${token}\n`)) {
28
+ throw new ClientError('SSH tunnel token file is malformed');
29
+ }
30
+ return token;
31
+ } catch (error) {
32
+ if (error instanceof ClientError) throw error;
33
+ throw new ClientError(`Cannot read SSH tunnel token file: ${error.message}`);
34
+ } finally {
35
+ if (fd !== undefined) fs.closeSync(fd);
36
+ }
37
+ }
38
+
39
+ export function runTunnelProxy(
40
+ ownerOrigin,
41
+ tokenPath,
42
+ { stdin = process.stdin, stdout = process.stdout, request = https.request } = {},
43
+ ) {
44
+ const token = readTunnelToken(tokenPath);
45
+ const url = new URL(TUNNEL_PATH, ownerOrigin);
46
+
47
+ return new Promise((resolve, reject) => {
48
+ let settled = false;
49
+ const fail = (error) => {
50
+ if (settled) return;
51
+ settled = true;
52
+ reject(error instanceof ClientError ? error : new ClientError(describeTunnelError(url.hostname, error)));
53
+ };
54
+ const req = request(url, {
55
+ method: 'GET',
56
+ agent: false,
57
+ headers: {
58
+ Authorization: `Bearer ${token}`,
59
+ Connection: 'Upgrade',
60
+ Upgrade: TUNNEL_PROTOCOL,
61
+ },
62
+ });
63
+
64
+ req.once('response', (response) => {
65
+ response.resume();
66
+ fail(new ClientError(describeTunnelStatus(url.hostname, response.statusCode ?? 0)));
67
+ });
68
+ req.once('upgrade', (response, socket, head) => {
69
+ if (response.statusCode !== 101 || response.headers.upgrade?.toLowerCase() !== TUNNEL_PROTOCOL) {
70
+ socket.destroy();
71
+ fail(new ClientError('SSH tunnel endpoint returned an invalid protocol upgrade'));
72
+ return;
73
+ }
74
+ if (settled) {
75
+ socket.destroy();
76
+ return;
77
+ }
78
+ socket.setNoDelay?.(true);
79
+ socket.setTimeout?.(0);
80
+ if (head.length > 0) stdout.write(head);
81
+ socket.pipe(stdout, { end: false });
82
+ stdin.pipe(socket);
83
+ socket.once('error', fail);
84
+ socket.once('close', () => {
85
+ if (settled) return;
86
+ settled = true;
87
+ resolve(0);
88
+ });
89
+ });
90
+ req.once('error', fail);
91
+ req.setTimeout?.(TUNNEL_CONNECT_TIMEOUT_MS, () => req.destroy(new ClientError(
92
+ `Cannot reach EmptyOS computer ${url.hostname}: no response within ${TUNNEL_CONNECT_TIMEOUT_MS / 1000} seconds, so the computer or its edge may be down`,
93
+ )));
94
+ req.end();
95
+ });
96
+ }
97
+
98
+ // ssh relays only this helper's stderr to the person, so the explanation is
99
+ // spelled out here; the parent client adds the alias-specific hint afterwards.
100
+ function describeTunnelError(host, error) {
101
+ const code = typeof error.code === 'string' ? error.code : '';
102
+ const unreachable = (reason) => `Cannot reach EmptyOS computer ${host}: ${reason} (${error.message})`;
103
+ if (code === 'ENOTFOUND') return unreachable('its address no longer resolves, so it may be decommissioned or renamed');
104
+ if (code === 'EAI_AGAIN') return unreachable('DNS lookup failed; check your network connection');
105
+ if (UNANSWERED_CODES.has(code)) return unreachable('nothing answered at its address, so the computer or its edge may be down');
106
+ if (TLS_CODE_RE.test(code)) return unreachable('its TLS certificate is invalid');
107
+ return `SSH tunnel failed: ${error.message}`;
108
+ }
109
+
110
+ function describeTunnelStatus(host, status) {
111
+ if (status >= 300 && status < 400) return `SSH tunnel endpoint refused an HTTP redirect (${status})`;
112
+ if (status === 401 || status === 403) {
113
+ return `EmptyOS computer ${host} rejected this client's tunnel token (HTTP ${status}); the agent may have been removed from it`;
114
+ }
115
+ if (status === 404 || status >= 500) {
116
+ return `Cannot reach EmptyOS computer ${host}: its edge answered HTTP ${status} without the EmptyOS recovery service, so the computer may be stopped or still starting`;
117
+ }
118
+ return `SSH tunnel endpoint returned HTTP ${status}; expected protocol upgrade`;
119
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@emptyos/client",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "External EmptyOS client",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "empty": "bin/empty.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "skills",
14
+ "README.md"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "homepage": "https://emptyos.com/docs/",
20
+ "scripts": {
21
+ "test": "node --test test/*.test.js"
22
+ },
23
+ "dependencies": {
24
+ "yaml": "^2.8.2"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ }
29
+ }