@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 +21 -0
- package/README.md +264 -0
- package/bin/empty.js +4 -0
- package/lib/browser.js +23 -0
- package/lib/catalog-output.js +73 -0
- package/lib/cli.js +985 -0
- package/lib/commands.js +272 -0
- package/lib/config.js +189 -0
- package/lib/constants.js +13 -0
- package/lib/errors.js +16 -0
- package/lib/git.js +28 -0
- package/lib/koans.js +26 -0
- package/lib/manifest.js +117 -0
- package/lib/pairing.js +466 -0
- package/lib/platform-update.js +304 -0
- package/lib/process.js +97 -0
- package/lib/rpc.js +161 -0
- package/lib/skill-install.js +65 -0
- package/lib/thing-put.js +406 -0
- package/lib/tunnel-proxy.js +119 -0
- package/package.json +29 -0
- package/skills/emptyos-computer/SKILL.md +233 -0
- package/skills/emptyos-computer/agents/openai.yaml +4 -0
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
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
|
+
}
|