@popoverinstall/cli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +196 -69
- package/LICENSE +21 -21
- package/README.md +142 -141
- package/dist/config-command.d.ts +2 -0
- package/dist/config-command.d.ts.map +1 -0
- package/dist/config-command.js +80 -0
- package/dist/config-command.js.map +1 -0
- package/dist/cursor-hooks.d.ts +18 -0
- package/dist/cursor-hooks.d.ts.map +1 -0
- package/dist/cursor-hooks.js +105 -0
- package/dist/cursor-hooks.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -25
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +10 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +44 -0
- package/dist/keys.js.map +1 -0
- package/dist/next-steps.d.ts +1 -5
- package/dist/next-steps.d.ts.map +1 -1
- package/dist/next-steps.js +9 -5
- package/dist/next-steps.js.map +1 -1
- package/dist/repo-scan.d.ts +130 -0
- package/dist/repo-scan.d.ts.map +1 -0
- package/dist/repo-scan.js +281 -0
- package/dist/repo-scan.js.map +1 -0
- package/dist/repos.d.ts +180 -0
- package/dist/repos.d.ts.map +1 -0
- package/dist/repos.js +1002 -0
- package/dist/repos.js.map +1 -0
- package/dist/snapshot.d.ts +35 -0
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/snapshot.js +16 -16
- package/dist/snapshot.js.map +1 -1
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +21 -0
- package/dist/terminal.js.map +1 -1
- package/dist/vaults.d.ts +276 -0
- package/dist/vaults.d.ts.map +1 -0
- package/dist/vaults.js +1224 -0
- package/dist/vaults.js.map +1 -0
- package/package.json +47 -47
- package/plugin/.claude-plugin/plugin.json +19 -19
- package/plugin/.mcp.json +9 -9
- package/plugin/README.md +84 -76
- package/plugin/commands/ask.md +65 -65
- package/plugin/commands/fork.md +119 -118
- package/plugin/commands/repos.md +107 -0
- package/plugin/commands/team.md +60 -60
- package/plugin/commands/tell.md +66 -66
- package/plugin/commands/vault.md +173 -0
- package/plugin/hooks/hooks.json +111 -111
- package/plugin/mcp/index.mjs +585 -355
- package/plugin/scripts/_ipc.mjs +146 -146
- package/plugin/scripts/announce-roster.mjs +141 -131
- package/plugin/scripts/deliver-messages.mjs +77 -77
- package/plugin/scripts/emit-event.mjs +44 -44
- package/plugin/scripts/ensure-daemon.mjs +156 -156
- package/plugin/scripts/roster.mjs +52 -52
- package/plugin/skills/popover/SKILL.md +175 -160
package/dist/vaults.js
ADDED
|
@@ -0,0 +1,1224 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { gzipSync } from "node:zlib";
|
|
4
|
+
import { MAX_TRANSCRIPT_BYTES, SNAPSHOT_FORMAT_VERSION, VAULT_FORMAT_VERSION, VaultDescriptionSchema, VaultIndexSchema, collectPathRoots, defaultApiUrl, isVaultName, loadCredentials, normalizeVaultName, parseTranscript, sanitizeTranscript, } from "@popoverinstall/shared";
|
|
5
|
+
import { askDaemon } from "./ipc-client.js";
|
|
6
|
+
import { findTranscript, lastTimestamp, readRepo, readTitle, settle } from "./snapshot.js";
|
|
7
|
+
import { bad, c, dim, info, ok, warn } from "./ui.js";
|
|
8
|
+
/**
|
|
9
|
+
* `popover vault` — a conversation frozen for the team, and asked later.
|
|
10
|
+
*
|
|
11
|
+
* The design is docs/vaults.md; this file is the terminal half of it. What matters when
|
|
12
|
+
* reading it is which of the three "copy of a conversation" things it is:
|
|
13
|
+
*
|
|
14
|
+
* - `popover fork` hands one conversation to one named person, keyed by a code the server
|
|
15
|
+
* never sees, and they carry it on as their own live session. It expires in 24 hours.
|
|
16
|
+
* - a vault is published to a *team*, never expires, and is never carried on. It is only
|
|
17
|
+
* ever asked, and the answer comes back attributed rather than as fact.
|
|
18
|
+
*
|
|
19
|
+
* Three consequences run through everything below.
|
|
20
|
+
*
|
|
21
|
+
* **The agent authors the description.** `--title` and `--answers` are written by the model,
|
|
22
|
+
* not prompted for, because the one moment anybody knows what a conversation is authoritative
|
|
23
|
+
* about is while they are still inside it (docs/vaults.md §3). So `create` validates the
|
|
24
|
+
* description properly and refuses a bad one with a sentence a model can act on, rather than
|
|
25
|
+
* relaying a constraint violation from Postgres.
|
|
26
|
+
*
|
|
27
|
+
* **Looking is free and asking is not.** `list` is stage one — a string comparison, no model
|
|
28
|
+
* call — and `ask` is stage two, which resumes a whole conversation and bills for it at
|
|
29
|
+
* roughly the cold-ask numbers in `fork.ts` ($0.46, 97s). They are separate verbs and
|
|
30
|
+
* separate IPC arms precisely so that an agent that cannot do the first without the second
|
|
31
|
+
* does not end up doing the second by default (§6).
|
|
32
|
+
*
|
|
33
|
+
* **A vault is readable by the service, and `popover fork` is not.** Anyone who has
|
|
34
|
+
* internalized the fork's promise will assume this one makes it too. §1 requires the publish
|
|
35
|
+
* confirmation to say otherwise out loud, so `create` prints it every time and reports
|
|
36
|
+
* `readable_by_service` in its JSON.
|
|
37
|
+
*
|
|
38
|
+
* There is deliberately no edit verb. A vault is archived, never rewritten; a correction is a
|
|
39
|
+
* `note`, which sits beside the original rather than replacing it (§2, §5). What a vault
|
|
40
|
+
* originally claimed is evidence about what the team believed at the time.
|
|
41
|
+
*/
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Entry point
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
export async function vaultCommand(rest) {
|
|
46
|
+
const sub = rest[0] ?? "help";
|
|
47
|
+
const args = rest.slice(1);
|
|
48
|
+
switch (sub) {
|
|
49
|
+
case "create":
|
|
50
|
+
return create(args);
|
|
51
|
+
case "list":
|
|
52
|
+
return list(args);
|
|
53
|
+
case "ask":
|
|
54
|
+
return ask(args);
|
|
55
|
+
case "note":
|
|
56
|
+
return note(args);
|
|
57
|
+
case "archive":
|
|
58
|
+
return archive(args);
|
|
59
|
+
default:
|
|
60
|
+
if (sub !== "help")
|
|
61
|
+
bad(`Unknown vault command: ${sub}`);
|
|
62
|
+
vaultUsage();
|
|
63
|
+
return sub === "help" ? 0 : 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function vaultUsage() {
|
|
67
|
+
console.log(`
|
|
68
|
+
${c.bold("popover vault")} — freeze a conversation for your team, and ask it later
|
|
69
|
+
|
|
70
|
+
${c.bold("popover vault create")} Publish this conversation, with a title and the questions it answers
|
|
71
|
+
${c.bold("popover vault list")} [query] Search your team's vaults. Free — no model runs.
|
|
72
|
+
${c.bold("popover vault ask")} <v> "…" Ask one vault. Loads a whole conversation, and costs money.
|
|
73
|
+
${c.bold("popover vault note")} <v> "…" Append a correction. Notes are added, never revised.
|
|
74
|
+
${c.bold("popover vault archive")} <v> Retire a vault without deleting the record that it existed.
|
|
75
|
+
|
|
76
|
+
${c.bold("list")} before ${c.bold("ask")}, always: the first is a string comparison and the second is a
|
|
77
|
+
full context load on this machine. There is no edit — see ${c.bold("note")}.
|
|
78
|
+
|
|
79
|
+
A vault does not expire and is readable by the popover service, which is the one way
|
|
80
|
+
it differs from ${c.cyan("/popover:fork")}. ${c.bold("popover vault create")} says so again before it uploads.
|
|
81
|
+
`);
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// The decisions, separated from the I/O so they can be tested
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
/**
|
|
87
|
+
* Flags that consume the token after them.
|
|
88
|
+
*
|
|
89
|
+
* Needed because a vault command's payload is free text — a question, a note — and is
|
|
90
|
+
* collected as "everything that is not a flag". Without this list, `popover vault ask v
|
|
91
|
+
* --timeout 300 why did we...` would splice `300` into the question.
|
|
92
|
+
*/
|
|
93
|
+
const VALUE_FLAGS = [
|
|
94
|
+
"--session",
|
|
95
|
+
"--name",
|
|
96
|
+
"--title",
|
|
97
|
+
"--answers",
|
|
98
|
+
"--repo",
|
|
99
|
+
"--limit",
|
|
100
|
+
"--timeout",
|
|
101
|
+
];
|
|
102
|
+
/** Everything that is not a flag and not a flag's value, in the order it was typed. */
|
|
103
|
+
export function positionals(args) {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
106
|
+
const arg = args[i];
|
|
107
|
+
if (VALUE_FLAGS.includes(arg)) {
|
|
108
|
+
i += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (arg.startsWith("--"))
|
|
112
|
+
continue;
|
|
113
|
+
out.push(arg);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
function flag(args, name) {
|
|
118
|
+
const idx = args.indexOf(name);
|
|
119
|
+
return idx === -1 ? undefined : args[idx + 1];
|
|
120
|
+
}
|
|
121
|
+
/** Every occurrence of a repeatable flag. `--answers` is the only one, and it is the point. */
|
|
122
|
+
export function flagAll(args, name) {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
125
|
+
if (args[i] === name && args[i + 1] !== undefined)
|
|
126
|
+
out.push(args[i + 1]);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Read `--answers`, which an agent may pass either repeatably or as one JSON array.
|
|
132
|
+
*
|
|
133
|
+
* Both forms exist because both are natural to generate: a shell-shaped caller repeats the
|
|
134
|
+
* flag, and a model writing a single argument writes JSON. What must not happen is the third
|
|
135
|
+
* outcome — a JSON array that fails to parse being stored verbatim as one long question, which
|
|
136
|
+
* would upload a vault whose entire retrieval surface is the string `["why did we...`. So
|
|
137
|
+
* anything that opens with `[` is committed to being JSON, and is refused rather than
|
|
138
|
+
* salvaged if it is not.
|
|
139
|
+
*/
|
|
140
|
+
export function parseAnswers(raw) {
|
|
141
|
+
const clean = (values) => values.map((v) => v.trim()).filter(Boolean);
|
|
142
|
+
const only = raw.length === 1 ? raw[0].trim() : undefined;
|
|
143
|
+
if (only !== undefined && only.startsWith("[")) {
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(only);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
message: "--answers starts with `[` but is not valid JSON.",
|
|
152
|
+
hints: [
|
|
153
|
+
"Either pass a JSON array of strings, or repeat --answers once per question.",
|
|
154
|
+
"It was not stored as-is: a vault whose only searchable question is a broken array",
|
|
155
|
+
"would never be found again.",
|
|
156
|
+
],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(parsed) || parsed.some((a) => typeof a !== "string")) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
message: "--answers as JSON has to be an array of strings.",
|
|
163
|
+
hints: ['Like: --answers \'["Why is the repo key not used for vaults?"]\''],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { ok: true, answers: clean(parsed) };
|
|
167
|
+
}
|
|
168
|
+
return { ok: true, answers: clean(raw) };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Decide what this vault is called and whether it is describable at all, before anything is
|
|
172
|
+
* read off the network.
|
|
173
|
+
*
|
|
174
|
+
* The name is checked here rather than left to the server for two reasons. The unique index
|
|
175
|
+
* is on `lower(name)`, so `normalizeVaultName` can produce the same collision locally and say
|
|
176
|
+
* something better than a constraint violation; and a name that normalizes to nothing —
|
|
177
|
+
* a title of "???" — would otherwise reach the route as an empty string and come back as a
|
|
178
|
+
* 400 that reads like a bug in popover rather than a title worth rewriting.
|
|
179
|
+
*
|
|
180
|
+
* `answers` is the one that matters most and is the one an agent is most likely to skip.
|
|
181
|
+
* It holds *questions this conversation can answer*, not a summary, because the incoming
|
|
182
|
+
* query at retrieval time is a question and matching question against summary retrieves
|
|
183
|
+
* badly (§6). Refusing an empty list is the only moment anybody is in a position to enforce
|
|
184
|
+
* that.
|
|
185
|
+
*/
|
|
186
|
+
export function planVault(input) {
|
|
187
|
+
const title = (input.title ?? "").trim();
|
|
188
|
+
const answers = input.answers.map((a) => a.trim()).filter(Boolean);
|
|
189
|
+
if (!title) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
code: "no_title",
|
|
193
|
+
message: "A vault needs a title.",
|
|
194
|
+
hints: [
|
|
195
|
+
"Pass one with --title. Inside Claude Code the session's own title is used when you",
|
|
196
|
+
"do not, but a session that has not been titled yet has nothing to fall back on.",
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (answers.length === 0) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
code: "no_answers",
|
|
204
|
+
message: "A vault needs at least one question it can answer.",
|
|
205
|
+
hints: [
|
|
206
|
+
"Pass them with --answers, repeated or as a JSON array. These are questions, not a",
|
|
207
|
+
"summary: the query that finds this vault later will be a question, and matching a",
|
|
208
|
+
"question against a summary retrieves badly. See docs/vaults.md §6.",
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const described = VaultDescriptionSchema.safeParse({ title, answers });
|
|
213
|
+
if (!described.success) {
|
|
214
|
+
const issue = described.error.issues[0];
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
code: "bad_description",
|
|
218
|
+
message: `That description cannot be published: ${issue?.path.join(".") ?? "description"} ${issue?.message ?? "is invalid"}.`,
|
|
219
|
+
hints: [
|
|
220
|
+
"A title is 3 to 120 characters; each answer is 8 to 200, and there can be at most 12.",
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const typed = input.name?.trim();
|
|
225
|
+
const name = normalizeVaultName(typed && typed.length > 0 ? typed : described.data.title);
|
|
226
|
+
if (!isVaultName(name)) {
|
|
227
|
+
const source = typed ? `\`${typed}\`` : `the title "${described.data.title}"`;
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
code: "bad_name",
|
|
231
|
+
message: name.length === 0
|
|
232
|
+
? `${source} leaves nothing usable as a vault name.`
|
|
233
|
+
: `\`${name}\`, from ${source}, is not a usable vault name.`,
|
|
234
|
+
hints: [
|
|
235
|
+
"Names are lowercase letters, digits and hyphens, 3 to 64 characters — they end up in",
|
|
236
|
+
"URLs and in the command your teammates type. Pass one directly with --name.",
|
|
237
|
+
],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return { ok: true, name, title: described.data.title, answers: described.data.answers };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Which vault a typed reference means: by id, then exact name, then unique prefix.
|
|
244
|
+
*
|
|
245
|
+
* Every match is returned rather than the first, and the same widening `resolveTeam` does is
|
|
246
|
+
* used, for the same reason: two names sharing a prefix is a real arrangement, and asking the
|
|
247
|
+
* wrong vault costs a dollar and produces a confident answer about the wrong subsystem, which
|
|
248
|
+
* is the failure nobody notices.
|
|
249
|
+
*/
|
|
250
|
+
export function resolveVaultRef(vaults, typed) {
|
|
251
|
+
const needle = typed.trim().toLowerCase();
|
|
252
|
+
if (!needle)
|
|
253
|
+
return { kind: "none" };
|
|
254
|
+
const byId = vaults.filter((v) => v.id.toLowerCase() === needle);
|
|
255
|
+
if (byId.length === 1)
|
|
256
|
+
return { kind: "one", vault: byId[0] };
|
|
257
|
+
const exact = vaults.filter((v) => v.name.toLowerCase() === needle);
|
|
258
|
+
if (exact.length === 1)
|
|
259
|
+
return { kind: "one", vault: exact[0] };
|
|
260
|
+
// Unique per team, not globally, so a person on two teams can genuinely see two.
|
|
261
|
+
if (exact.length > 1)
|
|
262
|
+
return { kind: "many", matches: exact };
|
|
263
|
+
const prefix = vaults.filter((v) => v.name.toLowerCase().startsWith(needle));
|
|
264
|
+
if (prefix.length === 1)
|
|
265
|
+
return { kind: "one", vault: prefix[0] };
|
|
266
|
+
if (prefix.length > 1)
|
|
267
|
+
return { kind: "many", matches: prefix };
|
|
268
|
+
return { kind: "none" };
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Whether a reference is already an id, so no lookup is needed to use it.
|
|
272
|
+
*
|
|
273
|
+
* The full UUID shape rather than "has hyphens", because vault names have hyphens in them by
|
|
274
|
+
* construction — `repo-key-gating` is the example the spec itself uses. Treating one as an id
|
|
275
|
+
* would POST a note to `/api/vaults/repo-key-gating/notes` and report the 404 as a missing
|
|
276
|
+
* vault, when the vault is right there under a name we simply never resolved.
|
|
277
|
+
*/
|
|
278
|
+
export function looksLikeVaultId(ref) {
|
|
279
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref.trim());
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The freeze date, in the form §4 writes it: `15 Aug 2026`.
|
|
283
|
+
*
|
|
284
|
+
* The locale is pinned rather than left to the machine because this string is part of an
|
|
285
|
+
* attribution that gets pasted into other conversations and read by other models, so
|
|
286
|
+
* `8/15/2026` on one laptop and `15/08/2026` on the next is an ambiguity with no upside.
|
|
287
|
+
*/
|
|
288
|
+
export function freezeDate(iso) {
|
|
289
|
+
const when = Date.parse(iso);
|
|
290
|
+
if (Number.isNaN(when))
|
|
291
|
+
return iso;
|
|
292
|
+
return new Date(when).toLocaleDateString("en-GB", {
|
|
293
|
+
day: "numeric",
|
|
294
|
+
month: "short",
|
|
295
|
+
year: "numeric",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The sentence a vault's answer is always wrapped in.
|
|
300
|
+
*
|
|
301
|
+
* Never optional, and never assembled by the caller. A human reading an answer supplies the
|
|
302
|
+
* skepticism unprompted; an agent will not unless the format forces it, and a vault can hold
|
|
303
|
+
* a hypothesis the team later disproved. So the title and the freeze date travel on
|
|
304
|
+
* `VaultAnswer` itself and this turns them into the one line that must precede the answer —
|
|
305
|
+
* including in `--json`, where the assembled string is a field rather than a suggestion.
|
|
306
|
+
*/
|
|
307
|
+
export function attribution(answer) {
|
|
308
|
+
const at = answer.gitSha ? ` at ${answer.gitSha.slice(0, 7)}` : "";
|
|
309
|
+
return `The ${answer.title} vault, frozen ${freezeDate(answer.frozenAt)}${at}, says:`;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* How a staleness score reads to a person.
|
|
313
|
+
*
|
|
314
|
+
* Words rather than the number, because the number is a ratio of paths and nobody ranks
|
|
315
|
+
* vaults by reading ratios. A vault that recorded no paths at all scores 0 the same way an
|
|
316
|
+
* untouched one does — `stalenessScore` deliberately does not punish absent evidence — so
|
|
317
|
+
* "nothing it touched has changed" is only claimed when the server sent a score at all.
|
|
318
|
+
*/
|
|
319
|
+
export function stalenessNote(staleness) {
|
|
320
|
+
if (staleness === undefined)
|
|
321
|
+
return "";
|
|
322
|
+
if (staleness === 0)
|
|
323
|
+
return "nothing it touched has changed";
|
|
324
|
+
if (staleness >= 0.5)
|
|
325
|
+
return "most of what it touched has changed";
|
|
326
|
+
return "some of what it touched has changed";
|
|
327
|
+
}
|
|
328
|
+
/** One vault as a person reads it in a list: a heading, a byline, and its questions. */
|
|
329
|
+
export function summaryLines(v) {
|
|
330
|
+
const facts = [
|
|
331
|
+
v.authorLabel,
|
|
332
|
+
`frozen ${freezeDate(v.frozenAt)}`,
|
|
333
|
+
v.repoLabel ?? "",
|
|
334
|
+
v.noteCount === 0 ? "" : `${v.noteCount} note${v.noteCount === 1 ? "" : "s"}`,
|
|
335
|
+
stalenessNote(v.staleness),
|
|
336
|
+
].filter(Boolean);
|
|
337
|
+
return [`${v.name} ${v.title}`, facts.join(" · "), ...v.answers.map((a) => `? ${a}`)];
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* One vault as a script reads it.
|
|
341
|
+
*
|
|
342
|
+
* Absent values are `null` rather than missing, because the consumer is usually a model
|
|
343
|
+
* deciding whether to spend a dollar on stage two, and a key that is sometimes there reads as
|
|
344
|
+
* a key it forgot to look at.
|
|
345
|
+
*/
|
|
346
|
+
export function summaryJson(v) {
|
|
347
|
+
return {
|
|
348
|
+
id: v.id,
|
|
349
|
+
name: v.name,
|
|
350
|
+
title: v.title,
|
|
351
|
+
answers: v.answers,
|
|
352
|
+
author_label: v.authorLabel,
|
|
353
|
+
repo_label: v.repoLabel ?? null,
|
|
354
|
+
frozen_at: v.frozenAt,
|
|
355
|
+
note_count: v.noteCount,
|
|
356
|
+
staleness: v.staleness ?? null,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Read a listing from the HTTP route, accepting either spelling of its fields.
|
|
361
|
+
*
|
|
362
|
+
* The IPC path parses `VaultSummarySchema` and gets camelCase by construction. The HTTP route
|
|
363
|
+
* is written in another package against §10, which names those same columns `author_label`,
|
|
364
|
+
* `created_at` and `note_count` — so both spellings are plausible and neither is worth a
|
|
365
|
+
* cross-package coordination round to settle. Accepting both here costs a few lines and means
|
|
366
|
+
* `note` and `archive` cannot be broken by a naming choice made in `apps/web`.
|
|
367
|
+
*
|
|
368
|
+
* Returns null for a body that is not a listing at all, which is what an SSO proxy or a
|
|
369
|
+
* protected preview answers with — see `ApiFailure.malformed`.
|
|
370
|
+
*/
|
|
371
|
+
export function readSummaries(body) {
|
|
372
|
+
const rows = body?.vaults;
|
|
373
|
+
if (!Array.isArray(rows))
|
|
374
|
+
return null;
|
|
375
|
+
const out = [];
|
|
376
|
+
for (const raw of rows) {
|
|
377
|
+
if (typeof raw !== "object" || raw === null)
|
|
378
|
+
return null;
|
|
379
|
+
const r = raw;
|
|
380
|
+
const str = (camel, snake) => {
|
|
381
|
+
const value = r[camel] ?? r[snake];
|
|
382
|
+
return typeof value === "string" ? value : undefined;
|
|
383
|
+
};
|
|
384
|
+
const num = (camel, snake) => {
|
|
385
|
+
const value = r[camel] ?? r[snake];
|
|
386
|
+
return typeof value === "number" ? value : undefined;
|
|
387
|
+
};
|
|
388
|
+
const id = str("id", "id");
|
|
389
|
+
const name = str("name", "name");
|
|
390
|
+
const title = str("title", "title");
|
|
391
|
+
if (id === undefined || name === undefined || title === undefined)
|
|
392
|
+
return null;
|
|
393
|
+
out.push({
|
|
394
|
+
id,
|
|
395
|
+
name,
|
|
396
|
+
title,
|
|
397
|
+
answers: Array.isArray(r["answers"])
|
|
398
|
+
? r["answers"].filter((a) => typeof a === "string")
|
|
399
|
+
: [],
|
|
400
|
+
authorLabel: str("authorLabel", "author_label") ?? "",
|
|
401
|
+
repoLabel: str("repoLabel", "repo_label"),
|
|
402
|
+
frozenAt: str("frozenAt", "frozen_at") ?? "",
|
|
403
|
+
noteCount: num("noteCount", "note_count") ?? 0,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Keep `--limit` inside what the daemon's schema will accept.
|
|
410
|
+
*
|
|
411
|
+
* Clamped rather than relayed, because `VaultSearchRequestSchema` caps it at 50 and floors it
|
|
412
|
+
* at 1, and a rejected request comes back as a generic `bad_request` from the daemon that
|
|
413
|
+
* says nothing about the number somebody typed.
|
|
414
|
+
*/
|
|
415
|
+
export function clampLimit(raw, fallback = 20) {
|
|
416
|
+
const n = Number(raw);
|
|
417
|
+
if (raw === undefined || raw === "" || !Number.isFinite(n))
|
|
418
|
+
return fallback;
|
|
419
|
+
return Math.min(50, Math.max(1, Math.trunc(n)));
|
|
420
|
+
}
|
|
421
|
+
/** Same clamp, for `--timeout`, whose schema window is 5 to 600 seconds. */
|
|
422
|
+
export function clampTimeout(raw, fallback = 90) {
|
|
423
|
+
const n = Number(raw);
|
|
424
|
+
if (raw === undefined || raw === "" || !Number.isFinite(n))
|
|
425
|
+
return fallback;
|
|
426
|
+
return Math.min(600, Math.max(5, Math.trunc(n)));
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Tools whose input names a file this conversation's reasoning rests on.
|
|
430
|
+
*
|
|
431
|
+
* Reads are in the set, and that is the point rather than an oversight. What a conversation
|
|
432
|
+
* concluded depends on what it *read* at least as much as on what it changed — an archaeology
|
|
433
|
+
* vault ("why is the backoff 400ms") typically writes nothing at all — and those are exactly
|
|
434
|
+
* the vaults worth keeping, so they must be able to go stale like any other.
|
|
435
|
+
*/
|
|
436
|
+
const PATH_TOOLS = new Set(["Read", "Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
437
|
+
/**
|
|
438
|
+
* The files this conversation read or wrote, from the transcript itself.
|
|
439
|
+
*
|
|
440
|
+
* This is the primary source for `touched_paths`, and the working tree is the secondary one,
|
|
441
|
+
* which is the opposite of how it first looked. `touched_paths` exists to answer "does this
|
|
442
|
+
* conversation's reasoning depend on files that have since changed", and `git status` answers
|
|
443
|
+
* a narrower question: what was uncommitted at the moment somebody vaulted. Two ordinary
|
|
444
|
+
* cases fall through that gap — a conversation that committed its work before vaulting, and a
|
|
445
|
+
* read-only conversation that changed nothing — and both land on an empty list.
|
|
446
|
+
*
|
|
447
|
+
* Empty is not neutral. `stalenessScore` deliberately returns 0 for a vault that recorded no
|
|
448
|
+
* paths, on the correct reasoning that absence of evidence is not churn; so a vault with an
|
|
449
|
+
* empty list ranks as permanently current and never decays. The two behaviours are each right
|
|
450
|
+
* and together wrong, and the read-heavy vaults they hit hardest are the valuable ones.
|
|
451
|
+
*
|
|
452
|
+
* Paths that do not fall under any of `roots` are dropped rather than stored absolute. They
|
|
453
|
+
* could never match a path from `git status`, so keeping them would only pad the denominator
|
|
454
|
+
* of `hits / touchedPaths.length` and make every vault look more current than it is.
|
|
455
|
+
*/
|
|
456
|
+
export function collectToolPaths(entries, roots, cap = 500) {
|
|
457
|
+
// Matched in the order given, first hit winning, because the caller puts the repo root
|
|
458
|
+
// first: `git status --porcelain` prints paths relative to the repo root regardless of
|
|
459
|
+
// config, so stripping a deeper cwd instead would produce paths that can never line up.
|
|
460
|
+
const prepared = roots.map(normalizeSlashes).filter(Boolean);
|
|
461
|
+
const out = [];
|
|
462
|
+
for (const entry of entries) {
|
|
463
|
+
const content = entry["message"]?.content;
|
|
464
|
+
if (!Array.isArray(content))
|
|
465
|
+
continue;
|
|
466
|
+
for (const block of content) {
|
|
467
|
+
if (typeof block !== "object" || block === null)
|
|
468
|
+
continue;
|
|
469
|
+
const b = block;
|
|
470
|
+
if (b.type !== "tool_use")
|
|
471
|
+
continue;
|
|
472
|
+
if (typeof b.name !== "string" || !PATH_TOOLS.has(b.name))
|
|
473
|
+
continue;
|
|
474
|
+
const input = b.input;
|
|
475
|
+
for (const key of ["file_path", "notebook_path"]) {
|
|
476
|
+
const raw = input?.[key];
|
|
477
|
+
if (typeof raw !== "string" || !raw)
|
|
478
|
+
continue;
|
|
479
|
+
const rel = relativizePath(raw, prepared);
|
|
480
|
+
if (rel && !isUncomparable(rel) && !out.includes(rel))
|
|
481
|
+
out.push(rel);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return out.slice(0, cap);
|
|
486
|
+
}
|
|
487
|
+
function normalizeSlashes(p) {
|
|
488
|
+
return p.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Paths inside the repo that git will still never report as changed.
|
|
492
|
+
*
|
|
493
|
+
* The same dilution problem as an out-of-root path, arrived at from the other side: reading a
|
|
494
|
+
* dependency's type definitions is a real thing a conversation did, but `node_modules` is
|
|
495
|
+
* ignored everywhere, so that path can never appear in `git status` and can only ever pad the
|
|
496
|
+
* denominator of `hits / touchedPaths.length`. Seen in practice — a single 15MB transcript
|
|
497
|
+
* contributed one — and a handful of them is enough to drag a churned vault back down toward
|
|
498
|
+
* looking current.
|
|
499
|
+
*/
|
|
500
|
+
function isUncomparable(rel) {
|
|
501
|
+
return /(^|\/)(node_modules|\.git)\//.test(rel);
|
|
502
|
+
}
|
|
503
|
+
function isAbsolutePath(p) {
|
|
504
|
+
return p.startsWith("/") || /^[a-zA-Z]:\//.test(p);
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* A tool's path as `git status` would spell it, or null if the two can never be compared.
|
|
508
|
+
*
|
|
509
|
+
* Case-insensitive, because Windows and macOS both hand back a path whose case need not match
|
|
510
|
+
* what git prints, and a false negative here silently understates churn.
|
|
511
|
+
*/
|
|
512
|
+
function relativizePath(raw, roots) {
|
|
513
|
+
const p = normalizeSlashes(raw);
|
|
514
|
+
if (!p)
|
|
515
|
+
return null;
|
|
516
|
+
for (const root of roots) {
|
|
517
|
+
if (p.length > root.length + 1 && p.toLowerCase().startsWith(`${root.toLowerCase()}/`)) {
|
|
518
|
+
return p.slice(root.length + 1);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// A tool call that already used a repo-relative path needs no rewriting; an absolute one
|
|
522
|
+
// outside every root belongs to another repository or to the user's home.
|
|
523
|
+
return isAbsolutePath(p) ? null : p.replace(/^\.\//, "");
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* The two sources, deduped and capped once.
|
|
527
|
+
*
|
|
528
|
+
* The transcript comes first so that it survives truncation: if a conversation named more
|
|
529
|
+
* than the cap on its own, what it read and wrote is a better description of what it depended
|
|
530
|
+
* on than whatever happened to be dirty in the tree at the time.
|
|
531
|
+
*/
|
|
532
|
+
export function unionPaths(primary, secondary, cap = 500) {
|
|
533
|
+
const out = [];
|
|
534
|
+
for (const p of [...primary, ...secondary]) {
|
|
535
|
+
if (p && !out.includes(p))
|
|
536
|
+
out.push(p);
|
|
537
|
+
}
|
|
538
|
+
return out.slice(0, cap);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The uncommitted half, from `git status --porcelain`.
|
|
542
|
+
*
|
|
543
|
+
* Unioned with `collectToolPaths` rather than replaced by it, because work that a conversation
|
|
544
|
+
* left dirty is genuine evidence about what it touched — it is only insufficient on its own.
|
|
545
|
+
*
|
|
546
|
+
* Renames are recorded at their destination: `R old -> new` is churn in `new`, and ranking a
|
|
547
|
+
* vault against a path that no longer exists would make every renamed file look permanently
|
|
548
|
+
* untouched. Quoted paths are unescaped because `core.quotePath` is on by default and a
|
|
549
|
+
* filename with an accent in it would otherwise be stored complete with its surrounding
|
|
550
|
+
* quotes and never match anything.
|
|
551
|
+
*
|
|
552
|
+
* The cap is `VaultIndexSchema`'s 500. A conversation that dirtied more than that is a
|
|
553
|
+
* generated-file situation where the first 500 are as good a signal as any.
|
|
554
|
+
*/
|
|
555
|
+
export function parseTouchedPaths(porcelain, cap = 500) {
|
|
556
|
+
const paths = [];
|
|
557
|
+
for (const line of porcelain.split("\n")) {
|
|
558
|
+
if (line.length < 4)
|
|
559
|
+
continue;
|
|
560
|
+
let rest = line.slice(3);
|
|
561
|
+
const arrow = rest.indexOf(" -> ");
|
|
562
|
+
if (arrow !== -1)
|
|
563
|
+
rest = rest.slice(arrow + 4);
|
|
564
|
+
const path = unquotePath(rest.trim());
|
|
565
|
+
if (path && !paths.includes(path))
|
|
566
|
+
paths.push(path);
|
|
567
|
+
}
|
|
568
|
+
return paths.slice(0, cap);
|
|
569
|
+
}
|
|
570
|
+
const SIMPLE_ESCAPES = { n: 10, t: 9, r: 13, b: 8, f: 12, a: 7, v: 11 };
|
|
571
|
+
/**
|
|
572
|
+
* Undo git's C-style quoting.
|
|
573
|
+
*
|
|
574
|
+
* The escapes are *octal bytes*, not characters: `café.ts` comes back as
|
|
575
|
+
* `"caf\303\251.ts"`, which is the two UTF-8 bytes of `é` written one escape each. Stripping
|
|
576
|
+
* backslashes the obvious way turns that into `caf303251.ts` — a path that exists nowhere, so
|
|
577
|
+
* the vault ranks as untouched against a file it actually changed. So the escapes are decoded
|
|
578
|
+
* to bytes and the bytes are decoded as UTF-8, in that order.
|
|
579
|
+
*/
|
|
580
|
+
function unquotePath(raw) {
|
|
581
|
+
if (raw.length < 2 || !raw.startsWith('"') || !raw.endsWith('"'))
|
|
582
|
+
return raw;
|
|
583
|
+
const inner = raw.slice(1, -1);
|
|
584
|
+
const bytes = [];
|
|
585
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
586
|
+
const ch = inner[i];
|
|
587
|
+
if (ch !== "\\") {
|
|
588
|
+
bytes.push(...Buffer.from(ch, "utf8"));
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const next = inner[i + 1];
|
|
592
|
+
if (next === undefined)
|
|
593
|
+
break;
|
|
594
|
+
const octal = inner.slice(i + 1, i + 4);
|
|
595
|
+
if (/^[0-7]{3}$/.test(octal)) {
|
|
596
|
+
bytes.push(parseInt(octal, 8));
|
|
597
|
+
i += 3;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
bytes.push(SIMPLE_ESCAPES[next] ?? Buffer.from(next, "utf8")[0]);
|
|
601
|
+
i += 1;
|
|
602
|
+
}
|
|
603
|
+
return Buffer.from(bytes).toString("utf8");
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* A commit sha, or nothing.
|
|
607
|
+
*
|
|
608
|
+
* `git rev-parse HEAD` in a repository with no commits prints the literal string `HEAD` and
|
|
609
|
+
* exits nonzero on some versions and zero on others. `VaultIndexSchema.gitSha` is a hex regex,
|
|
610
|
+
* so letting that through turns a first-commit-pending repo into a 400 from the create route
|
|
611
|
+
* that reads as a popover bug rather than as "there is no commit yet".
|
|
612
|
+
*/
|
|
613
|
+
export function cleanSha(raw) {
|
|
614
|
+
const sha = raw?.trim().toLowerCase();
|
|
615
|
+
return sha && /^[0-9a-f]{7,40}$/.test(sha) ? sha : undefined;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* The transcript, compressed, as one opaque string.
|
|
619
|
+
*
|
|
620
|
+
* Deliberately **not** `sealSnapshot`. That derives its AES key from the fork code, and a
|
|
621
|
+
* vault has no code — team membership is the whole credential, which is exactly the decision
|
|
622
|
+
* §1 records and the reason the server can read a vault at all. Encrypting here with a key
|
|
623
|
+
* stored beside the ciphertext would be worse than not encrypting: a promise that reads as
|
|
624
|
+
* strong and is not. The real protection is at-rest encryption under a key held outside the
|
|
625
|
+
* database, which is the server's to do.
|
|
626
|
+
*
|
|
627
|
+
* gzip + base64 anyway, because `body` is one opaque column by design (§2) so a later
|
|
628
|
+
* end-to-end version can wrap a per-vault key around this without moving anything else, and
|
|
629
|
+
* because a transcript compresses about 10:1.
|
|
630
|
+
*/
|
|
631
|
+
export function packBody(envelope) {
|
|
632
|
+
return gzipSync(Buffer.from(JSON.stringify(envelope), "utf8")).toString("base64");
|
|
633
|
+
}
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
// popover vault create
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
async function create(args) {
|
|
638
|
+
const asJson = args.includes("--json");
|
|
639
|
+
const sessionId = flag(args, "--session") ??
|
|
640
|
+
process.env["CLAUDE_SESSION_ID"] ??
|
|
641
|
+
process.env["CLAUDE_CODE_SESSION_ID"];
|
|
642
|
+
if (!sessionId) {
|
|
643
|
+
return fail(asJson, "no_session", "There is no session here to vault.", [
|
|
644
|
+
"Run /popover:vault create inside Claude Code, which knows which session you are in,",
|
|
645
|
+
"or pass one explicitly with --session <id>.",
|
|
646
|
+
]);
|
|
647
|
+
}
|
|
648
|
+
const credentials = loadCredentials();
|
|
649
|
+
if (!credentials) {
|
|
650
|
+
return fail(asJson, "not_signed_in", "This machine is not signed in.", [
|
|
651
|
+
"Run `popover login` first. A vault is published to a team, so it needs an account.",
|
|
652
|
+
]);
|
|
653
|
+
}
|
|
654
|
+
const transcriptPath = findTranscript(sessionId);
|
|
655
|
+
if (!transcriptPath) {
|
|
656
|
+
return fail(asJson, "no_transcript", "That session has no transcript on disk.", [
|
|
657
|
+
"A brand-new session may not have been written yet, and CLAUDE_CODE_SKIP_PROMPT_HISTORY",
|
|
658
|
+
"turns transcript writing off entirely. There is nothing to freeze either way.",
|
|
659
|
+
]);
|
|
660
|
+
}
|
|
661
|
+
// The transcript lags the live conversation, so wait for it to stop moving. `frozenAt`
|
|
662
|
+
// below is what says where the cut actually landed.
|
|
663
|
+
await settle(transcriptPath);
|
|
664
|
+
const size = statSync(transcriptPath).size;
|
|
665
|
+
if (size > MAX_TRANSCRIPT_BYTES) {
|
|
666
|
+
return fail(asJson, "too_large", `That conversation is ${mb(size)} MB, over the ${mb(MAX_TRANSCRIPT_BYTES)} MB limit.`, ["Run /compact first, then vault the compacted session."]);
|
|
667
|
+
}
|
|
668
|
+
const parsed = parseTranscript(readFileSync(transcriptPath, "utf8"));
|
|
669
|
+
// Sanitizing matters more here than for a fork. Attachments carry the author's hooks, skill
|
|
670
|
+
// listing and agent roster, and a vault is read months later, by which time those are not
|
|
671
|
+
// merely stale — they describe a machine that no longer exists. docs/vaults.md §3.
|
|
672
|
+
const { entries, dropped } = sanitizeTranscript(parsed);
|
|
673
|
+
if (entries.length === 0) {
|
|
674
|
+
return fail(asJson, "empty", "There is nothing in this conversation to vault yet.", []);
|
|
675
|
+
}
|
|
676
|
+
const answers = parseAnswers(flagAll(args, "--answers"));
|
|
677
|
+
if (!answers.ok)
|
|
678
|
+
return fail(asJson, "bad_answers", answers.message, answers.hints);
|
|
679
|
+
const plan = planVault({
|
|
680
|
+
name: flag(args, "--name"),
|
|
681
|
+
title: flag(args, "--title") ?? readTitle(parsed),
|
|
682
|
+
answers: answers.answers,
|
|
683
|
+
});
|
|
684
|
+
if (!plan.ok)
|
|
685
|
+
return fail(asJson, plan.code, plan.message, plan.hints);
|
|
686
|
+
const cwd = process.cwd();
|
|
687
|
+
const repo = readRepo(cwd);
|
|
688
|
+
// The normalized remote rather than the directory name: `repo_label` is used to filter a
|
|
689
|
+
// list several people are looking at, and a directory name is whatever each of them happened
|
|
690
|
+
// to call their clone. It is display and filtering only and must never reach a policy (§8).
|
|
691
|
+
const repoLabel = repo.keys[0] ?? repo.name;
|
|
692
|
+
const gitSha = cleanSha(git(cwd, ["rev-parse", "HEAD"]));
|
|
693
|
+
// Repo root first: porcelain output is relative to it, so that is the spelling the two
|
|
694
|
+
// sources have to agree on before they can be unioned.
|
|
695
|
+
const pathRoots = [repo.root, ...collectPathRoots(entries), cwd].filter((r) => typeof r === "string" && r.length > 0);
|
|
696
|
+
const touchedPaths = unionPaths(collectToolPaths(entries, pathRoots), parseTouchedPaths(git(cwd, ["status", "--porcelain"]) ?? ""));
|
|
697
|
+
const transcript = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
698
|
+
// The envelope's last turn, not the upload time. What a reader cares about is when the
|
|
699
|
+
// conversation stopped, not when somebody got around to publishing it (§2).
|
|
700
|
+
const frozenAt = lastTimestamp(entries) ?? new Date().toISOString();
|
|
701
|
+
const index = VaultIndexSchema.safeParse({
|
|
702
|
+
formatVersion: VAULT_FORMAT_VERSION,
|
|
703
|
+
name: plan.name,
|
|
704
|
+
title: plan.title,
|
|
705
|
+
answers: plan.answers,
|
|
706
|
+
repoLabel,
|
|
707
|
+
gitSha,
|
|
708
|
+
touchedPaths,
|
|
709
|
+
entryCount: entries.length,
|
|
710
|
+
byteSize: Buffer.byteLength(transcript, "utf8"),
|
|
711
|
+
frozenAt,
|
|
712
|
+
});
|
|
713
|
+
if (!index.success) {
|
|
714
|
+
return fail(asJson, "bad_description", "That vault is not something the index will accept.", index.error.issues.map((i) => `${i.path.join(".") || "index"}: ${i.message}`));
|
|
715
|
+
}
|
|
716
|
+
const envelope = {
|
|
717
|
+
formatVersion: SNAPSHOT_FORMAT_VERSION,
|
|
718
|
+
createdAt: new Date().toISOString(),
|
|
719
|
+
entryCount: entries.length,
|
|
720
|
+
transcript,
|
|
721
|
+
repoRemotes: repo.keys,
|
|
722
|
+
// Collected from the transcript as well as from git, because the two spell the same
|
|
723
|
+
// directory differently whenever a symlink is involved.
|
|
724
|
+
pathRoots: [...new Set([...collectPathRoots(entries), ...(repo.root ? [repo.root] : []), cwd])],
|
|
725
|
+
lastTurnAt: frozenAt,
|
|
726
|
+
title: plan.title,
|
|
727
|
+
repoName: repo.name,
|
|
728
|
+
gitBranch: repo.branch,
|
|
729
|
+
repoRoot: repo.root,
|
|
730
|
+
cwd,
|
|
731
|
+
};
|
|
732
|
+
const result = await request(credentials.device_token, "POST", `${apiBase()}/api/vaults`, { ...index.data, body: packBody(envelope) });
|
|
733
|
+
if (!result.ok) {
|
|
734
|
+
if (result.status === 409 && result.ours) {
|
|
735
|
+
return fail(asJson, "name_taken", `Your team already has a vault called \`${plan.name}\`.`, [
|
|
736
|
+
"Names are unique per team, case-insensitively, and a vault is never edited or",
|
|
737
|
+
"replaced. Pick another with --name, or add to the existing one with `popover vault",
|
|
738
|
+
`note ${plan.name} "…"\`.`,
|
|
739
|
+
]);
|
|
740
|
+
}
|
|
741
|
+
return reportApiFailure(result, asJson, "Publishing this vault");
|
|
742
|
+
}
|
|
743
|
+
const id = typeof result.body.id === "string" ? result.body.id : null;
|
|
744
|
+
const name = typeof result.body.name === "string" ? result.body.name : plan.name;
|
|
745
|
+
if (asJson) {
|
|
746
|
+
console.log(JSON.stringify({
|
|
747
|
+
ok: true,
|
|
748
|
+
id,
|
|
749
|
+
name,
|
|
750
|
+
title: plan.title,
|
|
751
|
+
answers: plan.answers,
|
|
752
|
+
entry_count: entries.length,
|
|
753
|
+
dropped,
|
|
754
|
+
byte_size: index.data.byteSize,
|
|
755
|
+
frozen_at: frozenAt,
|
|
756
|
+
git_sha: gitSha ?? null,
|
|
757
|
+
repo_label: repoLabel ?? null,
|
|
758
|
+
touched_paths: touchedPaths,
|
|
759
|
+
// Stated structurally, not only in prose, because the caller is usually the agent
|
|
760
|
+
// that decided to vault and it is the one fact about a vault that differs from a
|
|
761
|
+
// fork. docs/vaults.md §1.
|
|
762
|
+
readable_by_service: true,
|
|
763
|
+
ask_command: `popover vault ask ${name} "<question>"`,
|
|
764
|
+
}));
|
|
765
|
+
return 0;
|
|
766
|
+
}
|
|
767
|
+
console.log("");
|
|
768
|
+
ok(`Vaulted ${entries.length} messages as ${c.bold(c.cyan(name))}.`);
|
|
769
|
+
console.log("");
|
|
770
|
+
info(`${plan.title}`);
|
|
771
|
+
for (const answer of plan.answers)
|
|
772
|
+
dim(`? ${answer}`);
|
|
773
|
+
console.log("");
|
|
774
|
+
info("Anyone on your team can ask it, on their own machine and at their own cost:");
|
|
775
|
+
dim(` popover vault ask ${name} "…"`);
|
|
776
|
+
console.log("");
|
|
777
|
+
dim(`Frozen at the last completed turn (${freezeDate(frozenAt)}). Nothing said from here is included.`);
|
|
778
|
+
dim("It does not expire. `popover vault archive` retires it without erasing the record.");
|
|
779
|
+
console.log("");
|
|
780
|
+
// Said every time, in the same register the fork confirmation uses. Anyone who has
|
|
781
|
+
// internalized `/popover:fork` being end-to-end will assume this is too, and it is not.
|
|
782
|
+
warn("A vault is readable by the popover service. `popover fork` is not — this differs.");
|
|
783
|
+
dim("It is encrypted at rest, but not end-to-end. Publish accordingly.");
|
|
784
|
+
console.log("");
|
|
785
|
+
return 0;
|
|
786
|
+
}
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
// popover vault list — stage one
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
/**
|
|
791
|
+
* Stage one, through the daemon rather than straight to HTTP.
|
|
792
|
+
*
|
|
793
|
+
* The daemon is the same path the MCP tool takes, so a person typing this and an agent
|
|
794
|
+
* calling `vaults` get the same ranking — including the staleness half, which needs the
|
|
795
|
+
* caller's working directory to diff against and so cannot be computed by the server (§6).
|
|
796
|
+
* Going direct would produce two search implementations that drift.
|
|
797
|
+
*/
|
|
798
|
+
async function list(args) {
|
|
799
|
+
const asJson = args.includes("--json");
|
|
800
|
+
const query = positionals(args).join(" ").trim();
|
|
801
|
+
const repo = flag(args, "--repo");
|
|
802
|
+
const limit = clampLimit(flag(args, "--limit"));
|
|
803
|
+
const reply = await askDaemon({
|
|
804
|
+
t: "vaults",
|
|
805
|
+
id: "vault-list",
|
|
806
|
+
req: { query: query || undefined, repo, limit },
|
|
807
|
+
cwd: process.cwd(),
|
|
808
|
+
}, 20_000);
|
|
809
|
+
if (!reply)
|
|
810
|
+
return daemonSilent(asJson);
|
|
811
|
+
if (reply.t === "error")
|
|
812
|
+
return fail(asJson, reply.code, reply.message, []);
|
|
813
|
+
if (reply.t !== "vaults.ok")
|
|
814
|
+
return unexpectedReply(asJson);
|
|
815
|
+
const { vaults } = reply;
|
|
816
|
+
if (asJson) {
|
|
817
|
+
console.log(JSON.stringify({
|
|
818
|
+
ok: true,
|
|
819
|
+
query: query || null,
|
|
820
|
+
repo: repo ?? null,
|
|
821
|
+
count: vaults.length,
|
|
822
|
+
vaults: vaults.map(summaryJson),
|
|
823
|
+
}));
|
|
824
|
+
return 0;
|
|
825
|
+
}
|
|
826
|
+
if (vaults.length === 0) {
|
|
827
|
+
warn(query ? `No vaults match ${JSON.stringify(query)}.` : "Your team has no vaults yet.");
|
|
828
|
+
dim(query
|
|
829
|
+
? "Stage one is lexical, so try the words the conversation itself would have used."
|
|
830
|
+
: "Vault one with `popover vault create` — or let your agent decide to.");
|
|
831
|
+
return 0;
|
|
832
|
+
}
|
|
833
|
+
console.log("");
|
|
834
|
+
for (const vault of vaults) {
|
|
835
|
+
// The heading is rebuilt here rather than taken from `summaryLines`, which is the plain
|
|
836
|
+
// form the tests pin; only the colour of the name differs.
|
|
837
|
+
const [, facts, ...questions] = summaryLines(vault);
|
|
838
|
+
console.log(` ${c.bold(c.cyan(vault.name))} ${vault.title}`);
|
|
839
|
+
if (facts)
|
|
840
|
+
dim(facts);
|
|
841
|
+
for (const question of questions)
|
|
842
|
+
dim(` ${question}`);
|
|
843
|
+
console.log("");
|
|
844
|
+
}
|
|
845
|
+
dim(`Asking one loads its whole conversation on this machine: popover vault ask <name> "…"`);
|
|
846
|
+
console.log("");
|
|
847
|
+
return 0;
|
|
848
|
+
}
|
|
849
|
+
// ---------------------------------------------------------------------------
|
|
850
|
+
// popover vault ask — stage two
|
|
851
|
+
// ---------------------------------------------------------------------------
|
|
852
|
+
async function ask(args) {
|
|
853
|
+
const asJson = args.includes("--json");
|
|
854
|
+
const words = positionals(args);
|
|
855
|
+
const ref = words[0];
|
|
856
|
+
const question = words.slice(1).join(" ").trim();
|
|
857
|
+
if (!ref || !question) {
|
|
858
|
+
return fail(asJson, "bad_request", "Asking a vault takes a vault and a question.", [
|
|
859
|
+
'Usage: popover vault ask <name|id> "<question>"',
|
|
860
|
+
"Run `popover vault list` first — searching is free and this is not.",
|
|
861
|
+
]);
|
|
862
|
+
}
|
|
863
|
+
const timeoutSeconds = clampTimeout(flag(args, "--timeout"));
|
|
864
|
+
// Resolved through the daemon's own search rather than a separate lookup, so a name and an
|
|
865
|
+
// id reach the same vault and the caller never has to know which one they have.
|
|
866
|
+
const vaultId = looksLikeVaultId(ref) ? ref : await resolveThroughDaemon(ref, asJson);
|
|
867
|
+
if (typeof vaultId !== "string")
|
|
868
|
+
return vaultId;
|
|
869
|
+
const reply = await askDaemon({
|
|
870
|
+
t: "vaultAsk",
|
|
871
|
+
id: "vault-ask",
|
|
872
|
+
req: { vaultId, question, timeoutSeconds },
|
|
873
|
+
cwd: process.cwd(),
|
|
874
|
+
},
|
|
875
|
+
// Past the ask's own deadline, so a vault that times out reports as a timeout rather
|
|
876
|
+
// than as a daemon that stopped answering.
|
|
877
|
+
timeoutSeconds * 1000 + 15_000);
|
|
878
|
+
if (!reply)
|
|
879
|
+
return daemonSilent(asJson);
|
|
880
|
+
if (reply.t === "error")
|
|
881
|
+
return fail(asJson, reply.code, reply.message, hintsForCode(reply.code));
|
|
882
|
+
if (reply.t !== "vaultAsk.ok")
|
|
883
|
+
return unexpectedReply(asJson);
|
|
884
|
+
return renderAnswer(reply.answer, asJson);
|
|
885
|
+
}
|
|
886
|
+
function renderAnswer(answer, asJson) {
|
|
887
|
+
const line = attribution(answer);
|
|
888
|
+
if (answer.status !== "answered" || !answer.answer) {
|
|
889
|
+
return fail(asJson, answer.status === "timeout" ? "timeout" : "vault_ask_failed", answer.status === "timeout"
|
|
890
|
+
? `${answer.title} did not finish answering in time.`
|
|
891
|
+
: `${answer.title} could not answer that.`, [
|
|
892
|
+
answer.error ?? "The vault was loaded but produced no answer.",
|
|
893
|
+
answer.status === "timeout"
|
|
894
|
+
? "A cold vault takes about 90 seconds. Raise it with --timeout <seconds>."
|
|
895
|
+
: "Nothing was charged for a failed load beyond what it managed to run.",
|
|
896
|
+
]);
|
|
897
|
+
}
|
|
898
|
+
if (asJson) {
|
|
899
|
+
console.log(JSON.stringify({
|
|
900
|
+
ok: true,
|
|
901
|
+
vault_id: answer.vaultId,
|
|
902
|
+
title: answer.title,
|
|
903
|
+
frozen_at: answer.frozenAt,
|
|
904
|
+
git_sha: answer.gitSha ?? null,
|
|
905
|
+
status: answer.status,
|
|
906
|
+
// The assembled sentence, not just its parts. A caller that has to build the
|
|
907
|
+
// attribution is a caller that can forget to, and this answer may be a hypothesis
|
|
908
|
+
// the team has since disproved. docs/vaults.md §4.
|
|
909
|
+
attribution: line,
|
|
910
|
+
answer: answer.answer,
|
|
911
|
+
duration_ms: answer.durationMs ?? null,
|
|
912
|
+
cost_usd: answer.costUsd ?? null,
|
|
913
|
+
}));
|
|
914
|
+
return 0;
|
|
915
|
+
}
|
|
916
|
+
console.log("");
|
|
917
|
+
info(c.bold(line));
|
|
918
|
+
console.log("");
|
|
919
|
+
console.log(answer.answer);
|
|
920
|
+
console.log("");
|
|
921
|
+
dim("Frozen: it is authoritative about what was decided, not about the code as it is now.");
|
|
922
|
+
if (answer.costUsd !== undefined || answer.durationMs !== undefined) {
|
|
923
|
+
const cost = answer.costUsd === undefined ? "" : `$${answer.costUsd.toFixed(2)}`;
|
|
924
|
+
const secs = answer.durationMs === undefined ? "" : `${Math.round(answer.durationMs / 1000)}s`;
|
|
925
|
+
dim([cost, secs].filter(Boolean).join(" · ") + ", on this machine.");
|
|
926
|
+
}
|
|
927
|
+
console.log("");
|
|
928
|
+
return 0;
|
|
929
|
+
}
|
|
930
|
+
// ---------------------------------------------------------------------------
|
|
931
|
+
// popover vault note
|
|
932
|
+
// ---------------------------------------------------------------------------
|
|
933
|
+
/**
|
|
934
|
+
* Append a note. There is no verb that edits one, and there is not going to be.
|
|
935
|
+
*
|
|
936
|
+
* The motivating case is a vault that confidently explains a decision later reversed. Editing
|
|
937
|
+
* the description would erase what the team believed at the time, which is the evidence the
|
|
938
|
+
* archive exists to hold; a note records both, dated and attributed, beside the original (§5).
|
|
939
|
+
* Notes are searched alongside `answers`, so a correction is frequently the thing that decides
|
|
940
|
+
* whether a vault is worth the stage-two spend at all.
|
|
941
|
+
*/
|
|
942
|
+
async function note(args) {
|
|
943
|
+
const asJson = args.includes("--json");
|
|
944
|
+
const words = positionals(args);
|
|
945
|
+
const ref = words[0];
|
|
946
|
+
const body = words.slice(1).join(" ").trim();
|
|
947
|
+
if (!ref || !body) {
|
|
948
|
+
return fail(asJson, "bad_request", "Adding a note takes a vault and the note.", [
|
|
949
|
+
'Usage: popover vault note <name|id> "<what changed>"',
|
|
950
|
+
"A note is appended, never edited in, and never replaces what the vault already says.",
|
|
951
|
+
]);
|
|
952
|
+
}
|
|
953
|
+
const credentials = loadCredentials();
|
|
954
|
+
if (!credentials) {
|
|
955
|
+
return fail(asJson, "not_signed_in", "This machine is not signed in.", [
|
|
956
|
+
"Run `popover login` first.",
|
|
957
|
+
]);
|
|
958
|
+
}
|
|
959
|
+
const target = await resolveThroughApi(credentials.device_token, ref, asJson);
|
|
960
|
+
if (typeof target !== "object")
|
|
961
|
+
return target;
|
|
962
|
+
const result = await request(credentials.device_token, "POST", `${apiBase()}/api/vaults/${encodeURIComponent(target.id)}/notes`, { body });
|
|
963
|
+
if (!result.ok)
|
|
964
|
+
return reportApiFailure(result, asJson, "Adding a note");
|
|
965
|
+
const createdAt = typeof result.body.created_at === "string" ? result.body.created_at : new Date().toISOString();
|
|
966
|
+
if (asJson) {
|
|
967
|
+
console.log(JSON.stringify({
|
|
968
|
+
ok: true,
|
|
969
|
+
id: typeof result.body.id === "string" ? result.body.id : null,
|
|
970
|
+
vault_id: target.id,
|
|
971
|
+
vault_name: target.name,
|
|
972
|
+
body,
|
|
973
|
+
created_at: createdAt,
|
|
974
|
+
}));
|
|
975
|
+
return 0;
|
|
976
|
+
}
|
|
977
|
+
ok(`Noted on ${c.bold(target.name)}.`);
|
|
978
|
+
dim("It sits beside what the vault already said, which is unchanged. Notes are searched too.");
|
|
979
|
+
return 0;
|
|
980
|
+
}
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
// popover vault archive
|
|
983
|
+
// ---------------------------------------------------------------------------
|
|
984
|
+
async function archive(args) {
|
|
985
|
+
const asJson = args.includes("--json");
|
|
986
|
+
const ref = positionals(args)[0];
|
|
987
|
+
if (!ref) {
|
|
988
|
+
return fail(asJson, "bad_request", "Archiving takes a vault.", [
|
|
989
|
+
"Usage: popover vault archive <name|id>",
|
|
990
|
+
]);
|
|
991
|
+
}
|
|
992
|
+
const credentials = loadCredentials();
|
|
993
|
+
if (!credentials) {
|
|
994
|
+
return fail(asJson, "not_signed_in", "This machine is not signed in.", [
|
|
995
|
+
"Run `popover login` first.",
|
|
996
|
+
]);
|
|
997
|
+
}
|
|
998
|
+
const target = await resolveThroughApi(credentials.device_token, ref, asJson);
|
|
999
|
+
if (typeof target !== "object")
|
|
1000
|
+
return target;
|
|
1001
|
+
const result = await request(credentials.device_token, "POST", `${apiBase()}/api/vaults/${encodeURIComponent(target.id)}/archive`);
|
|
1002
|
+
if (!result.ok)
|
|
1003
|
+
return reportApiFailure(result, asJson, "Archiving this vault");
|
|
1004
|
+
if (asJson) {
|
|
1005
|
+
console.log(JSON.stringify({
|
|
1006
|
+
ok: true,
|
|
1007
|
+
id: target.id,
|
|
1008
|
+
name: target.name,
|
|
1009
|
+
archived_at: new Date().toISOString(),
|
|
1010
|
+
}));
|
|
1011
|
+
return 0;
|
|
1012
|
+
}
|
|
1013
|
+
ok(`Archived ${c.bold(target.name)}.`);
|
|
1014
|
+
// `archived_at` exists partly so that a vault the agent should not have made can be retired
|
|
1015
|
+
// without deleting the evidence that it was made — which is how the §3 bet gets audited.
|
|
1016
|
+
dim("It drops out of search. The record that it existed, and who made it, does not.");
|
|
1017
|
+
return 0;
|
|
1018
|
+
}
|
|
1019
|
+
// ---------------------------------------------------------------------------
|
|
1020
|
+
// Resolving a name to an id
|
|
1021
|
+
// ---------------------------------------------------------------------------
|
|
1022
|
+
/** A vault id on success; an exit code on failure, already reported. */
|
|
1023
|
+
async function resolveThroughDaemon(ref, asJson) {
|
|
1024
|
+
const reply = await askDaemon({ t: "vaults", id: "vault-resolve", req: { query: ref, limit: 50 }, cwd: process.cwd() }, 20_000);
|
|
1025
|
+
if (!reply)
|
|
1026
|
+
return daemonSilent(asJson);
|
|
1027
|
+
if (reply.t === "error")
|
|
1028
|
+
return fail(asJson, reply.code, reply.message, []);
|
|
1029
|
+
if (reply.t !== "vaults.ok")
|
|
1030
|
+
return unexpectedReply(asJson);
|
|
1031
|
+
const match = resolveVaultRef(reply.vaults, ref);
|
|
1032
|
+
if (match.kind === "one")
|
|
1033
|
+
return match.vault.id;
|
|
1034
|
+
return reportNoMatch(match, ref, asJson);
|
|
1035
|
+
}
|
|
1036
|
+
async function resolveThroughApi(deviceToken, ref, asJson) {
|
|
1037
|
+
// An id needs no listing at all, which also means these two verbs keep working against a
|
|
1038
|
+
// vault that has fallen off the end of a truncated list.
|
|
1039
|
+
if (looksLikeVaultId(ref))
|
|
1040
|
+
return { id: ref, name: ref };
|
|
1041
|
+
const result = await request(deviceToken, "GET", `${apiBase()}/api/vaults?query=${encodeURIComponent(ref)}`);
|
|
1042
|
+
if (!result.ok)
|
|
1043
|
+
return reportApiFailure(result, asJson, "Looking up that vault");
|
|
1044
|
+
const vaults = readSummaries(result.body);
|
|
1045
|
+
if (!vaults) {
|
|
1046
|
+
return fail(asJson, "unexpected_response", `${apiBase()} answered, but not with vaults.`, [
|
|
1047
|
+
"Either something is standing in front of the API — an SSO proxy or a protected preview",
|
|
1048
|
+
"answers a signed-out request with a login page rather than a refusal — or this backend",
|
|
1049
|
+
"predates vaults. Check POPOVER_API_URL, then run `popover update`.",
|
|
1050
|
+
]);
|
|
1051
|
+
}
|
|
1052
|
+
const match = resolveVaultRef(vaults, ref);
|
|
1053
|
+
if (match.kind === "one")
|
|
1054
|
+
return { id: match.vault.id, name: match.vault.name };
|
|
1055
|
+
return reportNoMatch(match, ref, asJson);
|
|
1056
|
+
}
|
|
1057
|
+
function reportNoMatch(match, ref, asJson) {
|
|
1058
|
+
if (match.kind === "many") {
|
|
1059
|
+
return fail(asJson, "ambiguous_vault", `\`${ref}\` matches ${match.matches.length} vaults.`, [
|
|
1060
|
+
`Name one of them exactly: ${match.matches.map((v) => v.name).join(", ")}`,
|
|
1061
|
+
"Or pass the id, which `popover vault list --json` prints.",
|
|
1062
|
+
]);
|
|
1063
|
+
}
|
|
1064
|
+
return fail(asJson, "vault_not_found", `No vault your team can see is called \`${ref}\`.`, [
|
|
1065
|
+
"Run `popover vault list` to see what there is. A vault is visible to its whole team and",
|
|
1066
|
+
"to nobody else, regardless of which repo you are standing in.",
|
|
1067
|
+
]);
|
|
1068
|
+
}
|
|
1069
|
+
// ---------------------------------------------------------------------------
|
|
1070
|
+
// Talking to the daemon
|
|
1071
|
+
// ---------------------------------------------------------------------------
|
|
1072
|
+
function daemonSilent(asJson) {
|
|
1073
|
+
return fail(asJson, "daemon_unreachable", "The popover daemon is not responding.", [
|
|
1074
|
+
"Start it with `popover daemon start`, or check `popover doctor`.",
|
|
1075
|
+
]);
|
|
1076
|
+
}
|
|
1077
|
+
function unexpectedReply(asJson) {
|
|
1078
|
+
return fail(asJson, "unexpected_response", "Unexpected response from the daemon.", [
|
|
1079
|
+
"The daemon may be older than this CLI. Run `popover update`.",
|
|
1080
|
+
]);
|
|
1081
|
+
}
|
|
1082
|
+
function hintsForCode(code) {
|
|
1083
|
+
if (code === "vault_not_found") {
|
|
1084
|
+
return ["It may have been archived since you listed it. Run `popover vault list` again."];
|
|
1085
|
+
}
|
|
1086
|
+
if (code === "not_authenticated")
|
|
1087
|
+
return ["Run `popover login` to reconnect this machine."];
|
|
1088
|
+
if (code === "cloud_unreachable")
|
|
1089
|
+
return ["Check the network, then try again."];
|
|
1090
|
+
return [];
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* The device token, on every vault route.
|
|
1094
|
+
*
|
|
1095
|
+
* `Authorization: Bearer` rather than the token-in-body scheme `/api/fork/*` uses, and pinned
|
|
1096
|
+
* that way in docs/vaults.md §10 for the reason already written at `repos.ts:799`: one of
|
|
1097
|
+
* these routes is a GET with no body to put a token in, and splitting the scheme across
|
|
1098
|
+
* methods is worse than moving it for all of them.
|
|
1099
|
+
*/
|
|
1100
|
+
function authHeaders(deviceToken) {
|
|
1101
|
+
return { authorization: `Bearer ${deviceToken}`, "content-type": "application/json" };
|
|
1102
|
+
}
|
|
1103
|
+
async function request(deviceToken, method, url, body) {
|
|
1104
|
+
let res;
|
|
1105
|
+
try {
|
|
1106
|
+
res = await fetch(url, {
|
|
1107
|
+
method,
|
|
1108
|
+
headers: authHeaders(deviceToken),
|
|
1109
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
1110
|
+
signal: AbortSignal.timeout(20_000),
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
catch (err) {
|
|
1114
|
+
// Reported as a status of 0, which nothing else can produce, so `httpHeadline` can say
|
|
1115
|
+
// "could not reach" rather than inventing a code.
|
|
1116
|
+
return { ok: false, status: 0, ours: false, error: String(err) };
|
|
1117
|
+
}
|
|
1118
|
+
if (res.ok) {
|
|
1119
|
+
// A 201 with no body is legal, and `json()` on an empty body throws. Throwing on success
|
|
1120
|
+
// would report a vault that was published as one that was not.
|
|
1121
|
+
const parsed = await res.json().catch(() => ({}));
|
|
1122
|
+
return { ok: true, body: parsed };
|
|
1123
|
+
}
|
|
1124
|
+
const parsed = (await res.json().catch(() => null));
|
|
1125
|
+
return {
|
|
1126
|
+
ok: false,
|
|
1127
|
+
status: res.status,
|
|
1128
|
+
...(typeof parsed?.error === "string" ? { error: parsed.error } : {}),
|
|
1129
|
+
...(typeof parsed?.message === "string" ? { message: parsed.message } : {}),
|
|
1130
|
+
ours: typeof parsed?.error === "string",
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
function httpHeadline(result, what) {
|
|
1134
|
+
if (result.status === 0)
|
|
1135
|
+
return `Could not reach ${apiBase()}.`;
|
|
1136
|
+
if (result.malformed)
|
|
1137
|
+
return `${apiBase()} answered, but not with vaults.`;
|
|
1138
|
+
if (result.status === 401 && result.ours)
|
|
1139
|
+
return "This machine's credentials were refused.";
|
|
1140
|
+
if (result.status === 401 || result.status === 403) {
|
|
1141
|
+
return `Something between here and ${apiBase()} refused the request (${result.status}).`;
|
|
1142
|
+
}
|
|
1143
|
+
if (result.status === 404)
|
|
1144
|
+
return `${what} is not something this backend knows how to do.`;
|
|
1145
|
+
if (result.status === 429)
|
|
1146
|
+
return "Too many requests. Wait a minute and try again.";
|
|
1147
|
+
if (result.status >= 500)
|
|
1148
|
+
return `${apiBase()} could not complete the request.`;
|
|
1149
|
+
// Anything else our API took the trouble to write a sentence about is relayed rather than
|
|
1150
|
+
// replaced by a code: a backend newer than this build knows things this file does not.
|
|
1151
|
+
if (result.ours && result.message)
|
|
1152
|
+
return result.message;
|
|
1153
|
+
return `${what} failed (${result.status}${result.error ? `: ${result.error}` : ""}).`;
|
|
1154
|
+
}
|
|
1155
|
+
function httpHints(result) {
|
|
1156
|
+
if (result.status === 0)
|
|
1157
|
+
return ["Check the network, then try again."];
|
|
1158
|
+
if (result.status === 401 && result.ours) {
|
|
1159
|
+
return ["Run `popover login` to reconnect this machine."];
|
|
1160
|
+
}
|
|
1161
|
+
if (result.status === 401 || result.status === 403) {
|
|
1162
|
+
return [
|
|
1163
|
+
"The refusal did not come from popover — an SSO proxy or a protected preview",
|
|
1164
|
+
"deployment answers this way. Signing in again would not help.",
|
|
1165
|
+
];
|
|
1166
|
+
}
|
|
1167
|
+
if (result.status === 404 && !result.ours) {
|
|
1168
|
+
return [
|
|
1169
|
+
"Vaults need a backend that has them. If you are pointed at a preview deployment,",
|
|
1170
|
+
"check POPOVER_API_URL; otherwise run `popover update`.",
|
|
1171
|
+
];
|
|
1172
|
+
}
|
|
1173
|
+
if (result.status === 404) {
|
|
1174
|
+
return ["It may have been archived. Run `popover vault list` to see what there is."];
|
|
1175
|
+
}
|
|
1176
|
+
if (result.status >= 500)
|
|
1177
|
+
return ["Nothing was published. Try again in a moment."];
|
|
1178
|
+
return [];
|
|
1179
|
+
}
|
|
1180
|
+
function reportApiFailure(result, asJson, what) {
|
|
1181
|
+
return fail(asJson, result.malformed ? "unexpected_response" : (result.error ?? `http_${result.status}`), httpHeadline(result, what), httpHints(result));
|
|
1182
|
+
}
|
|
1183
|
+
// ---------------------------------------------------------------------------
|
|
1184
|
+
// Small helpers
|
|
1185
|
+
// ---------------------------------------------------------------------------
|
|
1186
|
+
/**
|
|
1187
|
+
* The origin to talk to.
|
|
1188
|
+
*
|
|
1189
|
+
* `defaultApiUrl()` rather than `credentials.api_url` directly, so `POPOVER_API_URL` points
|
|
1190
|
+
* this at a dev checkout or a preview the same way it does `popover fork` and `popover repos`.
|
|
1191
|
+
*/
|
|
1192
|
+
function apiBase() {
|
|
1193
|
+
return defaultApiUrl().replace(/\/+$/, "");
|
|
1194
|
+
}
|
|
1195
|
+
function git(cwd, args) {
|
|
1196
|
+
try {
|
|
1197
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
1198
|
+
encoding: "utf8",
|
|
1199
|
+
timeout: 5000,
|
|
1200
|
+
windowsHide: true,
|
|
1201
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
1202
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
catch {
|
|
1206
|
+
// Not a repo, no git, or no commits. All three mean "no signal", and none of them are a
|
|
1207
|
+
// reason to refuse to vault a conversation.
|
|
1208
|
+
return undefined;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
function fail(asJson, code, message, hints) {
|
|
1212
|
+
if (asJson) {
|
|
1213
|
+
console.log(JSON.stringify({ ok: false, error: code, message, hints }));
|
|
1214
|
+
return 1;
|
|
1215
|
+
}
|
|
1216
|
+
bad(message);
|
|
1217
|
+
for (const hint of hints)
|
|
1218
|
+
dim(hint);
|
|
1219
|
+
return 1;
|
|
1220
|
+
}
|
|
1221
|
+
function mb(bytes) {
|
|
1222
|
+
return (bytes / (1024 * 1024)).toFixed(1);
|
|
1223
|
+
}
|
|
1224
|
+
//# sourceMappingURL=vaults.js.map
|