@popoverinstall/cli 0.8.1 → 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 +147 -78
- 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/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/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 -77
- package/plugin/commands/ask.md +65 -65
- package/plugin/commands/fork.md +119 -119
- 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 -141
- 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 -168
package/dist/repos.js
ADDED
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { defaultApiUrl, loadCredentials } from "@popoverinstall/shared";
|
|
3
|
+
import { keyReader } from "./pager.js";
|
|
4
|
+
import { box, glyph, style } from "./theme.js";
|
|
5
|
+
import { bad, c, dim, ok, warn } from "./ui.js";
|
|
6
|
+
import { WORKSPACE_REPO_CAP, tildify, whereAmI, } from "./repo-scan.js";
|
|
7
|
+
const KNOWN_REFUSALS = [
|
|
8
|
+
"not_admin",
|
|
9
|
+
"github_not_linked",
|
|
10
|
+
"github_scope_missing",
|
|
11
|
+
"not_repo_admin",
|
|
12
|
+
"not_github",
|
|
13
|
+
"github_org_restricted",
|
|
14
|
+
];
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Entry point
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export async function reposCommand(argv) {
|
|
19
|
+
const asJson = argv.includes("--json");
|
|
20
|
+
const assumeYes = argv.includes("--yes") || argv.includes("-y");
|
|
21
|
+
const words = argv.filter((a) => !a.startsWith("-"));
|
|
22
|
+
// `--help` is stripped by the filter above, so it has to be looked for before the default
|
|
23
|
+
// takes over — otherwise `popover repos --help` silently runs the report instead.
|
|
24
|
+
const sub = argv.includes("--help") || argv.includes("-h") ? "help" : (words[0] ?? "list");
|
|
25
|
+
// Joined rather than requiring quotes, the same way `popover rename` takes a name: a team
|
|
26
|
+
// called "Team Rocket" is what people type, and insisting on shell quoting would be
|
|
27
|
+
// pedantry about word splitting.
|
|
28
|
+
const team = words.slice(1).join(" ").trim() || undefined;
|
|
29
|
+
switch (sub) {
|
|
30
|
+
case "list":
|
|
31
|
+
return report({ asJson });
|
|
32
|
+
case "add":
|
|
33
|
+
return add({ asJson, assumeYes, team });
|
|
34
|
+
case "rm":
|
|
35
|
+
case "remove":
|
|
36
|
+
if (asJson) {
|
|
37
|
+
// Not an oversight, and said in the one place a script would find out. `--json` exists
|
|
38
|
+
// for `/popover:repos`, which deliberately cannot remove: removal takes other people's
|
|
39
|
+
// agents out of scope and should be typed by a person who meant it.
|
|
40
|
+
return fail(true, "removal_is_interactive", "`popover repos rm` has no --json mode.", [
|
|
41
|
+
"Removing a repo cuts other people's agents out of scope, so it is deliberately not",
|
|
42
|
+
"something an agent can drive. Run `popover repos rm` in a terminal.",
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
return remove({ team });
|
|
46
|
+
case "help":
|
|
47
|
+
usage();
|
|
48
|
+
return 0;
|
|
49
|
+
default:
|
|
50
|
+
if (asJson) {
|
|
51
|
+
return fail(true, "bad_command", `Unknown repos command: ${sub}`, [
|
|
52
|
+
"Valid forms: `popover repos --json` and `popover repos add [team] --json`.",
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
bad(`Unknown repos command: ${sub}`);
|
|
56
|
+
usage();
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function usage() {
|
|
61
|
+
console.log(`
|
|
62
|
+
${c.bold("popover repos")} — which repositories your teams treat as one product
|
|
63
|
+
|
|
64
|
+
${c.bold("popover repos")} What this repo is on, and what your teams have listed
|
|
65
|
+
${c.bold("popover repos add")} [team] Register this repo with a team you administer
|
|
66
|
+
${c.bold("popover repos rm")} [team] Remove it
|
|
67
|
+
|
|
68
|
+
There is no repo argument: which repo you mean comes from where you are standing,
|
|
69
|
+
because registering one proves you hold it rather than that you can name it.
|
|
70
|
+
|
|
71
|
+
Inside Claude Code, ${c.cyan("/popover:repos")} reports and adds. It does not remove.
|
|
72
|
+
`);
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// popover repos
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
async function report({ asJson }) {
|
|
78
|
+
const place = whereAmI(process.cwd());
|
|
79
|
+
const credentials = loadCredentials();
|
|
80
|
+
if (!credentials) {
|
|
81
|
+
if (asJson) {
|
|
82
|
+
console.log(JSON.stringify({ ok: false, error: "not_signed_in", ...placeJson(place) }));
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
// The local half is still worth printing. Somebody running this while signed out is
|
|
86
|
+
// usually asking "what does popover think this directory is", and that answer needs no
|
|
87
|
+
// account.
|
|
88
|
+
renderPlace(place);
|
|
89
|
+
bad("This machine is not signed in, so nothing about your teams can be shown.");
|
|
90
|
+
dim("Run `popover login` first.");
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
const view = await fetchView(credentials.device_token);
|
|
94
|
+
if (!view.ok) {
|
|
95
|
+
// The local half still gets printed. Half an answer beats none when the reason for
|
|
96
|
+
// running this was that something is not working, and what popover thinks this directory
|
|
97
|
+
// is happens to be the most common answer to that.
|
|
98
|
+
if (!asJson)
|
|
99
|
+
renderPlace(place);
|
|
100
|
+
return reportApiFailure(view, asJson);
|
|
101
|
+
}
|
|
102
|
+
if (asJson) {
|
|
103
|
+
console.log(JSON.stringify({ ok: true, ...placeJson(place), ...viewJson(place, view.body) }));
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
renderPlace(place, view.body);
|
|
107
|
+
renderTeams(view.body);
|
|
108
|
+
renderOthers(place, view.body);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// popover repos add
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
async function add(opts) {
|
|
115
|
+
const { asJson } = opts;
|
|
116
|
+
const place = whereAmI(process.cwd());
|
|
117
|
+
const here = placeRepos(place);
|
|
118
|
+
if (here.length === 0)
|
|
119
|
+
return nothingHere(place, asJson);
|
|
120
|
+
const credentials = loadCredentials();
|
|
121
|
+
if (!credentials) {
|
|
122
|
+
return fail(asJson, "not_signed_in", "This machine is not signed in.", [
|
|
123
|
+
"Run `popover login` first — registering a repo happens against your account, not this",
|
|
124
|
+
"directory alone.",
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
const view = await fetchView(credentials.device_token);
|
|
128
|
+
if (!view.ok)
|
|
129
|
+
return reportApiFailure(view, asJson);
|
|
130
|
+
const { teams } = view.body;
|
|
131
|
+
if (teams.length === 0) {
|
|
132
|
+
return fail(asJson, "no_teams", "You are not on a team yet, so there is no list to add to.", [
|
|
133
|
+
`Create or join one at ${dashboard()}, then run this again.`,
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
const chosen = await pickTeam(teams, opts);
|
|
137
|
+
if (chosen.kind === "cancelled") {
|
|
138
|
+
dim("Nothing added.");
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
if (chosen.kind === "failed")
|
|
142
|
+
return chosen.code;
|
|
143
|
+
const team = chosen.team;
|
|
144
|
+
// Named teams are listed rather than hidden, so the refusal is about permission rather than
|
|
145
|
+
// the team appearing not to exist — §4. This is the one branch a member of a big team hits
|
|
146
|
+
// most, and it is the reason `admins` is worth asking the API for.
|
|
147
|
+
if (!team.isAdmin) {
|
|
148
|
+
const lines = refusal("not_admin", { team, verb: "add" });
|
|
149
|
+
return fail(asJson, "not_admin", lines.headline, lines.hints);
|
|
150
|
+
}
|
|
151
|
+
const plan = planAdd(here, team, view.body);
|
|
152
|
+
if (plan.add.length === 0) {
|
|
153
|
+
return nothingToAdd(plan, team, asJson);
|
|
154
|
+
}
|
|
155
|
+
// The workspace case: everything found at once, behind one confirmation. A prompt per repo
|
|
156
|
+
// would be three prompts for the layout this feature exists to serve.
|
|
157
|
+
if (plan.add.length > 1 && !opts.assumeYes) {
|
|
158
|
+
const answer = await confirmAll(plan, team, place, asJson);
|
|
159
|
+
if (answer !== "yes")
|
|
160
|
+
return answer === "no" ? 0 : 1;
|
|
161
|
+
}
|
|
162
|
+
const added = [];
|
|
163
|
+
const failed = [];
|
|
164
|
+
for (const entry of plan.add) {
|
|
165
|
+
const result = await postRepo(credentials.device_token, team.id, entry.remote);
|
|
166
|
+
if (result.ok) {
|
|
167
|
+
added.push(entry.remote);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
// Reported per repo rather than abandoning the batch. Two repos in one workspace can fail
|
|
171
|
+
// for genuinely different reasons — one is on GitHub and one is not, one is in an
|
|
172
|
+
// organization that has approved us and one is not — and stopping at the first would hide
|
|
173
|
+
// the second behind a retry.
|
|
174
|
+
const lines = refusalFor(result, entry.remote, team, "add", entry.repo);
|
|
175
|
+
failed.push({
|
|
176
|
+
remote: entry.remote,
|
|
177
|
+
reason: result.reason ?? String(result.status),
|
|
178
|
+
message: lines.headline,
|
|
179
|
+
hints: lines.hints,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (asJson) {
|
|
183
|
+
console.log(JSON.stringify({
|
|
184
|
+
ok: added.length > 0,
|
|
185
|
+
team: { id: team.id, name: team.name },
|
|
186
|
+
added,
|
|
187
|
+
already: plan.already.map((r) => r.remote),
|
|
188
|
+
unpublished: plan.unpublished.map((r) => r.remote),
|
|
189
|
+
failed,
|
|
190
|
+
}));
|
|
191
|
+
return added.length > 0 ? 0 : 1;
|
|
192
|
+
}
|
|
193
|
+
for (const remote of added)
|
|
194
|
+
ok(`${remote} is on ${team.name}.`);
|
|
195
|
+
for (const entry of failed) {
|
|
196
|
+
bad(entry.message);
|
|
197
|
+
for (const hint of entry.hints)
|
|
198
|
+
dim(hint);
|
|
199
|
+
}
|
|
200
|
+
if (added.length > 0) {
|
|
201
|
+
dim(`Agents in ${added.length === 1 ? "it" : "them"} can see each other now, as long as both ` +
|
|
202
|
+
`people are on ${team.name} and both are in a repo on its list.`);
|
|
203
|
+
}
|
|
204
|
+
return added.length > 0 ? 0 : 1;
|
|
205
|
+
}
|
|
206
|
+
/** Everything the plan found was already listed, or holds no key. Say which. */
|
|
207
|
+
function nothingToAdd(plan, team, asJson) {
|
|
208
|
+
if (asJson) {
|
|
209
|
+
console.log(JSON.stringify({
|
|
210
|
+
ok: true,
|
|
211
|
+
team: { id: team.id, name: team.name },
|
|
212
|
+
added: [],
|
|
213
|
+
already: plan.already.map((r) => r.remote),
|
|
214
|
+
unpublished: plan.unpublished.map((r) => r.remote),
|
|
215
|
+
failed: [],
|
|
216
|
+
}));
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
for (const repo of plan.already)
|
|
220
|
+
ok(`${repo.remote} is already on ${team.name}.`);
|
|
221
|
+
for (const repo of plan.unpublished) {
|
|
222
|
+
bad(`popover holds no key for ${repo.remote}, so there is nothing to register.`);
|
|
223
|
+
for (const hint of unpublishedHints(repo))
|
|
224
|
+
dim(hint);
|
|
225
|
+
}
|
|
226
|
+
return plan.already.length > 0 && plan.unpublished.length === 0 ? 0 : 1;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Why a repo you are plainly standing in has no key on the server, and what to do about it.
|
|
230
|
+
*
|
|
231
|
+
* This is checked here rather than left to the API because the API cannot tell the difference.
|
|
232
|
+
* `register_team_repo` raises the same "no session of this user holds a key" for a clone
|
|
233
|
+
* popover has never seen and for a shallow clone it has seen and deliberately refused to key —
|
|
234
|
+
* and the second is both common (every CI-style `--depth 1` checkout) and fixable in one
|
|
235
|
+
* command, while the first needs a session to exist at all.
|
|
236
|
+
*/
|
|
237
|
+
function unpublishedHints(repo) {
|
|
238
|
+
if (repo.shallow) {
|
|
239
|
+
return [
|
|
240
|
+
"This is a shallow clone. popover will not key one, because the first commit it can see",
|
|
241
|
+
"is the graft boundary and that differs with every --depth — two teammates cloned at",
|
|
242
|
+
"different depths would be silently invisible to each other. `git fetch --unshallow`",
|
|
243
|
+
"here, then run this again.",
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
return [
|
|
247
|
+
"Registering proves possession rather than naming a repo, and the proof comes from a",
|
|
248
|
+
"session your daemon has published from this clone. Open Claude Code here for a moment",
|
|
249
|
+
"(or check `popover status`), then run this again.",
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// popover repos rm
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
async function remove({ team: typed }) {
|
|
256
|
+
const place = whereAmI(process.cwd());
|
|
257
|
+
const here = placeRepos(place);
|
|
258
|
+
if (here.length === 0)
|
|
259
|
+
return nothingHere(place, false);
|
|
260
|
+
const credentials = loadCredentials();
|
|
261
|
+
if (!credentials) {
|
|
262
|
+
bad("This machine is not signed in.");
|
|
263
|
+
dim("Run `popover login` first.");
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
const view = await fetchView(credentials.device_token);
|
|
267
|
+
if (!view.ok)
|
|
268
|
+
return reportApiFailure(view, false);
|
|
269
|
+
/*
|
|
270
|
+
* Only the teams that actually list something here are offered.
|
|
271
|
+
*
|
|
272
|
+
* `add` picks among teams you administer; `rm` picks among teams that have this repo,
|
|
273
|
+
* which is a different set and a smaller one. Offering a team that lists nothing here would
|
|
274
|
+
* be offering a no-op, and — worse in a workspace — inviting a removal of the wrong repo.
|
|
275
|
+
*/
|
|
276
|
+
const listings = removable(here, view.body);
|
|
277
|
+
if (listings.length === 0) {
|
|
278
|
+
warn(`Nothing here is on any of your teams' lists.`);
|
|
279
|
+
dim("`popover repos` shows what is where.");
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
let target = listings[0];
|
|
283
|
+
if (listings.length > 1) {
|
|
284
|
+
if (typed) {
|
|
285
|
+
const matches = listings.filter((l) => matchesTeam(l.team, typed));
|
|
286
|
+
if (matches.length !== 1) {
|
|
287
|
+
bad(matches.length === 0
|
|
288
|
+
? `Nothing here is listed on a team matching "${typed}".`
|
|
289
|
+
: `"${typed}" matches ${matches.map((m) => m.team.name).join(", ")}. Name one exactly.`);
|
|
290
|
+
for (const l of listings)
|
|
291
|
+
dim(`${l.remote} is on ${l.team.name}`);
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
target = matches[0];
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const picked = await chooseListing(listings);
|
|
298
|
+
if (!picked) {
|
|
299
|
+
dim("Nothing removed.");
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
target = picked;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else if (typed && !matchesTeam(target.team, typed)) {
|
|
306
|
+
bad(`${target.remote} is on ${target.team.name}, not on a team matching "${typed}".`);
|
|
307
|
+
return 1;
|
|
308
|
+
}
|
|
309
|
+
if (!target.team.isAdmin) {
|
|
310
|
+
const lines = refusal("not_admin", { team: target.team, verb: "remove" });
|
|
311
|
+
bad(lines.headline);
|
|
312
|
+
for (const hint of lines.hints)
|
|
313
|
+
dim(hint);
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
const result = await deleteRepo(credentials.device_token, target.team.id, target.remote);
|
|
317
|
+
if (!result.ok) {
|
|
318
|
+
const lines = refusalFor(result, target.remote, target.team, "remove");
|
|
319
|
+
bad(lines.headline);
|
|
320
|
+
for (const hint of lines.hints)
|
|
321
|
+
dim(hint);
|
|
322
|
+
return 1;
|
|
323
|
+
}
|
|
324
|
+
ok(`${target.remote} is off ${target.team.name}.`);
|
|
325
|
+
// Said plainly rather than left to be discovered. This is the sentence that makes removal
|
|
326
|
+
// worth thinking about before typing, and it is true of everyone on the team, not just you.
|
|
327
|
+
dim(`Agents in it and agents in ${target.team.name}'s other repos stop seeing each other at the`);
|
|
328
|
+
dim("next check — immediately for a new ask, and within the roster TTL for a cached roster.");
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
/** Every (repo here, team that lists it) pair. A fork can be listed under `upstream`. */
|
|
332
|
+
function removable(here, view) {
|
|
333
|
+
const listings = [];
|
|
334
|
+
for (const repo of here) {
|
|
335
|
+
for (const remote of repo.remotes) {
|
|
336
|
+
const row = view.repos.find((r) => r.remote === remote);
|
|
337
|
+
if (!row)
|
|
338
|
+
continue;
|
|
339
|
+
for (const teamId of row.teams) {
|
|
340
|
+
const team = view.teams.find((t) => t.id === teamId);
|
|
341
|
+
if (team)
|
|
342
|
+
listings.push({ team, remote, repo });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return listings;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Sort the repos in front of us into the three things that can be true of them.
|
|
350
|
+
*
|
|
351
|
+
* The subtle part is *which remote* gets registered for a clone that has several. A fork has
|
|
352
|
+
* `origin = alice/backend` and `upstream = acme/backend` and publishes a key for each, so the
|
|
353
|
+
* remote to register is the first one the server confirms a key for — not blindly the primary,
|
|
354
|
+
* which would produce a refusal ("no key for alice/backend") that is true, unhelpful, and
|
|
355
|
+
* avoidable with information we already hold.
|
|
356
|
+
*
|
|
357
|
+
* When the server confirms none of them the repo is `unpublished`, which is checked here
|
|
358
|
+
* rather than discovered from a 4xx, because only this side knows whether the cause is a
|
|
359
|
+
* shallow clone.
|
|
360
|
+
*/
|
|
361
|
+
export function planAdd(here, team, view) {
|
|
362
|
+
const plan = { add: [], already: [], unpublished: [] };
|
|
363
|
+
for (const repo of here) {
|
|
364
|
+
const rows = repo.remotes
|
|
365
|
+
.map((remote) => view.repos.find((r) => r.remote === remote))
|
|
366
|
+
.filter((r) => r !== undefined);
|
|
367
|
+
if (rows.length === 0) {
|
|
368
|
+
plan.unpublished.push(repo);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (rows.some((r) => r.teams.includes(team.id))) {
|
|
372
|
+
plan.already.push(repo);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
// `repo.remotes` is primary-first and `rows` preserves that order, so `origin` still wins
|
|
376
|
+
// whenever the server knows about it.
|
|
377
|
+
plan.add.push({ repo, remote: rows[0].remote });
|
|
378
|
+
}
|
|
379
|
+
return plan;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Which teams a typed argument could mean.
|
|
383
|
+
*
|
|
384
|
+
* Matched by id, then by exact name, then by prefix — the same widening a person expects from
|
|
385
|
+
* `popover rename B1`. Case-insensitive, because a team called "Quolabs" is typed `quolabs`.
|
|
386
|
+
* Returns every match rather than picking one: two teams whose names share a prefix is a real
|
|
387
|
+
* arrangement, and silently registering a repo with the wrong one is not recoverable by the
|
|
388
|
+
* person who did it.
|
|
389
|
+
*/
|
|
390
|
+
export function resolveTeam(teams, typed) {
|
|
391
|
+
const needle = typed.trim().toLowerCase();
|
|
392
|
+
if (!needle)
|
|
393
|
+
return [];
|
|
394
|
+
const byId = teams.filter((t) => t.id.toLowerCase() === needle);
|
|
395
|
+
if (byId.length > 0)
|
|
396
|
+
return byId;
|
|
397
|
+
const exact = teams.filter((t) => t.name.toLowerCase() === needle);
|
|
398
|
+
if (exact.length > 0)
|
|
399
|
+
return exact;
|
|
400
|
+
return teams.filter((t) => t.name.toLowerCase().startsWith(needle));
|
|
401
|
+
}
|
|
402
|
+
function matchesTeam(team, typed) {
|
|
403
|
+
return resolveTeam([team], typed).length === 1;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* One actionable sentence per reason, plus what to do about it.
|
|
407
|
+
*
|
|
408
|
+
* Written out longhand rather than generated, because the differences between them are the
|
|
409
|
+
* whole point and a template would flatten exactly the two that are easiest to confuse:
|
|
410
|
+
* `github_not_linked` and `github_scope_missing`. Most people who sign up for popover sign up
|
|
411
|
+
* *with GitHub*, which links an account with Clerk's default scopes and no `repo` — so the
|
|
412
|
+
* second is the common case, and telling that person "you have no GitHub account" sends them
|
|
413
|
+
* to link a second one and get the same refusal again.
|
|
414
|
+
*/
|
|
415
|
+
export function refusal(reason, ctx = {}) {
|
|
416
|
+
const team = ctx.team?.name ?? "that team";
|
|
417
|
+
const remote = ctx.remote ?? "this repo";
|
|
418
|
+
const url = `${dashboard()}/repos`;
|
|
419
|
+
switch (reason) {
|
|
420
|
+
case "not_admin": {
|
|
421
|
+
const verb = ctx.verb === "remove" ? "remove a repo" : "add a repo";
|
|
422
|
+
const admins = ctx.team?.admins?.filter(Boolean) ?? [];
|
|
423
|
+
return {
|
|
424
|
+
headline: `You need to be an admin of ${team} to ${verb}.`,
|
|
425
|
+
hints: [
|
|
426
|
+
admins.length > 0
|
|
427
|
+
? `Ask ${humanList(admins)}, or ${ctx.verb === "remove" ? "remove" : "add"} it from ${url}`
|
|
428
|
+
: `Ask an admin of ${team}, or ${ctx.verb === "remove" ? "remove" : "add"} it from ${url}`,
|
|
429
|
+
],
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
case "github_not_linked":
|
|
433
|
+
return {
|
|
434
|
+
headline: "popover has no GitHub account for you, and registering a repo needs one.",
|
|
435
|
+
hints: [
|
|
436
|
+
`Adding ${shortRepo(remote)} to ${team} means proving on GitHub that you administer`,
|
|
437
|
+
`it, which we cannot do without a linked account. Link one at ${url}, then run this`,
|
|
438
|
+
"again. Nothing else changes meanwhile: agents already in this repo still see each",
|
|
439
|
+
"other exactly as before.",
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
case "github_scope_missing":
|
|
443
|
+
return {
|
|
444
|
+
// Deliberately opens by confirming the account exists. Somebody who signed in with
|
|
445
|
+
// GitHub reads the first line and stops, so the first line has to be true for them.
|
|
446
|
+
headline: "Your GitHub account is linked, but has not granted popover access to repos.",
|
|
447
|
+
hints: [
|
|
448
|
+
"Signing in with GitHub does not grant it: repository access is a much heavier",
|
|
449
|
+
"consent screen, so it is asked for separately and only at the moment you add a repo.",
|
|
450
|
+
`Grant it at ${url}, then run this again.`,
|
|
451
|
+
],
|
|
452
|
+
};
|
|
453
|
+
case "not_repo_admin":
|
|
454
|
+
return {
|
|
455
|
+
headline: `GitHub says you are not an admin of ${shortRepo(remote)}.`,
|
|
456
|
+
hints: [
|
|
457
|
+
`Being an admin of ${team} is not enough — the repository half of the check is`,
|
|
458
|
+
"GitHub's, and it is what stops anyone registering a repo they merely hold a copy of.",
|
|
459
|
+
"Ask someone with admin rights on the repository to add it.",
|
|
460
|
+
],
|
|
461
|
+
};
|
|
462
|
+
case "not_github":
|
|
463
|
+
return {
|
|
464
|
+
headline: `Only GitHub repos can be registered, and ${shortRepo(remote)} is on ${host(remote)}.`,
|
|
465
|
+
hints: [
|
|
466
|
+
"There is nothing to check ownership against there, so it cannot go on a team's list.",
|
|
467
|
+
"This costs you nothing you had: agents in it still see each other the way they always",
|
|
468
|
+
"have — same repo, same team. It is only the cross-repo list that needs GitHub.",
|
|
469
|
+
],
|
|
470
|
+
};
|
|
471
|
+
case "github_org_restricted":
|
|
472
|
+
return {
|
|
473
|
+
// Never "that repo does not exist". GitHub answers 404 here, but we only ever ask
|
|
474
|
+
// about a repository the caller demonstrably has a clone of, so a 404 means access is
|
|
475
|
+
// being withheld — reporting it as absence would send somebody to check their spelling
|
|
476
|
+
// for as long as it took them to give up.
|
|
477
|
+
headline: `The ${owner(remote)} organization has not approved popover on GitHub.`,
|
|
478
|
+
hints: [
|
|
479
|
+
`GitHub will not answer for ${shortRepo(remote)} at all, which for a repo you have a`,
|
|
480
|
+
"clone of means access is being withheld rather than that it is missing. This is",
|
|
481
|
+
"GitHub's OAuth App access restrictions, and it applies however much scope you grant.",
|
|
482
|
+
`An owner of ${owner(remote)} approves popover under the organization's settings, in`,
|
|
483
|
+
"Third-party access. Then run this again.",
|
|
484
|
+
],
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Turn an API failure into text, mapping an unknown reason to something honest.
|
|
490
|
+
*
|
|
491
|
+
* Exported for the tests rather than for other callers: the three branches below all exist to
|
|
492
|
+
* stop a non-refusal being rendered as a refusal, and each of them is one careless edit away
|
|
493
|
+
* from telling somebody to go and change permissions they have.
|
|
494
|
+
*/
|
|
495
|
+
export function refusalFor(result, remote, team, verb = "add", repo) {
|
|
496
|
+
if (result.status === 403 && result.reason && KNOWN_REFUSALS.includes(result.reason)) {
|
|
497
|
+
return refusal(result.reason, { team, remote, verb });
|
|
498
|
+
}
|
|
499
|
+
/*
|
|
500
|
+
* Possession, not permission — and the difference is the whole reason this is a 409 rather
|
|
501
|
+
* than one of the six reasons above. The caller may well administer both the team and the
|
|
502
|
+
* repository; what is missing is a session of theirs holding a key for this remote.
|
|
503
|
+
*
|
|
504
|
+
* `planAdd` normally catches this before a request is made, using the repo list the server
|
|
505
|
+
* itself just sent. Reaching here means the two disagreed — the session was swept between
|
|
506
|
+
* the GET and the POST, or the remote is on the list under a key published by somebody
|
|
507
|
+
* else's clone — so the local reasons are still the useful ones, since only this side knows
|
|
508
|
+
* whether the clone is shallow.
|
|
509
|
+
*/
|
|
510
|
+
if (result.status === 409) {
|
|
511
|
+
return {
|
|
512
|
+
headline: `popover holds no key for ${shortRepo(remote)}, so there is nothing to register.`,
|
|
513
|
+
hints: repo ? unpublishedHints(repo) : [result.message ?? ""].filter(Boolean),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
// A GitHub outage is not a refusal, and must never be rendered as one: "we could not find
|
|
517
|
+
// out" sent through the `not_repo_admin` wording would have somebody changing permissions on
|
|
518
|
+
// GitHub over a timeout.
|
|
519
|
+
if (result.status === 503 && result.error === "github_unavailable") {
|
|
520
|
+
return {
|
|
521
|
+
headline: `GitHub could not be reached, so nobody could check who administers ${shortRepo(remote)}.`,
|
|
522
|
+
hints: [
|
|
523
|
+
"Nothing was changed, and this is not a permissions problem. Try again in a moment.",
|
|
524
|
+
...(result.message ? [result.message] : []),
|
|
525
|
+
],
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
// The team came from the GET a moment ago, so a 404 here is a membership that changed
|
|
529
|
+
// underneath the command rather than a route that does not exist.
|
|
530
|
+
if (result.status === 404 && result.error === "not_found") {
|
|
531
|
+
return {
|
|
532
|
+
headline: `You are not on ${team.name} any more.`,
|
|
533
|
+
hints: ["Run `popover repos` again to see where you actually stand."],
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
if (result.status === 403) {
|
|
537
|
+
// A newer backend with a reason this build has never heard of. Relayed rather than
|
|
538
|
+
// guessed at: mapping it to the nearest known reason would produce confident instructions
|
|
539
|
+
// for the wrong problem.
|
|
540
|
+
return {
|
|
541
|
+
headline: `${team.name} refused that (${result.reason ?? "no reason given"}).`,
|
|
542
|
+
hints: [
|
|
543
|
+
`Adding ${shortRepo(remote)} was refused for a reason this version does not know about.`,
|
|
544
|
+
"Run `popover update` — and if it persists, the dashboard will explain it.",
|
|
545
|
+
],
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
// Anything else our API took the trouble to write a sentence about. Relayed rather than
|
|
549
|
+
// replaced by a status code, because a backend newer than this build knows things this file
|
|
550
|
+
// does not, and "failed (422)" is not one of them.
|
|
551
|
+
if (result.ours && result.message) {
|
|
552
|
+
return { headline: result.message, hints: httpHints(result) };
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
headline: httpHeadline(result, `${verb === "remove" ? "Removing" : "Adding"} ${shortRepo(remote)}`),
|
|
556
|
+
hints: httpHints(result),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The device token, on every request.
|
|
561
|
+
*
|
|
562
|
+
* `Authorization: Bearer <device_token>` rather than the token in a JSON body, which is what
|
|
563
|
+
* `/api/device/session` and `/api/fork/*` do. Not a new credential and not a new flow — the
|
|
564
|
+
* same long-lived token `popover login` wrote, sent the standard way — but a different place
|
|
565
|
+
* to put it, and the reason is that one of these three routes is a GET with no body to put it
|
|
566
|
+
* in. Splitting the scheme across methods would be worse than moving it for all three.
|
|
567
|
+
*/
|
|
568
|
+
function authHeaders(deviceToken) {
|
|
569
|
+
return { authorization: `Bearer ${deviceToken}`, "content-type": "application/json" };
|
|
570
|
+
}
|
|
571
|
+
async function fetchView(deviceToken) {
|
|
572
|
+
const result = await request(deviceToken, "GET", `${dashboard()}/api/teams/repos`);
|
|
573
|
+
if (!result.ok)
|
|
574
|
+
return result;
|
|
575
|
+
// Everything downstream indexes into these two arrays. Checked once, here, rather than
|
|
576
|
+
// defended against at forty call sites — and checked at all because the thing most likely
|
|
577
|
+
// to answer this route is not this route. See `ApiFailure.malformed`.
|
|
578
|
+
if (!isReposView(result.body)) {
|
|
579
|
+
return { ok: false, status: 200, ours: false, malformed: true };
|
|
580
|
+
}
|
|
581
|
+
return result;
|
|
582
|
+
}
|
|
583
|
+
/** Shape only. The server is trusted about contents; it is not trusted to be the server. */
|
|
584
|
+
function isReposView(body) {
|
|
585
|
+
if (typeof body !== "object" || body === null)
|
|
586
|
+
return false;
|
|
587
|
+
const { teams, repos } = body;
|
|
588
|
+
return Array.isArray(teams) && Array.isArray(repos);
|
|
589
|
+
}
|
|
590
|
+
async function postRepo(deviceToken, teamId, remote) {
|
|
591
|
+
return request(deviceToken, "POST", `${dashboard()}/api/teams/${encodeURIComponent(teamId)}/repos`, { remote });
|
|
592
|
+
}
|
|
593
|
+
async function deleteRepo(deviceToken, teamId, remote) {
|
|
594
|
+
return request(deviceToken, "DELETE", `${dashboard()}/api/teams/${encodeURIComponent(teamId)}/repos`, { remote });
|
|
595
|
+
}
|
|
596
|
+
async function request(deviceToken, method, url, body) {
|
|
597
|
+
let res;
|
|
598
|
+
try {
|
|
599
|
+
res = await fetch(url, {
|
|
600
|
+
method,
|
|
601
|
+
headers: authHeaders(deviceToken),
|
|
602
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
603
|
+
// Long enough for a cold serverless function plus a GitHub round trip, which is what a
|
|
604
|
+
// POST actually costs, and short enough that a wedged network does not look like a hang.
|
|
605
|
+
signal: AbortSignal.timeout(20_000),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
// Reported as a status of 0, which nothing else can produce, so `httpHeadline` can say
|
|
610
|
+
// "could not reach" rather than inventing a code.
|
|
611
|
+
return { ok: false, status: 0, ours: false, error: String(err) };
|
|
612
|
+
}
|
|
613
|
+
if (res.ok) {
|
|
614
|
+
// 204 on DELETE, and a 201 with no body is legal too. `json()` on an empty body throws,
|
|
615
|
+
// and throwing on success would report a removal that happened as one that did not.
|
|
616
|
+
const parsed = await res.json().catch(() => ({}));
|
|
617
|
+
return { ok: true, body: parsed };
|
|
618
|
+
}
|
|
619
|
+
const parsed = (await res.json().catch(() => null));
|
|
620
|
+
return {
|
|
621
|
+
ok: false,
|
|
622
|
+
status: res.status,
|
|
623
|
+
...(typeof parsed?.reason === "string" ? { reason: parsed.reason } : {}),
|
|
624
|
+
...(typeof parsed?.error === "string" ? { error: parsed.error } : {}),
|
|
625
|
+
...(typeof parsed?.message === "string" ? { message: parsed.message } : {}),
|
|
626
|
+
ours: typeof parsed?.error === "string",
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
function httpHeadline(result, what) {
|
|
630
|
+
if (result.status === 0)
|
|
631
|
+
return `Could not reach ${dashboard()}.`;
|
|
632
|
+
if (result.malformed)
|
|
633
|
+
return `${dashboard()} answered, but not with a list of repos.`;
|
|
634
|
+
if (result.status === 401 && result.ours)
|
|
635
|
+
return "This machine's credentials were refused.";
|
|
636
|
+
if (result.status === 401 || result.status === 403) {
|
|
637
|
+
return `Something between here and ${dashboard()} refused the request (${result.status}).`;
|
|
638
|
+
}
|
|
639
|
+
if (result.status === 404)
|
|
640
|
+
return `${what} is not something this backend knows how to do.`;
|
|
641
|
+
if (result.status === 429)
|
|
642
|
+
return "Too many requests. Wait a minute and try again.";
|
|
643
|
+
if (result.status >= 500)
|
|
644
|
+
return `${dashboard()} could not complete the request.`;
|
|
645
|
+
return `${what} failed (${result.status}${result.error ? `: ${result.error}` : ""}).`;
|
|
646
|
+
}
|
|
647
|
+
function httpHints(result) {
|
|
648
|
+
if (result.status === 0)
|
|
649
|
+
return ["Check the network, then try again."];
|
|
650
|
+
if (result.malformed) {
|
|
651
|
+
return [
|
|
652
|
+
"Either something is standing in front of the API — an SSO proxy or a protected preview",
|
|
653
|
+
"answers a signed-out request with a login page rather than a refusal — or this backend",
|
|
654
|
+
"predates team repos. Check POPOVER_API_URL, then run `popover update`.",
|
|
655
|
+
];
|
|
656
|
+
}
|
|
657
|
+
if (result.status === 401 && result.ours) {
|
|
658
|
+
return ["Run `popover login` to reconnect this machine."];
|
|
659
|
+
}
|
|
660
|
+
if (result.status === 401 || result.status === 403) {
|
|
661
|
+
return [
|
|
662
|
+
"The refusal did not come from popover — an SSO proxy or a protected preview",
|
|
663
|
+
"deployment answers this way. Signing in again would not help.",
|
|
664
|
+
];
|
|
665
|
+
}
|
|
666
|
+
if (result.status === 404) {
|
|
667
|
+
return [
|
|
668
|
+
"Team repos need a backend that has them. If you are pointed at a preview deployment,",
|
|
669
|
+
"check POPOVER_API_URL; otherwise run `popover update`.",
|
|
670
|
+
];
|
|
671
|
+
}
|
|
672
|
+
if (result.status >= 500)
|
|
673
|
+
return ["Nothing was changed. Try again in a moment."];
|
|
674
|
+
return [];
|
|
675
|
+
}
|
|
676
|
+
function reportApiFailure(result, asJson) {
|
|
677
|
+
return fail(asJson, result.malformed ? "unexpected_response" : (result.error ?? `http_${result.status}`), httpHeadline(result, "That"), httpHints(result));
|
|
678
|
+
}
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
// Rendering
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
/** The repos this directory could register — one inside a clone, several in a workspace. */
|
|
683
|
+
function placeRepos(place) {
|
|
684
|
+
if (place.kind === "repo")
|
|
685
|
+
return [place.repo];
|
|
686
|
+
if (place.kind === "workspace")
|
|
687
|
+
return place.repos;
|
|
688
|
+
return [];
|
|
689
|
+
}
|
|
690
|
+
function renderPlace(place, view) {
|
|
691
|
+
console.log("");
|
|
692
|
+
if (place.kind === "none") {
|
|
693
|
+
console.log(` ${c.dim(tildify(place.dir))}`);
|
|
694
|
+
console.log(` ${noPlaceSentence(place.why)}`);
|
|
695
|
+
console.log("");
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (place.kind === "workspace") {
|
|
699
|
+
console.log(` ${c.bold(tildify(place.dir))} ${c.dim(`— ${place.repos.length} repos, none of them this directory`)}`);
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
console.log(` ${c.bold(tildify(place.dir))}`);
|
|
703
|
+
}
|
|
704
|
+
console.log("");
|
|
705
|
+
for (const repo of placeRepos(place)) {
|
|
706
|
+
console.log(` ${c.bold(repo.name)} ${c.dim(repo.remote)}`);
|
|
707
|
+
for (const line of repoStatus(repo, view))
|
|
708
|
+
console.log(` ${line}`);
|
|
709
|
+
}
|
|
710
|
+
console.log("");
|
|
711
|
+
}
|
|
712
|
+
/** Where one local repo stands: the teams listing it, or why it is on none. */
|
|
713
|
+
function repoStatus(repo, view) {
|
|
714
|
+
if (!view)
|
|
715
|
+
return [];
|
|
716
|
+
const rows = repo.remotes
|
|
717
|
+
.map((remote) => view.repos.find((r) => r.remote === remote))
|
|
718
|
+
.filter((r) => r !== undefined);
|
|
719
|
+
const onTeams = view.teams.filter((t) => rows.some((r) => r.teams.includes(t.id)));
|
|
720
|
+
const lines = onTeams.map((t) => c.green(`on ${t.name}`));
|
|
721
|
+
if (rows.length === 0) {
|
|
722
|
+
lines.push(c.yellow(repo.shallow
|
|
723
|
+
? "no key — shallow clone, run `git fetch --unshallow`"
|
|
724
|
+
: "no key yet — popover has published no session from this clone"));
|
|
725
|
+
return lines;
|
|
726
|
+
}
|
|
727
|
+
if (onTeams.length === 0) {
|
|
728
|
+
// Named alongside the fact, because "on no list" and "you cannot put it on one" are
|
|
729
|
+
// different situations and only one of them is worth acting on.
|
|
730
|
+
lines.push(host(repo.remote) === "github.com"
|
|
731
|
+
? c.dim("on no list — `popover repos add`")
|
|
732
|
+
: c.dim(`on no list, and ${host(repo.remote)} repos cannot be listed`));
|
|
733
|
+
}
|
|
734
|
+
return lines;
|
|
735
|
+
}
|
|
736
|
+
function renderTeams(view) {
|
|
737
|
+
for (const team of view.teams) {
|
|
738
|
+
const listed = view.repos.filter((r) => r.teams.includes(team.id));
|
|
739
|
+
console.log(` ${c.bold(team.name)}${team.isAdmin ? c.dim(" · you administer it") : ""}`);
|
|
740
|
+
if (listed.length === 0) {
|
|
741
|
+
console.log(` ${c.dim("no repos listed")}`);
|
|
742
|
+
}
|
|
743
|
+
for (const row of listed) {
|
|
744
|
+
// A null `lastSeen` is a repo on the list that this account has never worked in. Marked
|
|
745
|
+
// rather than dropped: "the team lists four and you hold two" is the fact somebody
|
|
746
|
+
// reading this is usually after.
|
|
747
|
+
console.log(` ${row.remote}${row.lastSeen === null ? c.dim(" (not on this machine)") : ""}`);
|
|
748
|
+
}
|
|
749
|
+
console.log("");
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/** Repos you have sessions in that no team lists, and are not standing in. */
|
|
753
|
+
function renderOthers(place, view) {
|
|
754
|
+
const hereRemotes = new Set(placeRepos(place).flatMap((r) => r.remotes));
|
|
755
|
+
const others = view.repos.filter((r) => !hereRemotes.has(r.remote) && r.teams.length === 0 && r.lastSeen !== null);
|
|
756
|
+
if (others.length === 0)
|
|
757
|
+
return;
|
|
758
|
+
console.log(` ${c.bold("You also work in")}`);
|
|
759
|
+
for (const row of others) {
|
|
760
|
+
console.log(` ${row.remote}${host(row.remote) === "github.com" ? "" : c.dim(` (${host(row.remote)} — cannot be listed)`)}`);
|
|
761
|
+
}
|
|
762
|
+
console.log("");
|
|
763
|
+
dim("Add one by running `popover repos add` from inside it.");
|
|
764
|
+
console.log("");
|
|
765
|
+
}
|
|
766
|
+
/** Why this directory offers nothing, in one sentence each. */
|
|
767
|
+
function noPlaceSentence(why) {
|
|
768
|
+
switch (why) {
|
|
769
|
+
case "not-a-repo":
|
|
770
|
+
return "is not a git repository, and nothing directly inside it is one either.";
|
|
771
|
+
case "no-remote":
|
|
772
|
+
return "is a git repository with no remote, so there is no shared repo to register.";
|
|
773
|
+
case "home":
|
|
774
|
+
return "is your home directory. popover will not treat it as a workspace — its children are usually every repo you have ever cloned rather than one project.";
|
|
775
|
+
case "filesystem-root":
|
|
776
|
+
return "is a filesystem root, which popover will not scan.";
|
|
777
|
+
case "too-many":
|
|
778
|
+
return `holds more repos than a workspace can publish (the limit is ${WORKSPACE_REPO_CAP}), so it is a code dump rather than a project. Run this from inside one of them instead.`;
|
|
779
|
+
case "unreadable":
|
|
780
|
+
return "could not be read.";
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function nothingHere(place, asJson) {
|
|
784
|
+
const why = place.kind === "none" ? place.why : "not-a-repo";
|
|
785
|
+
return fail(asJson, `no_repo_here:${why}`, `There is nothing here to register.`, [
|
|
786
|
+
`${tildify(place.dir)} ${noPlaceSentence(why)}`,
|
|
787
|
+
"Which repo you mean comes from where you are standing — registering one proves you hold",
|
|
788
|
+
"it rather than that you can name it, so there is deliberately no repo argument.",
|
|
789
|
+
]);
|
|
790
|
+
}
|
|
791
|
+
function placeJson(place) {
|
|
792
|
+
return {
|
|
793
|
+
cwd: place.dir,
|
|
794
|
+
kind: place.kind,
|
|
795
|
+
...(place.kind === "none" ? { why: place.why, message: noPlaceSentence(place.why) } : {}),
|
|
796
|
+
here: placeRepos(place).map((r) => ({
|
|
797
|
+
name: r.name,
|
|
798
|
+
remote: r.remote,
|
|
799
|
+
remotes: r.remotes,
|
|
800
|
+
shallow: r.shallow,
|
|
801
|
+
github: host(r.remote) === "github.com",
|
|
802
|
+
})),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
function viewJson(place, view) {
|
|
806
|
+
const rowFor = (remote) => view.repos.find((r) => r.remote === remote);
|
|
807
|
+
return {
|
|
808
|
+
teams: view.teams.map((t) => ({
|
|
809
|
+
id: t.id,
|
|
810
|
+
name: t.name,
|
|
811
|
+
isAdmin: t.isAdmin,
|
|
812
|
+
...(t.admins ? { admins: t.admins } : {}),
|
|
813
|
+
listed: view.repos.filter((r) => r.teams.includes(t.id)).map((r) => r.remote),
|
|
814
|
+
})),
|
|
815
|
+
// Every local repo, resolved against what the server knows, so the caller needs no
|
|
816
|
+
// second pass to work out what is addable.
|
|
817
|
+
status: placeRepos(place).map((repo) => {
|
|
818
|
+
const rows = repo.remotes.map(rowFor).filter((r) => r !== undefined);
|
|
819
|
+
return {
|
|
820
|
+
remote: repo.remote,
|
|
821
|
+
published: rows.length > 0,
|
|
822
|
+
onTeams: view.teams.filter((t) => rows.some((r) => r.teams.includes(t.id))).map((t) => t.id),
|
|
823
|
+
};
|
|
824
|
+
}),
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Which team to add to.
|
|
829
|
+
*
|
|
830
|
+
* Named explicitly, it is resolved against *every* team the caller is on — including ones they
|
|
831
|
+
* do not administer — so that a member who names their team gets the permission refusal rather
|
|
832
|
+
* than "no such team", which is §4's rule and the difference between a wall and a next step.
|
|
833
|
+
*
|
|
834
|
+
* Unnamed, the candidates are the teams they administer, because those are the ones an add can
|
|
835
|
+
* succeed on. One of them runs without asking; several prompt. Zero is not an ambiguity but a
|
|
836
|
+
* refusal, and it names the teams so the person can see which one to ask about.
|
|
837
|
+
*/
|
|
838
|
+
async function pickTeam(teams, opts) {
|
|
839
|
+
if (opts.team) {
|
|
840
|
+
const matches = resolveTeam(teams, opts.team);
|
|
841
|
+
if (matches.length === 1)
|
|
842
|
+
return { kind: "team", team: matches[0] };
|
|
843
|
+
const code = fail(opts.asJson, matches.length === 0 ? "no_such_team" : "ambiguous_team", matches.length === 0
|
|
844
|
+
? `You are not on a team called "${opts.team}".`
|
|
845
|
+
: `"${opts.team}" matches ${matches.map((t) => t.name).join(", ")}. Name one exactly.`, teams.map((t) => `${t.name}${t.isAdmin ? " (you administer it)" : ""}`));
|
|
846
|
+
return { kind: "failed", code };
|
|
847
|
+
}
|
|
848
|
+
const admin = teams.filter((t) => t.isAdmin);
|
|
849
|
+
if (admin.length === 0) {
|
|
850
|
+
// Every team, named, with its admins where we know them. A member of three teams should
|
|
851
|
+
// not have to guess which of the three this command was even talking about.
|
|
852
|
+
const lines = teams.flatMap((t) => {
|
|
853
|
+
const text = refusal("not_admin", { team: t, verb: "add" });
|
|
854
|
+
return [text.headline, ...text.hints];
|
|
855
|
+
});
|
|
856
|
+
const code = fail(opts.asJson, "not_admin", lines[0], lines.slice(1));
|
|
857
|
+
return { kind: "failed", code };
|
|
858
|
+
}
|
|
859
|
+
if (admin.length === 1)
|
|
860
|
+
return { kind: "team", team: admin[0] };
|
|
861
|
+
if (opts.asJson || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
862
|
+
// A choice, handed back rather than made. In `--json` this is what lets the model put the
|
|
863
|
+
// options in front of its user with `AskUserQuestion` instead of picking a team on their
|
|
864
|
+
// behalf — which is a decision about who can see whose agents.
|
|
865
|
+
const code = fail(opts.asJson, "ambiguous_team", `You administer ${admin.length} teams, so name the one to add to.`, admin.map((t) => `popover repos add ${t.name}`));
|
|
866
|
+
return { kind: "failed", code };
|
|
867
|
+
}
|
|
868
|
+
const shown = admin.slice(0, 9);
|
|
869
|
+
box({ title: "Add this repo to", lines: shown.map((t, i) => `${style.white(String(i + 1))} ${t.name}`) });
|
|
870
|
+
console.log("");
|
|
871
|
+
if (admin.length > shown.length) {
|
|
872
|
+
dim(`${admin.length - shown.length} more — name one instead: popover repos add <team>`);
|
|
873
|
+
console.log("");
|
|
874
|
+
}
|
|
875
|
+
if (teams.length > admin.length) {
|
|
876
|
+
// Never silently omit them: a list that shows one of your three teams looks like a bug in
|
|
877
|
+
// the roster rather than a statement about permissions.
|
|
878
|
+
dim(`${teams.length - admin.length} other ${teams.length - admin.length === 1 ? "team is" : "teams are"} not shown — you are not an admin of ${teams.length - admin.length === 1 ? "it" : "them"}.`);
|
|
879
|
+
console.log("");
|
|
880
|
+
}
|
|
881
|
+
const choice = await chooseNumber(shown.length);
|
|
882
|
+
return choice === null ? { kind: "cancelled" } : { kind: "team", team: shown[choice] };
|
|
883
|
+
}
|
|
884
|
+
/** Which listing to remove, when this directory is on more than one team's list. */
|
|
885
|
+
async function chooseListing(listings) {
|
|
886
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
887
|
+
bad("More than one team lists a repo here, and there is nothing to pick with.");
|
|
888
|
+
for (const l of listings)
|
|
889
|
+
dim(`popover repos rm ${l.team.name} (${l.remote})`);
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
const shown = listings.slice(0, 9);
|
|
893
|
+
box({
|
|
894
|
+
title: "Remove from",
|
|
895
|
+
lines: shown.map((l, i) => `${style.white(String(i + 1))} ${l.team.name} ${style.dim(l.remote)}`),
|
|
896
|
+
});
|
|
897
|
+
console.log("");
|
|
898
|
+
const choice = await chooseNumber(shown.length);
|
|
899
|
+
return choice === null ? null : shown[choice];
|
|
900
|
+
}
|
|
901
|
+
/** A digit, or null for anything meaning "leave it alone". Mirrors `popover rename`'s picker. */
|
|
902
|
+
async function chooseNumber(count) {
|
|
903
|
+
const range = count === 1 ? "1" : `1-${count}`;
|
|
904
|
+
process.stdout.write(style.dim(` ${range} to pick ${glyph.dot} q cancel `));
|
|
905
|
+
const keys = keyReader();
|
|
906
|
+
try {
|
|
907
|
+
for (;;) {
|
|
908
|
+
const key = await keys.next();
|
|
909
|
+
if (key === "q" || key === "escape" || key === "quit" || key === "enter")
|
|
910
|
+
return null;
|
|
911
|
+
const n = Number.parseInt(key, 10);
|
|
912
|
+
if (Number.isInteger(n) && n >= 1 && n <= count)
|
|
913
|
+
return n - 1;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
finally {
|
|
917
|
+
keys.close();
|
|
918
|
+
// Raw mode swallowed the echo of whatever ended this, so close the line ourselves.
|
|
919
|
+
console.log("");
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* The workspace confirmation: everything found, in one question.
|
|
924
|
+
*
|
|
925
|
+
* Defaults to yes. The person ran `add` in a directory whose children are the repos, which is
|
|
926
|
+
* as clear a statement of intent as this command ever gets — and the cost of a wrong yes is a
|
|
927
|
+
* repo on a list that an admin can remove, not something irreversible.
|
|
928
|
+
*/
|
|
929
|
+
async function confirmAll(plan, team, place, asJson) {
|
|
930
|
+
const names = plan.add.map((e) => e.repo.name);
|
|
931
|
+
if (asJson || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
932
|
+
// Not a prompt an agent may answer for its user. Handed back as a refusal carrying the
|
|
933
|
+
// list and the flag that would confirm it, so `/popover:repos` can ask a person.
|
|
934
|
+
fail(asJson, "needs_confirmation", `${tildify(place.dir)} contains ${plan.add.length} repos not on ${team.name}.`, [...names, `Re-run with --yes to add ${plan.add.length === 2 ? "both" : "all of them"}.`]);
|
|
935
|
+
return "cannot-ask";
|
|
936
|
+
}
|
|
937
|
+
console.log("");
|
|
938
|
+
console.log(` ${tildify(place.dir)} contains ${plan.add.length} ${plan.add.length === 1 ? "repo" : "repos"} not on ${team.name}:`);
|
|
939
|
+
console.log(` ${names.join(" ")}`);
|
|
940
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
941
|
+
try {
|
|
942
|
+
const answer = (await rl.question(` Add ${allOf(plan.add.length)}? [Y/n] `)).trim().toLowerCase();
|
|
943
|
+
return answer === "" || answer === "y" || answer === "yes" ? "yes" : "no";
|
|
944
|
+
}
|
|
945
|
+
finally {
|
|
946
|
+
rl.close();
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
/** "all three", "both", "all 12" — the shape the question is actually asked in. */
|
|
950
|
+
function allOf(count) {
|
|
951
|
+
if (count === 2)
|
|
952
|
+
return "both";
|
|
953
|
+
const words = ["", "", "", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
|
|
954
|
+
return `all ${words[count] ?? String(count)}`;
|
|
955
|
+
}
|
|
956
|
+
/** "Ana Oyelaran or Priya Raman" — an *or*, because asking either of them is enough. */
|
|
957
|
+
function humanList(names) {
|
|
958
|
+
if (names.length === 1)
|
|
959
|
+
return names[0];
|
|
960
|
+
if (names.length === 2)
|
|
961
|
+
return `${names[0]} or ${names[1]}`;
|
|
962
|
+
return `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`;
|
|
963
|
+
}
|
|
964
|
+
// ---------------------------------------------------------------------------
|
|
965
|
+
// Small shared pieces
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
/** `github.com` from `github.com/acme/backend`. */
|
|
968
|
+
function host(remote) {
|
|
969
|
+
return remote.split("/")[0] ?? remote;
|
|
970
|
+
}
|
|
971
|
+
/** `acme/backend` — what GitHub calls it, and what an error should name. */
|
|
972
|
+
function shortRepo(remote) {
|
|
973
|
+
const parts = remote.split("/");
|
|
974
|
+
return parts.length > 1 ? parts.slice(1).join("/") : remote;
|
|
975
|
+
}
|
|
976
|
+
/** `acme` — the organization whose owner has to approve us. */
|
|
977
|
+
function owner(remote) {
|
|
978
|
+
return remote.split("/")[1] ?? "that";
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* The origin to talk to, and to send people to.
|
|
982
|
+
*
|
|
983
|
+
* `defaultApiUrl()` rather than `credentials.api_url` directly, so `POPOVER_API_URL` points
|
|
984
|
+
* this at a dev checkout or a preview the same way it does `popover fork`. It still falls back
|
|
985
|
+
* to the origin this machine signed in to, which is what makes the printed dashboard link land
|
|
986
|
+
* somewhere the reader is already signed in.
|
|
987
|
+
*/
|
|
988
|
+
function dashboard() {
|
|
989
|
+
return defaultApiUrl().replace(/\/+$/, "");
|
|
990
|
+
}
|
|
991
|
+
/** The two failure shapes, from one call. Mirrors `popover fork`'s `fail`. */
|
|
992
|
+
function fail(asJson, code, message, hints) {
|
|
993
|
+
if (asJson) {
|
|
994
|
+
console.log(JSON.stringify({ ok: false, error: code, message, hints }));
|
|
995
|
+
return 1;
|
|
996
|
+
}
|
|
997
|
+
bad(message);
|
|
998
|
+
for (const hint of hints)
|
|
999
|
+
dim(hint);
|
|
1000
|
+
return 1;
|
|
1001
|
+
}
|
|
1002
|
+
//# sourceMappingURL=repos.js.map
|