@platless/axom-cli 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.
Files changed (90) hide show
  1. package/README.md +64 -0
  2. package/dist/args.js +29 -0
  3. package/dist/cli.js +76 -0
  4. package/dist/commands/auth.js +52 -0
  5. package/dist/commands/execute.js +40 -0
  6. package/dist/commands/guide.js +38 -0
  7. package/dist/commands/record.js +99 -0
  8. package/dist/commands/skills.js +69 -0
  9. package/dist/commands/struct.js +50 -0
  10. package/dist/commands/update.js +88 -0
  11. package/dist/commands/whoami.js +8 -0
  12. package/dist/commands/workstation.js +171 -0
  13. package/dist/config.js +131 -0
  14. package/dist/credentialBroker.js +340 -0
  15. package/dist/credentials.js +211 -0
  16. package/dist/install/agentsDir.js +35 -0
  17. package/dist/install/claude/commands.js +89 -0
  18. package/dist/install/claude/materialize.js +42 -0
  19. package/dist/install/installer.js +45 -0
  20. package/dist/install/targets/claude.js +154 -0
  21. package/dist/mcp.js +152 -0
  22. package/dist/output.js +37 -0
  23. package/dist/workstation/acp/acpAgents.js +1287 -0
  24. package/dist/workstation/acp/acpClient.js +691 -0
  25. package/dist/workstation/acp/acpSession.js +1047 -0
  26. package/dist/workstation/acp/adoption.js +396 -0
  27. package/dist/workstation/acp/auth.js +80 -0
  28. package/dist/workstation/acp/authUrlSniffer.js +52 -0
  29. package/dist/workstation/acp/binaryInstall.js +411 -0
  30. package/dist/workstation/acp/configOptions.js +257 -0
  31. package/dist/workstation/acp/contract.js +494 -0
  32. package/dist/workstation/acp/draftInterceptor.js +154 -0
  33. package/dist/workstation/acp/draftSubagents.js +38 -0
  34. package/dist/workstation/acp/installErrors.js +89 -0
  35. package/dist/workstation/acp/installProbe.js +363 -0
  36. package/dist/workstation/acp/permission.js +327 -0
  37. package/dist/workstation/acp/registry/catalog.js +29 -0
  38. package/dist/workstation/acp/registry/launch.js +128 -0
  39. package/dist/workstation/acp/registry/registryClient.js +160 -0
  40. package/dist/workstation/acp/registry/registryTypes.js +8 -0
  41. package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
  42. package/dist/workstation/acp/test/fakeAgent.js +435 -0
  43. package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
  44. package/dist/workstation/acp/timedCommand.js +47 -0
  45. package/dist/workstation/acp/uvxInstall.js +248 -0
  46. package/dist/workstation/acp/validate.js +134 -0
  47. package/dist/workstation/agentSessions.js +1628 -0
  48. package/dist/workstation/channels/agent.js +96 -0
  49. package/dist/workstation/channels/computer.js +91 -0
  50. package/dist/workstation/channels/management.js +231 -0
  51. package/dist/workstation/channels/url.js +42 -0
  52. package/dist/workstation/config.js +47 -0
  53. package/dist/workstation/connection/encryptedChannel.js +261 -0
  54. package/dist/workstation/connection/retryPolicy.js +14 -0
  55. package/dist/workstation/connection/supervisor.js +353 -0
  56. package/dist/workstation/crypto.js +63 -0
  57. package/dist/workstation/daemon.js +225 -0
  58. package/dist/workstation/daemonctl.js +95 -0
  59. package/dist/workstation/exec.js +112 -0
  60. package/dist/workstation/journal.js +257 -0
  61. package/dist/workstation/names.js +17 -0
  62. package/dist/workstation/oauth/browser.js +24 -0
  63. package/dist/workstation/oauth/deviceAuthorization.js +198 -0
  64. package/dist/workstation/osinfo.js +4 -0
  65. package/dist/workstation/protocol/agent.js +606 -0
  66. package/dist/workstation/protocol/common.js +136 -0
  67. package/dist/workstation/protocol/computer.js +31 -0
  68. package/dist/workstation/protocol/management.js +116 -0
  69. package/dist/workstation/register.js +42 -0
  70. package/dist/workstation/service/installer.js +362 -0
  71. package/dist/workstation/service/launchd.js +46 -0
  72. package/dist/workstation/service/loginShell.js +36 -0
  73. package/dist/workstation/service/systemd.js +48 -0
  74. package/dist/workstation/service/types.js +73 -0
  75. package/dist/workstation/supervision.js +52 -0
  76. package/dist/workstation/systemMetrics.js +271 -0
  77. package/dist/workstation/update/artifact.js +61 -0
  78. package/dist/workstation/update/coordinator.js +240 -0
  79. package/dist/workstation/update/npmInstaller.js +91 -0
  80. package/dist/workstation/update/redact.js +36 -0
  81. package/dist/workstation/update/runUpdate.js +215 -0
  82. package/dist/workstation/update/stateStore.js +87 -0
  83. package/dist/workstation/update/taskRegistry.js +142 -0
  84. package/dist/workstation/update/types.js +3 -0
  85. package/dist/workstation/update/watchdog.js +311 -0
  86. package/dist/workstation/version.js +12 -0
  87. package/package.json +39 -0
  88. package/skills/axom/SKILL.md +83 -0
  89. package/skills/axom-definition/SKILL.md +74 -0
  90. package/skills/axom-platform/SKILL.md +78 -0
