@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vishal Kapur
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,264 @@
1
+ # EmptyOS external client
2
+
3
+ This package is the workstation-side `empty` command, published to npm as
4
+ `@emptyos/client`. Run every `empty` command in the local terminal: the client
5
+ either handles it locally or transports it to the selected EmptyOS computer.
6
+ It requires Node.js 20 or newer.
7
+
8
+ Installing the client, connecting to a computer, putting files on it, sharing,
9
+ and publishing are described at https://emptyos.com/docs/. This README keeps
10
+ the contracts behind those pages: profile format, transport, publish
11
+ mechanics, and development notes.
12
+
13
+ The companion agent skill is at `skills/emptyos-computer/` and ships in the
14
+ package. `empty skill install` copies it to `~/.agents/skills/emptyos-computer/`;
15
+ `--dir <path>` chooses another skills directory, and a re-run replaces the
16
+ earlier copy. It is a small bootstrap into this client and the resident
17
+ `~/EMPTY.md` contract.
18
+
19
+ For checkout development, the repository root provides a managed local
20
+ computer and isolated client profile. Run `npm run dev` at the root, then use
21
+ `npm run empty -- <command>` in another terminal. This exercises the same
22
+ structured request and Git transport boundaries without linking the client or
23
+ editing your real profiles; see the root [`README.md`](../README.md#local-development)
24
+ for setup, persistence, testing, and limitations.
25
+
26
+ ## Computer profiles
27
+
28
+ Registering a computer makes one noninteractive RPC call to confirm that the
29
+ computer speaks protocol version 2. It does not make the new profile the
30
+ default automatically.
31
+
32
+ ```sh
33
+ empty computer add personal \
34
+ --host owner@personal.example
35
+ empty computer use personal
36
+ empty computers
37
+ empty computer show --json
38
+ empty computer remove personal
39
+ ```
40
+
41
+ `empty connect <computer-url>` pairs with an EmptyOS-managed computer without
42
+ an exe.dev account; the walkthrough is at https://emptyos.com/docs/start/. On
43
+ approval the client stores a new Ed25519 identity, tunnel token, and pinned
44
+ host key under its private config directory. Pairing secrets and tunnel token
45
+ contents are never placed in URLs, process arguments, or logs. `connect`
46
+ selects the new profile when no default computer exists; otherwise the
47
+ existing default remains unchanged.
48
+
49
+ Use an SSH config `Host` name when a connection needs a port, proxy, key, or
50
+ other SSH option. Stored targets intentionally accept only a hostname or
51
+ `user@hostname` so they cannot inject SSH options or remote shell text.
52
+
53
+ An EmptyOS-managed tunnel profile can use the same version 1 config format
54
+ without requiring an exe.dev account for the person using the client. Its
55
+ four tunnel fields are all required together:
56
+
57
+ ```json
58
+ {
59
+ "target": "exedev@computer.example",
60
+ "ownerOrigin": "https://computer.example",
61
+ "tunnelTokenPath": "/Users/me/.config/emptyos/computer.token",
62
+ "sshIdentityPath": "/Users/me/.ssh/emptyos_computer",
63
+ "sshKnownHostsPath": "/Users/me/.ssh/emptyos_known_hosts"
64
+ }
65
+ ```
66
+
67
+ `ownerOrigin` must be a credential-free HTTPS origin. All three configured
68
+ files must be regular files owned by the current user and use absolute paths.
69
+ The token and private key must have mode `0600`; the known-hosts file must not
70
+ be group- or other-writable. The client reads the bearer token only
71
+ inside its SSH proxy helper. Token contents never appear in process arguments,
72
+ URLs, profile output, or logs. Tunnel SSH ignores user SSH configuration,
73
+ uses only the configured identity, and requires the pinned known-hosts entry.
74
+ Existing profiles containing only `target` continue to use direct SSH.
75
+
76
+ Profiles live at `$XDG_CONFIG_HOME/emptyos/client.json`, or at
77
+ `~/.config/emptyos/client.json` when `XDG_CONFIG_HOME` is unset. The directory
78
+ is mode `0700` and the atomically replaced file is mode `0600`.
79
+
80
+ `--release-catalog` is optional. It replaces the official HTTPS catalog used
81
+ by `empty computer update` for that profile. Every profile can also update
82
+ from an explicitly supplied catalog or local release artifact.
83
+
84
+ `--computer <alias>` overrides the default for a command that operates on a
85
+ computer:
86
+
87
+ ```sh
88
+ empty --computer work things --json
89
+ ```
90
+
91
+ It is rejected for `connect` and the local profile-registry commands
92
+ `computers`, `computer add`, `computer use`, and `computer remove`, where an
93
+ override would otherwise be silently ignored.
94
+
95
+ ## Commands and help
96
+
97
+ `empty --help` is the complete command inventory. Help is resolved locally, so
98
+ it works without a selected computer or SSH connection:
99
+
100
+ ```sh
101
+ empty --help
102
+ empty thing --help
103
+ empty put --help
104
+ empty help data describe
105
+ ```
106
+
107
+ The client handles computer profiles, help, and version output locally. `put`,
108
+ `clone`, platform updates, and `ssh` combine local state with Git or SSH.
109
+ A route outside the client's command families is checked against the
110
+ computer's command inventory: a registered resident owner command is forwarded
111
+ as structured argv so it works without a client release, while anything else
112
+ fails locally with client usage. `empty commands` merges both command
113
+ inventories.
114
+
115
+ Human catalog commands use the selected computer alias in useful empty states:
116
+
117
+ ```text
118
+ Computer personal is empty — no Things yet
119
+
120
+ Put its first Thing:
121
+ empty put <path> [--as <id>]
122
+ empty put <path> [--as <id>] [--name <name>] [--project <project-id>] [--public]
123
+ ```
124
+
125
+ The client checks emptiness through the existing JSON form. Non-empty tables
126
+ remain resident-rendered and pass through unchanged. Explicit `--json` output
127
+ preserves valid resident results; workstation, Git, transport, and resident
128
+ failures return one JSON object with a stable `code` and human-readable
129
+ `error`. `thing add <path>` remains unavailable from a workstation because its
130
+ argument would be a path on the computer; its help explains the supported
131
+ `put` workflow.
132
+
133
+ Forwarded commands use one fixed SSH remote command with a base64url-encoded
134
+ JSON request. A Thing import uses a separate fixed endpoint for the same
135
+ structured metadata and streams a Git bundle on stdin, so file bytes never
136
+ enter the bounded argv payload. SSH runs with `BatchMode=yes`, a ten-second
137
+ connection timeout, and no TTY. Human-mode remote output passes through;
138
+ structured commands preserve valid resident JSON and normalize local or
139
+ transport failures at the client error boundary. When SSH itself fails (exit
140
+ 255), the tunnel helper first explains why the computer is unreachable, such
141
+ as an address that no longer resolves, and the client ends with a hint naming
142
+ the computer profile.
143
+
144
+ Before an assigned create or `put`, the client reads the resident version
145
+ response. Generated starter assignment requires `thingCreateProject`; `put`
146
+ requires the type-neutral `thingImport` capability. The client and computer use
147
+ protocol 2; unsupported forms stop before staging or upload with an explicit
148
+ `empty computer update` remediation.
149
+
150
+ `empty ssh` without arguments opens the ordinary owner SSH session. Arguments
151
+ after `--` are an explicit raw remote command and consequently have the normal
152
+ remote-shell semantics of `ssh host command...`; use transported `empty`
153
+ commands for structured, injection-resistant RPC.
154
+
155
+ ## Koans
156
+
157
+ At a terminal, the client's quiet moments end with one of the landing page's
158
+ koans: `empty computers` with no profiles, `empty things` / `projects` /
159
+ `data` / `changes` on a computer that has none yet, and the top-level help.
160
+ The koan rotates daily. Piped output, `--json`, errors, command help, and the
161
+ resident CLI never carry one; set `EMPTYOS_PLAIN=1` to turn them off entirely.
162
+
163
+ ## Platform updates
164
+
165
+ Check the selected computer without changing it, then apply the configured
166
+ release when the plan is acceptable:
167
+
168
+ ```sh
169
+ empty computer update --check
170
+ empty computer update
171
+ ```
172
+
173
+ `--computer <alias>` selects a different profile for either command. `--json`
174
+ returns the selected computer, release and artifact identifiers, status,
175
+ per-path plan, warnings, updater output, and exit status without decorating
176
+ them.
177
+
178
+ If apply reports `needs-reconciliation`, the structured result also includes a
179
+ retained stage and exact next command. Continue from an agent: inspect the
180
+ stage's `RECONCILE.md` and detached candidate through `empty ssh`, merge only
181
+ the declared conflicting paths, run the candidate tests, then resume the same
182
+ transaction:
183
+
184
+ ```sh
185
+ empty computer update --resume <stage-name>
186
+ ```
187
+
188
+ Resume does not fetch a newer release. It reuses the retained target and
189
+ rejects stale live state, edits outside the conflict set, or failed validators.
190
+
191
+ The default source is the official catalog at
192
+ `https://release.emptyos.com/platform/v1/catalog.json`. A profile-level
193
+ `--release-catalog` replaces it for that computer. Supply `--catalog` to
194
+ override either catalog for one invocation, or use an immutable artifact that
195
+ was obtained through a trusted operator channel:
196
+
197
+ ```sh
198
+ empty computer update --catalog https://releases.example/emptyos/catalog.json
199
+ empty --computer work computer update --release ./emptyos-platform-release.json
200
+ ```
201
+
202
+ The client validates HTTPS on every catalog redirect, bounds downloads,
203
+ verifies the catalog's artifact SHA-256, and validates the canonical artifact
204
+ and every embedded file before staging it. These checks detect corruption and
205
+ catalog/artifact mismatch; they are not a publisher signature. Trust comes
206
+ from the official HTTPS authority, a profile or invocation catalog selected by
207
+ the operator, or the trusted channel used to obtain a local artifact.
208
+
209
+ ## Publish contract
210
+
211
+ `put`, `clone`, and `put .` are described at https://emptyos.com/docs/publish/.
212
+ This section keeps the mechanics behind them.
213
+
214
+ `put` snapshots the selected content as one parentless commit without source
215
+ history, remotes, hooks, or Git configuration, and streams it as a Git bundle
216
+ on stdin to a fixed import endpoint. Upload happens before the resident
217
+ mutation lock; installation is revalidated and atomic, and a failed validation
218
+ leaves no partial Thing behind. Successful initial `--json` output reports the
219
+ detected `sourceKind`, content counts, installed head and manifest facts,
220
+ generated `scaffolding`, `preparation`, `verification`, `installation`, and
221
+ service registration state.
222
+
223
+ `clone` first confirms the Thing exists, clones `<ssh-target>:things/<id>`,
224
+ validates the top-level `id` in `thing.yaml`, and stores three clone-local Git
225
+ keys:
226
+
227
+ - `emptyos.computer`
228
+ - `emptyos.profileTarget`
229
+ - `emptyos.target` (`thing://<id>`)
230
+
231
+ The profile target snapshot prevents an alias that was later retargeted from
232
+ silently publishing a checkout to another computer. Clone JSON reports the
233
+ local root plus the computer, immutable profile target, and `thing://<id>`
234
+ binding.
235
+
236
+ Publish never rewrites local history. It requires:
237
+
238
+ - a bound repository on an attached branch;
239
+ - a matching profile target and `thing.yaml` ID;
240
+ - no tracked or untracked worktree changes;
241
+ - a regular, executable, non-symlink `./verify` that succeeds locally; and
242
+ - exactly one non-merge commit on top of the current origin default-branch
243
+ HEAD.
244
+
245
+ After fetching and validating the live head, publish pushes `HEAD` to
246
+ `refs/emptyos/candidates/<full-sha>` and requests:
247
+
248
+ ```text
249
+ thing activate <id> --sha <sha> --expected-base <live-head> [--json]
250
+ ```
251
+
252
+ The activation response and exit status are passed through to the caller.
253
+
254
+ ## Development
255
+
256
+ ```sh
257
+ cd client
258
+ npm install
259
+ npm link # optional: run this checkout as `empty`
260
+ npm test
261
+ ```
262
+
263
+ Tests use temporary real Git repositories and fake `ssh`/`git` executables;
264
+ they do not require a network connection or an EmptyOS computer.
package/bin/empty.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../lib/cli.js';
3
+
4
+ process.exitCode = await main(process.argv.slice(2));
package/lib/browser.js ADDED
@@ -0,0 +1,23 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ // Best effort: hand the URL to the platform's default handler and report
4
+ // whether that opener started. Callers keep the URL printed for the manual path.
5
+ export function openInBrowser(url, { platform = process.platform, spawnImpl = spawn } = {}) {
6
+ const [command, args] = platform === 'darwin' ? ['open', [url]]
7
+ : platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
8
+ : ['xdg-open', [url]];
9
+ return new Promise((resolve) => {
10
+ let child;
11
+ try {
12
+ child = spawnImpl(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
13
+ } catch {
14
+ resolve(false);
15
+ return;
16
+ }
17
+ child.once('error', () => resolve(false));
18
+ child.once('spawn', () => {
19
+ child.unref();
20
+ resolve(true);
21
+ });
22
+ });
23
+ }
@@ -0,0 +1,73 @@
1
+ const SPECS = Object.freeze({
2
+ things: {
3
+ empty(value) {
4
+ return Array.isArray(value) && value.length === 0;
5
+ },
6
+ message(alias, koan = []) {
7
+ return [
8
+ `Computer ${alias} is empty — no Things yet`,
9
+ ...koan,
10
+ '',
11
+ 'Put its first Thing:',
12
+ ' empty put <path> [--as <id>]',
13
+ '',
14
+ ].join('\n');
15
+ },
16
+ },
17
+ projects: {
18
+ empty(value) {
19
+ return Array.isArray(value) && value.length === 0;
20
+ },
21
+ message(alias, koan = []) {
22
+ return [
23
+ `Computer ${alias} has no Projects yet`,
24
+ ...koan,
25
+ '',
26
+ 'Create one:',
27
+ ' empty project create <id> <name>',
28
+ '',
29
+ ].join('\n');
30
+ },
31
+ },
32
+ data: {
33
+ empty(value) {
34
+ return Array.isArray(value) && value.length === 0;
35
+ },
36
+ message(alias, koan = []) {
37
+ return [
38
+ `Computer ${alias} has no Datasets yet`,
39
+ ...koan,
40
+ '',
41
+ 'Use `empty ssh` for resident Data work; SQLite files stay on the computer',
42
+ '',
43
+ ].join('\n');
44
+ },
45
+ },
46
+ changes: {
47
+ empty(value) {
48
+ return value && typeof value === 'object' && Array.isArray(value.changes) && value.changes.length === 0;
49
+ },
50
+ message(alias, koan = []) {
51
+ return [`Computer ${alias} has no committed changes yet`, ...koan, ''].join('\n');
52
+ },
53
+ humanEmptyOutput: 'No committed changes\n',
54
+ },
55
+ });
56
+
57
+ export function humanCatalogSpec(argv) {
58
+ if (argv.length !== 1) return null;
59
+ return SPECS[argv[0]] ? { command: argv[0], ...SPECS[argv[0]] } : null;
60
+ }
61
+
62
+ export function parseCatalogJson(command, stdout) {
63
+ let value;
64
+ try {
65
+ value = JSON.parse(stdout);
66
+ } catch {
67
+ return null;
68
+ }
69
+ const valid = command === 'changes'
70
+ ? value && typeof value === 'object' && Array.isArray(value.changes)
71
+ : Array.isArray(value);
72
+ return valid ? value : null;
73
+ }