@cello-protocol/cli 0.0.232 → 0.0.234
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/package.json +4 -4
- package/dist/arg-parse.d.ts +0 -19
- package/dist/arg-parse.d.ts.map +0 -1
- package/dist/arg-parse.js +0 -29
- package/dist/arg-parse.js.map +0 -1
- package/dist/bin/cello.d.ts +0 -15
- package/dist/bin/cello.d.ts.map +0 -1
- package/dist/bin/cello.js.map +0 -1
- package/dist/cli-args.d.ts +0 -62
- package/dist/cli-args.d.ts.map +0 -1
- package/dist/cli-args.js +0 -119
- package/dist/cli-args.js.map +0 -1
- package/dist/commands.d.ts +0 -139
- package/dist/commands.d.ts.map +0 -1
- package/dist/commands.js +0 -1005
- package/dist/commands.js.map +0 -1
- package/dist/hermes/assets.d.ts +0 -35
- package/dist/hermes/assets.d.ts.map +0 -1
- package/dist/hermes/assets.js +0 -1396
- package/dist/hermes/assets.js.map +0 -1
- package/dist/hermes/install-hermes.d.ts +0 -44
- package/dist/hermes/install-hermes.d.ts.map +0 -1
- package/dist/hermes/install-hermes.js +0 -172
- package/dist/hermes/install-hermes.js.map +0 -1
- package/dist/json-out.d.ts +0 -41
- package/dist/json-out.d.ts.map +0 -1
- package/dist/json-out.js +0 -59
- package/dist/json-out.js.map +0 -1
- package/dist/parity-commands.d.ts +0 -358
- package/dist/parity-commands.d.ts.map +0 -1
- package/dist/parity-commands.js +0 -720
- package/dist/parity-commands.js.map +0 -1
- package/dist/registry.d.ts +0 -111
- package/dist/registry.d.ts.map +0 -1
- package/dist/registry.js +0 -1552
- package/dist/registry.js.map +0 -1
- package/dist/screener-commands.d.ts +0 -57
- package/dist/screener-commands.d.ts.map +0 -1
- package/dist/screener-commands.js +0 -229
- package/dist/screener-commands.js.map +0 -1
package/dist/commands.js
DELETED
|
@@ -1,1005 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLI command implementations for the `cello` binary.
|
|
3
|
-
*/
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { CO_OWNERSHIP_NOTE } from "@cello-protocol/protocol-types";
|
|
6
|
-
import { screenerState, screenerModelDir, runtimeAvailable } from "@cello-protocol/gateway";
|
|
7
|
-
import { DOWNLOAD_MB } from "./screener-commands.js";
|
|
8
|
-
import { connectOrStart, connectToDaemon, readLock, removeLock,
|
|
9
|
-
// isProcessAlive may NEVER answer "does a daemon exist?" — a pid can be dead, or reused by an
|
|
10
|
-
// unrelated process. The kernel's lock is the only thing that knows that, and every EXISTENCE
|
|
11
|
-
// decision in this file still goes through probeSingletonLock.
|
|
12
|
-
//
|
|
13
|
-
// DOD-LOGOUT-EXIT-1 imports it for the one question it CAN answer: "is this specific pid, which
|
|
14
|
-
// was alive when I told it to stop, gone yet?" That is a non-existence check layered ON TOP of
|
|
15
|
-
// the lock, never instead of it, and being wrong fails safe (logout reports "did not complete"
|
|
16
|
-
// instead of claiming a stop that did not happen). See `daemonGone`.
|
|
17
|
-
isProcessAlive, probeSingletonLock, SINGLETON_LOCK_FILENAME, noAgentsGuidance, } from "@cello-protocol/daemon";
|
|
18
|
-
/**
|
|
19
|
-
* Open an IPC connection, run `fn`, and ALWAYS close it.
|
|
20
|
-
*
|
|
21
|
-
* The close belongs in a `finally`, never after the send. The daemon enforces IPC_CONNECTION_LIMIT,
|
|
22
|
-
* so a connection is a BOUNDED resource: a `client.close()` written after `client.send()` inside a
|
|
23
|
-
* try is SKIPPED on any throw, and each failed command leaks a socket the daemon never reclaims.
|
|
24
|
-
* Leak enough and it refuses new connections — a failure whose symptom looks nothing like its cause.
|
|
25
|
-
*
|
|
26
|
-
* Every single-call command goes through this. The three that cannot are `login` (the connection is
|
|
27
|
-
* created by connectOrStart and handed to us), `logout` (it must outlive the send to poll for death),
|
|
28
|
-
* and `daemonGone` (it races the connect against a timeout). Each closes in a `finally` or reaps the
|
|
29
|
-
* abandoned promise explicitly — see them. Any NEW command that reaches for connectToDaemon directly
|
|
30
|
-
* is reintroducing the leak.
|
|
31
|
-
*/
|
|
32
|
-
async function withIpc(socketPath, fn) {
|
|
33
|
-
const client = await connectToDaemon(socketPath);
|
|
34
|
-
try {
|
|
35
|
-
return await fn(client);
|
|
36
|
-
}
|
|
37
|
-
finally {
|
|
38
|
-
client.close();
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* M8C-LOGINSTART-1 CORE: bring every loaded agent online. Called by `cello login` after the daemon
|
|
43
|
-
* is up. login ALWAYS completes — a per-agent start failure is COLLECTED, never thrown, so one bad
|
|
44
|
-
* agent can't abort login. `cello_start_agent` is idempotent, so re-login is safe. The per-agent
|
|
45
|
-
* `autoStart: false` opt-out is PARKED until the M9 config store lands (D14).
|
|
46
|
-
* Lives here rather than inline in login() so it is unit-testable against an in-process daemon.
|
|
47
|
-
*/
|
|
48
|
-
export async function autoStartAllAgents(client) {
|
|
49
|
-
const started = [];
|
|
50
|
-
const failed = [];
|
|
51
|
-
const listRes = (await client.send("cello_list_agents"));
|
|
52
|
-
for (const a of listRes.agents ?? []) {
|
|
53
|
-
try {
|
|
54
|
-
const r = (await client.send("cello_start_agent", { name: a.name }));
|
|
55
|
-
if (r.ok)
|
|
56
|
-
started.push(a.name);
|
|
57
|
-
else
|
|
58
|
-
failed.push({ name: a.name, reason: r.reason ?? "unknown" });
|
|
59
|
-
}
|
|
60
|
-
catch (err) {
|
|
61
|
-
failed.push({ name: a.name, reason: err instanceof Error ? err.message : String(err) });
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return { started, failed };
|
|
65
|
-
}
|
|
66
|
-
export async function login(celloDir, daemonBin, logger) {
|
|
67
|
-
try {
|
|
68
|
-
const result = await connectOrStart(celloDir, logger, daemonBin);
|
|
69
|
-
// M8C-LOGINSTART-1: bring every registered agent online, then report. Never let this abort login.
|
|
70
|
-
const head = result.alreadyRunning ? "Daemon already running." : "Daemon started.";
|
|
71
|
-
let summary;
|
|
72
|
-
try {
|
|
73
|
-
await result.client.send("ipc.connect", { clientType: "cli" });
|
|
74
|
-
summary = formatLoginSummary(await autoStartAllAgents(result.client));
|
|
75
|
-
}
|
|
76
|
-
catch (err) {
|
|
77
|
-
// Auto-start is best-effort — the daemon IS up. Surface the reason but complete login (exit 0).
|
|
78
|
-
summary = `Agents were not auto-started (${err instanceof Error ? err.message : String(err)}); run 'cello status' and start them with cello_use_agent.`;
|
|
79
|
-
}
|
|
80
|
-
finally {
|
|
81
|
-
result.client.close();
|
|
82
|
-
}
|
|
83
|
-
// DOD-M9C-SCREENINSTALL-1: the screener line, EVERY login until it is installed.
|
|
84
|
-
//
|
|
85
|
-
// It repeats deliberately. An operator who skipped it once has postponed, not decided, and a
|
|
86
|
-
// one-time notice at the end of a busy install is indistinguishable from no notice at all. It
|
|
87
|
-
// rides on `guidance` (stderr), so a script parsing login's stdout is unaffected.
|
|
88
|
-
const screener = await screenerLoginLine();
|
|
89
|
-
return { exitCode: 0, output: `${head}\n${summary}`, ...(screener ? { guidance: screener } : {}) };
|
|
90
|
-
}
|
|
91
|
-
catch (err) {
|
|
92
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
93
|
-
return { exitCode: 1, output: `Failed to start daemon: ${message}` };
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* One line when the classifier is not installed-and-verified, empty when it is.
|
|
98
|
-
*
|
|
99
|
-
* Never throws and never blocks login: a screener check that could break sign-in would be a worse
|
|
100
|
-
* defect than the one it reports.
|
|
101
|
-
*/
|
|
102
|
-
export async function screenerLoginLine(stateImpl) {
|
|
103
|
-
try {
|
|
104
|
-
const status = stateImpl
|
|
105
|
-
? await stateImpl()
|
|
106
|
-
: await screenerState({ dir: screenerModelDir(), runtimePresent: await runtimeAvailable() });
|
|
107
|
-
if (status.state === "ready")
|
|
108
|
-
return "";
|
|
109
|
-
if (status.state === "broken") {
|
|
110
|
-
return `Screening: the classifier is BROKEN: ${status.problem ?? "unknown fault"}. Fix it with: cello screener install --repair`;
|
|
111
|
-
}
|
|
112
|
-
// The size comes from the command that quotes it, so the two cannot drift apart.
|
|
113
|
-
return `Screening: 1 of 2 layers active. Install the classifier (~${DOWNLOAD_MB} MB): cello screener install`;
|
|
114
|
-
}
|
|
115
|
-
catch (err) {
|
|
116
|
-
// Silence here means login stops nagging because the CHECK broke, which is indistinguishable
|
|
117
|
-
// from the classifier being installed. Say what happened instead; never throw, because a
|
|
118
|
-
// screener check that breaks sign-in is worse than the gap it reports.
|
|
119
|
-
return `Screening: state UNKNOWN — the screener check failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* M8C-LOGINSTART-1: compose the operator-facing auto-start summary — every failed agent enumerated
|
|
124
|
-
* by name + reason. Pure + exported so the enumeration string is directly testable.
|
|
125
|
-
*/
|
|
126
|
-
export function formatLoginSummary(result) {
|
|
127
|
-
const parts = [];
|
|
128
|
-
if (result.started.length > 0)
|
|
129
|
-
parts.push(`Started ${result.started.length} agent(s): ${result.started.join(", ")}.`);
|
|
130
|
-
if (result.failed.length > 0) {
|
|
131
|
-
parts.push(`${result.failed.length} agent(s) failed to start: ${result.failed.map((f) => `${f.name} (${f.reason})`).join(", ")}. ` +
|
|
132
|
-
"Run 'cello status' to check; a failed agent stays offline and can be retried with cello_use_agent.");
|
|
133
|
-
}
|
|
134
|
-
// The empty case is a NEW OPERATOR'S FIRST SCREEN far more often than it is a veteran's, and
|
|
135
|
-
// "No registered agents to start." told them nothing: not the next command, not that a token is
|
|
136
|
-
// needed, and not that tokens only exist for someone a cohort has admitted. That last omission
|
|
137
|
-
// is the expensive one — it sent people to the Telegram operations agent to ask for something it
|
|
138
|
-
// could not give them, with nothing in the product having warned them. See onboarding-guidance.ts.
|
|
139
|
-
if (result.started.length === 0 && result.failed.length === 0)
|
|
140
|
-
parts.push(noAgentsGuidance());
|
|
141
|
-
return parts.join("\n");
|
|
142
|
-
}
|
|
143
|
-
// DOD-LOGOUT-WAIT-1: how long logout waits for the daemon to actually die after acknowledging
|
|
144
|
-
// the shutdown request, and how often it re-checks. A daemon closing SQLCipher + libp2p takes
|
|
145
|
-
// real time; 5 s is generous headroom, and a daemon still alive past it is genuinely stuck.
|
|
146
|
-
const LOGOUT_WAIT_TIMEOUT_MS = 5_000;
|
|
147
|
-
const LOGOUT_WAIT_POLL_MS = 50;
|
|
148
|
-
/**
|
|
149
|
-
* DOD-LOGOUT-WAIT-1: is the daemon actually GONE — judged by the same evidence `connectOrStart`
|
|
150
|
-
* consults, so a completed logout GUARANTEES the next login starts fresh.
|
|
151
|
-
*
|
|
152
|
-
* Two facts, both required, neither of them a file:
|
|
153
|
-
* 1. nothing answers on the socket, and
|
|
154
|
-
* 2. no process holds the singleton lock.
|
|
155
|
-
*
|
|
156
|
-
* (1) alone is not enough: a daemon wedged mid-shutdown may have closed its IPC server while still
|
|
157
|
-
* holding the DB. (2) alone is not enough either, because a pre-singleton-lock daemon holds no
|
|
158
|
-
* singleton lock at all — for those, "free" says nothing about whether it is alive, and only the
|
|
159
|
-
* socket can tell us. Together they are exactly the conditions under which the next daemon can
|
|
160
|
-
* safely start.
|
|
161
|
-
*
|
|
162
|
-
* Neither `isProcessAlive(lock.pid)` nor the lock FILE may be SUBSTITUTED for these two facts: a pid
|
|
163
|
-
* can be reused, and a lock file can be deleted by anyone.
|
|
164
|
-
*
|
|
165
|
-
* DOD-LOGOUT-EXIT-1 adds a THIRD, and the distinction from the paragraph above is the whole point:
|
|
166
|
-
* `livePid` is an ADDITIONAL requirement, never a substitute. Both facts above are released inside
|
|
167
|
-
* `stop()`'s `finally`, BEFORE the daemon actually ends — that gap is precisely how logout came to
|
|
168
|
-
* report "Daemon stopped." over a process still holding a directory connection. So when we know a
|
|
169
|
-
* pid that was alive at the moment we asked it to stop, it must also be gone.
|
|
170
|
-
*
|
|
171
|
-
* The pid-reuse objection does not apply to this use: the window is the 5 s logout wait, and being
|
|
172
|
-
* wrong here fails SAFE — an unrelated process inheriting the pid makes logout report "did not
|
|
173
|
-
* complete" (exit 1, loud, actionable) rather than claim a stop that did not happen.
|
|
174
|
-
*/
|
|
175
|
-
async function daemonGone(celloDir, socketPath, logger, livePid) {
|
|
176
|
-
// Checked FIRST — it is the cheapest, and it is the only one of the three that is about the
|
|
177
|
-
// process rather than about a handle the process has already let go of.
|
|
178
|
-
if (livePid !== undefined && isProcessAlive(livePid))
|
|
179
|
-
return false;
|
|
180
|
-
// connectToDaemon has no connect timeout of its own — bound the probe so a pathological hang
|
|
181
|
-
// cannot stall the poll loop past its deadline. On the expected path the socket is already gone,
|
|
182
|
-
// so this fails fast (ENOENT/ECONNREFUSED).
|
|
183
|
-
//
|
|
184
|
-
// The race LOSER must still be closed. If the timeout wins, the connect promise is abandoned — but
|
|
185
|
-
// it can still resolve afterwards into a live IpcClient that nobody holds and nobody closes. This
|
|
186
|
-
// runs in a POLL LOOP inside one process, against the daemon's bounded IPC_CONNECTION_LIMIT, so
|
|
187
|
-
// those accumulate. Of every connect in this file it is the only one that can genuinely exhaust the
|
|
188
|
-
// pool — everywhere else the process exits and the OS reclaims the fd. So: keep the promise, and
|
|
189
|
-
// close whatever it eventually yields.
|
|
190
|
-
const connecting = connectToDaemon(socketPath);
|
|
191
|
-
connecting.then((late) => { try {
|
|
192
|
-
late.close();
|
|
193
|
-
}
|
|
194
|
-
catch { /* already closed by the winner below */ } }, () => { });
|
|
195
|
-
try {
|
|
196
|
-
const probe = await Promise.race([
|
|
197
|
-
connecting,
|
|
198
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("probe_timeout")), 500)),
|
|
199
|
-
]);
|
|
200
|
-
probe.close();
|
|
201
|
-
return false; // something still answers — the daemon is up
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
// Nothing answers. Now make sure the process is really gone and not merely deaf: a daemon that
|
|
205
|
-
// still holds the kernel lock is still alive, and the next login would refuse to start beside it.
|
|
206
|
-
return probeSingletonLock(celloDir, logger) === "free";
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
export async function logout(celloDir, onProgress,
|
|
210
|
-
// Injectable bounds so the timeout path is testable without a 5 s wait. Production (the bin)
|
|
211
|
-
// passes nothing and gets the named defaults.
|
|
212
|
-
waitOpts) {
|
|
213
|
-
const lockFilePath = join(celloDir, "daemon.lock");
|
|
214
|
-
const noopLogger = { debug() { }, info() { }, warn() { }, error() { } };
|
|
215
|
-
const timeoutMs = waitOpts?.timeoutMs ?? LOGOUT_WAIT_TIMEOUT_MS;
|
|
216
|
-
const pollMs = waitOpts?.pollMs ?? LOGOUT_WAIT_POLL_MS;
|
|
217
|
-
// The socket path is DETERMINISTIC. `daemon.lock`'s copy of it is metadata, and metadata is not
|
|
218
|
-
// to be trusted here.
|
|
219
|
-
const socketPath = join(celloDir, "daemon.sock");
|
|
220
|
-
const lock = await readLock(lockFilePath);
|
|
221
|
-
// DOD-SINGLE-DAEMON-1 (AC4) — the lock file does not get to decide whether a daemon exists.
|
|
222
|
-
//
|
|
223
|
-
// Never short-circuit on `if (!lock) return "No daemon running."`. That is a KILL SWITCH REPORTING
|
|
224
|
-
// SUCCESS WITHOUT DOING ANYTHING, and the state that triggers it is real: an exiting orphan unlinks
|
|
225
|
-
// a HEALTHY daemon's lock (so does `rm ~/.cello/daemon.lock`). The operator then runs `cello logout`
|
|
226
|
-
// to stop their agent, is told it was never running, and walks away — while the daemon is still
|
|
227
|
-
// online, still on the directory, still extending the hash chain. A kill switch may not lie.
|
|
228
|
-
//
|
|
229
|
-
// So we ask the daemon itself. If something ANSWERS on the socket, a daemon is running — that is
|
|
230
|
-
// the strongest evidence available, it needs no file, and it holds even for a pre-singleton-lock
|
|
231
|
-
// daemon, which holds no singleton lock at all.
|
|
232
|
-
let client;
|
|
233
|
-
try {
|
|
234
|
-
client = await connectToDaemon(socketPath);
|
|
235
|
-
}
|
|
236
|
-
catch (err) {
|
|
237
|
-
// Nothing answered. Now the kernel decides — not the pid in the JSON, which may be dead, or
|
|
238
|
-
// REUSED by an unrelated process (in which case `isProcessAlive` says "alive" forever).
|
|
239
|
-
const probe = probeSingletonLock(celloDir, noopLogger);
|
|
240
|
-
if (probe === "held") {
|
|
241
|
-
// A daemon holds the lock but will not talk to us. We have NOT stopped it, and must not imply
|
|
242
|
-
// otherwise. Point at the authoritative holder rather than a pid we did not verify.
|
|
243
|
-
const named = lock ? ` (daemon.lock names pid ${lock.pid}, which may be stale)` : "";
|
|
244
|
-
return {
|
|
245
|
-
exitCode: 1,
|
|
246
|
-
output: `A daemon is running${named} and holds the singleton lock, but is not answering on ` +
|
|
247
|
-
`${socketPath} (${err instanceof Error ? err.message : String(err)}). It has NOT been ` +
|
|
248
|
-
`stopped. Find the process that holds the lock with \`lsof ${join(celloDir, SINGLETON_LOCK_FILENAME)}\`, ` +
|
|
249
|
-
"stop it, then re-run 'cello logout' to clean up.",
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
if (probe === "unknown") {
|
|
253
|
-
// We could not ask. Reporting "no daemon running" on a guess is the whole failure mode.
|
|
254
|
-
return {
|
|
255
|
-
exitCode: 1,
|
|
256
|
-
output: `Could not determine whether a daemon is running: the singleton lock at ` +
|
|
257
|
-
`${join(celloDir, SINGLETON_LOCK_FILENAME)} could not be checked. Refusing to report a ` +
|
|
258
|
-
"daemon stopped without proof.",
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
// probe === "free": nothing holds the lock and nothing answers. There is no daemon.
|
|
262
|
-
if (!lock) {
|
|
263
|
-
return { exitCode: 0, output: "No daemon running." };
|
|
264
|
-
}
|
|
265
|
-
// DOD-LOGOUT-WAIT-1: only CLAIM the removal when it actually happened — removeLock swallows
|
|
266
|
-
// non-ENOENT errors into a (here discarded) warn, and printing "Removed" for a lock still on
|
|
267
|
-
// disk would be this unit's own lie in miniature.
|
|
268
|
-
const removed = await removeLock(lockFilePath, noopLogger)
|
|
269
|
-
.then(async () => (await readLock(lockFilePath)) === null)
|
|
270
|
-
.catch(() => false);
|
|
271
|
-
return {
|
|
272
|
-
exitCode: 0,
|
|
273
|
-
output: removed
|
|
274
|
-
? "No daemon running. (Removed a stale daemon.lock — no daemon holds the singleton lock.)"
|
|
275
|
-
: `No daemon running. (A stale daemon.lock remains at ${lockFilePath} and could not be removed — check its permissions.)`,
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
try {
|
|
279
|
-
await client.send("shutdown");
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
283
|
-
return { exitCode: 1, output: `Failed to stop daemon: ${message}` };
|
|
284
|
-
}
|
|
285
|
-
finally {
|
|
286
|
-
// In the `finally`, not after the send: a throw from `shutdown` must not leak the connection.
|
|
287
|
-
client.close();
|
|
288
|
-
}
|
|
289
|
-
// The request is in — tell the operator NOW, then report "stopped" only when it is true.
|
|
290
|
-
onProgress?.("Shutting down the daemon…");
|
|
291
|
-
// DOD-LOGOUT-WAIT-1: do NOT return the instant the shutdown request is WRITTEN. That leaves a
|
|
292
|
-
// window where `cello logout && cello login` finds the daemon mid-death — connectOrStart sees a
|
|
293
|
-
// live pid + connectable socket and prints "Daemon already running.", leaving the operator logged
|
|
294
|
-
// out while being told otherwise. Wait until the daemon is genuinely gone; "Daemon stopped." for a
|
|
295
|
-
// daemon that is still running is the same class of lie as a success log on a failed send
|
|
296
|
-
// (DOD-SENDRAW-1).
|
|
297
|
-
// DOD-LOGOUT-EXIT-1: the pid to hold the shutdown to. Taken from the lock read BEFORE the
|
|
298
|
-
// shutdown was sent, and only trusted if it was actually alive then — a stale lock naming a dead
|
|
299
|
-
// (or never-ours) pid must not make every logout wait out the full timeout. `undefined` here
|
|
300
|
-
// means we have no process-level evidence to demand, and the two handle facts stand alone, which
|
|
301
|
-
// is the pre-singleton-lock daemon's case.
|
|
302
|
-
//
|
|
303
|
-
// `lock.pid !== process.pid` is NOT a test accommodation: an IN-PROCESS daemon (an embedder, or
|
|
304
|
-
// vitest) writes OUR pid into the lock, and requiring it to exit would mean requiring the caller
|
|
305
|
-
// to exit before its own logout returns — never satisfiable, so every such logout would burn the
|
|
306
|
-
// full timeout and then report a failure that did not happen. For that daemon "the process
|
|
307
|
-
// ended" is not the right question; the handles are all there is, which is the pre-existing
|
|
308
|
-
// contract. The defect this unit fixes is only reachable when the daemon is a SEPARATE process.
|
|
309
|
-
const pidToOutlive = lock && lock.pid !== process.pid && isProcessAlive(lock.pid) ? lock.pid : undefined;
|
|
310
|
-
const deadline = Date.now() + timeoutMs;
|
|
311
|
-
while (Date.now() < deadline) {
|
|
312
|
-
if (await daemonGone(celloDir, socketPath, noopLogger, pidToOutlive)) {
|
|
313
|
-
return { exitCode: 0, output: "Daemon stopped." };
|
|
314
|
-
}
|
|
315
|
-
await new Promise((r) => setTimeout(r, pollMs));
|
|
316
|
-
}
|
|
317
|
-
const who = lock ? `pid ${lock.pid}, ` : "";
|
|
318
|
-
return {
|
|
319
|
-
exitCode: 1,
|
|
320
|
-
output: `Daemon shutdown did not complete within ${timeoutMs / 1000}s ` +
|
|
321
|
-
`(${who}socket ${socketPath}). The daemon acknowledged the request but is ` +
|
|
322
|
-
`still running — it may be stuck closing sessions or its database. Check 'cello status'; ` +
|
|
323
|
-
`if it never exits, find it with \`lsof ${join(celloDir, SINGLETON_LOCK_FILENAME)}\`, stop it, ` +
|
|
324
|
-
"and re-run 'cello logout' to clean up.",
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* register(celloDir, agent, preAuthToken, phoneStub):
|
|
329
|
-
* - Read lock file to find socket path (daemon must be running)
|
|
330
|
-
* - Connect to daemon, send 'cello_register' with { agent, preAuthToken, phoneStub }
|
|
331
|
-
* - The daemon runs ML-DSA keygen → FROST DKG → register_success and persists
|
|
332
|
-
* the agent's key material + registration state + agent→user link.
|
|
333
|
-
* - Print structured JSON; exit 0 on success, 1 on failure.
|
|
334
|
-
*/
|
|
335
|
-
/**
|
|
336
|
-
* Does this look like a pre-auth capability rather than a mis-paste?
|
|
337
|
-
*
|
|
338
|
-
* A capability (M8B-PREAUTH-CAP) is base64url JSON carrying a signed authorization. This decodes
|
|
339
|
-
* far enough to tell it apart from someone pasting the literal words "CELLO_PREAUTH_TOKEN", and no
|
|
340
|
-
* further: the directory verifies the signature, the issuer and the window. Anything stricter here
|
|
341
|
-
* would let a client-side "malformed" strand a capability the consortium would have accepted.
|
|
342
|
-
*/
|
|
343
|
-
function hasCapabilityShape(value) {
|
|
344
|
-
try {
|
|
345
|
-
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
346
|
-
if (typeof parsed !== "object" || parsed === null)
|
|
347
|
-
return false;
|
|
348
|
-
const c = parsed;
|
|
349
|
-
return typeof c["sig"] === "string" && typeof c["nonce"] === "string" && typeof c["expires_at"] === "string";
|
|
350
|
-
}
|
|
351
|
-
catch {
|
|
352
|
-
return false;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
export async function register(celloDir, agent, preAuthToken, phoneStub = "") {
|
|
356
|
-
// M8C-ONBOARD-ERRORS-1 (R3/R4): specific, actionable errors on the core onboarding path — never a
|
|
357
|
-
// bare Usage dump, never a pointless DKG round-trip to a generic dkg_failed for an obviously
|
|
358
|
-
// malformed token. Client-side because a typo'd token and a missing one are knowable without the
|
|
359
|
-
// directory. (Unknown-agent stays the daemon's job — it already returns a good agent_not_found.)
|
|
360
|
-
// DOD-M15-CLIJSON-1: the FAILURE branches emit JSON too, with the prose intact in `guidance` on
|
|
361
|
-
// stderr. These four are the ones a person scripting onboarding actually hits — an unset $TOKEN,
|
|
362
|
-
// a daemon that is not up — and prose on stdout breaks their pipe with a byte-offset parse error
|
|
363
|
-
// that hides the real problem. Same defect as the success path, one branch over.
|
|
364
|
-
const fail = (reason, guidance) => ({
|
|
365
|
-
exitCode: 1,
|
|
366
|
-
output: JSON.stringify({ ok: false, reason, guidance }, null, 2),
|
|
367
|
-
guidance,
|
|
368
|
-
});
|
|
369
|
-
if (!agent) {
|
|
370
|
-
return fail("missing_agent_name", "You didn't name an agent to register. Usage: cello register-agent <agent> <pre-auth-token>. See your agents with 'cello status'.");
|
|
371
|
-
}
|
|
372
|
-
if (!preAuthToken) {
|
|
373
|
-
return fail("missing_preauth_token", `You're missing the pre-auth token. Get a single-use token from the CELLO Operations Agent on Telegram, then run:\n cello register-agent ${agent} <token>\nor set it in the environment:\n CELLO_PREAUTH_TOKEN=<token> cello register-agent ${agent}\nThe token is single-use and expires in 24 hours.`);
|
|
374
|
-
}
|
|
375
|
-
// Client-side gate on the STABLE brand prefix only (real tokens are "CELLO-" + 33 base58 chars).
|
|
376
|
-
// Checking just the prefix catches the common typo (pasting the literal words "CELLO_PREAUTH_TOKEN")
|
|
377
|
-
// without hard-coding the exact length/alphabet — the directory stays the authority on the full format,
|
|
378
|
-
// so a future format bump can't strand a valid token behind a client-side "malformed". The "DEV-"
|
|
379
|
-
// sentinel is ALSO allowed: it is the dev/local pre-auth prefix that DevTokenValidator accepts
|
|
380
|
-
// (CELLO_ENV=local), and it is unmistakably intentional, not a paste error. A wrong token of either
|
|
381
|
-
// prefix reaches the daemon and is rejected there with a structured reason (the local DevTokenValidator
|
|
382
|
-
// rejects non-DEV-, the prod PgTokenValidator rejects non-CELLO-). Without this, local CLI registration
|
|
383
|
-
// is impossible — the CLI rejects the very DEV- tokens the local validator requires (it broke the whole
|
|
384
|
-
// spine suite).
|
|
385
|
-
// A pre-auth CAPABILITY (M8B-PREAUTH-CAP) is also valid here and has neither prefix: it is
|
|
386
|
-
// base64url JSON, and preauth-capability.ts specifies it is "carried in the existing round-1
|
|
387
|
-
// preAuthToken string field and pasted into `cello register`". Gating on the two legacy prefixes
|
|
388
|
-
// alone rejected the very artifact the capability design says to paste — a capability could be
|
|
389
|
-
// minted, signed and accepted by every directory, and never got past the client.
|
|
390
|
-
//
|
|
391
|
-
// Shape only, and deliberately NOT by importing @cello-protocol/crypto: the CLI does not depend
|
|
392
|
-
// on it, and adding a package to the operator's install for a paste-error guard is the wrong
|
|
393
|
-
// trade. The signature, the issuer and the validity window stay the directory's to verify — this
|
|
394
|
-
// must never become a second authority on whether a capability is valid.
|
|
395
|
-
const looksLikeCapability = hasCapabilityShape(preAuthToken);
|
|
396
|
-
if (!looksLikeCapability && !preAuthToken.startsWith("CELLO-") && !preAuthToken.startsWith("DEV-")) {
|
|
397
|
-
return fail("malformed_preauth_token", "That doesn't look like a pre-auth token or capability — tokens start with 'CELLO-' followed by 33 characters, and a capability is a long base64url blob. (Did you paste the words 'CELLO_PREAUTH_TOKEN' instead of the value itself?) Get one from the CELLO Operations Agent on Telegram, then retry.");
|
|
398
|
-
}
|
|
399
|
-
const lockFilePath = join(celloDir, "daemon.lock");
|
|
400
|
-
const lock = await readLock(lockFilePath);
|
|
401
|
-
if (!lock) {
|
|
402
|
-
return fail("daemon_not_running", "No daemon running. Run 'cello login' first, then retry registration.");
|
|
403
|
-
}
|
|
404
|
-
try {
|
|
405
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("cello_register", { agent, preAuthToken, phoneStub })));
|
|
406
|
-
if (!result.ok) {
|
|
407
|
-
return {
|
|
408
|
-
exitCode: 1,
|
|
409
|
-
output: JSON.stringify({ ok: false, reason: result.reason, guidance: result.guidance }, null, 2),
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
return {
|
|
413
|
-
exitCode: 0,
|
|
414
|
-
output: JSON.stringify({ ok: true, agent_id: result.agent_id, primary_pubkey: result.primary_pubkey }, null, 2),
|
|
415
|
-
// M8C-ONBOARD-NEXTSTEP-1: every command output carries the next step + state legibility.
|
|
416
|
-
// Broken onto multiple lines — a dense one-liner buries the three status cues.
|
|
417
|
-
//
|
|
418
|
-
// DOD-M15-CLIJSON-1: on STDERR, not appended to the JSON above. A human in a terminal sees
|
|
419
|
-
// both streams and loses nothing; a script gets parseable output. This text used to make
|
|
420
|
-
// `register-agent` unparseable on its SUCCESS path.
|
|
421
|
-
guidance: `Next: run cello status to confirm '${agent}' is registered.\n` +
|
|
422
|
-
` • it's normal for this to take a minute or two while registration settles.\n` +
|
|
423
|
-
` • ready = the agent shows state 'online' and directory_signaling 'connected'.\n` +
|
|
424
|
-
` • if it stays offline, run cello logout then cello login.`,
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
catch (err) {
|
|
428
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
429
|
-
return fail("register_failed", `Failed to register: ${message}`);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* createAgent(celloDir, name):
|
|
434
|
-
* - Connect to the daemon, send 'cello_create_agent' with { name }.
|
|
435
|
-
* - The daemon generates a fresh K_local seed, writes it as an `agents` row in the encrypted DB
|
|
436
|
-
* (PERSIST-002 — no key file), and wires the agent in so it can be registered immediately.
|
|
437
|
-
*/
|
|
438
|
-
export async function createAgent(celloDir, name) {
|
|
439
|
-
if (!name) {
|
|
440
|
-
return { exitCode: 1, output: "Usage: cello create-agent <name> — creates a new local agent identity." };
|
|
441
|
-
}
|
|
442
|
-
const lockFilePath = join(celloDir, "daemon.lock");
|
|
443
|
-
const lock = await readLock(lockFilePath);
|
|
444
|
-
if (!lock) {
|
|
445
|
-
return { exitCode: 1, output: "No daemon running. Run 'cello login' first, then retry create-agent." };
|
|
446
|
-
}
|
|
447
|
-
try {
|
|
448
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("cello_create_agent", { name })));
|
|
449
|
-
if (!result.ok) {
|
|
450
|
-
return { exitCode: 1, output: JSON.stringify({ ok: false, reason: result.reason, guidance: result.guidance }, null, 2) };
|
|
451
|
-
}
|
|
452
|
-
return {
|
|
453
|
-
exitCode: 0,
|
|
454
|
-
output: JSON.stringify({ ok: true, name: result.name, pubkey: result.pubkey, agentId: result.agentId }, null, 2),
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
catch (err) {
|
|
458
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
459
|
-
return { exitCode: 1, output: `Failed to create agent: ${message}` };
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
/**
|
|
463
|
-
* refreshShares(celloDir, name): M8B DOD-REFRESH-1.
|
|
464
|
-
* - Connect to the daemon, send 'cello_refresh_shares' with { agent }.
|
|
465
|
-
* - The daemon runs a proactive share refresh across the consortium: every shareholder rotates its
|
|
466
|
-
* share to a new epoch, the group public key is unchanged, and old-epoch shares no longer sign.
|
|
467
|
-
*/
|
|
468
|
-
export async function refreshShares(celloDir, name) {
|
|
469
|
-
if (!name) {
|
|
470
|
-
return { exitCode: 1, output: "Usage: cello refresh <name> — proactively refresh the agent's threshold shares (new epoch)." };
|
|
471
|
-
}
|
|
472
|
-
const lockFilePath = join(celloDir, "daemon.lock");
|
|
473
|
-
const lock = await readLock(lockFilePath);
|
|
474
|
-
if (!lock) {
|
|
475
|
-
return { exitCode: 1, output: "No daemon running. Run 'cello login' first, then retry refresh." };
|
|
476
|
-
}
|
|
477
|
-
try {
|
|
478
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("cello_refresh_shares", { agent: name })));
|
|
479
|
-
if (!result.ok) {
|
|
480
|
-
return { exitCode: 1, output: JSON.stringify({ ok: false, reason: result.reason, guidance: result.guidance }, null, 2) };
|
|
481
|
-
}
|
|
482
|
-
return {
|
|
483
|
-
exitCode: 0,
|
|
484
|
-
output: JSON.stringify({ ok: true, epoch: result.epoch, primary_pubkey: result.primary_pubkey, verifying_shares_digest: result.verifying_shares_digest }, null, 2),
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
catch (err) {
|
|
488
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
489
|
-
return { exitCode: 1, output: `Failed to refresh shares: ${message}` };
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
/**
|
|
493
|
-
* relayReceipts(celloDir, name): M8B DOD-RELAYSIG-1.
|
|
494
|
-
* - Connect to the daemon, send 'cello_get_relay_receipts' with { agent }.
|
|
495
|
-
* - Returns the agent's durably-stored, signature-verified relay ordering-record receipts.
|
|
496
|
-
*/
|
|
497
|
-
export async function relayReceipts(celloDir, name) {
|
|
498
|
-
if (!name) {
|
|
499
|
-
return { exitCode: 1, output: "Usage: cello relay-receipts <name> — ADVANCED/DEBUG: the per-message proofs a relay signed when it "
|
|
500
|
-
+ "delivered for this agent. For a session's notarized seal, use 'cello sealed-receipt <session-id>'." };
|
|
501
|
-
}
|
|
502
|
-
const lockFilePath = join(celloDir, "daemon.lock");
|
|
503
|
-
const lock = await readLock(lockFilePath);
|
|
504
|
-
if (!lock) {
|
|
505
|
-
return { exitCode: 1, output: "No daemon running. Run 'cello login' first, then retry receipts." };
|
|
506
|
-
}
|
|
507
|
-
try {
|
|
508
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("cello_get_relay_receipts", { agent: name })));
|
|
509
|
-
if (!result.ok) {
|
|
510
|
-
return { exitCode: 1, output: JSON.stringify({ ok: false, reason: result.reason, guidance: result.guidance }, null, 2) };
|
|
511
|
-
}
|
|
512
|
-
return { exitCode: 0, output: JSON.stringify({ ok: true, receipts: result.receipts ?? [] }, null, 2) };
|
|
513
|
-
}
|
|
514
|
-
catch (err) {
|
|
515
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
516
|
-
return { exitCode: 1, output: `Failed to get relay receipts: ${message}` };
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
/**
|
|
520
|
-
* removeAgent(celloDir, name) — CELLO-M7-REMOVE-001 (DOD-REMOVE-1):
|
|
521
|
-
* - Connect to the daemon, send 'cello_remove_agent' with { name }.
|
|
522
|
-
* - The daemon RETIRES the agent (state=retired; row, keys, and history KEPT for accountability) and
|
|
523
|
-
* FREES the human name for reuse. One-way.
|
|
524
|
-
*/
|
|
525
|
-
export async function removeAgent(celloDir, name) {
|
|
526
|
-
if (!name) {
|
|
527
|
-
return { exitCode: 1, output: "Usage: cello remove-agent <name> — retires a local agent (one-way) and frees its name." };
|
|
528
|
-
}
|
|
529
|
-
const lockFilePath = join(celloDir, "daemon.lock");
|
|
530
|
-
const lock = await readLock(lockFilePath);
|
|
531
|
-
if (!lock) {
|
|
532
|
-
return { exitCode: 1, output: "No daemon running. Run 'cello login' first, then retry remove-agent." };
|
|
533
|
-
}
|
|
534
|
-
try {
|
|
535
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("cello_remove_agent", { name })));
|
|
536
|
-
if (!result.ok) {
|
|
537
|
-
return { exitCode: 1, output: JSON.stringify({ ok: false, reason: result.reason, guidance: result.guidance }, null, 2) };
|
|
538
|
-
}
|
|
539
|
-
return {
|
|
540
|
-
exitCode: 0,
|
|
541
|
-
output: JSON.stringify({
|
|
542
|
-
ok: true,
|
|
543
|
-
name: result.name,
|
|
544
|
-
agentId: result.agentId,
|
|
545
|
-
oneWay: result.oneWay,
|
|
546
|
-
// DOD-REMOVE-2: whether the signed revocation was recorded at the directory (recorded /
|
|
547
|
-
// deferred — directory unreachable / skipped — never registered). The daemon's guidance
|
|
548
|
-
// carries the actionable detail.
|
|
549
|
-
directoryRevocation: result.directoryRevocation,
|
|
550
|
-
message: result.guidance ?? "Agent retired (one-way). Its identity and history are kept; the name is free to reuse.",
|
|
551
|
-
}, null, 2),
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
catch (err) {
|
|
555
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
556
|
-
return { exitCode: 1, output: `Failed to remove agent: ${message}` };
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
export async function status(celloDir) {
|
|
560
|
-
const lockFilePath = join(celloDir, "daemon.lock");
|
|
561
|
-
const lock = await readLock(lockFilePath);
|
|
562
|
-
// DOD-LOGOUT-EXIT-1 (AC3): `stopped` used to come from a single FILE STAT — no lock file, no
|
|
563
|
-
// daemon. That is exactly the reasoning `logout` refuses by name above ("the lock file does not
|
|
564
|
-
// get to decide whether a daemon exists"), and it fails the same way: an exiting orphan unlinks a
|
|
565
|
-
// HEALTHY daemon's lock, and so does `rm ~/.cello/daemon.lock`. The operator then reads `stopped`
|
|
566
|
-
// while the daemon is online, on the directory, and extending the hash chain.
|
|
567
|
-
//
|
|
568
|
-
// So ask the daemon itself first, at the DETERMINISTIC socket path — daemon.lock's copy is
|
|
569
|
-
// metadata, and here there may be no lock file to read it from anyway.
|
|
570
|
-
const socketPath = lock?.socketPath ?? join(celloDir, "daemon.sock");
|
|
571
|
-
try {
|
|
572
|
-
// Bounded, for the same reason `daemonGone` bounds its probe: connectToDaemon has no connect
|
|
573
|
-
// timeout of its own, so a half-dead daemon holding a listening socket it will never answer on
|
|
574
|
-
// would hang `cello status` forever. That is not a hypothetical here — it is a shape of the
|
|
575
|
-
// very broken-shutdown state this function now has to report on.
|
|
576
|
-
const result = (await Promise.race([
|
|
577
|
-
withIpc(socketPath, (client) => client.send("status")),
|
|
578
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("status probe timed out after 3000ms")), 3_000)),
|
|
579
|
-
]));
|
|
580
|
-
return { exitCode: 0, output: JSON.stringify(result, null, 2) };
|
|
581
|
-
}
|
|
582
|
-
catch (err) {
|
|
583
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
584
|
-
// Nothing answered. Now the kernel decides whether a daemon process exists — not a file.
|
|
585
|
-
const probe = probeSingletonLock(celloDir, { debug() { }, info() { }, warn() { }, error() { } });
|
|
586
|
-
if (probe === "held") {
|
|
587
|
-
// A daemon process exists but has released (or never opened) its socket. That is a BROKEN
|
|
588
|
-
// SHUTDOWN, not a clean stop, and it is the state DOD-LOGOUT-EXIT-1 was filed for: the
|
|
589
|
-
// process is still alive and may still be on the network. Reporting `stopped` here is the
|
|
590
|
-
// lie. Name the state and point at the holder.
|
|
591
|
-
return {
|
|
592
|
-
exitCode: 1,
|
|
593
|
-
output: JSON.stringify({
|
|
594
|
-
daemon: "broken_shutdown",
|
|
595
|
-
detail: "A daemon process holds the singleton lock but is not answering on its socket. It has " +
|
|
596
|
-
"NOT cleanly stopped and may still be connected to a directory. Find it with " +
|
|
597
|
-
`\`lsof ${join(celloDir, SINGLETON_LOCK_FILENAME)}\` and stop it, then run 'cello logout' to clean up.`,
|
|
598
|
-
socketPath,
|
|
599
|
-
error: message,
|
|
600
|
-
}, null, 2),
|
|
601
|
-
};
|
|
602
|
-
}
|
|
603
|
-
if (probe === "unknown") {
|
|
604
|
-
// We could not ask. Reporting either "running" or "stopped" on a guess is the failure mode.
|
|
605
|
-
return {
|
|
606
|
-
exitCode: 1,
|
|
607
|
-
output: JSON.stringify({
|
|
608
|
-
daemon: "unknown",
|
|
609
|
-
detail: `The singleton lock at ${join(celloDir, SINGLETON_LOCK_FILENAME)} could not be checked, ` +
|
|
610
|
-
"so whether a daemon is running cannot be determined.",
|
|
611
|
-
error: message,
|
|
612
|
-
}, null, 2),
|
|
613
|
-
};
|
|
614
|
-
}
|
|
615
|
-
// probe === "free": nothing holds the lock and nothing answers, so there is no daemon OF THIS
|
|
616
|
-
// VERSION. The claim is deliberately narrower than "no daemon": a pre-singleton-lock daemon
|
|
617
|
-
// holds no singleton lock at all, so `free` says nothing about it and only the socket could
|
|
618
|
-
// have told us — which is why the socket is tried first, above. That residue is bounded by the
|
|
619
|
-
// upgrade and cannot be closed from this side.
|
|
620
|
-
// A lock file that is still present is stale metadata, not evidence of a process.
|
|
621
|
-
if (!lock) {
|
|
622
|
-
return { exitCode: 1, output: JSON.stringify({ daemon: "stopped" }, null, 2) };
|
|
623
|
-
}
|
|
624
|
-
return {
|
|
625
|
-
exitCode: 1,
|
|
626
|
-
output: JSON.stringify({ daemon: "unreachable", error: message }, null, 2),
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
/**
|
|
631
|
-
* `cello sessions [--open|--closed|--failed|--all] [--limit N]` — the full, queryable session
|
|
632
|
-
* history (the discovery surface `cello status` deliberately does NOT dump). Defaults to OPEN
|
|
633
|
-
* (live + resumable) so a long-lived agent's failed/closed history doesn't flood it, and caps the
|
|
634
|
-
* count at the daemon's default limit. Output reports `totalMatched` so the operator can tell when
|
|
635
|
-
* results were truncated.
|
|
636
|
-
*/
|
|
637
|
-
export async function sessions(celloDir, opts = {}) {
|
|
638
|
-
const lockFilePath = join(celloDir, "daemon.lock");
|
|
639
|
-
const lock = await readLock(lockFilePath);
|
|
640
|
-
if (!lock) {
|
|
641
|
-
return { exitCode: 1, output: JSON.stringify({ daemon: "stopped" }, null, 2) };
|
|
642
|
-
}
|
|
643
|
-
try {
|
|
644
|
-
const params = {};
|
|
645
|
-
if (opts.filter)
|
|
646
|
-
params.filter = opts.filter;
|
|
647
|
-
if (opts.limit !== undefined)
|
|
648
|
-
params.limit = opts.limit;
|
|
649
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("list_sessions", params)));
|
|
650
|
-
return { exitCode: 0, output: JSON.stringify(result, null, 2) };
|
|
651
|
-
}
|
|
652
|
-
catch (err) {
|
|
653
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
654
|
-
return {
|
|
655
|
-
exitCode: 1,
|
|
656
|
-
output: JSON.stringify({ daemon: "unreachable", error: message }, null, 2),
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
/** M8C-TGDOOR-1: `cello telegram set-token <bot_token> <allowlisted_chat_id>` — persists the
|
|
661
|
-
* daemon-wide bot credentials (narrow, dedicated surface; NOT folded into the parked `cello
|
|
662
|
-
* config`, since a bot token has no sensible default and can't wait for M9-CFG-001). */
|
|
663
|
-
/**
|
|
664
|
-
* `cello attestations` — YOUR words about ANOTHER agent, and what became of them.
|
|
665
|
-
*
|
|
666
|
-
* SEPARATE FROM `trust-signals` ON PURPOSE. On the wire an attestation IS a trust signal, so folding
|
|
667
|
-
* the two together is the obvious move — and it is the wrong one. A trust signal is the NETWORK
|
|
668
|
-
* verifying an attribute of yours; an attestation is a PERSON vouching for a PERSON. That second
|
|
669
|
-
* thing is the primitive collaboration rests on, and filing it as a subcommand of a wallet listing
|
|
670
|
-
* makes the most important capability the hardest one to find.
|
|
671
|
-
*
|
|
672
|
-
* Subcommands:
|
|
673
|
-
* issue <pubkey> <text…> — attest to something you have seen them do
|
|
674
|
-
* issued — what happened to the ones you wrote
|
|
675
|
-
*
|
|
676
|
-
* The receiving direction — what others wrote about YOU — is `cello attestation-consent`.
|
|
677
|
-
*/
|
|
678
|
-
export async function attestations(celloDir, sub, args) {
|
|
679
|
-
const lockFilePath = join(celloDir, "daemon.lock");
|
|
680
|
-
const lock = await readLock(lockFilePath);
|
|
681
|
-
if (!lock) {
|
|
682
|
-
return { exitCode: 1, output: "No daemon running. Run 'cello login' first." };
|
|
683
|
-
}
|
|
684
|
-
// M10B / `M10B-D25r2` — "what happened to what I submitted?". A NETWORK call, so it is its own verb
|
|
685
|
-
// rather than folded into `list`: listing what you hold must keep working when the directory is
|
|
686
|
-
// unreachable, and a remote failure must not break a local read.
|
|
687
|
-
if (sub === "issued") {
|
|
688
|
-
try {
|
|
689
|
-
// TWO CALLS. `wallet_list_issued` is the local record of what was submitted; `wallet_fetch_results`
|
|
690
|
-
// is the network sweep for outcomes. Showing only the second made a submission still awaiting the
|
|
691
|
-
// subject INVISIBLE — three in flight printed as "no outcomes waiting", which reads as "nothing
|
|
692
|
-
// was ever sent" rather than "nobody has answered yet".
|
|
693
|
-
const [issued, res] = (await withIpc(lock.socketPath, (client) => Promise.all([client.send("wallet_list_issued"), client.send("wallet_fetch_results")])));
|
|
694
|
-
if (!issued.ok) {
|
|
695
|
-
return { exitCode: 1, output: `${issued.reason ?? "failed"}\n${issued.guidance ?? ""}`.trim() };
|
|
696
|
-
}
|
|
697
|
-
if (!res.ok) {
|
|
698
|
-
return { exitCode: 1, output: `${res.reason ?? "failed"}\n${res.guidance ?? ""}`.trim() };
|
|
699
|
-
}
|
|
700
|
-
const byId = new Map((res.results ?? []).map((r) => [r.submission_id, r]));
|
|
701
|
-
const rows = (issued.issued ?? []).map((s) => ({
|
|
702
|
-
...(byId.get(s.submission_id) ?? { outcome: "pending", reason: null, message: null }),
|
|
703
|
-
submission_id: s.submission_id,
|
|
704
|
-
}));
|
|
705
|
-
// Anything that came back for a submission this wallet has no local record of still gets shown —
|
|
706
|
-
// dropping it would hide a real outcome behind a local bookkeeping gap.
|
|
707
|
-
for (const r of res.results ?? [])
|
|
708
|
-
if (!rows.some((x) => x.submission_id === r.submission_id))
|
|
709
|
-
rows.push(r);
|
|
710
|
-
// A node that did not answer is stated, never folded into the list — an incomplete sweep must not
|
|
711
|
-
// be readable as "nothing came back".
|
|
712
|
-
const partial = (res.unreachable_nodes ?? []).length > 0
|
|
713
|
-
? `\n\n ⚠ ${res.unreachable_nodes.length} node(s) did not answer (${res.unreachable_nodes.join(", ")}).\n This list may be incomplete — an outcome recorded there is not shown yet.`
|
|
714
|
-
: "";
|
|
715
|
-
/**
|
|
716
|
-
* DOD-M15-ENDORSE-RETRY-1 — THE ONES THAT NEVER REACHED A NODE, printed in their own block.
|
|
717
|
-
*
|
|
718
|
-
* Not merged into the table above: those rows carry an OUTCOME from a directory, and these
|
|
719
|
-
* have none because no directory has ever seen them. Printing them as `pending` would say the
|
|
720
|
-
* subject has not answered yet, about a submission nobody was ever asked about.
|
|
721
|
-
*
|
|
722
|
-
* Printed BEFORE the empty check too, or an operator whose node was down for the whole
|
|
723
|
-
* session reads "You have submitted no endorsements" while the daemon is holding three.
|
|
724
|
-
*/
|
|
725
|
-
const flight = issued.in_flight ?? [];
|
|
726
|
-
const flightBlock = flight.length === 0 ? "" :
|
|
727
|
-
"\n\n Not yet at any directory node (held in memory — a daemon restart loses these):\n" +
|
|
728
|
-
flight.map((f) => {
|
|
729
|
-
const state = f.delivery === "gave_up" ? `gave up (${f.gave_up_because ?? "unknown"})` : "retrying";
|
|
730
|
-
return ` ${state.padEnd(30)} ${f.submission_id.slice(0, 12)}… ${f.op} last: ${f.last_reason}\n ${f.guidance}`;
|
|
731
|
-
}).join("\n");
|
|
732
|
-
if (rows.length === 0) {
|
|
733
|
-
return {
|
|
734
|
-
exitCode: 0,
|
|
735
|
-
output: (flight.length === 0
|
|
736
|
-
? "You have submitted no endorsements. Results are held until you collect them, so nothing has been missed."
|
|
737
|
-
: "No submission has reached a directory node yet.") + flightBlock + partial,
|
|
738
|
-
};
|
|
739
|
-
}
|
|
740
|
-
const lines = rows.map((r) => {
|
|
741
|
-
const head = ` ${r.outcome.padEnd(10)} ${r.submission_id.slice(0, 12)}… ${r.reason ?? "—"}`;
|
|
742
|
-
// THE MESSAGE ON ITS OWN LINE, quoted and attributed. It is the SUBJECT'S words about the
|
|
743
|
-
// operator's claim, and running it into the status columns would read as CELLO's verdict.
|
|
744
|
-
return r.message ? `${head}\n they said: "${r.message}"` : head;
|
|
745
|
-
});
|
|
746
|
-
const header = ` ${"outcome".padEnd(10)} submission reason`;
|
|
747
|
-
return {
|
|
748
|
-
exitCode: 0,
|
|
749
|
-
output: [header, " " + "─".repeat(60), ...lines].join("\n") +
|
|
750
|
-
flightBlock +
|
|
751
|
-
"\n\n A refusal is the subject declining to stand behind your claim — not a fault in it.\n" +
|
|
752
|
-
" Re-submitting a corrected version is the intended next step.\n" +
|
|
753
|
-
" 'pending' means the subject has not answered yet — the submission is not lost." + partial,
|
|
754
|
-
};
|
|
755
|
-
}
|
|
756
|
-
catch (err) {
|
|
757
|
-
return { exitCode: 1, output: `Could not reach the daemon: ${err instanceof Error ? err.message : String(err)}` };
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
if (sub === "issue") {
|
|
761
|
-
const [subject, ...rest] = args;
|
|
762
|
-
// `--` ENDS FLAG PARSING. Free prose is the whole point of this verb, and prose contains things
|
|
763
|
-
// that look like flags: "cut p99 -30ms" is rejected as an unknown flag, and "-h" anywhere prints
|
|
764
|
-
// help instead of issuing. The operator needs a way to say "the rest is text", and `--` is the
|
|
765
|
-
// convention every shell user already knows.
|
|
766
|
-
const body = (rest[0] === "--" ? rest.slice(1) : rest).join(" ");
|
|
767
|
-
if (!subject || body.length === 0) {
|
|
768
|
-
return {
|
|
769
|
-
exitCode: 1,
|
|
770
|
-
output: "Usage: cello attestations issue <subject-pubkey> <what you are endorsing them for…>\n" +
|
|
771
|
-
"\n" +
|
|
772
|
-
"The text: MAXIMUM 500 CHARACTERS — a testimonial, not a document. Line breaks are fine;\n" +
|
|
773
|
-
"no other control characters, and no < or >.\n" +
|
|
774
|
-
"\n" +
|
|
775
|
-
"If your text contains something that looks like a flag (a leading '-', or '-h'), put `--`\n" +
|
|
776
|
-
"before it so it is read as text:\n" +
|
|
777
|
-
" cello attestations issue <pubkey> -- cut p99 by -30ms on the auth path",
|
|
778
|
-
};
|
|
779
|
-
}
|
|
780
|
-
try {
|
|
781
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("cello_attestations_issue", { subject_pubkey: subject, body })));
|
|
782
|
-
if (!result.ok) {
|
|
783
|
-
return { exitCode: 1, output: `${result.reason}\n${result.guidance ?? ""}`.trim() };
|
|
784
|
-
}
|
|
785
|
-
return { exitCode: 0, output: result.guidance ?? "Submitted." };
|
|
786
|
-
}
|
|
787
|
-
catch (err) {
|
|
788
|
-
return { exitCode: 1, output: `Failed to issue signal: ${err instanceof Error ? err.message : String(err)}` };
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
return {
|
|
792
|
-
exitCode: 1,
|
|
793
|
-
output: "Usage:\n" +
|
|
794
|
-
" cello attestations issue <pubkey> <text…> — attest to something you have seen them do\n" +
|
|
795
|
-
" cello attestations issued — what happened to the ones you wrote\n" +
|
|
796
|
-
"\n" +
|
|
797
|
-
"What others wrote about YOU is 'cello attestation-consent'.",
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
/**
|
|
801
|
-
* `cello trust-signals` — inspect and manage the operator's trust-signal wallet.
|
|
802
|
-
*
|
|
803
|
-
* Subcommands:
|
|
804
|
-
* list — tabular view of all signals (includes default column)
|
|
805
|
-
* view <hash-prefix> — full decoded payload for one signal
|
|
806
|
-
* enable <hash-prefix> — include signal in the default presentation bundle
|
|
807
|
-
* disable <hash-prefix> — exclude signal from the default bundle
|
|
808
|
-
* revoke <hash-prefix> — tombstone at the directory AND hard-delete locally
|
|
809
|
-
*/
|
|
810
|
-
export async function trustSignals(celloDir, sub, args) {
|
|
811
|
-
const lockFilePath = join(celloDir, "daemon.lock");
|
|
812
|
-
const lock = await readLock(lockFilePath);
|
|
813
|
-
if (!lock) {
|
|
814
|
-
return { exitCode: 1, output: "No daemon running. Run 'cello login' first." };
|
|
815
|
-
}
|
|
816
|
-
if (sub === "list") {
|
|
817
|
-
const showAll = args.includes("--all");
|
|
818
|
-
try {
|
|
819
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("wallet_list_signals")));
|
|
820
|
-
if (!result.ok) {
|
|
821
|
-
return { exitCode: 1, output: JSON.stringify({ ok: false, reason: result.reason }, null, 2) };
|
|
822
|
-
}
|
|
823
|
-
const all = result.signals ?? [];
|
|
824
|
-
const signals = showAll ? all : all.filter((s) => s.status === "active");
|
|
825
|
-
const supersededCount = all.filter((s) => s.status !== "active").length;
|
|
826
|
-
if (signals.length === 0 && all.length === 0) {
|
|
827
|
-
return { exitCode: 0, output: "No trust signals in wallet." };
|
|
828
|
-
}
|
|
829
|
-
if (signals.length === 0) {
|
|
830
|
-
return { exitCode: 0, output: `No active trust signals. ${supersededCount} superseded (run with --all to show).` };
|
|
831
|
-
}
|
|
832
|
-
// M10B / DOD-END-SURFACE-1. `status` is the DIRECTORY's answer (is the notarization live);
|
|
833
|
-
// consent is the SUBJECT's answer (may it be shown at all). A signal needs both, and only
|
|
834
|
-
// `accepted` is presentable — so anything else must never render as included, or the
|
|
835
|
-
// operator's own list contradicts what presentation actually does.
|
|
836
|
-
const presentable = (s) => s.consent_state === "accepted";
|
|
837
|
-
const anyAwaiting = signals.some((s) => !presentable(s));
|
|
838
|
-
const lines = signals.map((s) => {
|
|
839
|
-
const date = new Date(s.issued_at * 1000).toISOString().slice(0, 10);
|
|
840
|
-
const hash = s.signal_hash.slice(0, 12) + "…";
|
|
841
|
-
const status = s.status === "active" ? "active" : s.status === "superseded" ? "superseded" : s.status;
|
|
842
|
-
// ABSENT IS NOT FINE (§5a): an unset or unrecognised consent state reads as "awaiting", never
|
|
843
|
-
// as presentable. The attacker never has to defeat this check — they omit what triggers it.
|
|
844
|
-
const consent = s.consent_state === "accepted" ? "—"
|
|
845
|
-
: s.consent_state === "pending" ? "PENDING"
|
|
846
|
-
: s.consent_state === "refused" ? "refused"
|
|
847
|
-
: "awaiting";
|
|
848
|
-
const inc = presentable(s) ? (s.default_present ? "✓" : "–") : "✗";
|
|
849
|
-
// M10B / DOD-END-COUNT-1 — MCP/CLI parity (DOD-END-SURFACE-1). The daemon returns
|
|
850
|
-
// `same_operator` and the MCP surface shows it; without this column the CLI operator sees two
|
|
851
|
-
// endorsements as identical when one is capped — a recipient's floor excludes a co-owned
|
|
852
|
-
// endorsement from `min_count` — and that reads as the protocol behaving arbitrarily.
|
|
853
|
-
const own = s.same_operator === true ? "own" : "—";
|
|
854
|
-
return ` ${s.type.padEnd(22)} ${hash} ${status.padEnd(12)} ${consent.padEnd(9)} ${own.padEnd(5)} ${inc.padEnd(4)} ${date}`;
|
|
855
|
-
});
|
|
856
|
-
const header = ` ${"type".padEnd(22)} hash status consent co-own include issued`;
|
|
857
|
-
const divider = " " + "─".repeat(92);
|
|
858
|
-
const consentLegend = anyAwaiting
|
|
859
|
-
? `\n consent: PENDING = someone issued this ABOUT you and it awaits your decision — it is NOT\n shown to anyone until you accept. Run 'cello attestation-consent list' to read and decide.\n refused = you refused it; it stays inert. ✗ = not presentable, whatever 'include' says.`
|
|
860
|
-
: "";
|
|
861
|
-
// The co-own legend appears only when a co-owned signal is present: a line explaining a column
|
|
862
|
-
// that reads "—" on every row is noise, and its APPEARANCE is what makes the operator look.
|
|
863
|
-
const anyCoOwned = signals.some((s) => s.same_operator === true);
|
|
864
|
-
const coOwnLegend = anyCoOwned
|
|
865
|
-
? `\n co-own: 'own' = the endorser and the subject are your own agents. Still shown to contacts,\n but it does NOT count toward a counterparty's minimum-endorsements requirement.`
|
|
866
|
-
: "";
|
|
867
|
-
const legend = `\n include: ✓ = presented to contacts by default – = excluded from presentation\n To change: 'cello trust-signals enable <hash>' or 'cello trust-signals disable <hash>'${coOwnLegend}${consentLegend}`;
|
|
868
|
-
const footer = !showAll && supersededCount > 0
|
|
869
|
-
? `\n (${supersededCount} superseded not shown — run with --all to include)`
|
|
870
|
-
: "";
|
|
871
|
-
return { exitCode: 0, output: [header, divider, ...lines].join("\n") + legend + footer };
|
|
872
|
-
}
|
|
873
|
-
catch (err) {
|
|
874
|
-
return { exitCode: 1, output: `Failed to list signals: ${err instanceof Error ? err.message : String(err)}` };
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
if (sub === "view") {
|
|
878
|
-
const prefix = args[0];
|
|
879
|
-
if (!prefix) {
|
|
880
|
-
return { exitCode: 1, output: "Usage: cello trust-signals view <hash-prefix>" };
|
|
881
|
-
}
|
|
882
|
-
try {
|
|
883
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("wallet_view_signal", { hash_prefix: prefix })));
|
|
884
|
-
if (!result.ok) {
|
|
885
|
-
return { exitCode: 1, output: result.guidance ?? result.reason ?? "signal not found" };
|
|
886
|
-
}
|
|
887
|
-
const lines = [
|
|
888
|
-
`type: ${result.type}`,
|
|
889
|
-
`signal_hash: ${result.signal_hash}`,
|
|
890
|
-
`status: ${result.status}`,
|
|
891
|
-
`default_present: ${result.default_present ? "yes" : "no"}`,
|
|
892
|
-
`subject_kind: ${result.subject_kind}`,
|
|
893
|
-
`subject: ${result.subject}`,
|
|
894
|
-
`issuer_kind: ${result.issuer_kind}`,
|
|
895
|
-
`issuer_pubkey: ${result.issuer_pubkey}`,
|
|
896
|
-
`schema_version: ${result.schema_version}`,
|
|
897
|
-
`issued_at: ${result.issued_at ? new Date(result.issued_at * 1000).toISOString() : "—"}`,
|
|
898
|
-
`expires_at: ${result.expires_at ? new Date(result.expires_at * 1000).toISOString() : "—"}`,
|
|
899
|
-
`supersedes: ${result.supersedes_hash ?? "—"}`,
|
|
900
|
-
`payload: ${JSON.stringify(result.payload, null, 2)}`,
|
|
901
|
-
];
|
|
902
|
-
// CO-OWNERSHIP, ON ITS OWN LINE. The portal also writes `co_ownership_note` into the payload,
|
|
903
|
-
// which prints above — but inside a JSON blob, where the one fact that decides what this
|
|
904
|
-
// endorsement is worth reads as another key. This line is driven by the ENVELOPE BOOLEAN, not
|
|
905
|
-
// by the payload note, so it also fires for signals minted before the note existed.
|
|
906
|
-
if (result.same_operator === true)
|
|
907
|
-
lines.push(`co-ownership: ${CO_OWNERSHIP_NOTE}`);
|
|
908
|
-
return { exitCode: 0, output: lines.join("\n") };
|
|
909
|
-
}
|
|
910
|
-
catch (err) {
|
|
911
|
-
return { exitCode: 1, output: `Failed to view signal: ${err instanceof Error ? err.message : String(err)}` };
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
if (sub === "enable") {
|
|
915
|
-
const prefix = args[0];
|
|
916
|
-
if (!prefix) {
|
|
917
|
-
return { exitCode: 1, output: "Usage: cello trust-signals enable <hash-prefix>" };
|
|
918
|
-
}
|
|
919
|
-
try {
|
|
920
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("wallet_enable_signal", { hash_prefix: prefix })));
|
|
921
|
-
if (!result.ok) {
|
|
922
|
-
return { exitCode: 1, output: result.guidance ?? result.reason ?? "failed to enable signal" };
|
|
923
|
-
}
|
|
924
|
-
return { exitCode: 0, output: `Signal ${result.signal_hash} enabled (included in default presentation).` };
|
|
925
|
-
}
|
|
926
|
-
catch (err) {
|
|
927
|
-
return { exitCode: 1, output: `Failed to enable signal: ${err instanceof Error ? err.message : String(err)}` };
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
if (sub === "disable") {
|
|
931
|
-
const prefix = args[0];
|
|
932
|
-
if (!prefix) {
|
|
933
|
-
return { exitCode: 1, output: "Usage: cello trust-signals disable <hash-prefix>" };
|
|
934
|
-
}
|
|
935
|
-
try {
|
|
936
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("wallet_disable_signal", { hash_prefix: prefix })));
|
|
937
|
-
if (!result.ok) {
|
|
938
|
-
return { exitCode: 1, output: result.guidance ?? result.reason ?? "failed to disable signal" };
|
|
939
|
-
}
|
|
940
|
-
return { exitCode: 0, output: `Signal ${result.signal_hash} disabled (excluded from default presentation).` };
|
|
941
|
-
}
|
|
942
|
-
catch (err) {
|
|
943
|
-
return { exitCode: 1, output: `Failed to disable signal: ${err instanceof Error ? err.message : String(err)}` };
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
if (sub === "revoke") {
|
|
947
|
-
const prefix = args[0];
|
|
948
|
-
if (!prefix) {
|
|
949
|
-
return { exitCode: 1, output: "Usage: cello trust-signals revoke <hash-prefix>" };
|
|
950
|
-
}
|
|
951
|
-
try {
|
|
952
|
-
const result = (await withIpc(lock.socketPath, (client) => client.send("wallet_revoke_signal", { hash_prefix: prefix })));
|
|
953
|
-
if (!result.ok) {
|
|
954
|
-
return { exitCode: 1, output: result.guidance ?? result.reason ?? "failed to revoke signal" };
|
|
955
|
-
}
|
|
956
|
-
// QUEUED, NOT REVOKED — and the daemon's own guidance says what happens next.
|
|
957
|
-
//
|
|
958
|
-
// This printed `Revoked signal <hash>. directory unreachable — tombstone may be pending.` on
|
|
959
|
-
// every success, at exit 0. Three untruths in one line: it was not revoked, no directory was
|
|
960
|
-
// contacted from here, and `directory_results` had stopped existing so `every()` on undefined
|
|
961
|
-
// fell to `false` and the failure branch ran unconditionally. It also DROPPED the daemon's
|
|
962
|
-
// guidance, which is the only place the operator learns that their local copy is kept and how
|
|
963
|
-
// to check the outcome.
|
|
964
|
-
return {
|
|
965
|
-
exitCode: 0,
|
|
966
|
-
output: `Revocation queued for ${result.signal_hash}` +
|
|
967
|
-
(result.submission_id ? ` (submission ${result.submission_id.slice(0, 12)}…)` : "") +
|
|
968
|
-
(result.guidance ? `\n${result.guidance}` : ""),
|
|
969
|
-
};
|
|
970
|
-
}
|
|
971
|
-
catch (err) {
|
|
972
|
-
return { exitCode: 1, output: `Failed to revoke signal: ${err instanceof Error ? err.message : String(err)}` };
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
return {
|
|
976
|
-
exitCode: 1,
|
|
977
|
-
output: "Usage:\n" +
|
|
978
|
-
" cello trust-signals list [--all] — show active signals (--all includes superseded)\n" +
|
|
979
|
-
" cello trust-signals view <hash> — decode and display a signal's full payload\n" +
|
|
980
|
-
" cello trust-signals enable <hash> — include signal in the default presentation bundle\n" +
|
|
981
|
-
" cello trust-signals disable <hash> — exclude signal from the default bundle\n" +
|
|
982
|
-
" cello trust-signals revoke <hash> — ask the portal to retract a signal (queued; your copy is kept)\n" +
|
|
983
|
-
"\n" +
|
|
984
|
-
"<hash> can be a prefix (min 8 chars). See 'cello trust-signals list' for hashes.",
|
|
985
|
-
};
|
|
986
|
-
}
|
|
987
|
-
export async function telegramSetToken(celloDir, botToken, chatId) {
|
|
988
|
-
const lockFilePath = join(celloDir, "daemon.lock");
|
|
989
|
-
const lock = await readLock(lockFilePath);
|
|
990
|
-
if (!lock) {
|
|
991
|
-
return { exitCode: 1, output: JSON.stringify({ daemon: "stopped" }, null, 2) };
|
|
992
|
-
}
|
|
993
|
-
try {
|
|
994
|
-
const result = (await withIpc(lock.socketPath, async (client) => {
|
|
995
|
-
await client.send("ipc.connect", { clientType: "cli" });
|
|
996
|
-
return client.send("cello_telegram_set_token", { bot_token: botToken, allowlisted_chat_id: chatId });
|
|
997
|
-
}));
|
|
998
|
-
return { exitCode: result.ok ? 0 : 1, output: JSON.stringify(result, null, 2) };
|
|
999
|
-
}
|
|
1000
|
-
catch (err) {
|
|
1001
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1002
|
-
return { exitCode: 1, output: JSON.stringify({ daemon: "unreachable", error: message }, null, 2) };
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
//# sourceMappingURL=commands.js.map
|