@@ -0,0 +1,136 @@
1
+ export function isObject(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
4
+ export const isString = (value) => typeof value === 'string';
5
+ export const isBoolean = (value) => typeof value === 'boolean';
6
+ export const isNumber = (value) => typeof value === 'number' && Number.isFinite(value);
7
+ export const isNullableString = (value) => value === null || typeof value === 'string';
8
+ export const isStringArray = (value) => Array.isArray(value) && value.every(isString);
9
+ export const isNullableStringArray = (value) => value === null || isStringArray(value);
10
+ export const isStringRecord = (value) => isObject(value) && Object.values(value).every(isString);
11
+ export function hasRequired(object, fields) {
12
+ return Object.entries(fields).every(([name, guard]) => Object.hasOwn(object, name) && guard(object[name]));
13
+ }
14
+ export function hasOptional(object, fields) {
15
+ return Object.entries(fields).every(([name, guard]) => !Object.hasOwn(object, name) || guard(object[name]));
16
+ }
17
+ /** Accepts `true` and nothing else — see {@link BridgeInfo.supportsSteering}. */
18
+ export const isTrueOnly = (value) => value === true;
19
+ export const isNullableBoolean = (value) => value === null || typeof value === 'boolean';
20
+ export function isAuthMethodInfo(value) {
21
+ return isObject(value)
22
+ && hasRequired(value, { id: isString, name: isString })
23
+ && hasOptional(value, { type: isNullableString, description: isNullableString, args: isNullableStringArray });
24
+ }
25
+ export const isNullableAuthMethodInfoArray = (value) => value === null || (Array.isArray(value) && value.every(isAuthMethodInfo));
26
+ export function isBridgeInfo(value) {
27
+ return isObject(value)
28
+ && hasRequired(value, { type: isString })
29
+ && hasOptional(value, {
30
+ available: isBoolean,
31
+ version: isNullableString,
32
+ displayName: isNullableString,
33
+ description: isNullableString,
34
+ // Optional for the same reason as the auth fields: absent is "nobody looked". No build
35
+ // produces these anymore (see `BridgeInfo`'s doc comment), but a guard that rejected them
36
+ // would turn a frame from an older peer into a decode failure.
37
+ cliCommand: isNullableString,
38
+ cliInstalled: isNullableBoolean,
39
+ adapterInstalled: isNullableBoolean,
40
+ // Unlike the "unknown vs no" fields above, `null` is a genuine answer here — see
41
+ // `BridgeInfo.adapterVersion`'s doc comment — so this accepts it the same as any other
42
+ // nullable string field.
43
+ adapterVersion: isNullableString,
44
+ registryVersion: isString,
45
+ // `true` and nothing else, like `recommended`/`supportsSteering`: see `BridgeInfo.unsupportedPlatform`.
46
+ unsupportedPlatform: isTrueOnly,
47
+ // Present only alongside `unsupportedPlatform: true`, but that pairing is a producer-side
48
+ // convention (see `acpAgents.ts`'s `roster`), not something this guard enforces -- a plain
49
+ // string is all a peer sending it without the flag is asked to satisfy.
50
+ platformTriple: isString,
51
+ // `true` and nothing else, like `supportsSteering`: see `BridgeInfo.recommended`.
52
+ recommended: isTrueOnly,
53
+ defaultName: isNullableString,
54
+ defaultLabel: isNullableString,
55
+ // Optional on purpose: see BridgeInfo's doc comment — absent is "unknown", and a guard that
56
+ // demanded these would turn "we have not connected to this agent yet" into a decode failure.
57
+ authMethods: isNullableAuthMethodInfoArray,
58
+ authenticated: isNullableBoolean,
59
+ // `true` and nothing else: like `agent_verified.authenticated`, this field has no `false`.
60
+ // A peer that sent one would be publishing "we looked and it cannot steer" through a key
61
+ // whose absence already says exactly that, so refusing it keeps one spelling per fact.
62
+ supportsSteering: isTrueOnly,
63
+ });
64
+ }
65
+ export function isBridgeInfoArray(value) {
66
+ return Array.isArray(value) && value.every(isBridgeInfo);
67
+ }
68
+ /**
69
+ * The roster entries a peer will actually be able to read, with the rest dropped loudly.
70
+ *
71
+ * Takes `unknown[]`, not `BridgeInfo[]`, because that is what it is: the argument's static type is
72
+ * a claim made by whoever assembled it out of another process's output, and this function exists
73
+ * precisely for the case where that claim is false.
74
+ *
75
+ * Every frame that carries a roster carries it as **optional cargo** — `management_hello`,
76
+ * `agent_hello`, `external_agents_scan_result` — while the frame itself is load-bearing. A peer
77
+ * that cannot decode one of those does not lose the roster; it loses the channel. The server's
78
+ * hello path in particular closes the socket with nothing said (`WorkstationGateway.kt`), so a
79
+ * single unreadable entry cost a workstation its connection *permanently*: the client rebuilt the
80
+ * same roster and re-sent it every 30 seconds forever. Degrading the way `agentHeartbeat` degrades
81
+ * its inventory — drop the cargo, keep the frame — is what bounds that damage to the agent that
82
+ * caused it.
83
+ *
84
+ * The roster's contents come from third-party agent processes over a pipe (see
85
+ * `toAuthMethodSnapshot` in `acpClient.ts` for how one gets in), so "our types say it is fine" is
86
+ * not evidence. Round-tripping through JSON before the check, and returning the *parsed* entries,
87
+ * is what makes the subject here identical to what the peer reads: `JSON.stringify` erases an
88
+ * `undefined` value, so a field can satisfy every `typeof` test in this process and still arrive
89
+ * missing. Same reasoning as `outboundAgentFrameRejection`'s round trip.
90
+ */
91
+ export function bridgesForWire(bridges, frame) {
92
+ const { kept, dropped } = partitionWireSafeBridges(bridges);
93
+ if (dropped.length > 0) {
94
+ console.error(`[wire] ${frame} omits ${dropped.length} unreadable roster ` +
95
+ `entr${dropped.length === 1 ? 'y' : 'ies'} (${dropped.join(', ')}): ` +
96
+ 'the agent advertised something this wire cannot carry');
97
+ }
98
+ return kept;
99
+ }
100
+ function partitionWireSafeBridges(bridges) {
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(JSON.stringify(bridges));
104
+ }
105
+ catch (error) {
106
+ // A roster that cannot be serialized at all still must not cost the channel.
107
+ return { kept: [], dropped: [`<unserializable roster: ${error instanceof Error ? error.message : String(error)}>`] };
108
+ }
109
+ if (!Array.isArray(parsed))
110
+ return { kept: [], dropped: ['<roster was not an array>'] };
111
+ const kept = [];
112
+ const dropped = [];
113
+ for (const entry of parsed) {
114
+ if (isBridgeInfo(entry))
115
+ kept.push(entry);
116
+ // Named by `type` where there is one: that is the registry agent id, which is what whoever
117
+ // reads the log has to go and look at. `typeof` rather than `isString` because `ValueGuard`'s
118
+ // annotation erases the type predicate and would leave `entry.type` as `unknown`.
119
+ else
120
+ dropped.push(isObject(entry) && typeof entry.type === 'string' ? entry.type : '<unnamed>');
121
+ }
122
+ return { kept, dropped };
123
+ }
124
+ export function decodeChannelFrame(text, discriminators, isValid) {
125
+ const value = JSON.parse(text);
126
+ if (!isObject(value)) {
127
+ throw new TypeError('Channel frame must be a JSON object');
128
+ }
129
+ const discriminator = value.t;
130
+ if (typeof discriminator !== 'string' || !discriminators.has(discriminator)) {
131
+ throw new TypeError(`Unexpected channel frame discriminator: ${String(discriminator)}`);
132
+ }
133
+ if (!isValid(value))
134
+ throw new TypeError(`Invalid channel frame shape: ${discriminator}`);
135
+ return value;
136
+ }
@@ -0,0 +1,31 @@
1
+ import { decodeChannelFrame, hasOptional, hasRequired, isBoolean, isNumber, isString, isStringArray, isStringRecord } from './common.js';
2
+ const DISCRIMINATORS = new Set([
3
+ 'computer_hello', 'computer_ready', 'heartbeat', 'heartbeat_ack',
4
+ 'exec_request', 'exec_output', 'exec_exit', 'exec_cancel',
5
+ ]);
6
+ function isComputerFrame(value) {
7
+ switch (value.t) {
8
+ case 'computer_hello': return hasRequired(value, { ticket: isString });
9
+ case 'computer_ready': return hasRequired(value, { generation: isString, wireVersion: isNumber });
10
+ case 'heartbeat': return hasRequired(value, { seq: isNumber, sentAtEpochMs: isNumber });
11
+ case 'heartbeat_ack': return hasRequired(value, { seq: isNumber });
12
+ case 'exec_request': return hasRequired(value, {
13
+ id: isString, command: isString, shell: isString, cwd: isString, env: isStringRecord,
14
+ pathPrepend: isStringArray, streaming: isBoolean,
15
+ }) && hasOptional(value, { timeoutSeconds: isNullableNumber });
16
+ case 'exec_output': return hasRequired(value, { id: isString, stream: isString, chunk: isString });
17
+ case 'exec_exit': return hasRequired(value, {
18
+ id: isString, exitCode: isNullableNumber, stdout: isString, stderr: isString,
19
+ timedOut: isBoolean, truncated: isBoolean,
20
+ });
21
+ case 'exec_cancel': return hasRequired(value, { id: isString });
22
+ default: return false;
23
+ }
24
+ }
25
+ function isNullableNumber(value) { return value === null || isNumber(value); }
26
+ export function encodeComputerFrame(frame) {
27
+ return JSON.stringify(frame);
28
+ }
29
+ export function decodeComputerFrame(text) {
30
+ return decodeChannelFrame(text, DISCRIMINATORS, isComputerFrame);
31
+ }
@@ -0,0 +1,116 @@
1
+ import { decodeChannelFrame, hasOptional, hasRequired, isBoolean, isBridgeInfoArray, isNullableString, isNumber, isObject, isString, } from './common.js';
2
+ export const MANAGEMENT_WIRE_MIN_VERSION = 1;
3
+ /** Adds the `DRAINING` value to ClientUpdatePhase. */
4
+ export const MANAGEMENT_WIRE_DRAINING_VERSION = 3;
5
+ /** Adds optional workstation load and storage-pool metrics to heartbeats. */
6
+ export const MANAGEMENT_WIRE_METRICS_VERSION = 4;
7
+ /** Adds normalized CPU load and OS memory-pressure metrics. */
8
+ export const MANAGEMENT_WIRE_PRESSURE_VERSION = 5;
9
+ /** Replaces normalized CPU load with raw 1/5/15-minute load averages and CPU capacity. */
10
+ export const MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION = 6;
11
+ export const MANAGEMENT_WIRE_VERSION = MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION;
12
+ const DISCRIMINATORS = new Set([
13
+ 'management_hello', 'management_ready', 'heartbeat', 'heartbeat_ack',
14
+ 'channel_ticket_request', 'channel_ticket', 'client_update', 'client_update_status',
15
+ 'external_agents_scan', 'external_agents_scan_result',
16
+ ]);
17
+ const CHANNEL_KINDS = new Set(['MANAGEMENT', 'COMPUTER', 'AGENT']);
18
+ const CHANNEL_STATES = new Set(['DISCONNECTED', 'CONNECTING', 'CONNECTED', 'RECONNECTING']);
19
+ function isChannelKind(value) { return typeof value === 'string' && CHANNEL_KINDS.has(value); }
20
+ function isChannelState(value) { return typeof value === 'string' && CHANNEL_STATES.has(value); }
21
+ function isNullable(guard) {
22
+ return (value) => value === null || guard(value);
23
+ }
24
+ function isProtocolRange(value) {
25
+ return isObject(value) && hasRequired(value, { min: isNumber, max: isNumber });
26
+ }
27
+ function isProtocolRangeMap(value, complete) {
28
+ if (!isObject(value))
29
+ return false;
30
+ if (complete && ![...CHANNEL_KINDS].every((kind) => Object.hasOwn(value, kind)))
31
+ return false;
32
+ return Object.entries(value).every(([kind, range]) => CHANNEL_KINDS.has(kind) && isProtocolRange(range));
33
+ }
34
+ function isSelectedProtocolMap(value) {
35
+ return isObject(value)
36
+ && [...CHANNEL_KINDS].every((kind) => Object.hasOwn(value, kind))
37
+ && Object.entries(value).every(([kind, version]) => CHANNEL_KINDS.has(kind) && isNumber(version));
38
+ }
39
+ function isRelease(value) {
40
+ return isObject(value) && hasRequired(value, { version: isString, url: isString, size: isNumber, sha512: isString });
41
+ }
42
+ const UPDATE_PHASES = new Set([
43
+ 'REQUESTED', 'DRAINING', 'BUSY', 'DOWNLOADING', 'VERIFYING', 'INSTALLING', 'RESTARTING',
44
+ 'SUCCEEDED', 'FAILED', 'ROLLED_BACK', 'UPDATE_IN_PROGRESS',
45
+ ]);
46
+ function isUpdateStatus(value) {
47
+ return isObject(value) && hasRequired(value, { phase: (phase) => typeof phase === 'string' && UPDATE_PHASES.has(phase) })
48
+ && hasOptional(value, {
49
+ commandId: isString, version: isString, releaseSha512: isString, force: isBoolean,
50
+ activeShellTasks: isNumber, activeAgentTasks: isNumber, error: isString,
51
+ });
52
+ }
53
+ function isOsInfo(value) {
54
+ return isObject(value) && hasRequired(value, { name: isString, version: isString, arch: isString })
55
+ && hasOptional(value, { hostname: isNullableString });
56
+ }
57
+ function isStoragePoolMetrics(value) {
58
+ return isObject(value) && hasRequired(value, {
59
+ id: isString,
60
+ label: isString,
61
+ workspaces: (workspaces) => Array.isArray(workspaces) && workspaces.every(isString),
62
+ totalBytes: isNumber,
63
+ availableBytes: isNumber,
64
+ });
65
+ }
66
+ function isWorkstationMetrics(value) {
67
+ return isObject(value) && hasRequired(value, {
68
+ cpuUtilization: isNullable(isNumber),
69
+ memoryTotalBytes: isNumber,
70
+ memoryAvailableBytes: isNumber,
71
+ storagePools: (pools) => Array.isArray(pools) && pools.every(isStoragePoolMetrics),
72
+ }) && hasOptional(value, {
73
+ cpuLoadNormalized: isNullable(isNumber),
74
+ cpuLoad1: isNullable(isNumber),
75
+ cpuLoad5: isNullable(isNumber),
76
+ cpuLoad15: isNullable(isNumber),
77
+ cpuLogicalCount: isNullable(isNumber),
78
+ memoryPressure: isNullable(isNumber),
79
+ });
80
+ }
81
+ function isManagementFrame(value) {
82
+ switch (value.t) {
83
+ case 'management_hello': return hasRequired(value, {
84
+ workstationId: isString, workstation: isString, clientNonce: isString, clientVersion: isString,
85
+ protocols: (protocols) => isProtocolRangeMap(protocols, true),
86
+ clientPackage: isString,
87
+ }) && hasOptional(value, { bridges: isBridgeInfoArray });
88
+ case 'management_ready': return hasRequired(value, {
89
+ serverNonce: isString, generation: isString, protocols: isSelectedProtocolMap,
90
+ }) && hasOptional(value, { release: isNullable(isRelease) });
91
+ case 'heartbeat': return hasRequired(value, {
92
+ seq: isNumber, sentAtEpochMs: isNumber, clientVersion: isString,
93
+ channels: (channels) => isObject(channels) && Object.entries(channels).every(([kind, state]) => CHANNEL_KINDS.has(kind) && isChannelState(state)),
94
+ activeShellTasks: isNumber, activeAgentTasks: isNumber,
95
+ }) && hasOptional(value, {
96
+ protocols: (protocols) => isProtocolRangeMap(protocols, false),
97
+ os: isNullable(isOsInfo),
98
+ update: isNullable(isUpdateStatus),
99
+ metrics: isNullable(isWorkstationMetrics),
100
+ });
101
+ case 'heartbeat_ack': return hasRequired(value, { seq: isNumber });
102
+ case 'channel_ticket_request': return hasRequired(value, { channel: isChannelKind });
103
+ case 'channel_ticket': return hasRequired(value, { channel: isChannelKind, ticket: isString, expiresAtEpochMs: isNumber });
104
+ case 'client_update': return hasRequired(value, { commandId: isString, release: isRelease, force: isBoolean });
105
+ case 'client_update_status': return hasRequired(value, { status: isUpdateStatus });
106
+ case 'external_agents_scan': return hasRequired(value, { commandId: isString });
107
+ case 'external_agents_scan_result': return hasRequired(value, { commandId: isString, bridges: isBridgeInfoArray });
108
+ default: return false;
109
+ }
110
+ }
111
+ export function encodeManagementFrame(frame) {
112
+ return JSON.stringify(frame);
113
+ }
114
+ export function decodeManagementFrame(text) {
115
+ return decodeChannelFrame(text, DISCRIMINATORS, isManagementFrame);
116
+ }
@@ -0,0 +1,42 @@
1
+ import { UsageError } from '../config.js';
2
+ import { clientDir, loadClientConfig, saveClientConfig, writeKeys } from './config.js';
3
+ import { osInfo } from './osinfo.js';
4
+ export async function registerWorkstation(server, clientName, token, ws) {
5
+ const dir = clientDir();
6
+ const existing = await loadClientConfig(dir).catch(() => undefined);
7
+ const body = {
8
+ name: clientName,
9
+ ...(existing?.server === server ? { workstationId: existing.workstationId } : {}),
10
+ os: osInfo(),
11
+ defaultWorkspace: { name: ws.name, cwd: ws.cwd, shell: ws.shell },
12
+ };
13
+ let res;
14
+ try {
15
+ res = await fetch(new URL('/workstation/register', server), {
16
+ method: 'POST',
17
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
18
+ body: JSON.stringify(body),
19
+ });
20
+ }
21
+ catch (err) {
22
+ throw new Error(`Failed to reach ${server}/workstation/register: ${err instanceof Error ? err.message : err}`);
23
+ }
24
+ if (!res.ok) {
25
+ // A workstation with this name is already registered for this account — most commonly because
26
+ // this same machine registered before an axom-cli upgrade and no longer has a local config at
27
+ // the new config-directory path (see the client's move from ~/.axom-workstation to
28
+ // ~/.axom/workstation). Rebinding the existing row silently would hand this machine's identity
29
+ // to whichever caller re-registers next, so the server refuses instead of upserting.
30
+ if (res.status === 409) {
31
+ throw new UsageError(`A workstation named '${clientName}' is already registered for this account. This is expected `
32
+ + `when the same machine re-registers after an axom-cli upgrade. Delete it under Settings → `
33
+ + 'Workstations, then run `axom workstation start` again.');
34
+ }
35
+ throw new Error(`Registration failed: ${res.status} ${await res.text()}`);
36
+ }
37
+ const data = (await res.json());
38
+ if (!data.workstationId)
39
+ throw new Error('Registration response missing workstationId');
40
+ await saveClientConfig(dir, { server, workstationId: data.workstationId, workspaces: [{ name: ws.name, cwd: ws.cwd }] });
41
+ await writeKeys(dir, data.clientKey, data.serverPub);
42
+ }