passalong 0.19.0 → 0.21.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/package.json +1 -1
- package/src/api.js +24 -10
- package/src/hooks.js +49 -23
- package/src/mcp.js +14 -9
- package/src/passalong.js +66 -23
- package/src/update.js +27 -3
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -120,6 +120,19 @@ export function loggedIn() {
|
|
|
120
120
|
return Boolean(token());
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Every login this machine holds, as `{ token, api }` for a call's `as`: each kept account and the
|
|
125
|
+
* one in use, once each. For work that belongs to all of them, like `tidy`, rather than to one.
|
|
126
|
+
*/
|
|
127
|
+
export function logins() {
|
|
128
|
+
const all = Object.values(readAccounts())
|
|
129
|
+
.filter((a) => a.token)
|
|
130
|
+
.map((a) => ({ token: a.token, api: a.api || readConfig().api || DEFAULT_API }));
|
|
131
|
+
if (token()) all.push({ token: token(), api: baseUrl() });
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
return all.filter((a) => !seen.has(`${a.token} ${a.api}`) && seen.add(`${a.token} ${a.api}`));
|
|
134
|
+
}
|
|
135
|
+
|
|
123
136
|
/** The team this account works in. It belongs to the account, not the machine. */
|
|
124
137
|
export function currentTeam() {
|
|
125
138
|
const p = picked();
|
|
@@ -149,12 +162,12 @@ export { nameFor };
|
|
|
149
162
|
|
|
150
163
|
async function call(
|
|
151
164
|
path,
|
|
152
|
-
{ method = "GET", body, auth = true, raw = false, uploadName = "" } = {},
|
|
165
|
+
{ method = "GET", body, auth = true, raw = false, uploadName = "", as = null } = {},
|
|
153
166
|
) {
|
|
154
167
|
const headers = { ...CLIENT };
|
|
155
168
|
if (auth) {
|
|
156
|
-
if (needsChoice()) throw new ApiError(409, choiceMessage());
|
|
157
|
-
const t = token();
|
|
169
|
+
if (!as && needsChoice()) throw new ApiError(409, choiceMessage());
|
|
170
|
+
const t = as ? as.token : token();
|
|
158
171
|
if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
|
|
159
172
|
headers.authorization = `Bearer ${t}`;
|
|
160
173
|
}
|
|
@@ -166,9 +179,10 @@ async function call(
|
|
|
166
179
|
: "application/json";
|
|
167
180
|
}
|
|
168
181
|
if (uploadName) headers["x-file-name"] = uploadName.replace(/[^\x20-\x7e]/g, "").slice(0, 120);
|
|
182
|
+
const base = as ? as.api.replace(/\/$/, "") : baseUrl();
|
|
169
183
|
let res;
|
|
170
184
|
try {
|
|
171
|
-
res = await fetch(`${
|
|
185
|
+
res = await fetch(`${base}${path}`, {
|
|
172
186
|
method,
|
|
173
187
|
headers,
|
|
174
188
|
body:
|
|
@@ -179,7 +193,7 @@ async function call(
|
|
|
179
193
|
: JSON.stringify(body),
|
|
180
194
|
});
|
|
181
195
|
} catch (err) {
|
|
182
|
-
throw new ApiError(0, `could not reach ${
|
|
196
|
+
throw new ApiError(0, `could not reach ${base} (${err.message})`);
|
|
183
197
|
}
|
|
184
198
|
if (!res.ok) {
|
|
185
199
|
const text = await res.text();
|
|
@@ -379,10 +393,10 @@ export const inbox = () => call("/v1/inbox");
|
|
|
379
393
|
export const createReport = (body) => call("/v1/reports", { method: "POST", body });
|
|
380
394
|
/** One report and the issues filed under it, grouped by product area. */
|
|
381
395
|
export const report = (id) => call(`/v1/reports/${encodeURIComponent(id)}`);
|
|
382
|
-
export const get = (id, { peek = false } = {}) =>
|
|
383
|
-
call(`/v1/guides/${id}${q({ peek: peek ? "1" : "" })}`, { raw: true });
|
|
396
|
+
export const get = (id, { peek = false, as = null } = {}) =>
|
|
397
|
+
call(`/v1/guides/${id}${q({ peek: peek ? "1" : "" })}`, { raw: true, as });
|
|
384
398
|
/** One page of every guide this account can read, for `export`. Records no pull. */
|
|
385
|
-
export const exportPage = (after = "") => call(`/v1/export${q({ after })}
|
|
399
|
+
export const exportPage = (after = "", as = null) => call(`/v1/export${q({ after })}`, { as });
|
|
386
400
|
/**
|
|
387
401
|
* A guide's follow-ups, one level down. `markdown` adds each one's content, oldest first and
|
|
388
402
|
* capped server-side; neither form counts as opening them.
|
|
@@ -485,7 +499,7 @@ export async function uploadFile(bytes, type, name = "") {
|
|
|
485
499
|
try {
|
|
486
500
|
res = await fetch(`${baseUrl()}/v1/attachments`, { method: "POST", headers, body: bytes });
|
|
487
501
|
} catch (err) {
|
|
488
|
-
throw new ApiError(0, `could not reach ${
|
|
502
|
+
throw new ApiError(0, `could not reach ${base} (${err.message})`);
|
|
489
503
|
}
|
|
490
504
|
if (!res.ok) {
|
|
491
505
|
const body = await res.text();
|
|
@@ -509,7 +523,7 @@ export async function uploadShot(bytes, type, name = "") {
|
|
|
509
523
|
try {
|
|
510
524
|
res = await fetch(`${baseUrl()}/v1/shots`, { method: "POST", headers, body: bytes });
|
|
511
525
|
} catch (err) {
|
|
512
|
-
throw new ApiError(0, `could not reach ${
|
|
526
|
+
throw new ApiError(0, `could not reach ${base} (${err.message})`);
|
|
513
527
|
}
|
|
514
528
|
if (!res.ok) {
|
|
515
529
|
const body = await res.text();
|
package/src/hooks.js
CHANGED
|
@@ -21,28 +21,47 @@ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
|
21
21
|
/**
|
|
22
22
|
* What this worktree's agent holds and what is waiting, as a few lines for an agent to read.
|
|
23
23
|
* Empty when there is nothing to say, so a session in an unrelated repo is not told anything.
|
|
24
|
+
*
|
|
25
|
+
* `held` is a list: an agent may hold several things, and several sessions in one worktree are one
|
|
26
|
+
* agent to the queue. So a session is told what the worktree holds, and to carry on only with what
|
|
27
|
+
* was its own before a restart — never to resume another session's work.
|
|
24
28
|
*/
|
|
25
|
-
export function nowText({ held, waiting = {} }) {
|
|
26
|
-
if (held) {
|
|
29
|
+
export function nowText({ held = [], waiting = {} }) {
|
|
30
|
+
if (held.length === 1) {
|
|
31
|
+
const [h] = held;
|
|
27
32
|
const lines = [
|
|
28
33
|
// `kind` is required on what /v1/working sends. The `|| "task"` that used to be here was
|
|
29
34
|
// both dead and wrong — absent has meant transfer since kinds existed, never task.
|
|
30
|
-
`Passalong:
|
|
35
|
+
`Passalong: This worktree holds ${h.id} (${h.kind}): ${h.title || h.id}.`,
|
|
31
36
|
];
|
|
32
|
-
if (
|
|
37
|
+
if (h.note) lines.push(`Last note: "${h.note}".`);
|
|
33
38
|
// The one thing here that is new information rather than a reminder: the person has written, and
|
|
34
39
|
// nothing else in the session will say so until the agent next checks in.
|
|
35
|
-
if (
|
|
40
|
+
if (h.replies)
|
|
36
41
|
lines.push(
|
|
37
|
-
`${plural(
|
|
38
|
-
`waiting
|
|
42
|
+
`${plural(h.replies, "message", "messages")} from the person ${h.replies === 1 ? "is" : "are"} ` +
|
|
43
|
+
`waiting: call take ${h.id} to read ${h.replies === 1 ? "it" : "them"} before anything else.`,
|
|
39
44
|
);
|
|
40
|
-
if (
|
|
45
|
+
if (h.state === "stalled")
|
|
41
46
|
lines.push("It is marked stalled: nobody has heard from its agent in 30 minutes.");
|
|
42
47
|
lines.push(
|
|
43
|
-
`
|
|
44
|
-
"milestone. When it is done, hand_in with evidence — what you ran and
|
|
45
|
-
"so keep it as you go; if it is not yours or you are stuck, pass with
|
|
48
|
+
`If it was yours before this session restarted, call take ${h.id} to carry on, then ` +
|
|
49
|
+
"progress at each milestone. When it is done, hand_in with evidence — what you ran and " +
|
|
50
|
+
"what came back — so keep it as you go; if it is not yours or you are stuck, pass with " +
|
|
51
|
+
"the reason. If another session here is on it, leave it to that session.",
|
|
52
|
+
);
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
if (held.length > 1) {
|
|
56
|
+
const lines = [`Passalong: This worktree holds ${held.length} things:`];
|
|
57
|
+
for (const h of held)
|
|
58
|
+
lines.push(
|
|
59
|
+
`- ${h.id} (${h.kind}): ${h.title || h.id}${h.state === "stalled" ? " — stalled" : ""}` +
|
|
60
|
+
`${h.replies ? ` — ${plural(h.replies, "message", "messages")} waiting` : ""}`,
|
|
61
|
+
);
|
|
62
|
+
lines.push(
|
|
63
|
+
"Several sessions may be working here. Carry on only with what was yours before this " +
|
|
64
|
+
"session restarted (take <id> resumes it) and leave the rest to the sessions on them.",
|
|
46
65
|
);
|
|
47
66
|
return lines.join("\n");
|
|
48
67
|
}
|
|
@@ -59,19 +78,22 @@ export function nowText({ held, waiting = {} }) {
|
|
|
59
78
|
* Whether a session may stop. `input` is what Claude Code sends the Stop hook on stdin. Null lets
|
|
60
79
|
* it stop; an object is the hook's answer, blocking once with what to call. `stop_hook_active`
|
|
61
80
|
* means this stop is already the session continuing because of this hook, and blocking again
|
|
62
|
-
* would leave it no way to end.
|
|
81
|
+
* would leave it no way to end. The hook cannot tell which session took what, so it names
|
|
82
|
+
* everything still open here and lets the session settle its own.
|
|
63
83
|
*/
|
|
64
|
-
export function stopVerdict({ held, input = {} }) {
|
|
84
|
+
export function stopVerdict({ held = [], input = {} }) {
|
|
65
85
|
// An agent that asked a question was told to stop and wait, so it must be let: blocking it would
|
|
66
86
|
// make the instruction and the hook contradict each other, and the agent would pick one.
|
|
67
|
-
|
|
87
|
+
const open = held.filter((h) => !h.asking);
|
|
88
|
+
if (!open.length || input.stop_hook_active) return null;
|
|
89
|
+
const ids = open.map((h) => h.id).join(", ");
|
|
68
90
|
return {
|
|
69
91
|
decision: "block",
|
|
70
92
|
reason:
|
|
71
|
-
`
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"
|
|
93
|
+
`This worktree still holds ${ids}. For anything there you worked on in this session, do ` +
|
|
94
|
+
"one of before ending: hand_in it with your evidence if it is done; pass it with the reason " +
|
|
95
|
+
"if it is not yours or you are stuck; or, if the user is stopping mid-way, progress it with " +
|
|
96
|
+
"a note saying what is left. Leave what other sessions are on — then end.",
|
|
75
97
|
};
|
|
76
98
|
}
|
|
77
99
|
|
|
@@ -112,15 +134,19 @@ const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
|
112
134
|
* `needs` counts everything waiting on the person: work handed in for review, agents stuck on
|
|
113
135
|
* them, and guides handed to them.
|
|
114
136
|
*/
|
|
115
|
-
export function statusText({ held, needs = 0, ready = 0 }) {
|
|
137
|
+
export function statusText({ held = [], needs = 0, ready = 0 }) {
|
|
116
138
|
const parts = [];
|
|
117
|
-
if (held) {
|
|
118
|
-
const
|
|
119
|
-
|
|
139
|
+
if (held.length === 1) {
|
|
140
|
+
const [h] = held;
|
|
141
|
+
const said = h.note ? ` "${clip(h.note, 32)}"` : "";
|
|
142
|
+
parts.push(`▸ ${h.id}${h.state === "stalled" ? " (stalled)" : ""}${said}`);
|
|
143
|
+
} else if (held.length > 1) {
|
|
144
|
+
const stalled = held.filter((h) => h.state === "stalled").length;
|
|
145
|
+
parts.push(`▸ ${held.length} held${stalled ? ` (${stalled} stalled)` : ""}`);
|
|
120
146
|
} else parts.push("passalong");
|
|
121
147
|
if (needs) parts.push(`${needs} need${needs === 1 ? "s" : ""} you`);
|
|
122
148
|
if (ready) parts.push(`${ready} ready`);
|
|
123
|
-
if (parts.length === 1 && !held) parts.push("clear");
|
|
149
|
+
if (parts.length === 1 && !held.length) parts.push("clear");
|
|
124
150
|
return parts.join(" · ");
|
|
125
151
|
}
|
|
126
152
|
|
package/src/mcp.js
CHANGED
|
@@ -334,8 +334,8 @@ export function buildServer() {
|
|
|
334
334
|
"session into a guide (guide_template kind transfer shows the shape) and call " +
|
|
335
335
|
"publish_guide, with `to` as team, team/@handle for one teammate, or team/#group for the " +
|
|
336
336
|
"people who do a thing. " +
|
|
337
|
-
"BEFORE PUBLISHING ANYTHING, CHECK WHAT IS ALREADY OPEN: call
|
|
338
|
-
"
|
|
337
|
+
"BEFORE PUBLISHING ANYTHING, CHECK WHAT IS ALREADY OPEN: call board (it lists what this " +
|
|
338
|
+
"worktree holds) or read the inbox. If this session's work answers something you hold, hand_in " +
|
|
339
339
|
"that — never publish a second guide about it. A transfer guide is for work that has to " +
|
|
340
340
|
"cross a boundary: another repo, another machine, a teammate without your branch. Work you " +
|
|
341
341
|
"committed and pushed where the team can already see it has crossed no boundary, and a " +
|
|
@@ -944,12 +944,17 @@ export function buildServer() {
|
|
|
944
944
|
"The state of this user's transfers as queues: waiting on you (handed to you, not " +
|
|
945
945
|
"pulled), not working (someone gave it a failing verdict — the most urgent), in flight " +
|
|
946
946
|
"(handed over, nobody has taken it — `stale` means it has sat over a week), and landed " +
|
|
947
|
-
"(someone else has it). Use it to answer
|
|
947
|
+
"(someone else has it), and `held`: what this worktree's agents hold now. Use it to answer " +
|
|
948
|
+
"'what is outstanding?' and to see what you hold, without taking anything.",
|
|
948
949
|
inputSchema: {},
|
|
949
950
|
},
|
|
950
951
|
async () => {
|
|
951
952
|
try {
|
|
952
|
-
|
|
953
|
+
const [board, now] = await Promise.all([
|
|
954
|
+
passalong.board(),
|
|
955
|
+
passalong.now().catch(() => ({ held: [] })),
|
|
956
|
+
]);
|
|
957
|
+
return json({ ...board, held: now.held });
|
|
953
958
|
} catch (err) {
|
|
954
959
|
return fail(err);
|
|
955
960
|
}
|
|
@@ -1100,9 +1105,9 @@ export function buildServer() {
|
|
|
1100
1105
|
annotations: ADDS,
|
|
1101
1106
|
description:
|
|
1102
1107
|
"Say you are doing it, and get it. With `id`, that guide — any kind: a task, a bug, a " +
|
|
1103
|
-
"handoff. With no id, the next thing waiting
|
|
1104
|
-
"no other agent can take it here, and its sender sees you are on it.
|
|
1105
|
-
"
|
|
1108
|
+
"handoff. With no id, the next thing waiting — always something new, never what you hold. " +
|
|
1109
|
+
"While you hold it no other agent can take it here, and its sender sees you are on it. You " +
|
|
1110
|
+
"may hold several at once; the person watches them on the board. If somebody else has it, the " +
|
|
1106
1111
|
"answer says who — tell the user rather than doing the work twice. Every answer ends with " +
|
|
1107
1112
|
"what to call next.",
|
|
1108
1113
|
inputSchema: {
|
|
@@ -1525,8 +1530,8 @@ export function buildServer() {
|
|
|
1525
1530
|
title: "Publish guide",
|
|
1526
1531
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
1527
1532
|
description:
|
|
1528
|
-
"Publish a guide from markdown (frontmatter + sections). Check what you hold first
|
|
1529
|
-
"
|
|
1533
|
+
"Publish a guide from markdown (frontmatter + sections). Check what you hold first " +
|
|
1534
|
+
"(board): if this session answers something you hold, hand_in that instead, and if the " +
|
|
1530
1535
|
"work never left a branch the team can see, publish what is still open as a bug or a task " +
|
|
1531
1536
|
"rather than a write-up of the fix. A transfer guide by default, or " +
|
|
1532
1537
|
"a single bug with `kind: bug`; use file_bugs for more than one; or a task for work nobody " +
|
package/src/passalong.js
CHANGED
|
@@ -264,7 +264,7 @@ export async function tasks() {
|
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
/**
|
|
267
|
-
* Take the next task for this worktree's repo
|
|
267
|
+
* Take the next task for this worktree's repo — always a new one, since an agent may hold several.
|
|
268
268
|
* `null` when there is nothing to do. Written to `.passalong/<id>.md` only when `save` asks.
|
|
269
269
|
*/
|
|
270
270
|
export async function nextTask({ cwd = process.cwd(), any = false, save = false } = {}) {
|
|
@@ -542,7 +542,7 @@ export async function ask(id, question, { cwd = process.cwd() } = {}) {
|
|
|
542
542
|
|
|
543
543
|
/**
|
|
544
544
|
* What this worktree's agent holds right now, and what is waiting for it: what the session-start
|
|
545
|
-
* and stop hooks read (src/hooks.js). `held`
|
|
545
|
+
* and stop hooks read (src/hooks.js). `held` lists everything it holds, empty when nothing. Counts are best effort
|
|
546
546
|
* — a hook must never fail a session over a count.
|
|
547
547
|
*/
|
|
548
548
|
export async function now({ cwd = process.cwd() } = {}) {
|
|
@@ -557,8 +557,7 @@ export async function now({ cwd = process.cwd() } = {}) {
|
|
|
557
557
|
api.handedIn(),
|
|
558
558
|
]);
|
|
559
559
|
if (w.status === "rejected") throw w.reason;
|
|
560
|
-
const held =
|
|
561
|
-
(who.agent && w.value.working.find((x) => x.agent === who.agent && x.by?.you)) || null;
|
|
560
|
+
const held = who.agent ? w.value.working.filter((x) => x.agent === who.agent && x.by?.you) : [];
|
|
562
561
|
const tasks = t.status === "fulfilled" ? t.value.tasks : [];
|
|
563
562
|
const waiting = {
|
|
564
563
|
ready: tasks.filter((x) => x.state === "ready" && x.target === who.repo).length,
|
|
@@ -1099,9 +1098,8 @@ export function workPrompt(t) {
|
|
|
1099
1098
|
* hand_in land on the claim this took. `agent` is a command the prompt is appended to;
|
|
1100
1099
|
* `claude -p` by default, or whatever PASSALONG_AGENT says.
|
|
1101
1100
|
*
|
|
1102
|
-
* An agent that exits without finishing stops the loop
|
|
1103
|
-
*
|
|
1104
|
-
* forever. The task stays claimed here, for the next run to resume or a person to release.
|
|
1101
|
+
* An agent that exits without finishing stops the loop, rather than piling up half-done work. The
|
|
1102
|
+
* task stays claimed here, for a person to see on the board and resume (take it) or release.
|
|
1105
1103
|
*/
|
|
1106
1104
|
export async function work({
|
|
1107
1105
|
cwd = process.cwd(),
|
|
@@ -1122,8 +1120,9 @@ export async function work({
|
|
|
1122
1120
|
stdio: "inherit",
|
|
1123
1121
|
env: { ...process.env, PASSALONG_TASK: t.id },
|
|
1124
1122
|
});
|
|
1123
|
+
// Finishing is final, so a finished task is Done; "review" is a server from before that.
|
|
1125
1124
|
const state = (await tasks()).find((x) => x.id === t.id)?.state;
|
|
1126
|
-
if (state !== "review")
|
|
1125
|
+
if (state !== "done" && state !== "review")
|
|
1127
1126
|
return {
|
|
1128
1127
|
finished,
|
|
1129
1128
|
stopped: "unfinished",
|
|
@@ -1182,26 +1181,70 @@ export async function exportAll(dir) {
|
|
|
1182
1181
|
});
|
|
1183
1182
|
}
|
|
1184
1183
|
|
|
1184
|
+
// The host this product answered on before passalong.dev. It serves the same data, so a copy
|
|
1185
|
+
// whose url names it belongs to the default API.
|
|
1186
|
+
const OLD_HOST = "passalong.kreativekorna.com";
|
|
1187
|
+
const hostOf = (url) => {
|
|
1188
|
+
try {
|
|
1189
|
+
const { host, hostname } = new URL(url);
|
|
1190
|
+
if (["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return "";
|
|
1191
|
+
return host === OLD_HOST ? new URL(api.DEFAULT_API).host : host;
|
|
1192
|
+
} catch {
|
|
1193
|
+
return "";
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
// Raised whenever tidy learns to remove something it used to keep, so machines that tidied run it once more.
|
|
1197
|
+
const TIDIED = 2;
|
|
1198
|
+
|
|
1185
1199
|
/**
|
|
1186
1200
|
* Once per machine, signed in: drop the local copies the server says it holds. They were written
|
|
1187
|
-
* by every share, pull and take before `keep()`, and nothing ever removed one.
|
|
1188
|
-
*
|
|
1201
|
+
* by every share, pull and take before `keep()`, and nothing ever removed one. Every login on the
|
|
1202
|
+
* machine is asked, since a copy may be any of theirs. A copy whose `url:` is on a login's own host
|
|
1203
|
+
* and that the server answers 404 for is a guide since deleted, and goes too. Anything else — no
|
|
1204
|
+
* url, localhost, another host, or an answer that was neither 200 nor 404 — is left where it is,
|
|
1205
|
+
* and an export that failed leaves the flag unset so the next start tries again.
|
|
1189
1206
|
*/
|
|
1190
1207
|
export async function tidy() {
|
|
1191
|
-
|
|
1192
|
-
|
|
1208
|
+
const logins = api.logins();
|
|
1209
|
+
if (!logins.length || store.readConfig().tidied === TIDIED) return 0;
|
|
1210
|
+
const local = new Map(store.list().map((g) => [g.meta.id, hostOf(g.meta.url)]));
|
|
1193
1211
|
let removed = 0;
|
|
1194
|
-
let
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1212
|
+
let sure = true;
|
|
1213
|
+
const drop = (id) => {
|
|
1214
|
+
if (!local.delete(id)) return;
|
|
1215
|
+
store.remove(id);
|
|
1216
|
+
removed++;
|
|
1217
|
+
};
|
|
1218
|
+
for (const as of logins) {
|
|
1219
|
+
try {
|
|
1220
|
+
let after = "";
|
|
1221
|
+
do {
|
|
1222
|
+
const page = await api.exportPage(after, as);
|
|
1223
|
+
for (const g of page.guides) drop(g.id);
|
|
1224
|
+
after = page.next;
|
|
1225
|
+
} while (after && local.size);
|
|
1226
|
+
} catch {
|
|
1227
|
+
sure = false;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
const said = new Map();
|
|
1231
|
+
for (const as of logins) {
|
|
1232
|
+
const host = hostOf(as.api);
|
|
1233
|
+
const ids = [...local].filter(([, h]) => h && h === host).map(([id]) => id);
|
|
1234
|
+
// Eight at a time: a few hundred copies is a few seconds, not a few hundred open requests.
|
|
1235
|
+
for (let i = 0; i < ids.length; i += 8)
|
|
1236
|
+
await Promise.all(
|
|
1237
|
+
ids.slice(i, i + 8).map(async (id) => {
|
|
1238
|
+
const status = await api.get(id, { peek: true, as }).then(
|
|
1239
|
+
() => 200,
|
|
1240
|
+
(err) => err.status ?? 0,
|
|
1241
|
+
);
|
|
1242
|
+
said.set(id, [...(said.get(id) ?? []), status]);
|
|
1243
|
+
}),
|
|
1244
|
+
);
|
|
1204
1245
|
}
|
|
1205
|
-
|
|
1246
|
+
for (const [id, statuses] of said)
|
|
1247
|
+
if (statuses.includes(200) || statuses.every((s) => s === 404)) drop(id);
|
|
1248
|
+
if (sure) store.writeConfig({ tidied: TIDIED });
|
|
1206
1249
|
return removed;
|
|
1207
1250
|
}
|
package/src/update.js
CHANGED
|
@@ -15,7 +15,31 @@
|
|
|
15
15
|
// feature would have shipped dead while looking fine in review. So the line is read from a file and
|
|
16
16
|
// the file is refreshed by a process nobody waits for. What a person sees is a day old at worst,
|
|
17
17
|
// which is the right age for "there is a new version".
|
|
18
|
-
|
|
18
|
+
//
|
|
19
|
+
// The answer is kept in its own file, never in config.json. The refresh runs beside whatever
|
|
20
|
+
// command started it, and config.json is read, patched and written whole: a refresh that read it
|
|
21
|
+
// just before `logout` or `use` wrote it, and wrote it back after, put the old accounts and the old
|
|
22
|
+
// default back. This file has one writer, so there is nothing to lose.
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { HOME } from "./store.js";
|
|
26
|
+
|
|
27
|
+
const FILE = join(HOME, "update.json");
|
|
28
|
+
|
|
29
|
+
function readUpdate() {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(FILE, "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeUpdate(held) {
|
|
38
|
+
if (!existsSync(HOME)) mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
39
|
+
const tmp = `${FILE}.${process.pid}.tmp`;
|
|
40
|
+
writeFileSync(tmp, `${JSON.stringify(held)}\n`, { mode: 0o600 });
|
|
41
|
+
renameSync(tmp, FILE);
|
|
42
|
+
}
|
|
19
43
|
|
|
20
44
|
/** How long a cached answer lasts before a refresh is worth starting. */
|
|
21
45
|
export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -70,7 +94,7 @@ export function cachedNotice({
|
|
|
70
94
|
current,
|
|
71
95
|
now = Date.now(),
|
|
72
96
|
env = process.env,
|
|
73
|
-
config =
|
|
97
|
+
config = readUpdate,
|
|
74
98
|
}) {
|
|
75
99
|
if (env.PASSALONG_NO_UPDATE_CHECK === "1") return { notice: "", stale: false };
|
|
76
100
|
const held = config();
|
|
@@ -90,7 +114,7 @@ export function cachedNotice({
|
|
|
90
114
|
* trying again — `cachedNotice` reads an empty `update_latest` as a failure and uses the shorter
|
|
91
115
|
* gap.
|
|
92
116
|
*/
|
|
93
|
-
export async function refresh({ now = Date.now(), fetchImpl = fetch, save =
|
|
117
|
+
export async function refresh({ now = Date.now(), fetchImpl = fetch, save = writeUpdate } = {}) {
|
|
94
118
|
let latest = "";
|
|
95
119
|
try {
|
|
96
120
|
const res = await fetchImpl(REGISTRY, {
|