passalong 0.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Hand tasks, bugs and finished work to AI coding agents, and get back a write-up with evidence.",
5
5
  "mcpName": "io.github.codellyson/passalong",
6
6
  "keywords": [
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(`${baseUrl()}${path}`, {
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 ${baseUrl()} (${err.message})`);
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 ${baseUrl()} (${err.message})`);
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 ${baseUrl()} (${err.message})`);
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/passalong.js CHANGED
@@ -1181,26 +1181,70 @@ export async function exportAll(dir) {
1181
1181
  });
1182
1182
  }
1183
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
+
1184
1199
  /**
1185
1200
  * Once per machine, signed in: drop the local copies the server says it holds. They were written
1186
- * by every share, pull and take before `keep()`, and nothing ever removed one. What the server
1187
- * does not return — shared while signed out, or from another account — is left where it is.
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.
1188
1206
  */
1189
1207
  export async function tidy() {
1190
- if (!api.loggedIn() || store.readConfig().tidied) return 0;
1191
- const local = new Set(store.list().map((g) => g.meta.id));
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)]));
1192
1211
  let removed = 0;
1193
- let after = "";
1194
- while (local.size) {
1195
- const page = await api.exportPage(after);
1196
- for (const g of page.guides)
1197
- if (local.delete(g.id)) {
1198
- store.remove(g.id);
1199
- removed++;
1200
- }
1201
- after = page.next;
1202
- if (!after) break;
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
+ );
1203
1245
  }
1204
- store.writeConfig({ tidied: true });
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 });
1205
1249
  return removed;
1206
1250
  }