@pellux/goodvibes-tui 1.22.0 → 1.24.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/CHANGELOG.md +24 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +4 -0
- package/src/cli/help.ts +5 -0
- package/src/cli/management-utils.ts +4 -0
- package/src/cluster/commands.ts +382 -0
- package/src/cluster/remote-daemon-target.ts +225 -0
- package/src/cluster/render.ts +268 -0
- package/src/core/conversation-line-cache.ts +11 -6
- package/src/core/conversation-render-context.ts +122 -0
- package/src/core/conversation-rendering.ts +86 -89
- package/src/daemon/cli.ts +71 -2
- package/src/daemon/handlers/inbox/index.ts +63 -3
- package/src/daemon/handlers/inbox/poller.ts +54 -8
- package/src/input/command-args-hint.ts +2 -0
- package/src/input/commands/cluster-runtime.ts +53 -0
- package/src/input/commands.ts +2 -0
- package/src/input/settings-modal-data.ts +6 -0
- package/src/main.ts +3 -0
- package/src/renderer/conversation-surface.ts +23 -6
- package/src/renderer/conversation-tree.ts +142 -93
- package/src/renderer/tool-call.ts +13 -12
- package/src/runtime/bootstrap.ts +6 -1
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/daemon-handler-composition.ts +80 -0
- package/src/runtime/disposal-wiring.ts +66 -0
- package/src/runtime/durability-services.ts +7 -3
- package/src/runtime/embedded-service-factories.ts +19 -1
- package/src/runtime/process-lifecycle.ts +9 -1
- package/src/runtime/remote-execution-composition.ts +62 -0
- package/src/runtime/services.ts +50 -50
- package/src/verification/verification-ledger.ts +38 -0
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ All notable changes to GoodVibes TUI.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [1.24.0] - 2026-07-27
|
|
8
|
+
|
|
9
|
+
### Changes
|
|
10
|
+
|
|
11
|
+
- Added: when you run GoodVibes on more than one machine on the same network, they now agree on which one reads each inbox instead of all of them reading it. A message used to be picked up twice and answered twice; now exactly one machine holds each account — your laptop can hold the work Slack while the desktop holds the mailbox — and if a machine goes away, only the accounts it was reading move. Off by default: turn it on with `cluster.enabled`.
|
|
12
|
+
- Added: `cluster` commands for saying which machines are yours — create a group, join one, list the machines in it, remove one, rotate the group key. Machines outside the group are not heard, and a machine you remove stops being heard immediately.
|
|
13
|
+
- Added: `cluster status` reports which surfaces THIS machine is holding, so the question "is this the one that should be reading my inbox?" has an answer. Account names never appear — each surface is shown as a digest, in the log and on the wire alike.
|
|
14
|
+
- Added: your daemon now keeps its own credentials, so a mailbox password follows the machine that takes over instead of staying behind on the one that went away.
|
|
15
|
+
- Fixed: a daemon told to stop now stops what it started. Timers and pollers it owned kept running after shutdown.
|
|
16
|
+
- Fixed: the daemon lost log lines written just before it exited, which is why an update that went wrong read as a daemon that simply vanished. What it says it wrote is now on disk before it stops.
|
|
17
|
+
- Fixed: a daemon that was switched off while releases shipped now catches up when it starts, rather than waiting out a full interval first. An update that will not start rolls itself back to the version that did.
|
|
18
|
+
- Fixed: the transcript's status markers sit in the assistant's own column and the tree's rails close properly.
|
|
19
|
+
- Fixed: the installer only ever signals daemons it installed. Running it used to stop a daemon belonging to a different install on the same machine — which is exactly the setup the multi-machine work above exists to support. It now names the other install and leaves it alone.
|
|
20
|
+
|
|
21
|
+
## [1.23.0] - 2026-07-26
|
|
22
|
+
|
|
23
|
+
### Changes
|
|
24
|
+
|
|
25
|
+
- Fixed: a work request sent over Telegram (or Google Chat, Signal, WhatsApp, telephony, iMessage, Microsoft Teams, BlueBubbles, Mattermost, Matrix, or Home Assistant) produced a proposal that could never be shown. The daemon asked for your agreement on a channel it had no way to send on, so you saw nothing and the work waited on an answer to a question that was never posed. Proposals now go out on every surface the gate covers.
|
|
26
|
+
- Fixed: the daemon discarded every log line it produced. Its activity log had no destination, so a rejected bot token, a surface that could not start, and a reply with nowhere to go all left no trace anywhere. The log now has a home before anything else starts, which is why the failures above were invisible until now.
|
|
27
|
+
- Fixed: an inbound message that landed in an already-running session started work immediately no matter how the conversation gate was configured. The gate's settings now reach the daemon, which is the process that receives the message.
|
|
28
|
+
- Fixed: a channel surface that was switched on but could not start said nothing. It now reports which surface, why it cannot start, and what would fix it.
|
|
29
|
+
- Fixed: conversation-gate settings edited from the TUI or the agent were written to that client's own settings file, which the daemon never reads, so they changed nothing. They now live with the daemon and existing values are moved there on first start, with a record of exactly what moved.
|
|
30
|
+
|
|
7
31
|
## [1.22.0] - 2026-07-26
|
|
8
32
|
|
|
9
33
|
### Changes
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/mgd34msu/goodvibes-tui)
|
|
6
6
|
|
|
7
7
|
GoodVibes is a terminal console for coding and operations work with an AI model. You run `goodvibes` in a project directory and get a full-screen terminal app where you talk to a model that can read and edit your files, run shell commands, search the web, and hand work off to background agents — asking your permission before anything that writes or executes. It talks to many model providers (OpenAI, Anthropic, Gemini, Bedrock, Copilot, OpenRouter and other OpenAI-compatible gateways, plus local servers like Ollama and LM Studio that it finds on startup), keeps its settings, sessions, and secrets on your own machine, and shows you the token count and running cost of every turn. Alongside the conversation, panels turn background work — running agents, git state, diffs, tokens, cost — into live control rooms.
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.ts",
|
|
@@ -106,8 +106,8 @@
|
|
|
106
106
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
107
107
|
"@ast-grep/napi": "^0.42.0",
|
|
108
108
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
109
|
-
"@pellux/goodvibes-sdk": "1.
|
|
110
|
-
"@pellux/goodvibes-terminal-shell": "1.
|
|
109
|
+
"@pellux/goodvibes-sdk": "1.17.0",
|
|
110
|
+
"@pellux/goodvibes-terminal-shell": "1.17.0",
|
|
111
111
|
"bash-language-server": "^5.6.0",
|
|
112
112
|
"fuse.js": "^7.1.0",
|
|
113
113
|
"graphql": "^16.13.2",
|
|
@@ -133,10 +133,10 @@
|
|
|
133
133
|
"typescript": "^5.9.3"
|
|
134
134
|
},
|
|
135
135
|
"optionalDependencies": {
|
|
136
|
-
"@pellux/goodvibes-tui-linux-x64": "1.
|
|
137
|
-
"@pellux/goodvibes-tui-linux-arm64": "1.
|
|
138
|
-
"@pellux/goodvibes-tui-darwin-x64": "1.
|
|
139
|
-
"@pellux/goodvibes-tui-darwin-arm64": "1.
|
|
136
|
+
"@pellux/goodvibes-tui-linux-x64": "1.23.0",
|
|
137
|
+
"@pellux/goodvibes-tui-linux-arm64": "1.23.0",
|
|
138
|
+
"@pellux/goodvibes-tui-darwin-x64": "1.23.0",
|
|
139
|
+
"@pellux/goodvibes-tui-darwin-arm64": "1.23.0"
|
|
140
140
|
},
|
|
141
141
|
"trustedDependencies": [],
|
|
142
142
|
"overrides": {
|
|
@@ -113,6 +113,10 @@ async function buildProviderReadiness(runtime: CliCommandRuntime) {
|
|
|
113
113
|
}));
|
|
114
114
|
} finally {
|
|
115
115
|
services.providerRegistry.stopWatching();
|
|
116
|
+
// A one-shot command still composes the whole graph, and that graph starts
|
|
117
|
+
// pollers. Disposing lets the command finish instead of relying on process
|
|
118
|
+
// exit to reclaim them.
|
|
119
|
+
services.dispose();
|
|
116
120
|
}
|
|
117
121
|
}
|
|
118
122
|
|
package/src/cli/help.ts
CHANGED
|
@@ -341,6 +341,11 @@ export function renderGoodVibesDaemonHelp(binary = 'goodvibes-daemon'): string {
|
|
|
341
341
|
' service-status Show whether the daemon service is installed / enabled / active',
|
|
342
342
|
' migrate-service Guided migration from an install-script goodvibes-daemon.service unit; prints a plan',
|
|
343
343
|
' unless run with -y/--yes (never auto-migrates)',
|
|
344
|
+
' cluster <command> Share inbound channel work with your other machines on this network.',
|
|
345
|
+
' status | create | join | key | nodes | forget <machine> | rotate [--now]',
|
|
346
|
+
' | leave | rename | groups',
|
|
347
|
+
' Talks to a running daemon: add --host/--port/--token for one on another',
|
|
348
|
+
' machine, or --json for a scriptable answer.',
|
|
344
349
|
'',
|
|
345
350
|
'Options:',
|
|
346
351
|
' --daemon-home <dir> Override daemon home',
|
|
@@ -242,6 +242,10 @@ export async function withRuntimeServices<T>(
|
|
|
242
242
|
return await fn(services);
|
|
243
243
|
} finally {
|
|
244
244
|
services.providerRegistry.stopWatching();
|
|
245
|
+
// A one-shot command still composes the whole graph, and that graph starts
|
|
246
|
+
// pollers. Disposing lets the command finish instead of relying on process
|
|
247
|
+
// exit to reclaim them.
|
|
248
|
+
services.dispose();
|
|
245
249
|
}
|
|
246
250
|
}
|
|
247
251
|
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands.ts — `goodvibes-daemon cluster …`, and `/cluster` in the TUI.
|
|
3
|
+
*
|
|
4
|
+
* The authoritative surface for LAN groups. A homelab node is headless: the
|
|
5
|
+
* operator SSHes in, and everything they can do to a group they must be able to
|
|
6
|
+
* do here.
|
|
7
|
+
*
|
|
8
|
+
* This module contains NO logic of its own. It parses arguments, calls a daemon
|
|
9
|
+
* verb over the convention in remote-daemon-target.ts, and renders the answer.
|
|
10
|
+
* The daemon decides everything else, which is what makes this command, the
|
|
11
|
+
* TUI's `/cluster` and any web UI three renderings of one implementation.
|
|
12
|
+
*
|
|
13
|
+
* Every failure exits non-zero with a plain-language message naming the fix.
|
|
14
|
+
*/
|
|
15
|
+
import { createInterface } from 'node:readline/promises';
|
|
16
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
17
|
+
import type {
|
|
18
|
+
RotateKeyResult,
|
|
19
|
+
CreateGroupResult,
|
|
20
|
+
DiscoveredGroup,
|
|
21
|
+
ForgetNodeResult,
|
|
22
|
+
GroupStatusReport,
|
|
23
|
+
JoinGroupResult,
|
|
24
|
+
JoinKeyResult,
|
|
25
|
+
NodesResult,
|
|
26
|
+
} from '@pellux/goodvibes-sdk/platform/cluster';
|
|
27
|
+
import {
|
|
28
|
+
callDaemonVerb,
|
|
29
|
+
resolveRemoteDaemonTarget,
|
|
30
|
+
type DaemonFetch,
|
|
31
|
+
type DaemonVerbOutcome,
|
|
32
|
+
} from './remote-daemon-target.ts';
|
|
33
|
+
import {
|
|
34
|
+
renderCreated,
|
|
35
|
+
renderDiscovered,
|
|
36
|
+
renderFailure,
|
|
37
|
+
renderForgotten,
|
|
38
|
+
renderJoinKey,
|
|
39
|
+
renderJoinKeyQr,
|
|
40
|
+
renderJoined,
|
|
41
|
+
renderNodes,
|
|
42
|
+
renderRotated,
|
|
43
|
+
renderStatus,
|
|
44
|
+
} from './render.ts';
|
|
45
|
+
|
|
46
|
+
export const CLUSTER_SUBCOMMANDS = [
|
|
47
|
+
'status', 'create', 'join', 'key', 'nodes', 'forget', 'rotate', 'leave', 'rename', 'groups',
|
|
48
|
+
] as const;
|
|
49
|
+
export type ClusterSubcommand = (typeof CLUSTER_SUBCOMMANDS)[number];
|
|
50
|
+
|
|
51
|
+
export function isClusterSubcommand(value: string | undefined): value is ClusterSubcommand {
|
|
52
|
+
return typeof value === 'string' && (CLUSTER_SUBCOMMANDS as readonly string[]).includes(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What the parser produced, or why it could not. */
|
|
56
|
+
export interface ParsedClusterCommand {
|
|
57
|
+
readonly subcommand: ClusterSubcommand | null;
|
|
58
|
+
readonly positional: string | null;
|
|
59
|
+
readonly name: string | null;
|
|
60
|
+
readonly passphrase: string | null;
|
|
61
|
+
readonly group: string | null;
|
|
62
|
+
readonly key: string | null;
|
|
63
|
+
readonly host: string | null;
|
|
64
|
+
readonly port: number | null;
|
|
65
|
+
readonly token: string | null;
|
|
66
|
+
readonly json: boolean;
|
|
67
|
+
readonly yes: boolean;
|
|
68
|
+
/** `key` only: also render the join key as a QR code. */
|
|
69
|
+
readonly qr: boolean;
|
|
70
|
+
/** `rotate` only: stop accepting the outgoing key at once. */
|
|
71
|
+
readonly now: boolean;
|
|
72
|
+
readonly errors: readonly string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const VALUE_FLAGS = new Set(['--name', '--passphrase', '--group', '--key', '--host', '--port', '--token']);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse `cluster <subcommand> [flags]`.
|
|
79
|
+
*
|
|
80
|
+
* Unknown flags are an ERROR rather than being ignored. A typo in `--group`
|
|
81
|
+
* that silently became "no group given" would send the operator to the
|
|
82
|
+
* interactive picker with no explanation of why their arguments vanished.
|
|
83
|
+
*/
|
|
84
|
+
export function parseClusterCommand(argv: readonly string[]): ParsedClusterCommand {
|
|
85
|
+
const errors: string[] = [];
|
|
86
|
+
let subcommand: ClusterSubcommand | null = null;
|
|
87
|
+
let positional: string | null = null;
|
|
88
|
+
const values = new Map<string, string>();
|
|
89
|
+
let json = false;
|
|
90
|
+
let yes = false;
|
|
91
|
+
let qr = false;
|
|
92
|
+
let now = false;
|
|
93
|
+
|
|
94
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
95
|
+
const token = argv[index] ?? '';
|
|
96
|
+
if (token === '--json') { json = true; continue; }
|
|
97
|
+
if (token === '-y' || token === '--yes') { yes = true; continue; }
|
|
98
|
+
if (token === '--qr') { qr = true; continue; }
|
|
99
|
+
if (token === '--now') { now = true; continue; }
|
|
100
|
+
if (VALUE_FLAGS.has(token)) {
|
|
101
|
+
const value = argv[index + 1];
|
|
102
|
+
if (value === undefined || value.startsWith('--')) {
|
|
103
|
+
errors.push(`${token} needs a value`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
values.set(token, value);
|
|
107
|
+
index += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const inline = /^(--[a-z]+)=(.*)$/.exec(token);
|
|
111
|
+
if (inline && VALUE_FLAGS.has(inline[1] ?? '')) {
|
|
112
|
+
values.set(inline[1] as string, inline[2] ?? '');
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (token.startsWith('-')) {
|
|
116
|
+
errors.push(`'${token}' is not a flag this command understands`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (subcommand === null) {
|
|
120
|
+
if (!isClusterSubcommand(token)) {
|
|
121
|
+
errors.push(`'${token}' is not a cluster command — try one of: ${CLUSTER_SUBCOMMANDS.join(', ')}`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
subcommand = token;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (positional === null) {
|
|
128
|
+
positional = token;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
errors.push(`'${token}' is one argument too many`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const portText = values.get('--port');
|
|
135
|
+
let port: number | null = null;
|
|
136
|
+
if (portText !== undefined) {
|
|
137
|
+
const parsed = Number(portText);
|
|
138
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) {
|
|
139
|
+
errors.push(`--port must be a number between 1 and 65535, not '${portText}'`);
|
|
140
|
+
} else {
|
|
141
|
+
port = parsed;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (subcommand === 'forget' && positional === null) {
|
|
145
|
+
errors.push('forget needs the machine to remove — run `cluster nodes` to see them');
|
|
146
|
+
}
|
|
147
|
+
if (subcommand === 'rename' && positional === null && values.get('--name') === undefined) {
|
|
148
|
+
errors.push('rename needs the new name — `cluster rename "the workshop"`');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
subcommand,
|
|
153
|
+
positional,
|
|
154
|
+
name: values.get('--name') ?? null,
|
|
155
|
+
passphrase: values.get('--passphrase') ?? null,
|
|
156
|
+
group: values.get('--group') ?? null,
|
|
157
|
+
key: values.get('--key') ?? null,
|
|
158
|
+
host: values.get('--host') ?? null,
|
|
159
|
+
port,
|
|
160
|
+
token: values.get('--token') ?? null,
|
|
161
|
+
json,
|
|
162
|
+
yes,
|
|
163
|
+
qr,
|
|
164
|
+
now,
|
|
165
|
+
errors,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** What the caller prints and exits with. */
|
|
170
|
+
export interface ClusterCommandResult {
|
|
171
|
+
readonly lines: readonly string[];
|
|
172
|
+
readonly exitCode: number;
|
|
173
|
+
/** Written raw to stdout — the OSC 52 clipboard attempt, when there is one. */
|
|
174
|
+
readonly rawOutput?: string | undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface RunClusterCommandInput {
|
|
178
|
+
readonly argv: readonly string[];
|
|
179
|
+
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
180
|
+
readonly daemonHomeDir: string;
|
|
181
|
+
readonly now?: (() => number) | undefined;
|
|
182
|
+
readonly isTerminal?: boolean | undefined;
|
|
183
|
+
/** Injected in tests so nothing opens a socket or reads a real token. */
|
|
184
|
+
readonly fetchImpl?: DaemonFetch | undefined;
|
|
185
|
+
readonly readToken?: ((daemonHomeDir: string) => string | undefined) | undefined;
|
|
186
|
+
/** Injected in tests; the real one reads stdin. */
|
|
187
|
+
readonly prompt?: ((question: string) => Promise<string>) | undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function failure(error: string, fix: string, json: boolean): ClusterCommandResult {
|
|
191
|
+
return {
|
|
192
|
+
lines: json
|
|
193
|
+
? [JSON.stringify({ ok: false, error, fix }, null, 2)]
|
|
194
|
+
: renderFailure(error, fix),
|
|
195
|
+
exitCode: 1,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function success(data: unknown, lines: readonly string[], json: boolean, rawOutput?: string): ClusterCommandResult {
|
|
200
|
+
return {
|
|
201
|
+
lines: json ? [JSON.stringify({ ok: true, data }, null, 2)] : lines,
|
|
202
|
+
exitCode: 0,
|
|
203
|
+
...(rawOutput ? { rawOutput } : {}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Ask a question on stdin. Only ever called when stdin is a terminal. */
|
|
208
|
+
async function askOnStdin(question: string): Promise<string> {
|
|
209
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
210
|
+
try {
|
|
211
|
+
return (await rl.question(question)).trim();
|
|
212
|
+
} finally {
|
|
213
|
+
rl.close();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function runClusterCommand(input: RunClusterCommandInput): Promise<ClusterCommandResult> {
|
|
218
|
+
const parsed = parseClusterCommand(input.argv);
|
|
219
|
+
if (parsed.errors.length > 0) {
|
|
220
|
+
return failure(parsed.errors[0] as string, `usage: goodvibes-daemon cluster <${CLUSTER_SUBCOMMANDS.join('|')}>`, parsed.json);
|
|
221
|
+
}
|
|
222
|
+
if (!parsed.subcommand) {
|
|
223
|
+
return failure(
|
|
224
|
+
'no cluster command was given',
|
|
225
|
+
`usage: goodvibes-daemon cluster <${CLUSTER_SUBCOMMANDS.join('|')}>`,
|
|
226
|
+
parsed.json,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const resolved = resolveRemoteDaemonTarget({
|
|
231
|
+
flags: {
|
|
232
|
+
...(parsed.host !== null ? { host: parsed.host } : {}),
|
|
233
|
+
...(parsed.port !== null ? { port: parsed.port } : {}),
|
|
234
|
+
...(parsed.token !== null ? { token: parsed.token } : {}),
|
|
235
|
+
},
|
|
236
|
+
configManager: input.configManager,
|
|
237
|
+
daemonHomeDir: input.daemonHomeDir,
|
|
238
|
+
...(input.readToken ? { readToken: input.readToken } : {}),
|
|
239
|
+
});
|
|
240
|
+
if (!resolved.ok) return failure(resolved.error, resolved.fix, parsed.json);
|
|
241
|
+
|
|
242
|
+
const now = input.now?.() ?? Date.now();
|
|
243
|
+
const isTerminal = input.isTerminal ?? Boolean(process.stdout.isTTY);
|
|
244
|
+
const call = <T>(path: string, method: 'GET' | 'POST' = 'GET', body?: unknown): Promise<DaemonVerbOutcome<T>> =>
|
|
245
|
+
callDaemonVerb<T>(resolved.target, path, { method, ...(body === undefined ? {} : { body }) }, input.fetchImpl ?? fetch);
|
|
246
|
+
|
|
247
|
+
switch (parsed.subcommand) {
|
|
248
|
+
case 'status': {
|
|
249
|
+
const result = await call<GroupStatusReport>('/api/cluster/status');
|
|
250
|
+
return result.ok
|
|
251
|
+
? success(result.data, renderStatus(result.data, now), parsed.json)
|
|
252
|
+
: failure(result.error, result.fix, parsed.json);
|
|
253
|
+
}
|
|
254
|
+
case 'nodes': {
|
|
255
|
+
const result = await call<NodesResult>('/api/cluster/nodes');
|
|
256
|
+
return result.ok
|
|
257
|
+
? success(result.data, renderNodes(result.data, now), parsed.json)
|
|
258
|
+
: failure(result.error, result.fix, parsed.json);
|
|
259
|
+
}
|
|
260
|
+
case 'groups': {
|
|
261
|
+
const result = await call<readonly DiscoveredGroup[]>('/api/cluster/groups');
|
|
262
|
+
return result.ok
|
|
263
|
+
? success(result.data, renderDiscovered(result.data, now), parsed.json)
|
|
264
|
+
: failure(result.error, result.fix, parsed.json);
|
|
265
|
+
}
|
|
266
|
+
case 'key': {
|
|
267
|
+
const result = await call<JoinKeyResult>('/api/cluster/key');
|
|
268
|
+
if (!result.ok) return failure(result.error, result.fix, parsed.json);
|
|
269
|
+
const rendered = renderJoinKey(result.data, isTerminal && !parsed.json);
|
|
270
|
+
const lines = parsed.qr
|
|
271
|
+
? [...rendered.lines, '', ...renderJoinKeyQr(result.data.joinKey)]
|
|
272
|
+
: rendered.lines;
|
|
273
|
+
return success(result.data, lines, parsed.json, rendered.clipboardSequence ?? undefined);
|
|
274
|
+
}
|
|
275
|
+
case 'create': {
|
|
276
|
+
const result = await call<CreateGroupResult>('/api/cluster/create', 'POST', {
|
|
277
|
+
...(parsed.name !== null ? { name: parsed.name } : {}),
|
|
278
|
+
...(parsed.passphrase !== null ? { passphrase: parsed.passphrase } : {}),
|
|
279
|
+
});
|
|
280
|
+
return result.ok
|
|
281
|
+
? success(result.data, renderCreated(result.data), parsed.json)
|
|
282
|
+
: failure(result.error, result.fix, parsed.json);
|
|
283
|
+
}
|
|
284
|
+
case 'rotate': {
|
|
285
|
+
const result = await call<RotateKeyResult>('/api/cluster/rotate', 'POST', { immediate: parsed.now });
|
|
286
|
+
return result.ok
|
|
287
|
+
? success(result.data, renderRotated(result.data), parsed.json)
|
|
288
|
+
: failure(result.error, result.fix, parsed.json);
|
|
289
|
+
}
|
|
290
|
+
case 'leave': {
|
|
291
|
+
const result = await call<{ groupId: string; groupName: string }>('/api/cluster/leave', 'POST');
|
|
292
|
+
return result.ok
|
|
293
|
+
? success(result.data, [`left "${result.data.groupName}" [${result.data.groupId}]`,
|
|
294
|
+
'this machine no longer coordinates with it. The other machines still list it —',
|
|
295
|
+
'run `cluster forget` on one of them to tidy up.'], parsed.json)
|
|
296
|
+
: failure(result.error, result.fix, parsed.json);
|
|
297
|
+
}
|
|
298
|
+
case 'rename': {
|
|
299
|
+
const name = parsed.name ?? parsed.positional ?? '';
|
|
300
|
+
const result = await call<{ groupId: string; groupName: string }>('/api/cluster/rename', 'POST', { name });
|
|
301
|
+
return result.ok
|
|
302
|
+
? success(result.data, [`the group is now called "${result.data.groupName}"`,
|
|
303
|
+
'this name is visible to anything on this network, and every machine in the',
|
|
304
|
+
'group will pick it up shortly.'], parsed.json)
|
|
305
|
+
: failure(result.error, result.fix, parsed.json);
|
|
306
|
+
}
|
|
307
|
+
case 'forget': {
|
|
308
|
+
const result = await call<ForgetNodeResult>('/api/cluster/forget', 'POST', { nodeId: parsed.positional });
|
|
309
|
+
return result.ok
|
|
310
|
+
? success(result.data, renderForgotten(result.data), parsed.json)
|
|
311
|
+
: failure(result.error, result.fix, parsed.json);
|
|
312
|
+
}
|
|
313
|
+
case 'join':
|
|
314
|
+
return runJoin(parsed, call, now, input, isTerminal);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* `cluster join`.
|
|
320
|
+
*
|
|
321
|
+
* Two shapes, and both matter. With `--group` and `--key` it is a scriptable
|
|
322
|
+
* one-liner, which is what provisioning a machine from a config-management tool
|
|
323
|
+
* needs. Without them it lists the groups advertising themselves on this
|
|
324
|
+
* network and asks — which is what a person adding their second machine needs,
|
|
325
|
+
* because they do not know the group id and should not have to.
|
|
326
|
+
*/
|
|
327
|
+
async function runJoin(
|
|
328
|
+
parsed: ParsedClusterCommand,
|
|
329
|
+
call: <T>(path: string, method?: 'GET' | 'POST', body?: unknown) => Promise<DaemonVerbOutcome<T>>,
|
|
330
|
+
now: number,
|
|
331
|
+
input: RunClusterCommandInput,
|
|
332
|
+
isTerminal: boolean,
|
|
333
|
+
): Promise<ClusterCommandResult> {
|
|
334
|
+
let groupId = parsed.group;
|
|
335
|
+
let joinKey = parsed.key;
|
|
336
|
+
|
|
337
|
+
if (groupId === null || joinKey === null) {
|
|
338
|
+
const interactive = input.prompt !== undefined || (isTerminal && Boolean(process.stdin.isTTY));
|
|
339
|
+
if (!interactive) {
|
|
340
|
+
return failure(
|
|
341
|
+
'a group and a join key are both needed, and this is not an interactive terminal',
|
|
342
|
+
'pass them directly: cluster join --group <id> --key <join key>',
|
|
343
|
+
parsed.json,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const ask = input.prompt ?? askOnStdin;
|
|
347
|
+
|
|
348
|
+
if (groupId === null) {
|
|
349
|
+
const found = await call<readonly DiscoveredGroup[]>('/api/cluster/groups');
|
|
350
|
+
if (!found.ok) return failure(found.error, found.fix, parsed.json);
|
|
351
|
+
if (found.data.length === 0) {
|
|
352
|
+
return failure(
|
|
353
|
+
'no goodvibes groups are advertising themselves on this network',
|
|
354
|
+
'check the other machine is switched on with clustering enabled, or join by id: '
|
|
355
|
+
+ 'cluster join --group <id> --key <join key>',
|
|
356
|
+
parsed.json,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const listing = renderDiscovered(found.data, now);
|
|
360
|
+
for (const line of listing) process.stdout.write(`${line}\n`);
|
|
361
|
+
const answer = await ask('\nwhich group? (name or id, blank to cancel): ');
|
|
362
|
+
if (answer.length === 0) return { lines: ['cancelled'], exitCode: 130 };
|
|
363
|
+
const chosen = found.data.find((group) => group.groupId === answer)
|
|
364
|
+
?? found.data.find((group) => group.displayName === answer)
|
|
365
|
+
?? found.data.find((group) => group.groupId.startsWith(answer));
|
|
366
|
+
if (!chosen) {
|
|
367
|
+
return failure(`no group on this network matches '${answer}'`, 'run `cluster groups` to see them again', parsed.json);
|
|
368
|
+
}
|
|
369
|
+
groupId = chosen.groupId;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (joinKey === null) {
|
|
373
|
+
joinKey = await ask('join key (run `cluster key` on a machine already in the group): ');
|
|
374
|
+
if (joinKey.length === 0) return { lines: ['cancelled'], exitCode: 130 };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const result = await call<JoinGroupResult>('/api/cluster/join', 'POST', { groupId, joinKey });
|
|
379
|
+
return result.ok
|
|
380
|
+
? success(result.data, renderJoined(result.data), parsed.json)
|
|
381
|
+
: failure(result.error, result.fix, parsed.json);
|
|
382
|
+
}
|