@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/lib/cli.js
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CLIENT_VERSION, ID_PATTERN, PROTOCOL_VERSION } from './constants.js';
|
|
4
|
+
import {
|
|
5
|
+
readConfig,
|
|
6
|
+
selectComputer,
|
|
7
|
+
validateAlias,
|
|
8
|
+
validateReleaseCatalog,
|
|
9
|
+
validateTarget,
|
|
10
|
+
writeConfig,
|
|
11
|
+
} from './config.js';
|
|
12
|
+
import { ClientError, UsageError } from './errors.js';
|
|
13
|
+
import { gitCaptured, gitInherited } from './git.js';
|
|
14
|
+
import {
|
|
15
|
+
cleanPutStage,
|
|
16
|
+
derivedThingId,
|
|
17
|
+
inspectGeneratedSource,
|
|
18
|
+
inspectManifestedSource,
|
|
19
|
+
stageGeneratedThing,
|
|
20
|
+
} from './thing-put.js';
|
|
21
|
+
import { readThingId } from './manifest.js';
|
|
22
|
+
import { runCaptured, runInherited } from './process.js';
|
|
23
|
+
import { rawSsh, rpcCaptured, rpcFileCaptured, rpcFileInherited, rpcInherited } from './rpc.js';
|
|
24
|
+
import { commandHelpText, commandInventory, helpResponse, isKnownCommandFamily, topHelpText } from './commands.js';
|
|
25
|
+
import { humanCatalogSpec, parseCatalogJson } from './catalog-output.js';
|
|
26
|
+
import { updateComputer, validateCatalogUrl } from './platform-update.js';
|
|
27
|
+
import { runTunnelProxy } from './tunnel-proxy.js';
|
|
28
|
+
import { defaultSkillsDir, installSkill } from './skill-install.js';
|
|
29
|
+
import { deriveComputerAlias, pairComputer, validateOwnerOrigin } from './pairing.js';
|
|
30
|
+
import { koanLines, withKoan } from './koans.js';
|
|
31
|
+
|
|
32
|
+
export async function main(rawArgv, {
|
|
33
|
+
env = process.env,
|
|
34
|
+
stdin = process.stdin,
|
|
35
|
+
stdout = process.stdout,
|
|
36
|
+
stderr = process.stderr,
|
|
37
|
+
pairComputerImpl = pairComputer,
|
|
38
|
+
} = {}) {
|
|
39
|
+
let json = false;
|
|
40
|
+
try {
|
|
41
|
+
if (rawArgv[0] === '__ssh-tunnel') {
|
|
42
|
+
if (rawArgv.length !== 3) throw new ClientError('Internal SSH tunnel helper received invalid arguments');
|
|
43
|
+
return await runTunnelProxy(rawArgv[1], rawArgv[2], { stdin, stdout });
|
|
44
|
+
}
|
|
45
|
+
const parsed = parseGlobalOptions(rawArgv);
|
|
46
|
+
const argv = parsed.argv;
|
|
47
|
+
const separator = argv.indexOf('--');
|
|
48
|
+
json = argv.slice(0, separator === -1 ? argv.length : separator).includes('--json');
|
|
49
|
+
|
|
50
|
+
if (argv.length === 1 && argv[0] === '--version') {
|
|
51
|
+
stdout.write(`empty client ${CLIENT_VERSION} (protocol ${PROTOCOL_VERSION})\n`);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (argv.length === 0) {
|
|
55
|
+
stdout.write(withKoan(helpResponse(['--help']).text, { env, stdout }));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
const help = helpResponse(argv);
|
|
59
|
+
if (help) {
|
|
60
|
+
if (help.error) stderr.write(`empty: ${help.error}\n${help.text}`);
|
|
61
|
+
else stdout.write(help.top ? withKoan(help.text, { env, stdout }) : help.text);
|
|
62
|
+
return help.code;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (argv[0] === 'connect') {
|
|
66
|
+
return await commandConnect(argv.slice(1), parsed.computer, { env, stdout, pairComputerImpl });
|
|
67
|
+
}
|
|
68
|
+
if (argv[0] === 'computers') {
|
|
69
|
+
return commandComputers(argv.slice(1), parsed.computer, { env, stdout });
|
|
70
|
+
}
|
|
71
|
+
if (argv[0] === 'computer') {
|
|
72
|
+
return await commandComputer(argv.slice(1), parsed.computer, { env, stdout, stderr, pairComputerImpl });
|
|
73
|
+
}
|
|
74
|
+
if (argv[0] === 'skill') return commandSkill(argv.slice(1), parsed.computer, { env, stdout });
|
|
75
|
+
if (argv[0] === 'ssh') return await commandSsh(argv.slice(1), parsed.computer, { env });
|
|
76
|
+
if (argv[0] === 'put') return await commandPut(argv.slice(1), parsed.computer, { env, stdout, stderr });
|
|
77
|
+
if (argv[0] === 'clone') return await commandClone(argv.slice(1), parsed.computer, { env, stdout, stderr });
|
|
78
|
+
if (argv[0] === 'commands') return await commandCommands(argv.slice(1), parsed.computer, { env, stdout, stderr });
|
|
79
|
+
if (argv[0] === 'thing' && argv[1] === 'add') {
|
|
80
|
+
throw new ClientError(
|
|
81
|
+
'`thing add` takes a path already on the computer and is unavailable from a workstation; use `empty put <local-path>` instead',
|
|
82
|
+
'resident-only-command',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const selected = selectComputer(readConfig(env), parsed.computer);
|
|
86
|
+
const catalog = humanCatalogSpec(argv);
|
|
87
|
+
if (catalog) return await commandHumanCatalog(selected, argv, catalog, { env, stdout, stderr });
|
|
88
|
+
if (isKnownCommandFamily(argv[0])) return await rpcInherited(selected, argv, env);
|
|
89
|
+
return await forwardExtensionCommand(selected, argv, env);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error?.message || String(error);
|
|
92
|
+
if (json) stdout.write(`${JSON.stringify({ error: message, ...(typeof error?.code === 'string' ? { code: error.code } : {}) })}\n`);
|
|
93
|
+
else {
|
|
94
|
+
stderr.write(`empty: ${message}\n`);
|
|
95
|
+
const help = error instanceof UsageError ? (error.command ? commandHelpText(error.command) : topHelpText()) : null;
|
|
96
|
+
if (help) stderr.write(`\n${help}`);
|
|
97
|
+
}
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseGlobalOptions(rawArgv) {
|
|
103
|
+
const argv = [];
|
|
104
|
+
let computer;
|
|
105
|
+
let optionsEnded = false;
|
|
106
|
+
for (let i = 0; i < rawArgv.length; i += 1) {
|
|
107
|
+
const arg = rawArgv[i];
|
|
108
|
+
if (arg === '--') {
|
|
109
|
+
optionsEnded = true;
|
|
110
|
+
argv.push(...rawArgv.slice(i));
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
if (!optionsEnded && arg === '--computer') {
|
|
114
|
+
if (computer !== undefined) throw new ClientError('--computer may only be specified once');
|
|
115
|
+
computer = validateAlias(rawArgv[i + 1]);
|
|
116
|
+
i += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!optionsEnded && arg.startsWith('--computer=')) {
|
|
120
|
+
if (computer !== undefined) throw new ClientError('--computer may only be specified once');
|
|
121
|
+
computer = validateAlias(arg.slice('--computer='.length));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
argv.push(arg);
|
|
125
|
+
}
|
|
126
|
+
return { argv, computer };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function commandComputer(args, explicitComputer, context) {
|
|
130
|
+
const [subcommand, ...rest] = args;
|
|
131
|
+
if (explicitComputer && ['add', 'use', 'remove'].includes(subcommand)) {
|
|
132
|
+
throw new UsageError(`--computer cannot be used with local profile command \`computer ${subcommand}\``, `computer ${subcommand}`);
|
|
133
|
+
}
|
|
134
|
+
if (subcommand === 'add') return computerAdd(rest, context);
|
|
135
|
+
if (subcommand === 'use') return computerUse(rest, context);
|
|
136
|
+
if (subcommand === 'show') return computerShow(rest, explicitComputer, context);
|
|
137
|
+
if (subcommand === 'update') return computerUpdate(rest, explicitComputer, context);
|
|
138
|
+
if (subcommand === 'remove') return computerRemove(rest, context);
|
|
139
|
+
throw new UsageError(`Unknown computer command "${subcommand ?? ''}"`, 'computer');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function commandSkill(args, explicitComputer, { env, stdout }) {
|
|
143
|
+
const [subcommand, ...rest] = args;
|
|
144
|
+
if (subcommand !== 'install') throw new UsageError(`Unknown skill command "${subcommand ?? ''}"`, 'skill install');
|
|
145
|
+
if (explicitComputer) {
|
|
146
|
+
throw new UsageError('--computer cannot be used with local command `skill install`', 'skill install');
|
|
147
|
+
}
|
|
148
|
+
const json = takeJson(rest);
|
|
149
|
+
let dir;
|
|
150
|
+
for (let index = 0; index < json.args.length; index += 1) {
|
|
151
|
+
const option = json.args[index];
|
|
152
|
+
if (option === '--dir') {
|
|
153
|
+
if (dir !== undefined) throw new UsageError('--dir may only be specified once', 'skill install');
|
|
154
|
+
dir = json.args[++index];
|
|
155
|
+
if (!dir || dir.startsWith('--')) throw new UsageError('--dir needs a <path>', 'skill install');
|
|
156
|
+
} else {
|
|
157
|
+
throw new UsageError(`Unknown skill install option ${JSON.stringify(option)}`, 'skill install');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const installed = installSkill(dir ?? defaultSkillsDir(env));
|
|
161
|
+
if (json.enabled) stdout.write(`${JSON.stringify({ installed })}\n`);
|
|
162
|
+
else stdout.write(`Installed skill to ${installed}\n`);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function commandConnect(args, explicitComputer, { env, stdout, pairComputerImpl }) {
|
|
167
|
+
if (explicitComputer) {
|
|
168
|
+
throw new UsageError('--computer cannot be used with local command `connect`', 'connect');
|
|
169
|
+
}
|
|
170
|
+
const [ownerOrigin, ...options] = args;
|
|
171
|
+
if (!ownerOrigin || ownerOrigin.startsWith('--')) {
|
|
172
|
+
throw new UsageError('`connect` needs a <computer-url>', 'connect');
|
|
173
|
+
}
|
|
174
|
+
let alias;
|
|
175
|
+
let label;
|
|
176
|
+
let browser = true;
|
|
177
|
+
for (let index = 0; index < options.length; index += 1) {
|
|
178
|
+
const option = options[index];
|
|
179
|
+
if (option === '--as') {
|
|
180
|
+
if (alias !== undefined) throw new UsageError('--as may only be specified once', 'connect');
|
|
181
|
+
alias = options[++index];
|
|
182
|
+
if (!alias || alias.startsWith('--')) throw new UsageError('--as needs an alias', 'connect');
|
|
183
|
+
validateAlias(alias);
|
|
184
|
+
} else if (option === '--label') {
|
|
185
|
+
if (label !== undefined) throw new UsageError('--label may only be specified once', 'connect');
|
|
186
|
+
label = options[++index];
|
|
187
|
+
if (!label || label.startsWith('--')) throw new UsageError('--label needs a value', 'connect');
|
|
188
|
+
} else if (option === '--no-browser') {
|
|
189
|
+
if (!browser) throw new UsageError('--no-browser may only be specified once', 'connect');
|
|
190
|
+
browser = false;
|
|
191
|
+
} else {
|
|
192
|
+
throw new UsageError(`Unknown connect option ${JSON.stringify(option)}`, 'connect');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const origin = validateOwnerOrigin(ownerOrigin);
|
|
196
|
+
await pairComputerImpl({
|
|
197
|
+
alias: alias ?? deriveComputerAlias(origin),
|
|
198
|
+
ownerOrigin: origin,
|
|
199
|
+
label,
|
|
200
|
+
browser,
|
|
201
|
+
}, { env, stdout });
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function computerAdd(args, { env, stdout, stderr }) {
|
|
206
|
+
const json = takeJson(args);
|
|
207
|
+
const [alias, ...options] = json.args;
|
|
208
|
+
let target;
|
|
209
|
+
let releaseCatalog;
|
|
210
|
+
for (let index = 0; index < options.length; index += 1) {
|
|
211
|
+
const option = options[index];
|
|
212
|
+
if (option === '--host' && target === undefined) {
|
|
213
|
+
target = options[++index];
|
|
214
|
+
if (!target) throw new UsageError('--host needs a host', 'computer add');
|
|
215
|
+
}
|
|
216
|
+
else if (option === '--release-catalog' && releaseCatalog === undefined) {
|
|
217
|
+
releaseCatalog = options[++index];
|
|
218
|
+
if (!releaseCatalog) throw new UsageError('--release-catalog needs an HTTPS URL', 'computer add');
|
|
219
|
+
} else throw new UsageError(`Unknown computer add option ${JSON.stringify(option)}`, 'computer add');
|
|
220
|
+
}
|
|
221
|
+
if (!alias || !target) {
|
|
222
|
+
throw new UsageError('`computer add` needs an <alias> and --host <host>', 'computer add');
|
|
223
|
+
}
|
|
224
|
+
validateAlias(alias);
|
|
225
|
+
validateTarget(target);
|
|
226
|
+
if (releaseCatalog !== undefined) releaseCatalog = validateReleaseCatalog(releaseCatalog);
|
|
227
|
+
const config = readConfig(env);
|
|
228
|
+
if (config.computers[alias]) throw new ClientError(`Computer "${alias}" is already configured`);
|
|
229
|
+
|
|
230
|
+
const check = await rpcCaptured(target, ['--version', '--json'], env);
|
|
231
|
+
if (check.code !== 0) {
|
|
232
|
+
stdout.write(check.stdout);
|
|
233
|
+
stderr.write(check.stderr);
|
|
234
|
+
if (check.transportHint) stderr.write(check.transportHint);
|
|
235
|
+
return check.code;
|
|
236
|
+
}
|
|
237
|
+
const version = parseComputerVersion(check.stdout, alias);
|
|
238
|
+
|
|
239
|
+
config.computers[alias] = { target, ...(releaseCatalog ? { releaseCatalog } : {}) };
|
|
240
|
+
writeConfig(config, env);
|
|
241
|
+
if (json.enabled) stdout.write(`${JSON.stringify({ alias, target, protocolVersion: version.protocolVersion, ...(releaseCatalog ? { releaseCatalog } : {}) })}\n`);
|
|
242
|
+
else {
|
|
243
|
+
stdout.write(config.defaultComputer
|
|
244
|
+
? `Added computer ${alias} (${target}); the default computer remains ${config.defaultComputer}\nSwitch with \`empty computer use ${alias}\`\n`
|
|
245
|
+
: `Added computer ${alias} (${target}); no default computer is selected\nSelect it with \`empty computer use ${alias}\`\n`);
|
|
246
|
+
}
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function commandComputers(args, explicitComputer, { env, stdout }) {
|
|
251
|
+
if (explicitComputer) {
|
|
252
|
+
throw new UsageError('--computer cannot be used with local profile command `computers`', 'computers');
|
|
253
|
+
}
|
|
254
|
+
const json = takeJson(args);
|
|
255
|
+
if (json.args.length) throw new UsageError('`computers` takes no arguments', 'computers');
|
|
256
|
+
const config = readConfig(env);
|
|
257
|
+
const entries = Object.entries(config.computers).sort(([a], [b]) => a.localeCompare(b));
|
|
258
|
+
const computers = entries
|
|
259
|
+
.map(([alias, profile]) => ({ alias, target: profile.target, default: alias === config.defaultComputer }));
|
|
260
|
+
if (json.enabled) stdout.write(`${JSON.stringify({ defaultComputer: config.defaultComputer, computers })}\n`);
|
|
261
|
+
else if (computers.length === 0) {
|
|
262
|
+
stdout.write([
|
|
263
|
+
'No computers configured',
|
|
264
|
+
...koanLines({ env, stdout }),
|
|
265
|
+
'',
|
|
266
|
+
'Connect through EmptyOS:',
|
|
267
|
+
' empty connect <computer-url> [--as <alias>]',
|
|
268
|
+
'',
|
|
269
|
+
'Or add a direct SSH host:',
|
|
270
|
+
' empty computer add <alias> --host <host>',
|
|
271
|
+
'',
|
|
272
|
+
].join('\n'));
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
const width = Math.max(...entries.map(([alias]) => alias.length));
|
|
276
|
+
for (const [alias, profile] of entries) {
|
|
277
|
+
const marker = alias === config.defaultComputer ? ' (default)' : '';
|
|
278
|
+
stdout.write(`${alias.padEnd(width)} ${computerDisplayTarget(profile)}${marker}\n`);
|
|
279
|
+
}
|
|
280
|
+
if (config.defaultComputer === null) stdout.write('\nNo default computer is selected; choose one with `empty computer use <alias>`\n');
|
|
281
|
+
stdout.write('\nConnect another computer with `empty connect <computer-url>`\n');
|
|
282
|
+
}
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function computerUse(args, { env, stdout }) {
|
|
287
|
+
const json = takeJson(args);
|
|
288
|
+
if (json.args.length !== 1) throw new UsageError('`computer use` needs an <alias>', 'computer use');
|
|
289
|
+
const alias = validateAlias(json.args[0]);
|
|
290
|
+
const config = readConfig(env);
|
|
291
|
+
if (!config.computers[alias]) throw new ClientError(`Computer "${alias}" is not configured`);
|
|
292
|
+
config.defaultComputer = alias;
|
|
293
|
+
writeConfig(config, env);
|
|
294
|
+
if (json.enabled) stdout.write(`${JSON.stringify({ defaultComputer: alias })}\n`);
|
|
295
|
+
else stdout.write(`Default computer is now ${alias}\n`);
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function computerShow(args, explicitComputer, { env, stdout }) {
|
|
300
|
+
const json = takeJson(args);
|
|
301
|
+
if (json.args.length) throw new UsageError('`computer show` takes no arguments', 'computer show');
|
|
302
|
+
const selected = selectComputer(readConfig(env), explicitComputer);
|
|
303
|
+
const result = {
|
|
304
|
+
alias: selected.alias,
|
|
305
|
+
target: selected.target,
|
|
306
|
+
source: selected.source,
|
|
307
|
+
transport: selected.ownerOrigin ? 'emptyos-ssh-tunnel' : 'ssh',
|
|
308
|
+
...(selected.releaseCatalog ? { releaseCatalog: selected.releaseCatalog } : {}),
|
|
309
|
+
};
|
|
310
|
+
if (json.enabled) stdout.write(`${JSON.stringify(result)}\n`);
|
|
311
|
+
else stdout.write(`${result.alias} ${computerDisplayTarget(selected)} (${result.source})\n`);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function computerDisplayTarget(profile) {
|
|
316
|
+
return profile.ownerOrigin ? new URL(profile.ownerOrigin).hostname : profile.target;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function computerUpdate(args, explicitComputer, context) {
|
|
320
|
+
const json = takeJson(args);
|
|
321
|
+
let check = false;
|
|
322
|
+
let apply = false;
|
|
323
|
+
let release;
|
|
324
|
+
let catalog;
|
|
325
|
+
let resume;
|
|
326
|
+
for (let index = 0; index < json.args.length; index += 1) {
|
|
327
|
+
const option = json.args[index];
|
|
328
|
+
if (option === '--check') {
|
|
329
|
+
if (check) throw new UsageError('--check may only be specified once', 'computer update');
|
|
330
|
+
check = true;
|
|
331
|
+
} else if (option === '--apply') {
|
|
332
|
+
if (apply) throw new UsageError('--apply may only be specified once', 'computer update');
|
|
333
|
+
apply = true;
|
|
334
|
+
} else if (option === '--release') {
|
|
335
|
+
if (release !== undefined) throw new UsageError('--release may only be specified once', 'computer update');
|
|
336
|
+
release = json.args[++index];
|
|
337
|
+
if (!release) throw new UsageError('--release needs a local artifact path', 'computer update');
|
|
338
|
+
} else if (option === '--catalog') {
|
|
339
|
+
if (catalog !== undefined) throw new UsageError('--catalog may only be specified once', 'computer update');
|
|
340
|
+
catalog = json.args[++index];
|
|
341
|
+
if (!catalog) throw new UsageError('--catalog needs an HTTPS URL', 'computer update');
|
|
342
|
+
catalog = validateCatalogUrl(catalog);
|
|
343
|
+
} else if (option === '--resume') {
|
|
344
|
+
if (resume !== undefined) throw new UsageError('--resume may only be specified once', 'computer update');
|
|
345
|
+
resume = json.args[++index];
|
|
346
|
+
if (!resume) throw new UsageError('--resume needs a retained stage name', 'computer update');
|
|
347
|
+
if (!/^stage\.[A-Za-z0-9]{1,64}$/.test(resume)) {
|
|
348
|
+
throw new UsageError('--resume needs a stage name printed by computer update', 'computer update');
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
throw new UsageError(`Unknown computer update option ${JSON.stringify(option)}`, 'computer update');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (check && apply) throw new UsageError('--check and --apply cannot be combined', 'computer update');
|
|
355
|
+
if (release && catalog) throw new UsageError('--release and --catalog cannot be combined', 'computer update');
|
|
356
|
+
if (resume && (check || apply || release || catalog)) {
|
|
357
|
+
throw new UsageError('--resume cannot be combined with check, apply, release, or catalog options', 'computer update');
|
|
358
|
+
}
|
|
359
|
+
const selected = selectComputer(readConfig(context.env), explicitComputer);
|
|
360
|
+
return updateComputer(selected, { check, release, catalog, resume, json: json.enabled }, context);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function computerRemove(args, { env, stdout }) {
|
|
364
|
+
const json = takeJson(args);
|
|
365
|
+
if (json.args.length !== 1) throw new UsageError('`computer remove` needs an <alias>', 'computer remove');
|
|
366
|
+
const alias = validateAlias(json.args[0]);
|
|
367
|
+
const config = readConfig(env);
|
|
368
|
+
if (!config.computers[alias]) throw new ClientError(`Computer "${alias}" is not configured`);
|
|
369
|
+
delete config.computers[alias];
|
|
370
|
+
if (config.defaultComputer === alias) config.defaultComputer = null;
|
|
371
|
+
writeConfig(config, env);
|
|
372
|
+
if (json.enabled) stdout.write(`${JSON.stringify({ removed: alias })}\n`);
|
|
373
|
+
else stdout.write(`Removed computer ${alias}\n`);
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function commandSsh(args, explicitComputer, { env }) {
|
|
378
|
+
const config = readConfig(env);
|
|
379
|
+
const selected = selectComputer(config, explicitComputer);
|
|
380
|
+
if (args.length > 0 && args[0] !== '--') throw new UsageError('Remote command arguments must follow `empty ssh --`', 'ssh');
|
|
381
|
+
return rawSsh(selected, args[0] === '--' ? args.slice(1) : [], env);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function parseComputerVersion(output, alias) {
|
|
385
|
+
let version;
|
|
386
|
+
try {
|
|
387
|
+
version = JSON.parse(output);
|
|
388
|
+
} catch {
|
|
389
|
+
throw new ClientError(`Computer "${alias}" returned an invalid protocol response; check SSH routing and retry`, 'protocol-error');
|
|
390
|
+
}
|
|
391
|
+
if (version.product !== 'EmptyOS' || version.component !== 'computer-cli' || typeof version.version !== 'string') {
|
|
392
|
+
throw new ClientError(`Computer "${alias}" did not identify itself as an EmptyOS computer CLI; check the configured host`, 'protocol-error');
|
|
393
|
+
}
|
|
394
|
+
if (version.protocolVersion !== PROTOCOL_VERSION) {
|
|
395
|
+
throw new ClientError(
|
|
396
|
+
`Computer "${alias}" uses protocol ${JSON.stringify(version.protocolVersion)}; client requires ${PROTOCOL_VERSION}; update the client and computer together`,
|
|
397
|
+
'protocol-error',
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return version;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function commandPut(args, explicitComputer, { env, stdout, stderr }) {
|
|
404
|
+
const options = parsePut(args);
|
|
405
|
+
const source = path.resolve(options.source);
|
|
406
|
+
const bound = await findBoundCheckout(source, env);
|
|
407
|
+
if (bound) {
|
|
408
|
+
if (options.as !== null || options.name !== null || options.projectId !== null || options.public) {
|
|
409
|
+
throw new UsageError('Metadata options are only valid for an initial put, not a bound Thing checkout', 'put');
|
|
410
|
+
}
|
|
411
|
+
return commandPublish(bound, explicitComputer, options.json, { env, stdout, stderr });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const sourceInfo = inspectGeneratedSource(source);
|
|
415
|
+
const manifested = sourceInfo.kind === 'manifested-thing' ? await inspectManifestedSource(source, env) : null;
|
|
416
|
+
if (manifested) sourceInfo.manifested = manifested;
|
|
417
|
+
const inferredId = manifested?.id ?? derivedThingId(source, sourceInfo.kind);
|
|
418
|
+
if (options.as !== null && manifested && options.as !== manifested.id) {
|
|
419
|
+
throw new ClientError(
|
|
420
|
+
`--as ${JSON.stringify(options.as)} does not match thing.yaml id ${JSON.stringify(manifested.id)}; update thing.yaml or remove --as`,
|
|
421
|
+
'manifest-assertion-failed',
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (options.name !== null && manifested && options.name !== manifested.name) {
|
|
425
|
+
throw new ClientError(
|
|
426
|
+
`--name ${JSON.stringify(options.name)} does not match thing.yaml name ${JSON.stringify(manifested.name)}; update thing.yaml or remove --name`,
|
|
427
|
+
'manifest-assertion-failed',
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (options.projectId !== null && manifested && options.projectId !== manifested.project) {
|
|
431
|
+
throw new ClientError(
|
|
432
|
+
`--project ${JSON.stringify(options.projectId)} does not match thing.yaml project ${JSON.stringify(manifested.project)}; update thing.yaml or remove --project`,
|
|
433
|
+
'manifest-assertion-failed',
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
if (manifested?.type === 'service' && (options.public || manifested.visibility === 'public')) {
|
|
437
|
+
throw new ClientError(
|
|
438
|
+
'Public service Things are not supported; keep visibility private and retry without --public',
|
|
439
|
+
'unsupported-public-service',
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (options.public && manifested && manifested.visibility !== 'public') {
|
|
443
|
+
throw new ClientError(
|
|
444
|
+
'--public does not match the private manifested Thing; set visibility: public in thing.yaml or remove --public',
|
|
445
|
+
'manifest-assertion-failed',
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
const id = options.as ?? inferredId;
|
|
449
|
+
if (!ID_PATTERN.test(id)) {
|
|
450
|
+
throw new ClientError(
|
|
451
|
+
`Derived Thing id ${JSON.stringify(id)} does not match [a-z0-9-]+; choose one with --as <id>`,
|
|
452
|
+
'invalid-generated-id',
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
const name = options.name ?? id;
|
|
456
|
+
const selected = selectComputer(readConfig(env), explicitComputer);
|
|
457
|
+
const capabilityFailure = await requireComputerCapability(
|
|
458
|
+
selected,
|
|
459
|
+
'thingImport',
|
|
460
|
+
1,
|
|
461
|
+
'put',
|
|
462
|
+
{ env, stdout, stderr, json: options.json },
|
|
463
|
+
);
|
|
464
|
+
if (capabilityFailure !== null) return capabilityFailure;
|
|
465
|
+
|
|
466
|
+
const staged = await stageGeneratedThing(sourceInfo, id, name, {
|
|
467
|
+
env,
|
|
468
|
+
projectId: options.projectId,
|
|
469
|
+
visibility: options.public ? 'public' : 'private',
|
|
470
|
+
});
|
|
471
|
+
try {
|
|
472
|
+
const remoteArgs = [
|
|
473
|
+
'thing', 'import', id,
|
|
474
|
+
'--expected-head', staged.head,
|
|
475
|
+
'--source-kind', staged.sourceKind,
|
|
476
|
+
'--file-count', String(staged.fileCount),
|
|
477
|
+
'--byte-count', String(staged.byteCount),
|
|
478
|
+
];
|
|
479
|
+
if (sourceInfo.kind === 'manifested-thing') {
|
|
480
|
+
if (options.name !== null) remoteArgs.push('--assert-name', options.name);
|
|
481
|
+
if (options.projectId !== null) remoteArgs.push('--assert-project', options.projectId);
|
|
482
|
+
if (options.public) remoteArgs.push('--assert-public');
|
|
483
|
+
}
|
|
484
|
+
if (options.json) remoteArgs.push('--json');
|
|
485
|
+
if (!options.json) return await rpcFileInherited(selected, remoteArgs, staged.bundle, env);
|
|
486
|
+
const result = await rpcFileCaptured(selected, remoteArgs, staged.bundle, env);
|
|
487
|
+
return relayRemoteResult(result, true, { stdout, stderr }, 'Thing upload failed');
|
|
488
|
+
} finally {
|
|
489
|
+
cleanPutStage(staged.stage);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function parsePut(args) {
|
|
494
|
+
const json = takeJson(args);
|
|
495
|
+
let source = null;
|
|
496
|
+
let as = null;
|
|
497
|
+
let name = null;
|
|
498
|
+
let projectId = null;
|
|
499
|
+
let publicValue = false;
|
|
500
|
+
let optionsEnded = false;
|
|
501
|
+
for (let index = 0; index < json.args.length; index += 1) {
|
|
502
|
+
const argument = json.args[index];
|
|
503
|
+
if (!optionsEnded && argument === '--') {
|
|
504
|
+
optionsEnded = true;
|
|
505
|
+
} else if (!optionsEnded && argument === '--public') {
|
|
506
|
+
if (publicValue) throw new UsageError('--public may only be specified once', 'put');
|
|
507
|
+
publicValue = true;
|
|
508
|
+
} else if (!optionsEnded && ['--as', '--name', '--project'].includes(argument)) {
|
|
509
|
+
const field = argument === '--as' ? 'as' : argument === '--name' ? 'name' : 'projectId';
|
|
510
|
+
const current = field === 'as' ? as : field === 'name' ? name : projectId;
|
|
511
|
+
if (current !== null) throw new UsageError(`${argument} may only be specified once`, 'put');
|
|
512
|
+
const value = json.args[++index];
|
|
513
|
+
if (!value) throw new UsageError(`${argument} needs a value`, 'put');
|
|
514
|
+
if (field === 'as') as = value;
|
|
515
|
+
else if (field === 'name') name = value;
|
|
516
|
+
else projectId = value;
|
|
517
|
+
} else if (!optionsEnded && ['--as=', '--name=', '--project='].some((prefix) => argument.startsWith(prefix))) {
|
|
518
|
+
const [flag, value] = argument.split(/=(.*)/s, 2);
|
|
519
|
+
if (!value) throw new UsageError(`${flag} needs a value`, 'put');
|
|
520
|
+
if (flag === '--as') {
|
|
521
|
+
if (as !== null) throw new UsageError('--as may only be specified once', 'put');
|
|
522
|
+
as = value;
|
|
523
|
+
} else if (flag === '--name') {
|
|
524
|
+
if (name !== null) throw new UsageError('--name may only be specified once', 'put');
|
|
525
|
+
name = value;
|
|
526
|
+
} else {
|
|
527
|
+
if (projectId !== null) throw new UsageError('--project may only be specified once', 'put');
|
|
528
|
+
projectId = value;
|
|
529
|
+
}
|
|
530
|
+
} else if (!optionsEnded && argument.startsWith('--')) {
|
|
531
|
+
throw new UsageError(`Unknown put option ${JSON.stringify(argument)}`, 'put');
|
|
532
|
+
} else if (source === null) {
|
|
533
|
+
source = argument;
|
|
534
|
+
} else {
|
|
535
|
+
throw new UsageError('`put` takes exactly one <path>', 'put');
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (source === null) throw new UsageError('`put` needs a <path>', 'put');
|
|
539
|
+
if (as !== null && !ID_PATTERN.test(as)) throw new UsageError('Thing id must match [a-z0-9-]+', 'put');
|
|
540
|
+
if (projectId !== null && !ID_PATTERN.test(projectId)) throw new UsageError('Project id must match [a-z0-9-]+', 'put');
|
|
541
|
+
return { source, as, name, projectId, public: publicValue, json: json.enabled };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function findBoundCheckout(source, env) {
|
|
545
|
+
let stat;
|
|
546
|
+
try {
|
|
547
|
+
stat = fs.lstatSync(source);
|
|
548
|
+
} catch {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
const cwd = stat.isDirectory() ? source : path.dirname(source);
|
|
552
|
+
const rootResult = await capturedGit(['rev-parse', '--show-toplevel'], { cwd, env }, 'Cannot inspect the source repository');
|
|
553
|
+
if (rootResult.code !== 0) return null;
|
|
554
|
+
const root = rootResult.stdout.trim();
|
|
555
|
+
if (fs.realpathSync(root) !== fs.realpathSync(source)) return null;
|
|
556
|
+
for (const key of ['emptyos.computer', 'emptyos.profileTarget', 'emptyos.target']) {
|
|
557
|
+
const result = await capturedGit(['config', '--local', '--get', key], { cwd: root, env }, `Cannot inspect ${key} binding`);
|
|
558
|
+
if (result.code !== 0 || !result.stdout.trim()) return null;
|
|
559
|
+
}
|
|
560
|
+
return root;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function requireComputerCapability(selected, capability, minimum, feature, { env, stdout, stderr, json = false }) {
|
|
564
|
+
const check = await rpcCaptured(selected, ['--version', '--json'], env);
|
|
565
|
+
if (check.code !== 0) {
|
|
566
|
+
return relayRemoteResult(check, json, { stdout, stderr }, `Cannot check computer ${JSON.stringify(selected.alias)}`);
|
|
567
|
+
}
|
|
568
|
+
const version = parseComputerVersion(check.stdout, selected.alias);
|
|
569
|
+
if (!Number.isInteger(version.capabilities?.[capability]) || version.capabilities[capability] < minimum) {
|
|
570
|
+
throw new ClientError(
|
|
571
|
+
`Computer "${selected.alias}" does not support ${feature}; update it with \`${selected.source === 'explicit' ? `empty --computer ${selected.alias} computer update` : 'empty computer update'}\` and retry`,
|
|
572
|
+
'unsupported-capability',
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function commandClone(args, explicitComputer, { env, stdout, stderr }) {
|
|
579
|
+
const json = takeJson(args);
|
|
580
|
+
if (json.args.length < 1) throw new UsageError('`clone` needs an <id>', 'clone');
|
|
581
|
+
if (json.args.length > 2) throw new UsageError('`clone` takes only an <id> and an optional [directory]', 'clone');
|
|
582
|
+
const [id, directory = id] = json.args;
|
|
583
|
+
if (!ID_PATTERN.test(id)) throw new UsageError('Thing id must match [a-z0-9-]+', 'clone');
|
|
584
|
+
const selected = selectComputer(readConfig(env), explicitComputer);
|
|
585
|
+
const destinationExisted = fs.existsSync(directory);
|
|
586
|
+
|
|
587
|
+
const describe = await rpcCaptured(selected, ['thing', 'describe', id, '--json'], env);
|
|
588
|
+
if (describe.code !== 0) {
|
|
589
|
+
return relayRemoteResult(describe, json.enabled, { stdout, stderr }, `Cannot describe Thing ${JSON.stringify(id)}`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (json.enabled) {
|
|
593
|
+
const clone = await capturedGit(
|
|
594
|
+
['clone', '--', `${selected.target}:things/${id}`, directory],
|
|
595
|
+
{ env, profile: selected },
|
|
596
|
+
`Cannot clone Thing ${JSON.stringify(id)}; check the destination and retry`,
|
|
597
|
+
);
|
|
598
|
+
requireGitSuccess(clone, `Cannot clone Thing ${JSON.stringify(id)}; check the destination and retry`);
|
|
599
|
+
} else {
|
|
600
|
+
const cloneCode = await inheritedGit(
|
|
601
|
+
['clone', '--', `${selected.target}:things/${id}`, directory],
|
|
602
|
+
{ env, profile: selected },
|
|
603
|
+
);
|
|
604
|
+
if (cloneCode !== 0) return cloneCode;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
const rootResult = await capturedGit(['-C', directory, 'rev-parse', '--show-toplevel'], { env }, 'Cannot inspect cloned repository');
|
|
608
|
+
requireGitSuccess(rootResult, 'Cannot inspect cloned repository');
|
|
609
|
+
const root = rootResult.stdout.trim();
|
|
610
|
+
const manifestId = readThingId(root);
|
|
611
|
+
if (manifestId !== id) {
|
|
612
|
+
throw new ClientError(
|
|
613
|
+
`Cloned Thing id "${manifestId}" does not match requested id "${id}"; remove the destination and clone again`,
|
|
614
|
+
'clone-binding-failed',
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
for (const [key, value] of [
|
|
619
|
+
['emptyos.computer', selected.alias],
|
|
620
|
+
['emptyos.profileTarget', selected.target],
|
|
621
|
+
['emptyos.target', `thing://${id}`],
|
|
622
|
+
]) {
|
|
623
|
+
const result = await capturedGit(['-C', root, 'config', '--local', key, value], { env }, `Cannot bind cloned repository (${key})`);
|
|
624
|
+
requireGitSuccess(result, `Cannot bind cloned repository (${key})`);
|
|
625
|
+
}
|
|
626
|
+
if (json.enabled) {
|
|
627
|
+
stdout.write(`${JSON.stringify({
|
|
628
|
+
id,
|
|
629
|
+
directory: root,
|
|
630
|
+
computer: selected.alias,
|
|
631
|
+
profileTarget: selected.target,
|
|
632
|
+
target: `thing://${id}`,
|
|
633
|
+
})}\n`);
|
|
634
|
+
}
|
|
635
|
+
else stdout.write(`Bound ${root} to ${selected.alias}:thing://${id}\n`);
|
|
636
|
+
return 0;
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (!destinationExisted) fs.rmSync(directory, { recursive: true, force: true });
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function commandPublish(root, explicitComputer, json, { env, stdout, stderr }) {
|
|
644
|
+
const binding = await readBinding(root, env);
|
|
645
|
+
|
|
646
|
+
if (explicitComputer && explicitComputer !== binding.computer) {
|
|
647
|
+
throw new ClientError(
|
|
648
|
+
`--computer "${explicitComputer}" does not match repository binding "${binding.computer}"; clone from the intended computer instead`,
|
|
649
|
+
'binding-mismatch',
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
const config = readConfig(env);
|
|
653
|
+
const profile = config.computers[binding.computer];
|
|
654
|
+
if (!profile) {
|
|
655
|
+
throw new ClientError(`Bound computer "${binding.computer}" is not configured; restore that profile or clone again`, 'binding-mismatch');
|
|
656
|
+
}
|
|
657
|
+
if (profile.target !== binding.profileTarget) {
|
|
658
|
+
throw new ClientError(
|
|
659
|
+
`Computer "${binding.computer}" now targets "${profile.target}", but this checkout was cloned from "${binding.profileTarget}"; restore the profile target or clone again`,
|
|
660
|
+
'binding-mismatch',
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
const expectedTarget = `thing://${binding.id}`;
|
|
664
|
+
if (binding.target !== expectedTarget) throw new ClientError(`Invalid repository target "${binding.target}"; clone the Thing again`, 'binding-mismatch');
|
|
665
|
+
const manifestId = readThingId(root);
|
|
666
|
+
if (manifestId !== binding.id) {
|
|
667
|
+
throw new ClientError(
|
|
668
|
+
`thing.yaml id "${manifestId}" does not match repository binding "${binding.id}"; restore the bound id or clone again`,
|
|
669
|
+
'binding-mismatch',
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
await validateOrigin(root, profile.target, binding.id, env);
|
|
673
|
+
|
|
674
|
+
const branch = await capturedGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: root, env }, 'Cannot inspect the current branch');
|
|
675
|
+
if (branch.code !== 0 || !branch.stdout.trim()) throw new ClientError('This checkout is on a detached HEAD; check out a branch before running `put`', 'detached-head');
|
|
676
|
+
const status = await capturedGit(['status', '--porcelain'], { cwd: root, env }, 'Cannot inspect repository status');
|
|
677
|
+
requireGitSuccess(status, 'Cannot inspect repository status');
|
|
678
|
+
if (status.stdout.length !== 0) throw new ClientError('This checkout has uncommitted changes; commit or stash them before running `put`', 'dirty-worktree');
|
|
679
|
+
|
|
680
|
+
validateVerify(root);
|
|
681
|
+
const trackedVerify = await capturedGit(['ls-files', '--error-unmatch', '--', 'verify'], { cwd: root, env }, 'Cannot inspect ./verify');
|
|
682
|
+
if (trackedVerify.code !== 0) {
|
|
683
|
+
throw new ClientError('`put` requires ./verify to be tracked by Git; add it, commit, and retry', 'verify-invalid');
|
|
684
|
+
}
|
|
685
|
+
if (json) {
|
|
686
|
+
const localVerify = await runVerifyCaptured(root, env);
|
|
687
|
+
if (localVerify.code !== 0) {
|
|
688
|
+
const detail = localVerify.stderr.trim() || localVerify.stdout.trim();
|
|
689
|
+
throw new ClientError(
|
|
690
|
+
`${detail ? `./verify failed: ${detail}` : `./verify failed with exit code ${localVerify.code}`}; fix ./verify and retry`,
|
|
691
|
+
'verify-failed',
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
} else {
|
|
695
|
+
const localVerifyCode = await runVerify(root, env);
|
|
696
|
+
if (localVerifyCode !== 0) return localVerifyCode;
|
|
697
|
+
}
|
|
698
|
+
const statusAfterVerify = await capturedGit(['status', '--porcelain'], { cwd: root, env }, 'Cannot inspect repository status after ./verify');
|
|
699
|
+
requireGitSuccess(statusAfterVerify, 'Cannot inspect repository status after ./verify');
|
|
700
|
+
if (statusAfterVerify.stdout.length !== 0) {
|
|
701
|
+
throw new ClientError('./verify left tracked or untracked changes; clean them and run `put` again', 'dirty-worktree');
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (json) {
|
|
705
|
+
const fetch = await capturedGit(
|
|
706
|
+
['fetch', 'origin'],
|
|
707
|
+
{ cwd: root, env, profile },
|
|
708
|
+
'Cannot fetch origin; check the bound computer and retry',
|
|
709
|
+
);
|
|
710
|
+
requireGitSuccess(fetch, 'Cannot fetch origin; check the bound computer and retry');
|
|
711
|
+
} else {
|
|
712
|
+
const fetchCode = await inheritedGit(['fetch', 'origin'], { cwd: root, env, profile });
|
|
713
|
+
if (fetchCode !== 0) return fetchCode;
|
|
714
|
+
}
|
|
715
|
+
const remote = await capturedGit(
|
|
716
|
+
['ls-remote', '--symref', 'origin', 'HEAD'],
|
|
717
|
+
{ cwd: root, env, profile },
|
|
718
|
+
'Cannot resolve the origin default branch',
|
|
719
|
+
);
|
|
720
|
+
requireGitSuccess(remote, 'Cannot resolve the origin default branch');
|
|
721
|
+
const { defaultBranch, liveHead } = parseRemoteHead(remote.stdout);
|
|
722
|
+
|
|
723
|
+
const headResult = await capturedGit(['rev-parse', 'HEAD'], { cwd: root, env }, 'Cannot resolve local HEAD');
|
|
724
|
+
requireGitSuccess(headResult, 'Cannot resolve local HEAD');
|
|
725
|
+
const sha = requireFullSha(headResult.stdout.trim(), 'local HEAD');
|
|
726
|
+
const ancestor = await capturedGit(['merge-base', '--is-ancestor', liveHead, sha], { cwd: root, env }, 'Cannot compare local HEAD with the live origin head');
|
|
727
|
+
if (ancestor.code === 1) {
|
|
728
|
+
throw new ClientError(
|
|
729
|
+
`Local HEAD does not contain origin/${defaultBranch} (${liveHead}); fetch and rebase, then run \`put\` again`,
|
|
730
|
+
'stale-publication',
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
requireGitSuccess(ancestor, 'Cannot compare local HEAD with the live origin head');
|
|
734
|
+
|
|
735
|
+
const parents = await capturedGit(['rev-list', '--parents', '-n', '1', sha], { cwd: root, env }, 'Cannot inspect the candidate commit');
|
|
736
|
+
requireGitSuccess(parents, 'Cannot inspect the candidate commit');
|
|
737
|
+
const parts = parents.stdout.trim().split(/\s+/);
|
|
738
|
+
if (parts.length !== 2 || parts[0] !== sha || parts[1] !== liveHead) {
|
|
739
|
+
throw new ClientError(
|
|
740
|
+
`\`put\` requires exactly one non-merge commit on top of origin/${defaultBranch}; squash or rebase this checkout to one commit`,
|
|
741
|
+
'stale-publication',
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const candidateRef = `refs/emptyos/candidates/${sha}`;
|
|
746
|
+
if (json) {
|
|
747
|
+
const push = await capturedGit(
|
|
748
|
+
['push', 'origin', `HEAD:${candidateRef}`],
|
|
749
|
+
{ cwd: root, env, profile },
|
|
750
|
+
'Cannot push the candidate commit; check the bound computer and retry',
|
|
751
|
+
);
|
|
752
|
+
requireGitSuccess(push, 'Cannot push the candidate commit; check the bound computer and retry');
|
|
753
|
+
} else {
|
|
754
|
+
const pushCode = await inheritedGit(['push', 'origin', `HEAD:${candidateRef}`], { cwd: root, env, profile });
|
|
755
|
+
if (pushCode !== 0) return pushCode;
|
|
756
|
+
}
|
|
757
|
+
const activateArgs = ['thing', 'activate', binding.id, '--sha', sha, '--expected-base', liveHead];
|
|
758
|
+
if (json) activateArgs.push('--json');
|
|
759
|
+
if (!json) return rpcInherited(profile, activateArgs, env);
|
|
760
|
+
const activation = await rpcCaptured(profile, activateArgs, env);
|
|
761
|
+
return relayRemoteResult(activation, true, { stdout, stderr }, 'Thing activation failed');
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function commandCommands(args, explicitComputer, { env, stdout, stderr }) {
|
|
765
|
+
const json = takeJson(args);
|
|
766
|
+
if (json.args.length) throw new UsageError('`commands` takes no arguments', 'commands');
|
|
767
|
+
const selected = selectComputer(readConfig(env), explicitComputer);
|
|
768
|
+
const response = await rpcCaptured(selected, ['commands', '--json'], env);
|
|
769
|
+
if (response.code !== 0) {
|
|
770
|
+
stdout.write(response.stdout);
|
|
771
|
+
stderr.write(response.stderr);
|
|
772
|
+
return response.code;
|
|
773
|
+
}
|
|
774
|
+
let resident;
|
|
775
|
+
try { resident = JSON.parse(response.stdout); }
|
|
776
|
+
catch { throw new ClientError(`Computer "${selected.alias}" returned an invalid command inventory`, 'invalid-command-inventory'); }
|
|
777
|
+
if (!resident || !Array.isArray(resident.commands) || !Array.isArray(resident.collisions)) {
|
|
778
|
+
throw new ClientError(`Computer "${selected.alias}" returned an invalid command inventory`, 'invalid-command-inventory');
|
|
779
|
+
}
|
|
780
|
+
const byCommand = new Map();
|
|
781
|
+
for (const command of resident.commands) byCommand.set(command.command, command);
|
|
782
|
+
for (const command of commandInventory()) {
|
|
783
|
+
const existing = byCommand.get(command.command);
|
|
784
|
+
byCommand.set(command.command, existing ? { ...existing, summary: command.summary || existing.summary, location: command.location } : command);
|
|
785
|
+
}
|
|
786
|
+
const result = {
|
|
787
|
+
commands: [...byCommand.values()].sort((a, b) => a.command.localeCompare(b.command)),
|
|
788
|
+
collisions: resident.collisions,
|
|
789
|
+
dirty: Boolean(resident.dirty),
|
|
790
|
+
};
|
|
791
|
+
if (json.enabled) stdout.write(`${JSON.stringify(result)}\n`);
|
|
792
|
+
else {
|
|
793
|
+
for (const command of result.commands) if (!command.hidden) stdout.write(`${command.command}${command.summary ? `\t${command.summary}` : ''}\n`);
|
|
794
|
+
for (const collision of result.collisions) stderr.write(`empty: warning: Local command ${collision.command} collides with built-in ${collision.collision}\n`);
|
|
795
|
+
if (result.dirty) stderr.write('empty: warning: Local extensions have uncommitted changes; commit them before using Undo\n');
|
|
796
|
+
}
|
|
797
|
+
return 0;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// An unknown first token is either a resident extension command or a typo.
|
|
801
|
+
// Ask the computer's inventory before forwarding so a typo fails here with
|
|
802
|
+
// client usage instead of the resident CLI's client-blind usage screen. A
|
|
803
|
+
// failed or unreadable probe degrades to forwarding.
|
|
804
|
+
async function forwardExtensionCommand(selected, argv, env) {
|
|
805
|
+
const probe = await rpcCaptured(selected, ['commands', '--json'], env);
|
|
806
|
+
if (probe.code === 0 && !extensionRouteMatches(probe.stdout, argv)) {
|
|
807
|
+
throw new UsageError(`Unknown command "${argv[0]}"`);
|
|
808
|
+
}
|
|
809
|
+
return rpcInherited(selected, argv, env);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function extensionRouteMatches(stdout, argv) {
|
|
813
|
+
let inventory;
|
|
814
|
+
try { inventory = JSON.parse(stdout); } catch { return true; }
|
|
815
|
+
if (!Array.isArray(inventory?.commands)) return true;
|
|
816
|
+
return inventory.commands.some((command) => Array.isArray(command.route)
|
|
817
|
+
&& command.route.length > 0
|
|
818
|
+
&& command.route.every((word, index) => argv[index] === word));
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function commandHumanCatalog(selected, argv, catalog, { env, stdout, stderr }) {
|
|
822
|
+
const probe = await rpcCaptured(selected, [...argv, '--json'], env);
|
|
823
|
+
if (probe.code !== 0) return rpcInherited(selected, argv, env);
|
|
824
|
+
const value = parseCatalogJson(catalog.command, probe.stdout);
|
|
825
|
+
if (value === null || !catalog.empty(value)) return rpcInherited(selected, argv, env);
|
|
826
|
+
|
|
827
|
+
if (catalog.humanEmptyOutput !== undefined) {
|
|
828
|
+
const human = await rpcCaptured(selected, argv, env);
|
|
829
|
+
stdout.write(human.stdout === catalog.humanEmptyOutput ? '' : human.stdout);
|
|
830
|
+
stderr.write(human.stderr);
|
|
831
|
+
if (human.code !== 0) {
|
|
832
|
+
return human.code;
|
|
833
|
+
}
|
|
834
|
+
if (human.stdout !== catalog.humanEmptyOutput) return 0;
|
|
835
|
+
} else {
|
|
836
|
+
stderr.write(probe.stderr);
|
|
837
|
+
}
|
|
838
|
+
stdout.write(catalog.message(selected.alias, koanLines({ env, stdout })));
|
|
839
|
+
return 0;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function readBinding(root, env) {
|
|
843
|
+
const values = {};
|
|
844
|
+
for (const [field, key] of [
|
|
845
|
+
['computer', 'emptyos.computer'],
|
|
846
|
+
['profileTarget', 'emptyos.profileTarget'],
|
|
847
|
+
['target', 'emptyos.target'],
|
|
848
|
+
]) {
|
|
849
|
+
const result = await capturedGit(['config', '--local', '--get', key], { cwd: root, env }, `Cannot inspect ${key} binding`);
|
|
850
|
+
if (result.code !== 0 || !result.stdout.trim()) {
|
|
851
|
+
throw new ClientError(`This repository has no ${key} binding; use \`empty clone <id>\``, 'binding-missing');
|
|
852
|
+
}
|
|
853
|
+
values[field] = result.stdout.trim();
|
|
854
|
+
}
|
|
855
|
+
try {
|
|
856
|
+
validateAlias(values.computer);
|
|
857
|
+
validateTarget(values.profileTarget);
|
|
858
|
+
} catch (error) {
|
|
859
|
+
throw new ClientError(`${error.message}; clone the Thing again`, 'binding-mismatch');
|
|
860
|
+
}
|
|
861
|
+
const match = /^thing:\/\/([a-z0-9-]+)$/.exec(values.target);
|
|
862
|
+
if (!match) throw new ClientError(`Invalid repository target "${values.target}"; clone the Thing again`, 'binding-mismatch');
|
|
863
|
+
values.id = match[1];
|
|
864
|
+
return values;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function validateOrigin(root, target, id, env) {
|
|
868
|
+
const expected = `${target}:things/${id}`;
|
|
869
|
+
const fetchUrl = await capturedGit(['config', '--local', '--get', 'remote.origin.url'], { cwd: root, env }, 'Cannot inspect origin URL');
|
|
870
|
+
if (fetchUrl.code !== 0 || fetchUrl.stdout.trim() !== expected) {
|
|
871
|
+
throw new ClientError(
|
|
872
|
+
`The origin URL must remain ${JSON.stringify(expected)}; clone the Thing again instead of retargeting it`,
|
|
873
|
+
'binding-mismatch',
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
const pushUrls = await capturedGit(['config', '--local', '--get-all', 'remote.origin.pushurl'], { cwd: root, env }, 'Cannot inspect origin push URL');
|
|
877
|
+
if (pushUrls.code !== 0 && pushUrls.code !== 1) requireGitSuccess(pushUrls, 'Cannot inspect origin push URL');
|
|
878
|
+
const configured = pushUrls.stdout.split(/\r?\n/).filter(Boolean);
|
|
879
|
+
if (configured.some((url) => url !== expected)) {
|
|
880
|
+
throw new ClientError(
|
|
881
|
+
`The origin push URL must remain ${JSON.stringify(expected)}; remove the custom push URL or clone again`,
|
|
882
|
+
'binding-mismatch',
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function validateVerify(root) {
|
|
888
|
+
const file = path.join(root, 'verify');
|
|
889
|
+
let stat;
|
|
890
|
+
try {
|
|
891
|
+
stat = fs.lstatSync(file);
|
|
892
|
+
} catch (error) {
|
|
893
|
+
if (error.code === 'ENOENT') throw new ClientError('`put` requires an executable ./verify file; restore it, commit, and retry', 'verify-missing');
|
|
894
|
+
throw error;
|
|
895
|
+
}
|
|
896
|
+
if (stat.isSymbolicLink() || !stat.isFile() || (stat.mode & 0o111) === 0) {
|
|
897
|
+
throw new ClientError('`put` requires ./verify to be an executable regular file, not a symlink; fix it, commit, and retry', 'verify-invalid');
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function runVerify(root, env) {
|
|
902
|
+
return runInherited(path.join(root, 'verify'), [], { cwd: root, env });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function runVerifyCaptured(root, env) {
|
|
906
|
+
try {
|
|
907
|
+
return await runCaptured(path.join(root, 'verify'), [], { cwd: root, env });
|
|
908
|
+
} catch (error) {
|
|
909
|
+
throw new ClientError(`Cannot run ./verify: ${error.message}; fix ./verify and retry`, 'verify-failed');
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function parseRemoteHead(output) {
|
|
914
|
+
let defaultRef;
|
|
915
|
+
let liveHead;
|
|
916
|
+
for (const line of output.split(/\r?\n/)) {
|
|
917
|
+
const symbolic = /^ref:\s+(refs\/heads\/[^\s]+)\s+HEAD$/.exec(line);
|
|
918
|
+
if (symbolic) defaultRef = symbolic[1];
|
|
919
|
+
const head = /^([0-9a-fA-F]{40,64})\s+HEAD$/.exec(line);
|
|
920
|
+
if (head) liveHead = head[1].toLowerCase();
|
|
921
|
+
}
|
|
922
|
+
if (!defaultRef || !liveHead) throw new ClientError('The origin remote did not advertise a default branch and HEAD; repair the remote or clone again', 'git-failed');
|
|
923
|
+
return { defaultBranch: defaultRef.slice('refs/heads/'.length), liveHead };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function requireFullSha(value, label) {
|
|
927
|
+
if (!/^[0-9a-fA-F]{40,64}$/.test(value)) throw new ClientError(`Cannot resolve ${label} to a full commit id`, 'git-failed');
|
|
928
|
+
return value.toLowerCase();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function requireGitSuccess(result, fallback) {
|
|
932
|
+
if (result.code === 0) return;
|
|
933
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
934
|
+
throw new ClientError(detail ? `${fallback}: ${detail}` : fallback, 'git-failed');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async function capturedGit(args, options, fallback) {
|
|
938
|
+
try {
|
|
939
|
+
return await gitCaptured(args, options);
|
|
940
|
+
} catch (error) {
|
|
941
|
+
throw new ClientError(`${fallback}: ${error.message}`, 'git-failed');
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
async function inheritedGit(args, options) {
|
|
946
|
+
try {
|
|
947
|
+
return await gitInherited(args, options);
|
|
948
|
+
} catch (error) {
|
|
949
|
+
throw new ClientError(`Cannot run Git: ${error.message}`, 'git-failed');
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function relayRemoteResult(result, json, { stdout, stderr }, fallback) {
|
|
954
|
+
if (!json || isJsonValue(result.stdout)) {
|
|
955
|
+
stdout.write(result.stdout);
|
|
956
|
+
stderr.write(result.stderr);
|
|
957
|
+
if (!json && result.transportHint) stderr.write(result.transportHint);
|
|
958
|
+
return result.code;
|
|
959
|
+
}
|
|
960
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
961
|
+
throw new ClientError(
|
|
962
|
+
result.code === 0
|
|
963
|
+
? `${fallback}: the computer returned a non-JSON response; update the client and computer together, then retry`
|
|
964
|
+
: `${fallback}; check SSH connectivity and retry${detail ? `: ${detail}` : ''}`,
|
|
965
|
+
result.code === 0 ? 'protocol-error' : 'transport-failed',
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function isJsonValue(output) {
|
|
970
|
+
try {
|
|
971
|
+
const value = JSON.parse(output);
|
|
972
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
973
|
+
} catch {
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function commandName(argv) {
|
|
979
|
+
return argv.slice(0, 2).join(' ');
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function takeJson(args) {
|
|
983
|
+
const filtered = args.filter((arg) => arg !== '--json');
|
|
984
|
+
return { enabled: filtered.length !== args.length, args: filtered };
|
|
985
|
+
}
|