@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,272 @@
1
+ const COMMANDS = [
2
+ command('connect', 'connect <computer-url> [--as <alias>] [--label <label>] [--no-browser]', 'Connect this client to an EmptyOS computer', 'computers', 'local', [
3
+ 'empty connect vishal.emptyos.com',
4
+ 'empty connect maya-chen.emptyos.com --as maya',
5
+ 'empty connect jordan.emptyos.com --label "Jordan\'s AI Computer"',
6
+ ], true, 'prefix', [
7
+ 'Options:',
8
+ ' --as <alias> Local shorthand used to select this computer; defaults to the first part of its URL.',
9
+ ' --label <label> This client\'s display label on the approval and approved-agent screens; defaults to this machine\'s hostname.',
10
+ ' --no-browser Print the approval page URL without opening it in your default browser.',
11
+ ]),
12
+ command('computers', 'computers [--json]', 'List configured computers', 'computers', 'local'),
13
+ command('computer add', 'computer add <alias> --host <host> [--release-catalog <https-url>] [--json]', 'Connect an EmptyOS computer', 'computers', 'local', [
14
+ 'empty computer add personal --host owner@personal.example',
15
+ ]),
16
+ command('computer use', 'computer use <alias> [--json]', 'Select the default computer', 'computers', 'local'),
17
+ command('computer show', '[--computer <alias>] computer show [--json]', 'Show the selected computer', 'computers', 'local'),
18
+ command('computer update', '[--computer <alias>] computer update [--check | --apply | --resume <stage-name>] [--release <local-release.json> | --catalog <https-url>] [--json]', 'Check, update, or resume reconciliation for the selected computer platform', 'computers', 'hybrid', [
19
+ 'empty computer update --check',
20
+ 'empty computer update --apply',
21
+ 'empty computer update --resume stage.A1b2C3',
22
+ 'empty --computer work computer update --release ./platform-release.json',
23
+ ]),
24
+ command('computer remove', 'computer remove <alias> [--json]', 'Remove a computer profile', 'computers', 'local'),
25
+
26
+ command('things', '[--computer <alias>] things [--json]', 'List Things on the selected computer', 'things', 'remote', [
27
+ 'empty things',
28
+ 'empty --computer work things --json',
29
+ ]),
30
+ command('put', '[--computer <alias>] put <path> [--as <id>] [--name <name>] [--project <project-id>] [--public] [--json]', 'Put local content on the selected computer', 'things', 'hybrid', [
31
+ 'empty put ./portfolio --as portfolio',
32
+ 'empty put ./olive.jpg --as olive --public',
33
+ 'empty put .',
34
+ ], true, 'prefix', [
35
+ 'A directory is the deployment boundary: every selected file becomes owner-served content and may become anonymous with --public. Prefer a ready build-output directory over a source repository root.',
36
+ 'A manifested repository may run its tracked ./prepare as trusted owner code on the computer before offline verification.',
37
+ ]),
38
+ command('clone', '[--computer <alias>] clone <id> [directory] [--json]', 'Clone a Thing for local work', 'things', 'hybrid'),
39
+ command('thing describe', '[--computer <alias>] thing describe <id> [--json]', 'Inspect a Thing', 'things', 'remote'),
40
+ command('thing logs', '[--computer <alias>] thing logs <id> [--lines <count>] [--json]', 'Read recent service logs', 'things', 'remote'),
41
+ command('thing assign', '[--computer <alias>] thing assign <thing-id> <project-id> [--json]', 'Assign a Thing to a Project', 'things', 'remote'),
42
+ command('thing unassign', '[--computer <alias>] thing unassign <thing-id> [--json]', 'Remove a Thing from its Project', 'things', 'remote'),
43
+ command('thing start', '[--computer <alias>] thing start <id> [--json]', 'Start a service Thing', 'things', 'remote'),
44
+ command('thing stop', '[--computer <alias>] thing stop <id> [--json]', 'Stop a service Thing', 'things', 'remote'),
45
+ command('thing restart', '[--computer <alias>] thing restart <id> [--json]', 'Restart a service Thing', 'things', 'remote'),
46
+ command('thing remove', '[--computer <alias>] thing remove <id> [--json]', 'Remove a Thing service registration', 'things', 'remote'),
47
+ command(
48
+ 'thing add',
49
+ 'thing add <computer-path>',
50
+ 'Unavailable from a workstation; use put for local content',
51
+ 'things',
52
+ 'unavailable',
53
+ [],
54
+ false,
55
+ ),
56
+
57
+ command('projects', '[--computer <alias>] projects [--json]', 'List Projects', 'projects', 'remote'),
58
+ command('project create', '[--computer <alias>] project create <id> <name...> [--json]', 'Create a Project', 'projects', 'remote'),
59
+ command('data', '[--computer <alias>] data [--json]', 'List registered Datasets', 'data', 'remote', [], true, 'json-only'),
60
+ command('data describe', '[--computer <alias>] data describe <id> [--json]', 'Inspect a Dataset', 'data', 'remote'),
61
+
62
+ command('status', '[--computer <alias>] status [--json]', 'Show computer health', 'system', 'remote'),
63
+ command('changes', '[--computer <alias>] changes [--json]', 'List committed changes', 'system', 'remote'),
64
+ command('commands', '[--computer <alias>] commands [--json]', 'List built-in and local commands', 'system', 'hybrid'),
65
+ command('hooks', '[--computer <alias>] hooks [--json]', 'List lifecycle hook events', 'system', 'remote'),
66
+ command('hook install', '[--computer <alias>] hook install <event> <path> [--json]', 'Install a hook from a path on the computer', 'system', 'remote'),
67
+ command('undo', '[--computer <alias>] undo <change-id> [--json]', 'Undo one committed change', 'system', 'remote'),
68
+ command('expose', '[--computer <alias>] expose <id> [--json]', 'Publish a Thing to anyone on the internet', 'system', 'remote'),
69
+ command('unexpose', '[--computer <alias>] unexpose <id> [--json]', 'Make a Thing private', 'system', 'remote'),
70
+ command('ssh', '[--computer <alias>] ssh [-- <command...>]', 'Enter the computer or run a raw command', 'system', 'hybrid'),
71
+ command('skill install', 'skill install [--dir <path>] [--json]', 'Install the bundled agent skill into a skills directory', 'system', 'local', [
72
+ 'empty skill install',
73
+ 'empty skill install --dir ~/.claude/skills',
74
+ ], true, 'prefix', [
75
+ 'Copies the emptyos-computer skill that ships with this client to <dir>/emptyos-computer/, replacing an earlier copy. The default directory is ~/.agents/skills.',
76
+ ]),
77
+ command('--json', '--json', 'Show the complete computer overview as JSON', 'system', 'remote', [], true, 'exact'),
78
+ command('--version', '--version', 'Show the client and protocol version', 'system', 'local'),
79
+ command('help', 'help [command...]', 'Show command help', 'system', 'local'),
80
+ ];
81
+
82
+ const SECTIONS = Object.freeze([
83
+ { id: 'computers', title: 'Computers' },
84
+ { id: 'things', title: 'Things' },
85
+ { id: 'projects', title: 'Projects' },
86
+ { id: 'data', title: 'Data' },
87
+ { id: 'system', title: 'System' },
88
+ ]);
89
+
90
+ const GROUPS = Object.freeze([
91
+ { path: ['computer'], section: 'computers', title: 'Computer commands', usage: 'empty computer <command> [options]' },
92
+ { path: ['thing'], section: 'things', title: 'Thing commands', usage: 'empty thing <command> [options]' },
93
+ { path: ['project'], section: 'projects', title: 'Project commands', usage: 'empty project <command> [options]' },
94
+ { path: ['data'], section: 'data', title: 'Data commands', usage: 'empty data [command] [options]' },
95
+ ]);
96
+
97
+ const HELP_FLAGS = new Set(['--help', '-h']);
98
+ const CATALOG = Object.freeze(COMMANDS.map((entry) => Object.freeze(entry)));
99
+ const BY_PATH = new Map(CATALOG.map((entry) => [entry.path.join(' '), entry]));
100
+ const ROUTABLE = [...CATALOG]
101
+ .filter((entry) => entry.execution === 'remote');
102
+
103
+ export function isRemoteCommand(argv) {
104
+ return ROUTABLE.some((entry) => {
105
+ if (!startsWithPath(argv, entry.path)) return false;
106
+ if (entry.routing === 'exact') return argv.length === entry.path.length;
107
+ if (entry.routing === 'json-only') {
108
+ return argv.slice(entry.path.length).every((argument) => argument === '--json');
109
+ }
110
+ return true;
111
+ });
112
+ }
113
+
114
+ export function remoteCommandPaths() {
115
+ return ROUTABLE.map((entry) => entry.path.join(' ')).sort();
116
+ }
117
+
118
+ export function commandInventory() {
119
+ return CATALOG.map((entry) => ({
120
+ command: entry.path.join(' '),
121
+ route: [...entry.path],
122
+ summary: entry.summary,
123
+ source: 'built-in',
124
+ location: entry.execution === 'local' ? 'client' : entry.execution === 'remote' ? 'resident' : 'hybrid',
125
+ }));
126
+ }
127
+
128
+ // The visible command table in section order, for generated documentation.
129
+ export function commandReference() {
130
+ return SECTIONS.map((section) => ({
131
+ id: section.id,
132
+ title: section.title,
133
+ commands: CATALOG
134
+ .filter((entry) => entry.visible && entry.section === section.id)
135
+ .map((entry) => ({
136
+ command: entry.path.join(' '),
137
+ usage: entry.usage,
138
+ summary: entry.summary,
139
+ execution: entry.execution,
140
+ examples: [...entry.examples],
141
+ notes: [...entry.notes],
142
+ })),
143
+ }));
144
+ }
145
+
146
+ export function helpResponse(argv) {
147
+ const request = parseHelpRequest(argv);
148
+ if (!request) return null;
149
+ if (request.kind === 'top') return { code: 0, text: renderTopHelp(), error: null, top: true };
150
+
151
+ const key = request.path.join(' ');
152
+ const group = GROUPS.find((entry) => pathsEqual(entry.path, request.path));
153
+ const entry = BY_PATH.get(key);
154
+ if (group) return { code: 0, text: renderGroupHelp(group), error: null };
155
+ if (entry) return { code: 0, text: renderCommandHelp(entry), error: null };
156
+
157
+ const ancestor = [...GROUPS]
158
+ .sort((left, right) => right.path.length - left.path.length)
159
+ .find((candidate) => startsWithPath(request.path, candidate.path));
160
+ return {
161
+ code: 1,
162
+ text: ancestor ? renderGroupHelp(ancestor) : renderTopHelp(),
163
+ error: `Unknown help topic "${key}"`,
164
+ };
165
+ }
166
+
167
+ // Whether the first argv token belongs to a client-known command family.
168
+ // Unknown families are candidate resident extension commands and are
169
+ // resolved against the computer's inventory before forwarding.
170
+ export function isKnownCommandFamily(token) {
171
+ return CATALOG.some((entry) => entry.path[0] === token) || GROUPS.some((group) => group.path[0] === token);
172
+ }
173
+
174
+ export function topHelpText() {
175
+ return renderTopHelp();
176
+ }
177
+
178
+ export function commandHelpText(pathText) {
179
+ const path = pathText.split(' ');
180
+ const group = GROUPS.find((entry) => pathsEqual(entry.path, path));
181
+ if (group) return renderGroupHelp(group);
182
+ const entry = BY_PATH.get(pathText);
183
+ return entry ? renderCommandHelp(entry) : null;
184
+ }
185
+
186
+ function command(pathText, usage, summary, section, execution, examples = [], visible = true, routing = 'prefix', notes = []) {
187
+ return { path: pathText.split(' '), usage, summary, section, execution, examples, visible, routing, notes };
188
+ }
189
+
190
+ function parseHelpRequest(argv) {
191
+ const args = argv.filter((argument) => argument !== '--json');
192
+ if (args[0] === 'help') {
193
+ const path = args.slice(1);
194
+ return path.length === 0 ? { kind: 'top' } : { kind: 'path', path };
195
+ }
196
+ if (args.length === 1 && HELP_FLAGS.has(args[0])) return { kind: 'top' };
197
+
198
+ const separator = args.indexOf('--');
199
+ const last = args.at(-1);
200
+ if (!HELP_FLAGS.has(last) || separator !== -1) return null;
201
+ const path = args.slice(0, -1);
202
+ if (path.length === 0) return { kind: 'top' };
203
+ if (BY_PATH.has(path.join(' ')) || GROUPS.some((entry) => pathsEqual(entry.path, path))) {
204
+ return { kind: 'path', path };
205
+ }
206
+
207
+ const leafPrefix = CATALOG.find((entry) => entry.path.length < path.length && startsWithPath(path, entry.path));
208
+ if (leafPrefix) return null;
209
+ return null;
210
+ }
211
+
212
+ function renderTopHelp() {
213
+ const lines = [
214
+ 'EmptyOS external client',
215
+ '',
216
+ 'Usage:',
217
+ ' empty [--computer <alias>] <command> [options]',
218
+ ];
219
+ for (const section of SECTIONS) {
220
+ const entries = CATALOG.filter((entry) => entry.visible && entry.section === section.id);
221
+ lines.push('', section.title, ...formatEntries(entries));
222
+ }
223
+ lines.push('', 'Run `empty <command> --help` for details.');
224
+ return `${lines.join('\n')}\n`;
225
+ }
226
+
227
+ function renderGroupHelp(group) {
228
+ const primary = BY_PATH.get(group.path.join(' '));
229
+ const entries = CATALOG.filter((entry) => entry.visible && entry.section === group.section && entry !== primary);
230
+ const lines = [
231
+ group.title,
232
+ '',
233
+ 'Usage:',
234
+ ` ${primary ? `empty ${primary.usage}` : group.usage}`,
235
+ ];
236
+ if (primary) lines.push('', primary.summary);
237
+ lines.push(
238
+ '',
239
+ 'Commands:',
240
+ ...formatEntries(entries),
241
+ '',
242
+ 'Run `empty <command> --help` for details.',
243
+ '',
244
+ );
245
+ return lines.join('\n');
246
+ }
247
+
248
+ function renderCommandHelp(entry) {
249
+ const lines = [
250
+ 'Usage:',
251
+ ` empty ${entry.usage}`,
252
+ '',
253
+ entry.summary,
254
+ ];
255
+ if (entry.notes.length > 0) lines.push('', ...entry.notes);
256
+ if (entry.examples.length > 0) lines.push('', 'Examples:', ...entry.examples.map((example) => ` ${example}`));
257
+ return `${lines.join('\n')}\n`;
258
+ }
259
+
260
+ function formatEntries(entries) {
261
+ const names = entries.map((entry) => entry.path.join(' '));
262
+ const width = Math.max(...names.map((name) => name.length));
263
+ return entries.map((entry, index) => ` ${names[index].padEnd(width)} ${entry.summary}`);
264
+ }
265
+
266
+ function startsWithPath(argv, commandPath) {
267
+ return commandPath.every((part, index) => argv[index] === part);
268
+ }
269
+
270
+ function pathsEqual(left, right) {
271
+ return left.length === right.length && left.every((part, index) => right[index] === part);
272
+ }
package/lib/config.js ADDED
@@ -0,0 +1,189 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { ALIAS_PATTERN, SSH_TARGET_PATTERN } from './constants.js';
6
+ import { ClientError } from './errors.js';
7
+
8
+ const CONFIG_VERSION = 1;
9
+
10
+ export function configPath(env = process.env) {
11
+ const base = env.XDG_CONFIG_HOME ? path.resolve(env.XDG_CONFIG_HOME) : path.join(os.homedir(), '.config');
12
+ return path.join(base, 'emptyos', 'client.json');
13
+ }
14
+
15
+ export function emptyConfig() {
16
+ return { version: CONFIG_VERSION, defaultComputer: null, computers: {} };
17
+ }
18
+
19
+ export function readConfig(env = process.env) {
20
+ const file = configPath(env);
21
+ let raw;
22
+ try {
23
+ raw = fs.readFileSync(file, 'utf8');
24
+ } catch (error) {
25
+ if (error.code === 'ENOENT') return emptyConfig();
26
+ throw new ClientError(`Cannot read client config at ${file}: ${error.message}`);
27
+ }
28
+
29
+ let value;
30
+ try {
31
+ value = JSON.parse(raw);
32
+ } catch (error) {
33
+ throw new ClientError(`Invalid client config at ${file}: ${error.message}`);
34
+ }
35
+ validateConfig(value, file);
36
+ return value;
37
+ }
38
+
39
+ export function writeConfig(config, env = process.env) {
40
+ // TODO: serialize the client profile read-modify-write transaction across
41
+ // processes before external beta. Atomic replacement prevents torn files,
42
+ // but concurrent profile mutations can still overwrite one another.
43
+ const file = configPath(env);
44
+ validateConfig(config, file);
45
+ const dir = path.dirname(file);
46
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
47
+ fs.chmodSync(dir, 0o700);
48
+
49
+ const temp = path.join(dir, `.${path.basename(file)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
50
+ let fd;
51
+ try {
52
+ fd = fs.openSync(temp, 'wx', 0o600);
53
+ fs.writeFileSync(fd, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
54
+ fs.fsyncSync(fd);
55
+ fs.closeSync(fd);
56
+ fd = undefined;
57
+ fs.renameSync(temp, file);
58
+ fs.chmodSync(file, 0o600);
59
+ } catch (error) {
60
+ if (fd !== undefined) fs.closeSync(fd);
61
+ try {
62
+ fs.unlinkSync(temp);
63
+ } catch (unlinkError) {
64
+ if (unlinkError.code !== 'ENOENT') throw unlinkError;
65
+ }
66
+ throw new ClientError(`Cannot write client config at ${file}: ${error.message}`);
67
+ }
68
+ }
69
+
70
+ export function validateAlias(alias) {
71
+ if (!ALIAS_PATTERN.test(alias ?? '')) {
72
+ throw new ClientError('Computer alias must contain only lowercase letters, digits, and single hyphens');
73
+ }
74
+ return alias;
75
+ }
76
+
77
+ export function validateTarget(target) {
78
+ if (!SSH_TARGET_PATTERN.test(target ?? '')) {
79
+ throw new ClientError('SSH host must be a hostname or user@hostname (use ~/.ssh/config for ports and options)');
80
+ }
81
+ return target;
82
+ }
83
+
84
+ export function validateReleaseCatalog(value) {
85
+ let url;
86
+ try {
87
+ url = new URL(value);
88
+ } catch {
89
+ throw new ClientError('Platform release catalog must be an HTTPS URL');
90
+ }
91
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash) {
92
+ throw new ClientError('Platform release catalog must be an HTTPS URL without credentials or a fragment');
93
+ }
94
+ return url.href;
95
+ }
96
+
97
+ export function selectComputer(config, explicitAlias) {
98
+ const alias = explicitAlias ?? config.defaultComputer;
99
+ const source = explicitAlias ? 'explicit' : 'default';
100
+ if (!alias) {
101
+ throw new ClientError(
102
+ 'No computer selected; run `empty connect <computer-url>` or select one with --computer <alias> or `empty computer use <alias>`',
103
+ );
104
+ }
105
+ const profile = config.computers[alias];
106
+ if (!profile) throw new ClientError(`Computer "${alias}" is not configured`);
107
+ return { alias, ...profile, source };
108
+ }
109
+
110
+ function validateConfig(value, file) {
111
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
112
+ throw new ClientError(`Invalid client config at ${file}: expected an object`);
113
+ }
114
+ if (value.version !== CONFIG_VERSION) {
115
+ throw new ClientError(`Unsupported client config version ${JSON.stringify(value.version)} at ${file}`);
116
+ }
117
+ if (!value.computers || typeof value.computers !== 'object' || Array.isArray(value.computers)) {
118
+ throw new ClientError(`Invalid client config at ${file}: computers must be an object`);
119
+ }
120
+ for (const [alias, profile] of Object.entries(value.computers)) {
121
+ validateAlias(alias);
122
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
123
+ throw new ClientError(`Invalid profile for computer "${alias}" at ${file}`);
124
+ }
125
+ validateTarget(profile.target);
126
+ if (profile.releaseCatalog !== undefined) validateReleaseCatalog(profile.releaseCatalog);
127
+ validateTunnelProfile(profile, alias, file);
128
+ }
129
+ if (value.defaultComputer !== null && typeof value.defaultComputer !== 'string') {
130
+ throw new ClientError(`Invalid default computer at ${file}`);
131
+ }
132
+ if (value.defaultComputer !== null && !value.computers[value.defaultComputer]) {
133
+ throw new ClientError(`Default computer "${value.defaultComputer}" is not configured`);
134
+ }
135
+ }
136
+
137
+ function validateTunnelProfile(profile, alias, file) {
138
+ const fields = ['ownerOrigin', 'tunnelTokenPath', 'sshIdentityPath', 'sshKnownHostsPath'];
139
+ const present = fields.filter((field) => profile[field] !== undefined);
140
+ if (present.length === 0) return;
141
+ if (present.length !== fields.length) {
142
+ throw new ClientError(`Invalid tunnel profile for computer "${alias}" at ${file}: ${fields.join(', ')} must be configured together`);
143
+ }
144
+
145
+ let origin;
146
+ try {
147
+ origin = new URL(profile.ownerOrigin);
148
+ } catch {
149
+ throw new ClientError(`Invalid tunnel profile for computer "${alias}" at ${file}: ownerOrigin must be an HTTPS origin`);
150
+ }
151
+ if (
152
+ origin.protocol !== 'https:'
153
+ || origin.username
154
+ || origin.password
155
+ || origin.pathname !== '/'
156
+ || origin.search
157
+ || origin.hash
158
+ ) {
159
+ throw new ClientError(`Invalid tunnel profile for computer "${alias}" at ${file}: ownerOrigin must be an HTTPS origin`);
160
+ }
161
+
162
+ validateAbsoluteFile(profile.tunnelTokenPath, 'tunnelTokenPath', alias, file, { privateFile: true });
163
+ validateAbsoluteFile(profile.sshIdentityPath, 'sshIdentityPath', alias, file, { privateFile: true });
164
+ validateAbsoluteFile(profile.sshKnownHostsPath, 'sshKnownHostsPath', alias, file);
165
+ }
166
+
167
+ function validateAbsoluteFile(value, field, alias, configFile, { privateFile = false } = {}) {
168
+ if (typeof value !== 'string' || !path.isAbsolute(value) || value.includes('\0') || value.includes('\n') || value.includes('\r')) {
169
+ throw new ClientError(`Invalid tunnel profile for computer "${alias}" at ${configFile}: ${field} must be an absolute file path`);
170
+ }
171
+ let stat;
172
+ try {
173
+ stat = fs.lstatSync(value);
174
+ } catch (error) {
175
+ throw new ClientError(`Invalid tunnel profile for computer "${alias}" at ${configFile}: cannot inspect ${field}: ${error.message}`);
176
+ }
177
+ if (!stat.isFile() || stat.isSymbolicLink()) {
178
+ throw new ClientError(`Invalid tunnel profile for computer "${alias}" at ${configFile}: ${field} must be a regular file, not a symbolic link`);
179
+ }
180
+ if (privateFile && (stat.mode & 0o777) !== 0o600) {
181
+ throw new ClientError(`Invalid tunnel profile for computer "${alias}" at ${configFile}: ${field} must have mode 0600`);
182
+ }
183
+ if (!privateFile && (stat.mode & 0o022) !== 0) {
184
+ throw new ClientError(`Invalid tunnel profile for computer "${alias}" at ${configFile}: ${field} must not be writable by group or other users`);
185
+ }
186
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
187
+ throw new ClientError(`Invalid tunnel profile for computer "${alias}" at ${configFile}: ${field} must be owned by the current user`);
188
+ }
189
+ }
@@ -0,0 +1,13 @@
1
+ export const CLIENT_VERSION = '0.1.0';
2
+ export const PROTOCOL_VERSION = 2;
3
+
4
+ export const ALIAS_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
+ export const ID_PATTERN = /^[a-z0-9-]+$/;
6
+
7
+ // Deliberately excludes SSH options, ports, paths, IPv6 literals, and shell
8
+ // metacharacters. Users that need richer SSH setup can use ~/.ssh/config and
9
+ // register its Host alias here.
10
+ const USER = '[A-Za-z0-9_][A-Za-z0-9_.-]*';
11
+ const HOST_LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?';
12
+ const HOST = `${HOST_LABEL}(?:\\.${HOST_LABEL})*`;
13
+ export const SSH_TARGET_PATTERN = new RegExp(`^(?:${USER}@)?${HOST}$`);
package/lib/errors.js ADDED
@@ -0,0 +1,16 @@
1
+ export class ClientError extends Error {
2
+ constructor(message, code = 'client-error') {
3
+ super(message);
4
+ this.code = code;
5
+ }
6
+ }
7
+
8
+ // An argument/usage mistake: `command` is the catalog path ("computer add")
9
+ // whose scoped help is appended to the error output; without one the
10
+ // top-level client help is appended instead.
11
+ export class UsageError extends ClientError {
12
+ constructor(message, command) {
13
+ super(message, 'usage');
14
+ this.command = command;
15
+ }
16
+ }
package/lib/git.js ADDED
@@ -0,0 +1,28 @@
1
+ import { runCaptured, runInherited } from './process.js';
2
+ import { sshCommand, sshTransport } from './rpc.js';
3
+
4
+ export function gitCaptured(args, { cwd, env = process.env, profile } = {}) {
5
+ return runCaptured(gitCommand(env), args, { cwd, env: gitEnvironment(profile, env) });
6
+ }
7
+
8
+ export function gitInherited(args, { cwd, env = process.env, profile } = {}) {
9
+ return runInherited(gitCommand(env), args, { cwd, env: gitEnvironment(profile, env) });
10
+ }
11
+
12
+ export function gitEnvironment(profile, env = process.env) {
13
+ if (!profile || typeof profile === 'string' || !profile.ownerOrigin) return env;
14
+ const transport = sshTransport(profile, env);
15
+ return {
16
+ ...env,
17
+ GIT_SSH_COMMAND: [sshCommand(env), ...transport.options].map(shellQuote).join(' '),
18
+ GIT_SSH_VARIANT: 'ssh',
19
+ };
20
+ }
21
+
22
+ function gitCommand(env) {
23
+ return env.EMPTYOS_GIT_COMMAND || 'git';
24
+ }
25
+
26
+ function shellQuote(value) {
27
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
28
+ }
package/lib/koans.js ADDED
@@ -0,0 +1,26 @@
1
+ // The landing page's voice, rationed: a koan decorates the client's quiet
2
+ // moments — an empty catalog, the top-level help — and only for a person at
3
+ // a terminal. Pipes, --json, errors, and the resident CLI never carry one.
4
+ export const KOANS = Object.freeze([
5
+ ['Nothing is missing.'],
6
+ ['Emptiness is infinite possibility, plus excellent storage.'],
7
+ ['Form is emptiness. Emptiness is a 2-core VM.'],
8
+ ['An empty room is not lacking. It is waiting.'],
9
+ ['A student asked the master: "What software should my computer come with?"', 'The master said nothing.'],
10
+ ['EmptyOS — nothing, as a service.'],
11
+ ['Nothing yet. Nothing wrong.'],
12
+ ].map(Object.freeze));
13
+
14
+ // One koan per UTC day, so a session sees a steady line rather than a slot
15
+ // machine. Returns the lines to splice after a message: a blank line, then
16
+ // the koan indented like a hint. Empty when the output is not for a person.
17
+ export function koanLines({ env = process.env, stdout = process.stdout, now = new Date() } = {}) {
18
+ if (env.EMPTYOS_PLAIN === '1' || !stdout.isTTY) return [];
19
+ const day = Math.floor(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) / 86_400_000);
20
+ return ['', ...KOANS[day % KOANS.length].map((line) => ` ${line}`)];
21
+ }
22
+
23
+ export function withKoan(text, options) {
24
+ const lines = koanLines(options);
25
+ return lines.length === 0 ? text : `${text}${lines.join('\n')}\n`;
26
+ }
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import YAML from 'yaml';
4
+ import { ClientError } from './errors.js';
5
+ import { ID_PATTERN } from './constants.js';
6
+
7
+ export function readThingId(root) {
8
+ const file = path.join(root, 'thing.yaml');
9
+ const raw = readManifestText(file);
10
+
11
+ const ids = [];
12
+ for (const line of raw.split(/\r?\n/)) {
13
+ const match = /^(?:id|"id"|'id')\s*:\s*(?:([a-z0-9-]+)|"([a-z0-9-]+)"|'([a-z0-9-]+)')\s*(?:#.*)?$/.exec(line);
14
+ if (match) ids.push(match[1] ?? match[2] ?? match[3]);
15
+ else if (/^(?:id|"id"|'id')\s*:/.test(line)) {
16
+ throw new ClientError(`${file} must use a lowercase top-level id scalar`, 'invalid-manifest');
17
+ }
18
+ }
19
+ if (ids.length !== 1 || !ID_PATTERN.test(ids[0])) {
20
+ throw new ClientError(`${file} must contain exactly one top-level \`id: <thing-id>\``, 'invalid-manifest');
21
+ }
22
+ return ids[0];
23
+ }
24
+
25
+ export function readThingMetadata(root) {
26
+ const file = path.join(root, 'thing.yaml');
27
+ const text = readManifestText(file);
28
+ let raw;
29
+ try {
30
+ raw = YAML.parse(text);
31
+ } catch (error) {
32
+ throw new ClientError(`Cannot parse ${file}: ${error.message}`, 'invalid-manifest');
33
+ }
34
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
35
+ throw new ClientError(`${file} must be a YAML mapping`, 'invalid-manifest');
36
+ }
37
+ if (typeof raw.id !== 'string' || !ID_PATTERN.test(raw.id)) {
38
+ throw new ClientError(`${file} id is required and must match [a-z0-9-]+`, 'invalid-manifest');
39
+ }
40
+ if (typeof raw.name !== 'string' || raw.name.trim() === '') {
41
+ throw new ClientError(`${file} name is required`, 'invalid-manifest');
42
+ }
43
+ if (raw.type !== 'static' && raw.type !== 'service') {
44
+ throw new ClientError(`${file} type must be "static" or "service"`, 'invalid-manifest');
45
+ }
46
+ const visibility = raw.visibility ?? 'private';
47
+ if (visibility !== 'private' && visibility !== 'public') {
48
+ throw new ClientError(`${file} visibility must be "private" or "public"`, 'invalid-manifest');
49
+ }
50
+ const route = raw.route ?? `/${raw.id}`;
51
+ if (
52
+ typeof route !== 'string' || !route.startsWith('/') || route.length < 2 || route.endsWith('/') ||
53
+ route === '/api' || route.startsWith('/api/') || route === '/_system' || route.startsWith('/_system/')
54
+ ) {
55
+ throw new ClientError(`${file} has an invalid or reserved route`, 'invalid-manifest');
56
+ }
57
+ let project = null;
58
+ if (Object.hasOwn(raw, 'project')) {
59
+ if (typeof raw.project !== 'string' || !ID_PATTERN.test(raw.project)) {
60
+ throw new ClientError(`${file} project must match [a-z0-9-]+`, 'invalid-manifest');
61
+ }
62
+ project = raw.project;
63
+ }
64
+ if (Object.hasOwn(raw, 'data') && (
65
+ !Array.isArray(raw.data) ||
66
+ !raw.data.every((id) => typeof id === 'string' && ID_PATTERN.test(id)) ||
67
+ new Set(raw.data).size !== raw.data.length
68
+ )) {
69
+ throw new ClientError(`${file} data must be a list of unique Dataset ids matching [a-z0-9-]+`, 'invalid-manifest');
70
+ }
71
+ if (raw.type === 'static') validateStaticMetadata(root, file, raw);
72
+ else validateServiceMetadata(file, raw);
73
+ return { id: raw.id, name: raw.name, type: raw.type, visibility, project };
74
+ }
75
+
76
+ function validateStaticMetadata(root, file, raw) {
77
+ const directory = raw.directory ?? '.';
78
+ if (typeof directory !== 'string' || directory === '') {
79
+ throw new ClientError(`${file} directory must be a non-empty string`, 'invalid-manifest');
80
+ }
81
+ const resolved = path.resolve(root, directory);
82
+ if (!pathInside(root, resolved)) {
83
+ throw new ClientError(`${file} directory must resolve inside the Thing repository`, 'invalid-manifest');
84
+ }
85
+ if (!Object.hasOwn(raw, 'file')) return;
86
+ if (typeof raw.file !== 'string' || raw.file === '' || path.isAbsolute(raw.file) || /[\u0000-\u001f\u007f]/.test(raw.file)) {
87
+ throw new ClientError(`${file} file must be a non-empty safe relative path`, 'invalid-manifest');
88
+ }
89
+ const finalSegment = raw.file.split('/').at(-1);
90
+ const filePath = path.resolve(resolved, path.normalize(raw.file));
91
+ if (finalSegment === '' || finalSegment === '.' || finalSegment === '..' || filePath === resolved || !pathInside(resolved, filePath)) {
92
+ throw new ClientError(`${file} file must name a file inside the static directory`, 'invalid-manifest');
93
+ }
94
+ }
95
+
96
+ function validateServiceMetadata(file, raw) {
97
+ if (Object.hasOwn(raw, 'file')) throw new ClientError(`${file} file is only valid for a static Thing`, 'invalid-manifest');
98
+ if (typeof raw.command !== 'string' || raw.command.trim() === '' || /[\u0000-\u001f]/.test(raw.command)) {
99
+ throw new ClientError(`${file} service command is required and must not contain control characters`, 'invalid-manifest');
100
+ }
101
+ if (!Number.isInteger(raw.port) || raw.port < 1024 || raw.port > 65535) {
102
+ throw new ClientError(`${file} service port must be an integer from 1024 to 65535`, 'invalid-manifest');
103
+ }
104
+ }
105
+
106
+ function pathInside(base, target) {
107
+ const relative = path.relative(base, target);
108
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
109
+ }
110
+
111
+ function readManifestText(file) {
112
+ try {
113
+ return fs.readFileSync(file, 'utf8');
114
+ } catch (error) {
115
+ throw new ClientError(`Cannot read ${file}: ${error.message}`, 'invalid-manifest');
116
+ }
117
+ }