@popoverinstall/cli 0.5.0 → 0.6.1
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 +48 -0
- package/README.md +8 -5
- package/dist/aardvark.d.ts +30 -0
- package/dist/aardvark.d.ts.map +1 -0
- package/dist/aardvark.js +97 -0
- package/dist/aardvark.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/login.d.ts +8 -1
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +51 -23
- package/dist/login.js.map +1 -1
- package/dist/next-steps.d.ts +18 -0
- package/dist/next-steps.d.ts.map +1 -0
- package/dist/next-steps.js +81 -0
- package/dist/next-steps.js.map +1 -0
- package/dist/scene.d.ts +43 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +276 -0
- package/dist/scene.js.map +1 -0
- package/dist/snapshot.d.ts +2 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +828 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/terminal.d.ts +58 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +139 -0
- package/dist/terminal.js.map +1 -0
- package/dist/theme.d.ts +123 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +257 -0
- package/dist/theme.js.map +1 -0
- package/dist/welcome.d.ts +24 -0
- package/dist/welcome.d.ts.map +1 -0
- package/dist/welcome.js +159 -0
- package/dist/welcome.js.map +1 -0
- package/package.json +3 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/README.md +11 -1
- package/plugin/commands/fork.md +118 -0
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { MAX_CIPHERTEXT_BYTES, MAX_TRANSCRIPT_BYTES, SNAPSHOT_FORMAT_VERSION, claudeHome, collectPathRoots, defaultApiUrl, encodeProjectDirName, forkCodeLookupHash, loadCredentials, materializeTranscript, normalizeForkCode, normalizeRemote, openSnapshot, parseTranscript, popoverHome, randomForkCode, repoKeysOverlap, sanitizeTranscript, sealSnapshot, } from "@popoverinstall/shared";
|
|
7
|
+
import { MIN_CLAUDE_VERSION, claudeVersion, versionAtLeast } from "./doctor.js";
|
|
8
|
+
import { openInNewTerminal, claudeResumeCommand, writeLauncherScript } from "./terminal.js";
|
|
9
|
+
import { bad, c, dim, info, ok, warn } from "./ui.js";
|
|
10
|
+
/**
|
|
11
|
+
* `popover fork` — hand someone a conversation they can carry on.
|
|
12
|
+
*
|
|
13
|
+
* Not the ask-fork. `packages/shared/src/fork.ts` builds the ephemeral, read-only
|
|
14
|
+
* `claude -p --resume --fork-session` that answers one teammate's question on the owner's
|
|
15
|
+
* machine and is never persisted. This creates a durable copy of a conversation that leaves
|
|
16
|
+
* the machine and becomes someone else's own local session.
|
|
17
|
+
*
|
|
18
|
+
* The empirical basis for the transcript handling is docs/phase1-snapshot-spike.md.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* How long a staged fork survives before being swept.
|
|
22
|
+
*
|
|
23
|
+
* Short on purpose, and shorter than the code's own 24 hours. A staged fork is a decrypted
|
|
24
|
+
* conversation sitting in a file, which is the one moment in this feature where plaintext is
|
|
25
|
+
* at rest on a machine that did not author it. Staging exists only to bridge `open` and the
|
|
26
|
+
* confirmation that follows it, so an hour is already generous — and the code can simply be
|
|
27
|
+
* opened again.
|
|
28
|
+
*/
|
|
29
|
+
const STAGE_TTL_MS = 60 * 60 * 1000;
|
|
30
|
+
export async function forkCommand(rest) {
|
|
31
|
+
const sub = rest[0] ?? "help";
|
|
32
|
+
const args = rest.slice(1);
|
|
33
|
+
switch (sub) {
|
|
34
|
+
case "create":
|
|
35
|
+
return create(args);
|
|
36
|
+
case "open":
|
|
37
|
+
return open(args);
|
|
38
|
+
case "launch":
|
|
39
|
+
return launch(args);
|
|
40
|
+
case "list":
|
|
41
|
+
return list();
|
|
42
|
+
case "revoke":
|
|
43
|
+
return revoke(args);
|
|
44
|
+
default:
|
|
45
|
+
if (sub !== "help")
|
|
46
|
+
bad(`Unknown fork command: ${sub}`);
|
|
47
|
+
forkUsage();
|
|
48
|
+
return sub === "help" ? 0 : 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function forkUsage() {
|
|
52
|
+
console.log(`
|
|
53
|
+
${c.bold("popover fork")} — share a conversation, or open one you were sent
|
|
54
|
+
|
|
55
|
+
${c.bold("popover fork create")} Freeze this conversation and print a code (24h)
|
|
56
|
+
${c.bold("popover fork open")} <code> Fetch a fork and stage it
|
|
57
|
+
${c.bold("popover fork launch")} <id> Start the staged fork in a new window
|
|
58
|
+
${c.bold("popover fork list")} Codes you have handed out from this machine
|
|
59
|
+
${c.bold("popover fork revoke")} <code> Destroy a snapshot before it expires
|
|
60
|
+
|
|
61
|
+
Inside Claude Code, ${c.cyan("/popover:fork create")} and ${c.cyan("/popover:fork open <code>")} do the same
|
|
62
|
+
thing and know which session you are in.
|
|
63
|
+
`);
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// create
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
async function create(args) {
|
|
69
|
+
const asJson = args.includes("--json");
|
|
70
|
+
const sessionId = flag(args, "--session") ??
|
|
71
|
+
process.env["CLAUDE_SESSION_ID"] ??
|
|
72
|
+
process.env["CLAUDE_CODE_SESSION_ID"];
|
|
73
|
+
if (!sessionId) {
|
|
74
|
+
return fail(asJson, "no_session", "There is no session here to fork.", [
|
|
75
|
+
"Run /popover:fork create inside Claude Code, which knows which session you are in,",
|
|
76
|
+
"or pass one explicitly with --session <id>.",
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
const credentials = loadCredentials();
|
|
80
|
+
if (!credentials) {
|
|
81
|
+
return fail(asJson, "not_signed_in", "This machine is not signed in.", [
|
|
82
|
+
"Run `popover login` first. Opening a fork needs no account; sharing one does.",
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
const transcriptPath = findTranscript(sessionId);
|
|
86
|
+
if (!transcriptPath) {
|
|
87
|
+
return fail(asJson, "no_transcript", "That session has no transcript on disk.", [
|
|
88
|
+
"A brand-new session may not have been written yet, and CLAUDE_CODE_SKIP_PROMPT_HISTORY",
|
|
89
|
+
"turns transcript writing off entirely. There is nothing to share either way.",
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
// The transcript is written asynchronously and lags the live conversation, so wait for it
|
|
93
|
+
// to stop moving. It can still miss a turn that is in flight; `lastTurnAt` below is what
|
|
94
|
+
// tells the sharer where the cut actually landed.
|
|
95
|
+
await settle(transcriptPath);
|
|
96
|
+
const size = statSync(transcriptPath).size;
|
|
97
|
+
if (size > MAX_TRANSCRIPT_BYTES) {
|
|
98
|
+
return fail(asJson, "too_large", `That conversation is ${mb(size)} MB, over the ${mb(MAX_TRANSCRIPT_BYTES)} MB limit.`, ["Run /compact first, then fork the compacted session."]);
|
|
99
|
+
}
|
|
100
|
+
const parsed = parseTranscript(readFileSync(transcriptPath, "utf8"));
|
|
101
|
+
const { entries, dropped } = sanitizeTranscript(parsed);
|
|
102
|
+
if (entries.length === 0) {
|
|
103
|
+
return fail(asJson, "empty", "There is nothing in this conversation to share yet.", []);
|
|
104
|
+
}
|
|
105
|
+
// `defaultApiUrl()` rather than `credentials.api_url` directly, so POPOVER_API_URL points
|
|
106
|
+
// this at a preview deployment the same way it does `open` and `revoke`. It still falls
|
|
107
|
+
// back to the origin this machine signed in to. The device token comes from credentials
|
|
108
|
+
// either way — a preview shares the backing database, so the same token authenticates.
|
|
109
|
+
const apiUrl = defaultApiUrl();
|
|
110
|
+
const repo = readRepo(process.cwd());
|
|
111
|
+
const envelope = {
|
|
112
|
+
formatVersion: SNAPSHOT_FORMAT_VERSION,
|
|
113
|
+
createdAt: new Date().toISOString(),
|
|
114
|
+
entryCount: entries.length,
|
|
115
|
+
transcript: entries.map((e) => JSON.stringify(e)).join("\n") + "\n",
|
|
116
|
+
repoRemotes: repo.keys,
|
|
117
|
+
// Collected from the transcript, plus git's answer and this process's own, because the
|
|
118
|
+
// three can spell the same directory differently when a symlink is involved.
|
|
119
|
+
pathRoots: [
|
|
120
|
+
...new Set([...collectPathRoots(entries), ...(repo.root ? [repo.root] : []), process.cwd()]),
|
|
121
|
+
],
|
|
122
|
+
...optional("lastTurnAt", lastTimestamp(entries)),
|
|
123
|
+
...optional("claudeVersion", (await claudeVersion()) ?? undefined),
|
|
124
|
+
...optional("sharedBy", await resolveSharerName(apiUrl, credentials)),
|
|
125
|
+
...optional("title", readTitle(parsed)),
|
|
126
|
+
...optional("repoName", repo.name),
|
|
127
|
+
...optional("gitBranch", repo.branch),
|
|
128
|
+
...optional("repoRoot", repo.root),
|
|
129
|
+
cwd: process.cwd(),
|
|
130
|
+
};
|
|
131
|
+
const code = randomForkCode();
|
|
132
|
+
const ciphertext = sealSnapshot(envelope, code);
|
|
133
|
+
if (ciphertext.length > MAX_CIPHERTEXT_BYTES) {
|
|
134
|
+
return fail(asJson, "too_large", "That conversation is too large to send even compressed.", ["Run /compact first, then fork the compacted session."]);
|
|
135
|
+
}
|
|
136
|
+
let created;
|
|
137
|
+
try {
|
|
138
|
+
const result = await storeCreate(apiUrl, credentials.device_token, forkCodeLookupHash(code), ciphertext);
|
|
139
|
+
if (!result.ok) {
|
|
140
|
+
if (result.status === 401 && result.ours) {
|
|
141
|
+
return fail(asJson, "not_signed_in", "This machine is no longer authorized.", [
|
|
142
|
+
"It may have been revoked from the dashboard. Run `popover login` again.",
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
145
|
+
if (!result.ours) {
|
|
146
|
+
return fail(asJson, "blocked", `Something in front of ${apiUrl} refused the request (${result.status}).`, [
|
|
147
|
+
"That answer did not come from popover. A protected preview deployment, an SSO",
|
|
148
|
+
"proxy, or a captive portal will do this. Check the URL is reachable in a browser.",
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
if (result.status === 409) {
|
|
152
|
+
// 2^77 codes, so this is not luck: the same request arrived twice. Worth naming,
|
|
153
|
+
// because "409" on its own is impossible to act on.
|
|
154
|
+
return fail(asJson, "code_taken", "That code already exists on the server.", [
|
|
155
|
+
"Nothing was overwritten. Run the command again for a new code.",
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
return fail(asJson, "server", `The server refused the snapshot (${result.status}).`, []);
|
|
159
|
+
}
|
|
160
|
+
created = { id: result.id, expires_at: result.expires_at };
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
return fail(asJson, "unreachable", `Could not reach ${apiUrl}.`, [
|
|
164
|
+
err instanceof Error ? err.message : String(err),
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
167
|
+
recordCreated({
|
|
168
|
+
id: created.id,
|
|
169
|
+
code,
|
|
170
|
+
createdAt: envelope.createdAt,
|
|
171
|
+
expiresAt: created.expires_at,
|
|
172
|
+
repoName: repo.name,
|
|
173
|
+
entryCount: entries.length,
|
|
174
|
+
});
|
|
175
|
+
if (asJson) {
|
|
176
|
+
console.log(JSON.stringify({
|
|
177
|
+
ok: true,
|
|
178
|
+
code,
|
|
179
|
+
id: created.id,
|
|
180
|
+
expires_at: created.expires_at,
|
|
181
|
+
entry_count: entries.length,
|
|
182
|
+
dropped,
|
|
183
|
+
last_turn_at: envelope.lastTurnAt ?? null,
|
|
184
|
+
repo: repo.name ?? null,
|
|
185
|
+
open_command: `popover fork open ${code}`,
|
|
186
|
+
npx_command: `npx @popoverinstall/cli fork open ${code}`,
|
|
187
|
+
}));
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
console.log("");
|
|
191
|
+
ok(`Forked ${entries.length} messages from this conversation.`);
|
|
192
|
+
console.log("");
|
|
193
|
+
console.log(` ${c.bold(c.cyan(code))}`);
|
|
194
|
+
console.log("");
|
|
195
|
+
info("Send that to whoever should carry it on. They run:");
|
|
196
|
+
dim(` popover fork open ${code}`);
|
|
197
|
+
dim(` npx @popoverinstall/cli fork open ${code} (if they have no popover yet)`);
|
|
198
|
+
console.log("");
|
|
199
|
+
dim("The code stops working in 24 hours. Their chat does not — once opened it is theirs.");
|
|
200
|
+
dim(`Frozen at your last completed turn (${friendlyTime(envelope.lastTurnAt ?? envelope.createdAt)}). Nothing you say from here is included.`);
|
|
201
|
+
console.log("");
|
|
202
|
+
warn("This sends the whole conversation, including everything your agent read into it.");
|
|
203
|
+
dim("Anyone you give the code to can pass it on. `popover fork revoke` destroys it.");
|
|
204
|
+
console.log("");
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// open
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
async function open(args) {
|
|
211
|
+
const asJson = args.includes("--json");
|
|
212
|
+
const raw = args.find((a) => !a.startsWith("--"));
|
|
213
|
+
const code = raw ? normalizeForkCode(raw) : undefined;
|
|
214
|
+
if (!code) {
|
|
215
|
+
return fail(asJson, "bad_code", "That does not look like a fork code.", [
|
|
216
|
+
"A code is 16 characters in four groups, like PQRS-2345-BCDF-7892.",
|
|
217
|
+
]);
|
|
218
|
+
}
|
|
219
|
+
const apiUrl = defaultApiUrl();
|
|
220
|
+
let payload;
|
|
221
|
+
try {
|
|
222
|
+
const result = await storeRedeem(apiUrl, forkCodeLookupHash(code));
|
|
223
|
+
if (!result.ok) {
|
|
224
|
+
if (result.status === 404) {
|
|
225
|
+
// Expired, revoked, never existed, mistyped — the server cannot tell these apart on
|
|
226
|
+
// purpose, and neither can we.
|
|
227
|
+
return fail(asJson, "not_found", "That code does not open anything.", [
|
|
228
|
+
"It may have expired, been revoked, or been mistyped. Codes last 24 hours.",
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
return fail(asJson, "server", `The server refused the code (${result.status}).`, []);
|
|
232
|
+
}
|
|
233
|
+
payload = result.snapshot;
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
return fail(asJson, "unreachable", `Could not reach ${apiUrl}.`, [
|
|
237
|
+
err instanceof Error ? err.message : String(err),
|
|
238
|
+
]);
|
|
239
|
+
}
|
|
240
|
+
let envelope;
|
|
241
|
+
try {
|
|
242
|
+
envelope = openSnapshot(payload.ciphertext ?? "", code);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
return fail(asJson, "unreadable", err instanceof Error ? err.message : String(err), []);
|
|
246
|
+
}
|
|
247
|
+
const here = readRepo(process.cwd());
|
|
248
|
+
const repoMatch = matchRepo(envelope, here);
|
|
249
|
+
const targetCwd = repoMatch === "same" && here.root ? here.root : process.cwd();
|
|
250
|
+
const local = await claudeVersion();
|
|
251
|
+
const claudeProblem = !local
|
|
252
|
+
? "Claude Code is not on your PATH, so there is nothing to open the fork in."
|
|
253
|
+
: !versionAtLeast(local, MIN_CLAUDE_VERSION)
|
|
254
|
+
? `Claude Code ${local} is older than ${MIN_CLAUDE_VERSION.join(".")}, which popover needs.`
|
|
255
|
+
: undefined;
|
|
256
|
+
const stageId = randomUUID();
|
|
257
|
+
sweepStages();
|
|
258
|
+
writeFileSync(stagePath(stageId), `${JSON.stringify({
|
|
259
|
+
stagedAt: new Date().toISOString(),
|
|
260
|
+
snapshotId: payload.id,
|
|
261
|
+
targetCwd,
|
|
262
|
+
gitBranch: here.branch ?? null,
|
|
263
|
+
repoMatch,
|
|
264
|
+
envelope,
|
|
265
|
+
})}\n`, { mode: 0o600 });
|
|
266
|
+
const skew = envelope.claudeVersion && local && majorMinor(envelope.claudeVersion) !== majorMinor(local)
|
|
267
|
+
? `Made on Claude Code ${envelope.claudeVersion}; you are on ${local}.`
|
|
268
|
+
: undefined;
|
|
269
|
+
if (asJson) {
|
|
270
|
+
console.log(JSON.stringify({
|
|
271
|
+
ok: true,
|
|
272
|
+
stage_id: stageId,
|
|
273
|
+
shared_by: envelope.sharedBy ?? null,
|
|
274
|
+
title: envelope.title ?? null,
|
|
275
|
+
repo: envelope.repoName ?? null,
|
|
276
|
+
git_branch: envelope.gitBranch ?? null,
|
|
277
|
+
entry_count: envelope.entryCount,
|
|
278
|
+
frozen_at: envelope.lastTurnAt ?? envelope.createdAt,
|
|
279
|
+
repo_match: repoMatch,
|
|
280
|
+
target_cwd: targetCwd,
|
|
281
|
+
version_skew: skew ?? null,
|
|
282
|
+
claude_problem: claudeProblem ?? null,
|
|
283
|
+
launch_command: `popover fork launch ${stageId}`,
|
|
284
|
+
}));
|
|
285
|
+
return claudeProblem ? 1 : 0;
|
|
286
|
+
}
|
|
287
|
+
console.log("");
|
|
288
|
+
ok(`Fork ready${envelope.sharedBy ? ` from ${envelope.sharedBy}` : ""}.`);
|
|
289
|
+
if (envelope.title)
|
|
290
|
+
info(envelope.title);
|
|
291
|
+
info(`${envelope.entryCount} messages, frozen ${friendlyTime(envelope.lastTurnAt ?? envelope.createdAt)}` +
|
|
292
|
+
(envelope.repoName ? ` in ${envelope.repoName}` : ""));
|
|
293
|
+
if (repoMatch === "different") {
|
|
294
|
+
console.log("");
|
|
295
|
+
warn(`This came from ${envelope.repoName ?? "another repo"}, and you are in ${here.name ?? "a different one"}.`);
|
|
296
|
+
dim("The conversation will refer to files you may not have. You can still open it.");
|
|
297
|
+
}
|
|
298
|
+
else if (repoMatch === "unknown") {
|
|
299
|
+
console.log("");
|
|
300
|
+
warn("You are not in a git repository, so file paths in it may not resolve here.");
|
|
301
|
+
}
|
|
302
|
+
if (skew)
|
|
303
|
+
dim(skew);
|
|
304
|
+
if (claudeProblem) {
|
|
305
|
+
console.log("");
|
|
306
|
+
bad(claudeProblem);
|
|
307
|
+
return 1;
|
|
308
|
+
}
|
|
309
|
+
console.log("");
|
|
310
|
+
info("Open it in a new window with:");
|
|
311
|
+
dim(` popover fork launch ${stageId}`);
|
|
312
|
+
dim(` popover fork launch ${stageId} --here (in this terminal instead)`);
|
|
313
|
+
console.log("");
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// launch
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
async function launch(args) {
|
|
320
|
+
const asJson = args.includes("--json");
|
|
321
|
+
const here = args.includes("--here");
|
|
322
|
+
const stageId = args.find((a) => !a.startsWith("--"));
|
|
323
|
+
if (!stageId || !existsSync(stagePath(stageId))) {
|
|
324
|
+
return fail(asJson, "no_stage", "There is no staged fork with that id.", [
|
|
325
|
+
"Run `popover fork open <code>` first. Staged forks are cleared after 24 hours.",
|
|
326
|
+
]);
|
|
327
|
+
}
|
|
328
|
+
const staged = JSON.parse(readFileSync(stagePath(stageId), "utf8"));
|
|
329
|
+
const envelope = staged.envelope;
|
|
330
|
+
// Before writing this run's launcher, not after: sweeping afterwards would race the
|
|
331
|
+
// terminal that has just been asked to read it.
|
|
332
|
+
sweepStages();
|
|
333
|
+
// A fresh id every time, and never the sharer's. Claude Code resolves `--resume <id>`
|
|
334
|
+
// across every project on the machine, and refuses outright when two of them hold the
|
|
335
|
+
// same id — so a reused id would strand the recipient, and reusing the *sharer's* id
|
|
336
|
+
// would collide with their live session if they ever opened their own fork.
|
|
337
|
+
let sessionId = randomUUID();
|
|
338
|
+
for (let attempt = 0; sessionIdExists(sessionId); attempt += 1) {
|
|
339
|
+
if (attempt > 8) {
|
|
340
|
+
return fail(asJson, "collision", "Could not find an unused session id.", []);
|
|
341
|
+
}
|
|
342
|
+
sessionId = randomUUID();
|
|
343
|
+
}
|
|
344
|
+
const projectDir = path.join(claudeHome(), "projects", encodeProjectDirName(staged.targetCwd));
|
|
345
|
+
mkdirSync(projectDir, { recursive: true });
|
|
346
|
+
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
|
347
|
+
writeFileSync(transcriptPath, materializeTranscript(parseTranscript(envelope.transcript), {
|
|
348
|
+
sessionId,
|
|
349
|
+
cwd: staged.targetCwd,
|
|
350
|
+
...optional("gitBranch", staged.gitBranch ?? undefined),
|
|
351
|
+
fromRoots: envelope.pathRoots,
|
|
352
|
+
toRoot: staged.targetCwd,
|
|
353
|
+
}));
|
|
354
|
+
const name = envelope.sharedBy
|
|
355
|
+
? `fork-from-${envelope.sharedBy.split(/\s+/)[0]?.toLowerCase().replace(/[^a-z0-9-]/g, "") || "teammate"}`
|
|
356
|
+
: "fork";
|
|
357
|
+
const options = {
|
|
358
|
+
cwd: staged.targetCwd,
|
|
359
|
+
sessionId,
|
|
360
|
+
name,
|
|
361
|
+
banner: [
|
|
362
|
+
`popover — a fork${envelope.sharedBy ? ` from ${envelope.sharedBy}` : ""}`,
|
|
363
|
+
`${envelope.entryCount} messages, frozen ${friendlyTime(envelope.lastTurnAt ?? envelope.createdAt)}.`,
|
|
364
|
+
"This is your own copy. Nothing you do here reaches the person who shared it.",
|
|
365
|
+
],
|
|
366
|
+
};
|
|
367
|
+
const command = claudeResumeCommand(options, process.env).join(" ");
|
|
368
|
+
if (here) {
|
|
369
|
+
rmSync(stagePath(stageId), { force: true });
|
|
370
|
+
if (asJson) {
|
|
371
|
+
console.log(JSON.stringify({ ok: true, session_id: sessionId, command, opened: false }));
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
ok("Fork written. Start it with:");
|
|
375
|
+
console.log("");
|
|
376
|
+
console.log(` ${c.bold(c.cyan(command))}`);
|
|
377
|
+
console.log("");
|
|
378
|
+
dim(`in ${staged.targetCwd}`);
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
const script = writeLauncherScript(forksDir(), options, process.platform, process.env);
|
|
382
|
+
const outcome = openInNewTerminal(script);
|
|
383
|
+
rmSync(stagePath(stageId), { force: true });
|
|
384
|
+
if (asJson) {
|
|
385
|
+
console.log(JSON.stringify({
|
|
386
|
+
ok: true,
|
|
387
|
+
session_id: sessionId,
|
|
388
|
+
command,
|
|
389
|
+
cwd: staged.targetCwd,
|
|
390
|
+
opened: outcome.ok,
|
|
391
|
+
via: outcome.via ?? null,
|
|
392
|
+
reason: outcome.reason ?? null,
|
|
393
|
+
}));
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
396
|
+
console.log("");
|
|
397
|
+
if (outcome.ok) {
|
|
398
|
+
ok(`Opened the fork in ${outcome.via}.`);
|
|
399
|
+
dim(`If the window did not appear, run: ${command}`);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
// Not an error. Over SSH or in a container there is no window to open, and the command
|
|
403
|
+
// is the whole answer.
|
|
404
|
+
warn(`Could not open a new window (${outcome.reason}).`);
|
|
405
|
+
console.log("");
|
|
406
|
+
info(`Run this in ${staged.targetCwd}:`);
|
|
407
|
+
console.log("");
|
|
408
|
+
console.log(` ${c.bold(c.cyan(command))}`);
|
|
409
|
+
}
|
|
410
|
+
console.log("");
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Kept on this machine rather than asked of the server.
|
|
415
|
+
*
|
|
416
|
+
* The server holds no readable record of what a snapshot is — no repo, no title, no code —
|
|
417
|
+
* which is the point of the design, so there is nothing there to list. It also means a
|
|
418
|
+
* `--json` roster of your own forks can never become a way to enumerate anyone else's.
|
|
419
|
+
*/
|
|
420
|
+
function list() {
|
|
421
|
+
const records = readCreated();
|
|
422
|
+
if (records.length === 0) {
|
|
423
|
+
info("You have not shared any forks from this machine.");
|
|
424
|
+
dim("Run /popover:fork create inside Claude Code to share one.");
|
|
425
|
+
return 0;
|
|
426
|
+
}
|
|
427
|
+
console.log("");
|
|
428
|
+
for (const r of records.slice(-20)) {
|
|
429
|
+
const expired = Date.parse(r.expiresAt) < Date.now();
|
|
430
|
+
const state = r.revokedAt ? c.dim("revoked") : expired ? c.dim("expired") : c.green("live");
|
|
431
|
+
console.log(` ${c.bold(r.code)} ${state.padEnd(18)} ${c.dim(`${r.entryCount} messages${r.repoName ? `, ${r.repoName}` : ""}, ${friendlyTime(r.createdAt)}`)}`);
|
|
432
|
+
}
|
|
433
|
+
console.log("");
|
|
434
|
+
dim("A live code can still be opened by anyone holding it. `popover fork revoke <code>`");
|
|
435
|
+
dim("destroys the snapshot — but not a copy someone has already opened.");
|
|
436
|
+
console.log("");
|
|
437
|
+
return 0;
|
|
438
|
+
}
|
|
439
|
+
async function revoke(args) {
|
|
440
|
+
const raw = args.find((a) => !a.startsWith("--"));
|
|
441
|
+
const code = raw ? normalizeForkCode(raw) : undefined;
|
|
442
|
+
if (!code) {
|
|
443
|
+
bad("That does not look like a fork code.");
|
|
444
|
+
dim("A code is 16 characters in four groups, like PQRS-2345-BCDF-7892.");
|
|
445
|
+
return 1;
|
|
446
|
+
}
|
|
447
|
+
const apiUrl = defaultApiUrl();
|
|
448
|
+
let opens = null;
|
|
449
|
+
try {
|
|
450
|
+
const result = await storeRevoke(apiUrl, forkCodeLookupHash(code));
|
|
451
|
+
if (!result.ok) {
|
|
452
|
+
bad(`The server refused the request (${result.status}).`);
|
|
453
|
+
return 1;
|
|
454
|
+
}
|
|
455
|
+
opens = result.opens;
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
bad(`Could not reach ${apiUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
459
|
+
return 1;
|
|
460
|
+
}
|
|
461
|
+
markRevoked(code);
|
|
462
|
+
ok("That code opens nothing now, and the snapshot is destroyed.");
|
|
463
|
+
if (opens && opens > 0) {
|
|
464
|
+
// Said plainly rather than left to be discovered. Revoking cannot reach a copy that is
|
|
465
|
+
// already on someone else's machine, and implying otherwise would be worse than useless.
|
|
466
|
+
warn(`It had already been opened ${opens === 1 ? "once" : `${opens} times`}.`);
|
|
467
|
+
dim("Those copies are local to whoever opened them and cannot be recalled.");
|
|
468
|
+
}
|
|
469
|
+
return 0;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Who a fork says it came from.
|
|
473
|
+
*
|
|
474
|
+
* `credentials.display_name` cannot be it on its own: `popover login` writes that field as
|
|
475
|
+
* the empty string and nothing ever fills it in, so every fork would arrive from nobody.
|
|
476
|
+
* The real name lives in `profiles`, and the snapshot is sealed on this machine before it is
|
|
477
|
+
* sent, so the server can never add it afterwards — it has to be resolved first.
|
|
478
|
+
*
|
|
479
|
+
* `/api/device/session` is the daemon's existing token exchange and already authenticates
|
|
480
|
+
* exactly this machine, so the name is carried there rather than on a route of its own.
|
|
481
|
+
* Failure is not an error: a fork from an unnamed teammate is worth far more than no fork,
|
|
482
|
+
* so this falls back to the credentials copy, then to the hostname, then to nothing at all,
|
|
483
|
+
* and never delays a create by more than a moment.
|
|
484
|
+
*/
|
|
485
|
+
async function resolveSharerName(apiUrl, credentials) {
|
|
486
|
+
if (localStoreDir())
|
|
487
|
+
return credentials.display_name || undefined;
|
|
488
|
+
try {
|
|
489
|
+
const res = await fetch(`${apiUrl}/api/device/session`, {
|
|
490
|
+
method: "POST",
|
|
491
|
+
headers: { "content-type": "application/json" },
|
|
492
|
+
body: JSON.stringify({ device_token: credentials.device_token }),
|
|
493
|
+
signal: AbortSignal.timeout(4000),
|
|
494
|
+
});
|
|
495
|
+
if (res.ok) {
|
|
496
|
+
const body = (await res.json());
|
|
497
|
+
if (body.display_name)
|
|
498
|
+
return body.display_name;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
/* offline, slow, or an older backend that does not return a name */
|
|
503
|
+
}
|
|
504
|
+
return credentials.display_name || hostname() || undefined;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Test seam: keep snapshots in a directory instead of on the server.
|
|
508
|
+
*
|
|
509
|
+
* The same idea as `POPOVER_AGENTS_JSON` in the daemon — the one thing a test cannot
|
|
510
|
+
* provide is stubbed, and everything else runs for real. It lets `npm run smoke` drive a
|
|
511
|
+
* genuine create → open → launch round trip, including the sealing, the sanitizing, and
|
|
512
|
+
* the materialized transcript, with no network and no account. The crypto is never stubbed:
|
|
513
|
+
* what lands in the directory is the same sealed payload the server would hold.
|
|
514
|
+
*/
|
|
515
|
+
function localStoreDir() {
|
|
516
|
+
return process.env["POPOVER_FORK_LOCAL_DIR"];
|
|
517
|
+
}
|
|
518
|
+
function localStorePath(dir, codeHash) {
|
|
519
|
+
return path.join(dir, `${codeHash}.json`);
|
|
520
|
+
}
|
|
521
|
+
async function storeCreate(apiUrl, deviceToken, codeHash, ciphertext) {
|
|
522
|
+
const dir = localStoreDir();
|
|
523
|
+
if (dir) {
|
|
524
|
+
mkdirSync(dir, { recursive: true });
|
|
525
|
+
const record = {
|
|
526
|
+
id: randomUUID(),
|
|
527
|
+
ciphertext,
|
|
528
|
+
byte_size: ciphertext.length,
|
|
529
|
+
created_at: new Date().toISOString(),
|
|
530
|
+
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
531
|
+
opens: 0,
|
|
532
|
+
};
|
|
533
|
+
writeFileSync(localStorePath(dir, codeHash), JSON.stringify(record), { mode: 0o600 });
|
|
534
|
+
return { ok: true, id: record.id, expires_at: record.expires_at };
|
|
535
|
+
}
|
|
536
|
+
const res = await fetch(`${apiUrl}/api/fork/create`, {
|
|
537
|
+
method: "POST",
|
|
538
|
+
headers: { "content-type": "application/json" },
|
|
539
|
+
body: JSON.stringify({
|
|
540
|
+
device_token: deviceToken,
|
|
541
|
+
code_hash: codeHash,
|
|
542
|
+
ciphertext,
|
|
543
|
+
byte_size: ciphertext.length,
|
|
544
|
+
}),
|
|
545
|
+
});
|
|
546
|
+
if (!res.ok)
|
|
547
|
+
return { ok: false, status: res.status, ours: await isOurs(res) };
|
|
548
|
+
const body = (await res.json());
|
|
549
|
+
return { ok: true, id: body.id, expires_at: body.expires_at };
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Did our API refuse this, or did something in front of it?
|
|
553
|
+
*
|
|
554
|
+
* A 401 from the route means the device token is no longer good, and the fix is
|
|
555
|
+
* `popover login`. A 401 from an auth wall between the client and the app — a protected
|
|
556
|
+
* Vercel preview, an SSO proxy, a captive portal — means nothing of the sort, and sending
|
|
557
|
+
* someone to re-authenticate popover over it wastes their time on the wrong problem.
|
|
558
|
+
* Our routes always answer with a JSON `error` string; a wall answers with something else.
|
|
559
|
+
*/
|
|
560
|
+
async function isOurs(res) {
|
|
561
|
+
try {
|
|
562
|
+
const body = await res.json();
|
|
563
|
+
return (typeof body === "object" &&
|
|
564
|
+
body !== null &&
|
|
565
|
+
typeof body.error === "string");
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async function storeRedeem(apiUrl, codeHash) {
|
|
572
|
+
const dir = localStoreDir();
|
|
573
|
+
if (dir) {
|
|
574
|
+
const file = localStorePath(dir, codeHash);
|
|
575
|
+
if (!existsSync(file))
|
|
576
|
+
return { ok: false, status: 404 };
|
|
577
|
+
const record = JSON.parse(readFileSync(file, "utf8"));
|
|
578
|
+
if (!record.ciphertext || record.revoked_at || Date.parse(record.expires_at) < Date.now()) {
|
|
579
|
+
return { ok: false, status: 404 };
|
|
580
|
+
}
|
|
581
|
+
record.opens += 1;
|
|
582
|
+
writeFileSync(file, JSON.stringify(record), { mode: 0o600 });
|
|
583
|
+
return { ok: true, snapshot: record };
|
|
584
|
+
}
|
|
585
|
+
const res = await fetch(`${apiUrl}/api/fork/redeem`, {
|
|
586
|
+
method: "POST",
|
|
587
|
+
headers: { "content-type": "application/json" },
|
|
588
|
+
body: JSON.stringify({ code_hash: codeHash }),
|
|
589
|
+
});
|
|
590
|
+
if (!res.ok)
|
|
591
|
+
return { ok: false, status: res.status };
|
|
592
|
+
return { ok: true, snapshot: (await res.json()) };
|
|
593
|
+
}
|
|
594
|
+
async function storeRevoke(apiUrl, codeHash) {
|
|
595
|
+
const dir = localStoreDir();
|
|
596
|
+
if (dir) {
|
|
597
|
+
const file = localStorePath(dir, codeHash);
|
|
598
|
+
if (!existsSync(file))
|
|
599
|
+
return { ok: true, opens: null };
|
|
600
|
+
const record = JSON.parse(readFileSync(file, "utf8"));
|
|
601
|
+
record.ciphertext = null;
|
|
602
|
+
record.revoked_at = new Date().toISOString();
|
|
603
|
+
writeFileSync(file, JSON.stringify(record), { mode: 0o600 });
|
|
604
|
+
return { ok: true, opens: record.opens };
|
|
605
|
+
}
|
|
606
|
+
const res = await fetch(`${apiUrl}/api/fork/revoke`, {
|
|
607
|
+
method: "POST",
|
|
608
|
+
headers: { "content-type": "application/json" },
|
|
609
|
+
body: JSON.stringify({ code_hash: codeHash }),
|
|
610
|
+
});
|
|
611
|
+
if (!res.ok)
|
|
612
|
+
return { ok: false, status: res.status };
|
|
613
|
+
return { ok: true, opens: (await res.json()).opens };
|
|
614
|
+
}
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// Transcript and repo helpers
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
/**
|
|
619
|
+
* Find a session's transcript by scanning the project directories.
|
|
620
|
+
*
|
|
621
|
+
* Deliberately not by recomputing the directory name: Claude Code truncates and hashes it
|
|
622
|
+
* past 200 characters, and that hash is not reproducible here. The id is unique, so a scan
|
|
623
|
+
* is both simpler and correct.
|
|
624
|
+
*/
|
|
625
|
+
function findTranscript(sessionId) {
|
|
626
|
+
const root = path.join(claudeHome(), "projects");
|
|
627
|
+
if (!existsSync(root))
|
|
628
|
+
return null;
|
|
629
|
+
for (const dir of readdirSync(root)) {
|
|
630
|
+
const candidate = path.join(root, dir, `${sessionId}.jsonl`);
|
|
631
|
+
if (existsSync(candidate))
|
|
632
|
+
return candidate;
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
function sessionIdExists(sessionId) {
|
|
637
|
+
return findTranscript(sessionId) !== null;
|
|
638
|
+
}
|
|
639
|
+
/** Wait for the transcript to stop being written, so the fork includes the latest turn. */
|
|
640
|
+
async function settle(file, budgetMs = 2000) {
|
|
641
|
+
let previous = -1;
|
|
642
|
+
const deadline = Date.now() + budgetMs;
|
|
643
|
+
while (Date.now() < deadline) {
|
|
644
|
+
const { size } = statSync(file);
|
|
645
|
+
if (size === previous)
|
|
646
|
+
return;
|
|
647
|
+
previous = size;
|
|
648
|
+
await sleep(150);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function lastTimestamp(entries) {
|
|
652
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
653
|
+
const ts = entries[i]?.["timestamp"];
|
|
654
|
+
if (typeof ts === "string")
|
|
655
|
+
return ts;
|
|
656
|
+
}
|
|
657
|
+
return undefined;
|
|
658
|
+
}
|
|
659
|
+
/** The session's own title, if Claude Code has generated or been given one. */
|
|
660
|
+
function readTitle(entries) {
|
|
661
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
662
|
+
const entry = entries[i];
|
|
663
|
+
if (entry["type"] === "custom-title" && typeof entry["customTitle"] === "string") {
|
|
664
|
+
return entry["customTitle"];
|
|
665
|
+
}
|
|
666
|
+
if (entry["type"] === "ai-title" && typeof entry["aiTitle"] === "string") {
|
|
667
|
+
return entry["aiTitle"];
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return undefined;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Identify the repository, using the same normalization the roster uses so that "same repo"
|
|
674
|
+
* means the same thing here as it does everywhere else in popover — including a fork of an
|
|
675
|
+
* upstream, which shares a key with it.
|
|
676
|
+
*/
|
|
677
|
+
function readRepo(cwd) {
|
|
678
|
+
const git = (args) => {
|
|
679
|
+
try {
|
|
680
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
681
|
+
encoding: "utf8",
|
|
682
|
+
timeout: 2000,
|
|
683
|
+
windowsHide: true,
|
|
684
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
685
|
+
}).trim();
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
return undefined;
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
const root = git(["rev-parse", "--show-toplevel"]) || undefined;
|
|
692
|
+
if (!root)
|
|
693
|
+
return { keys: [] };
|
|
694
|
+
const keys = [];
|
|
695
|
+
for (const line of (git(["remote", "-v"]) ?? "").split("\n")) {
|
|
696
|
+
const url = line.split(/\s+/)[1];
|
|
697
|
+
const key = url ? normalizeRemote(url) : null;
|
|
698
|
+
if (key && !keys.includes(key))
|
|
699
|
+
keys.push(key);
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
root,
|
|
703
|
+
name: path.basename(root),
|
|
704
|
+
branch: git(["rev-parse", "--abbrev-ref", "HEAD"]) || undefined,
|
|
705
|
+
keys,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function matchRepo(envelope, here) {
|
|
709
|
+
if (!here.root)
|
|
710
|
+
return "unknown";
|
|
711
|
+
if (envelope.repoRemotes.length === 0 || here.keys.length === 0) {
|
|
712
|
+
// No shared remote on one side or the other. Fall back to the directory name, which is
|
|
713
|
+
// a weak signal but better than warning someone about a repo that is plainly the same.
|
|
714
|
+
return envelope.repoName && envelope.repoName === here.name ? "same" : "unknown";
|
|
715
|
+
}
|
|
716
|
+
return repoKeysOverlap(envelope.repoRemotes, here.keys) ? "same" : "different";
|
|
717
|
+
}
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
// Local state
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
function forksDir() {
|
|
722
|
+
const dir = path.join(popoverHome(), "forks");
|
|
723
|
+
mkdirSync(dir, { recursive: true });
|
|
724
|
+
return dir;
|
|
725
|
+
}
|
|
726
|
+
function stagePath(stageId) {
|
|
727
|
+
// Guard the path: the id reaches here from a command line and must not escape the dir.
|
|
728
|
+
return path.join(forksDir(), `stage-${stageId.replace(/[^a-zA-Z0-9-]/g, "")}.json`);
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Clear out anything `open` and `launch` left behind.
|
|
732
|
+
*
|
|
733
|
+
* Two kinds of leftovers, for two different reasons. A stage file is a decrypted
|
|
734
|
+
* conversation and must not linger. A launcher script cannot be deleted the moment it is
|
|
735
|
+
* spawned — the terminal may not have read it yet — so it is cleaned up on the next run
|
|
736
|
+
* instead.
|
|
737
|
+
*/
|
|
738
|
+
function sweepStages() {
|
|
739
|
+
for (const file of readdirSync(forksDir())) {
|
|
740
|
+
if (!file.startsWith("stage-") && !file.startsWith("open-fork-"))
|
|
741
|
+
continue;
|
|
742
|
+
const full = path.join(forksDir(), file);
|
|
743
|
+
try {
|
|
744
|
+
if (Date.now() - statSync(full).mtimeMs > STAGE_TTL_MS)
|
|
745
|
+
rmSync(full, { force: true });
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
/* a file that vanished under us needs no cleanup */
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function createdPath() {
|
|
753
|
+
return path.join(forksDir(), "created.jsonl");
|
|
754
|
+
}
|
|
755
|
+
function recordCreated(record) {
|
|
756
|
+
try {
|
|
757
|
+
appendFileSync(createdPath(), `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
/* the record is a convenience; failing to write it must not lose the code */
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function readCreated() {
|
|
764
|
+
try {
|
|
765
|
+
return readFileSync(createdPath(), "utf8")
|
|
766
|
+
.split("\n")
|
|
767
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
768
|
+
.map((l) => JSON.parse(l));
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
return [];
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
function markRevoked(code) {
|
|
775
|
+
const records = readCreated().map((r) => r.code === code ? { ...r, revokedAt: new Date().toISOString() } : r);
|
|
776
|
+
try {
|
|
777
|
+
writeFileSync(createdPath(), records.map((r) => `${JSON.stringify(r)}\n`).join(""), {
|
|
778
|
+
mode: 0o600,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
/* best effort */
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
// Small helpers
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
function flag(args, name) {
|
|
789
|
+
const idx = args.indexOf(name);
|
|
790
|
+
return idx === -1 ? undefined : args[idx + 1];
|
|
791
|
+
}
|
|
792
|
+
/** Only set a key when there is a value, so `exactOptionalPropertyTypes` stays satisfied. */
|
|
793
|
+
function optional(key, value) {
|
|
794
|
+
return value === undefined ? {} : { [key]: value };
|
|
795
|
+
}
|
|
796
|
+
function fail(asJson, code, message, hints) {
|
|
797
|
+
if (asJson) {
|
|
798
|
+
console.log(JSON.stringify({ ok: false, error: code, message, hints }));
|
|
799
|
+
return 1;
|
|
800
|
+
}
|
|
801
|
+
bad(message);
|
|
802
|
+
for (const hint of hints)
|
|
803
|
+
dim(hint);
|
|
804
|
+
return 1;
|
|
805
|
+
}
|
|
806
|
+
function mb(bytes) {
|
|
807
|
+
return (bytes / (1024 * 1024)).toFixed(1);
|
|
808
|
+
}
|
|
809
|
+
function majorMinor(version) {
|
|
810
|
+
return version.split(".").slice(0, 2).join(".");
|
|
811
|
+
}
|
|
812
|
+
function friendlyTime(iso) {
|
|
813
|
+
const when = Date.parse(iso);
|
|
814
|
+
if (Number.isNaN(when))
|
|
815
|
+
return iso;
|
|
816
|
+
const minutes = Math.round((Date.now() - when) / 60000);
|
|
817
|
+
if (minutes < 1)
|
|
818
|
+
return "just now";
|
|
819
|
+
if (minutes < 60)
|
|
820
|
+
return `${minutes}m ago`;
|
|
821
|
+
if (minutes < 60 * 24)
|
|
822
|
+
return `${Math.round(minutes / 60)}h ago`;
|
|
823
|
+
return new Date(when).toLocaleDateString();
|
|
824
|
+
}
|
|
825
|
+
function sleep(ms) {
|
|
826
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
827
|
+
}
|
|
828
|
+
//# sourceMappingURL=snapshot.js.map
|