passalong 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,11 @@ passalong archive <id> off your board and out of the free tier's count (w
42
42
  passalong promote <id> deprecated: mark promoted
43
43
  passalong rm <id> delete a guide locally and from sync
44
44
  passalong export [dir] dump every guide as plain markdown
45
- passalong login [token] sign in with your email and password, or attach this machine with a token
45
+ passalong login [token] sign in from the browser, or attach this machine with a token
46
+ passalong login --new add another account to this machine
47
+ passalong accounts the accounts signed in here, and which is the default
48
+ passalong use <name> make one of them the default
49
+ passalong logout <name> forget one of them
46
50
  passalong me [--handle H] [--name N] [--email E] who you are to teammates
47
51
  passalong team current team and its members
48
52
  passalong team create <name> start a team (you become its owner)
@@ -104,3 +108,15 @@ Plain markdown with frontmatter in `~/.passalong/guides`. Yours to edit, grep, a
104
108
  `passalong export` dumps everything. Works with no account; `passalong login` adds sync and share links.
105
109
 
106
110
  Point at a self-hosted server with `PASSALONG_API=https://your-host` before `passalong login`.
111
+
112
+ ## More than one account
113
+
114
+ A machine can be signed in to several accounts, say a work one and a personal one.
115
+ `passalong login --new` adds another beside the first, `passalong accounts` lists them, and
116
+ `passalong use <name>` picks the default a person at a terminal gets.
117
+
118
+ An agent has no terminal to be asked at, so with two or more accounts and nothing saying which, it
119
+ is refused and told to ask you. It puts the question to you in plain words, you answer, and it calls
120
+ `use_account` with the name. That lasts for the session and moves nothing on disk. From a shell,
121
+ `--as <name>` or `PASSALONG_ACCOUNT=<name>` says the same for one command. The team you work in
122
+ (`passalong team use`) belongs to the account, not the machine.
package/bin/passalong CHANGED
@@ -40,6 +40,10 @@
40
40
  // passalong rm <id> delete a guide locally and from sync
41
41
  // passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
42
42
  // passalong login [token] sign in from the browser, or attach this machine with a token
43
+ // passalong login --new add another account to this machine
44
+ // passalong accounts the accounts signed in here, and which is the default
45
+ // passalong use <name> make one of them the default
46
+ // passalong logout <name> forget one of them
43
47
  // passalong me [--handle H] [--name N] [--email E] who you are to teammates
44
48
  // passalong team current team and its members
45
49
  // passalong team create <name> start a team (you become its owner)
@@ -107,6 +111,40 @@ function openPage(url) {
107
111
  } catch {}
108
112
  }
109
113
 
114
+ /**
115
+ * Keep a login under a name, and say what that did. The first one on a machine is the default; one
116
+ * added beside it is not, because a default that changes by itself is how an agent ends up acting
117
+ * as the wrong person. A machine that has only the older single login gets that one named first,
118
+ * so adding a second does not lose it.
119
+ */
120
+ async function keepLogin(token, base = api.baseUrl()) {
121
+ const who = await api.meWith(token, base).catch((e) => die(`token rejected: ${e.message}`));
122
+ const cfg = store.readConfig();
123
+ if (cfg.token && !cfg.accounts && cfg.token !== token) {
124
+ const old = await api.meWith(cfg.token, cfg.api || base).catch(() => null);
125
+ store.saveAccount(
126
+ { token: cfg.token, api: cfg.api || base, account: old?.account || "", handle: old?.handle || "", email: old?.email || "", team: cfg.team || "" },
127
+ { activate: true },
128
+ );
129
+ }
130
+ const before = Object.keys(store.readAccounts()).length;
131
+ const name = store.saveAccount(
132
+ { token, api: base, account: who.account, handle: who.handle || "", email: who.email || "" },
133
+ { activate: before === 0 },
134
+ );
135
+ if (!api.currentTeam() && who.teams?.length === 1) store.saveTeam(name, who.teams[0].slug);
136
+ const now = store.readConfig();
137
+ const count = Object.keys(store.readAccounts(now)).length;
138
+ console.error(`signed in as ${name} (account ${who.account}) on ${base}. Sync is on.`);
139
+ if (count > 1)
140
+ console.error(
141
+ `${count} accounts are signed in here; the default is ${now.active}. ` +
142
+ (now.active === name ? "" : `Switch it with \`passalong use ${name}\`. `) +
143
+ "Agents are asked which to use each session.",
144
+ );
145
+ return { name, who };
146
+ }
147
+
110
148
  /**
111
149
  * Sign in by approving in the browser: show a code, wait, and be handed a token. No password is
112
150
  * typed here and no token is pasted between windows. Needs no terminal to ask on, so an agent can
@@ -125,8 +163,7 @@ async function browserLogin() {
125
163
  await new Promise((r) => setTimeout(r, start.interval * 1000));
126
164
  const got = await api.devicePoll(start.id, verifier).catch((e) => die(e.message));
127
165
  if (!got) continue;
128
- store.writeConfig({ token: got.token, api: api.baseUrl() });
129
- console.error(`signed in (account ${got.account}) on ${api.baseUrl()}. Sync is on.`);
166
+ await keepLogin(got.token);
130
167
  console.error("Attach other machines with:");
131
168
  console.error(` passalong login ${got.token}`);
132
169
  console.error("(keep it private — it is a credential, not a password)");
@@ -166,8 +203,7 @@ async function passwordLogin(f) {
166
203
  who = await api.signIn(email, password, { make: true, label }).catch((e) => die(e.message));
167
204
  }
168
205
 
169
- store.writeConfig({ token: who.token, api: api.baseUrl() });
170
- console.error(`signed in as ${email} (account ${who.account}) on ${api.baseUrl()}. Sync is on.`);
206
+ await keepLogin(who.token);
171
207
  console.error("Attach other machines with:");
172
208
  console.error(` passalong login ${who.token}`);
173
209
  console.error("(keep it private — it is a credential, not a password)");
@@ -356,6 +392,7 @@ const commands = {
356
392
  console.log(id);
357
393
  const target = guide.meta.target_context;
358
394
  console.error(`task "${guide.meta.title}" is in Draft${target ? ` for ${target}` : " (no repo)"}`);
395
+ if (guide.meta.team) console.error(`handed to ${guide.meta.team} (team inbox)`);
359
396
  console.error(`when an agent may take it: passalong ready ${id}`);
360
397
  if (!synced) console.error("not synced (run `passalong login` — agents elsewhere cannot see it)");
361
398
  },
@@ -765,9 +802,7 @@ const commands = {
765
802
  async login(f) {
766
803
  const given = f._[0];
767
804
  if (given) {
768
- store.writeConfig({ token: given, api: api.baseUrl() });
769
- const who = await api.me().catch((e) => die(`token rejected: ${e.message}`));
770
- console.error(`logged in (account ${who.account}) — ${api.baseUrl()}`);
805
+ await keepLogin(given);
771
806
  return;
772
807
  }
773
808
  if (api.loggedIn() && !f.new) {
@@ -775,6 +810,7 @@ const commands = {
775
810
  if (who) {
776
811
  console.error(`already logged in (account ${who.account}). Attach another machine with:`);
777
812
  console.error(` passalong login ${api.token()}`);
813
+ console.error("Add another account to this machine with `passalong login --new`.");
778
814
  return;
779
815
  }
780
816
  }
@@ -785,6 +821,49 @@ const commands = {
785
821
  return browserLogin();
786
822
  },
787
823
 
824
+ /** The accounts signed in here, with the default marked. */
825
+ async accounts() {
826
+ const cfg = store.readConfig();
827
+ const all = store.readAccounts(cfg);
828
+ const names = Object.keys(all);
829
+ if (!names.length) {
830
+ console.log(
831
+ cfg.token
832
+ ? "one account is signed in here (a login from before accounts had names). `passalong login --new` adds another."
833
+ : "no account is signed in here — passalong login",
834
+ );
835
+ return;
836
+ }
837
+ for (const n of names) {
838
+ const a = all[n];
839
+ const who = [a.handle ? `@${a.handle}` : "", a.email].filter(Boolean).join(" ");
840
+ console.log(`${n === cfg.active ? "*" : " "} ${n}${who ? ` ${who}` : ""} ${a.api}`);
841
+ }
842
+ if (names.length > 1)
843
+ console.log("\n* is the default for a person at a terminal. Agents are asked which to use each session.");
844
+ },
845
+
846
+ /** Make one of them the default. This changes what a terminal gets, never what a session of an agent is told. */
847
+ async use(f) {
848
+ const name = f._[0] || die("usage: passalong use <name> (see `passalong accounts`)");
849
+ if (!store.readAccounts()[name])
850
+ die(`no account called "${name}" here. Signed in: ${Object.keys(store.readAccounts()).join(", ") || "none"}`);
851
+ store.setActive(name);
852
+ console.error(`the default account is now ${name}`);
853
+ },
854
+
855
+ /** Forget one of them on this machine. The account itself is untouched: only its token here goes. */
856
+ async logout(f) {
857
+ const name = f._[0] || die("usage: passalong logout <name> (see `passalong accounts`)");
858
+ const r = store.removeAccount(name);
859
+ if (!r.removed) die(`no account called "${name}" here`);
860
+ console.error(
861
+ r.active
862
+ ? `signed out of ${name}; the default is now ${r.active}`
863
+ : `signed out of ${name}; no account is signed in here now`,
864
+ );
865
+ },
866
+
788
867
  async me(f) {
789
868
  const patch = {};
790
869
  for (const k of ["handle", "name", "email"]) if (typeof f[k] === "string") patch[k] = f[k];
@@ -804,7 +883,7 @@ const commands = {
804
883
  ? " (no plan — nothing is syncing)"
805
884
  : " (no limit on your plan)";
806
885
  console.log(`guides ${who.guides} synced${ceiling}`);
807
- const current = store.readConfig().team;
886
+ const current = api.currentTeam();
808
887
  console.log(
809
888
  `teams ${who.teams.length ? who.teams.map((t) => `${t.slug}${t.slug === current ? "*" : ""} (${t.role})`).join(", ") : "(none — passalong team create <name>)"}`,
810
889
  );
@@ -812,7 +891,7 @@ const commands = {
812
891
 
813
892
  async team(f) {
814
893
  const [sub, ...rest] = f._;
815
- const cfg = store.readConfig();
894
+ const cfg = { team: api.currentTeam() };
816
895
  if (!sub) {
817
896
  if (!cfg.team) {
818
897
  const { teams } = await api.teams().catch((e) => die(e.message));
@@ -828,7 +907,7 @@ const commands = {
828
907
  if (sub === "create") {
829
908
  const name = rest.join(" ") || die("usage: passalong team create <name>");
830
909
  const t = await api.createTeam(name).catch((e) => die(e.message));
831
- store.writeConfig({ team: t.slug });
910
+ api.setTeam(t.slug);
832
911
  console.error(`team ${t.name} created as "${t.slug}" — now current. Invite people: passalong team invite [email]`);
833
912
  return;
834
913
  }
@@ -849,7 +928,7 @@ const commands = {
849
928
  const ref = rest[0] || die("usage: passalong team join <link|code>");
850
929
  const code = ref.includes("/") ? ref.split("/").filter(Boolean).pop() : ref;
851
930
  const r = await api.join(code).catch((e) => die(e.message));
852
- store.writeConfig({ team: r.slug });
931
+ api.setTeam(r.slug);
853
932
  console.error(`${r.joined ? "joined" : "already in"} ${r.name} (${r.slug}) — now current`);
854
933
  const who = await api.me().catch(() => null);
855
934
  if (who && !who.handle) console.error("teammates address you by handle: passalong me --handle <you>");
@@ -858,7 +937,7 @@ const commands = {
858
937
  if (sub === "use") {
859
938
  const slug = rest[0] || die("usage: passalong team use <slug>");
860
939
  await api.team(slug).catch((e) => die(e.message));
861
- store.writeConfig({ team: slug });
940
+ api.setTeam(slug);
862
941
  console.error(`current team: ${slug}`);
863
942
  return;
864
943
  }
@@ -940,6 +1019,19 @@ const commands = {
940
1019
  input = JSON.parse(raw || "{}");
941
1020
  }
942
1021
  if (!api.loggedIn()) return;
1022
+ // Two or more accounts and nothing saying which: the one thing worth telling the agent at the
1023
+ // start of a session, because every call after it would be refused until somebody chooses.
1024
+ if (f.hook === "session" && api.needsChoice()) {
1025
+ console.log(
1026
+ JSON.stringify({
1027
+ hookSpecificOutput: {
1028
+ hookEventName: "SessionStart",
1029
+ additionalContext: `Passalong: ${api.choiceMessage()}`,
1030
+ },
1031
+ }),
1032
+ );
1033
+ return;
1034
+ }
943
1035
  // The timeout is cleared as soon as the answer is in. Left running it holds the process open
944
1036
  // for the full five seconds after the hook has printed, and a session start or a stop that
945
1037
  // waits five seconds for nothing is the hook getting in the way, which it must never do.
@@ -989,6 +1081,15 @@ const commands = {
989
1081
 
990
1082
  const argv = flags(process.argv.slice(2));
991
1083
  const cmd = argv._.shift() || "help";
1084
+ // `--as <name>` says which of the accounts on this machine this one command is for. It exists for an
1085
+ // agent that was told which by the person, since it has no terminal to be asked at.
1086
+ if (typeof argv.as === "string") {
1087
+ try {
1088
+ api.chooseAccount(argv.as);
1089
+ } catch (e) {
1090
+ die(e.message);
1091
+ }
1092
+ }
992
1093
  // The first thing anyone runs when something is off, and until now it printed the help page. The
993
1094
  // parser only knows `--x`, so `--version` arrives as a flag on an empty command and `-v` as a word;
994
1095
  // both are caught here, with `version`, before dispatch. Only the number goes to stdout, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
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
@@ -1,7 +1,7 @@
1
1
  // Client for the hosted sync API (apps/api). Everything here is optional: with no token the
2
2
  // CLI is a purely local tool, and every function throws an ApiError the CLI turns into a hint.
3
3
  import { readFileSync } from "node:fs";
4
- import { readConfig } from "./store.js";
4
+ import { nameFor, readAccounts, readConfig, saveTeam } from "./store.js";
5
5
 
6
6
  /**
7
7
  * This build's version, sent on every authenticated call as `x-passalong-version`. The server
@@ -22,21 +22,133 @@ export class ApiError extends Error {
22
22
  }
23
23
  }
24
24
 
25
+ // ---- which account -----------------------------------------------------------------------------
26
+ //
27
+ // A machine may be signed in to several (store.js). Who a call is made as comes from, in order:
28
+ // PASSALONG_TOKEN, an account named by PASSALONG_ACCOUNT, `--as` or `use_account` (this process
29
+ // only), then the machine's default. With two or more logins and nobody having said which, a
30
+ // person at a terminal gets the default and an agent — a process with no terminal to ask on — is
31
+ // refused with the names, so it asks the person rather than choosing. That refusal is the prompt:
32
+ // an agent cannot be asked a question, but it can be told to put one to somebody.
33
+
34
+ let chosen = "";
35
+
36
+ const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
37
+
38
+ /** Names of the logins on this machine, each with what tells them apart. */
39
+ export function accountList() {
40
+ return Object.entries(readAccounts()).map(([name, a]) => ({
41
+ name,
42
+ handle: a.handle || "",
43
+ email: a.email || "",
44
+ api: a.api || "",
45
+ }));
46
+ }
47
+
48
+ /** Name an account for this process only, the way a person confirming one for a session does. */
49
+ export function chooseAccount(name) {
50
+ const all = readAccounts();
51
+ if (!all[name]) {
52
+ const names = Object.keys(all).join(", ");
53
+ throw new ApiError(
54
+ 0,
55
+ `no account called "${name}" on this machine${names ? `. Signed in: ${names}` : ""}`,
56
+ );
57
+ }
58
+ chosen = name;
59
+ return { name, ...all[name] };
60
+ }
61
+
62
+ /** Take back a choice made with chooseAccount: the session is undecided again. */
63
+ export function forgetChoice() {
64
+ chosen = "";
65
+ }
66
+
67
+ /** The account this process was told to use, or null when nobody said. */
68
+ function picked() {
69
+ const want = chosen || process.env.PASSALONG_ACCOUNT || "";
70
+ if (!want) return null;
71
+ const a = readAccounts()[want];
72
+ if (!a) {
73
+ const names = Object.keys(readAccounts()).join(", ");
74
+ throw new ApiError(0, `no account called "${want}" on this machine. Signed in: ${names}`);
75
+ }
76
+ return { name: want, ...a };
77
+ }
78
+
79
+ /** The name of the account in use, if there is one: for saying who a command is about to act as. */
80
+ export function accountInUse() {
81
+ if (process.env.PASSALONG_TOKEN) return "";
82
+ const p = picked();
83
+ if (p) return p.name;
84
+ return readConfig().active || "";
85
+ }
86
+
87
+ /** True when a call would have to guess which of several accounts is meant. */
88
+ export function needsChoice() {
89
+ if (process.env.PASSALONG_TOKEN || chosen || process.env.PASSALONG_ACCOUNT) return false;
90
+ return Object.keys(readAccounts()).length >= 2 && !interactive();
91
+ }
92
+
93
+ /** What an agent is told when it would have to guess. It is written to be passed on to a person. */
94
+ export function choiceMessage() {
95
+ const list = accountList()
96
+ .map((a) => {
97
+ const who = a.email || (a.handle ? `@${a.handle}` : "");
98
+ return who && who !== a.name ? `${a.name} (${who})` : a.name;
99
+ })
100
+ .join(", ");
101
+ return (
102
+ `More than one Passalong account is signed in on this machine (${list}) and nothing says which ` +
103
+ "this session is for. Ask the person which one to use, then call use_account with its name " +
104
+ "(or run the command with --as <name>). Do not choose one yourself."
105
+ );
106
+ }
107
+
25
108
  export function baseUrl() {
26
- return (process.env.PASSALONG_API || readConfig().api || DEFAULT_API).replace(/\/$/, "");
109
+ const base = process.env.PASSALONG_API || picked()?.api || readConfig().api || DEFAULT_API;
110
+ return base.replace(/\/$/, "");
27
111
  }
28
112
 
29
113
  export function token() {
30
- return process.env.PASSALONG_TOKEN || readConfig().token || null;
114
+ return process.env.PASSALONG_TOKEN || picked()?.token || readConfig().token || null;
31
115
  }
32
116
 
33
117
  export function loggedIn() {
34
118
  return Boolean(token());
35
119
  }
36
120
 
121
+ /** The team this account works in. It belongs to the account, not the machine. */
122
+ export function currentTeam() {
123
+ const p = picked();
124
+ return (p ? p.team : readConfig().team) || "";
125
+ }
126
+
127
+ export function setTeam(slug) {
128
+ saveTeam(picked()?.name || readConfig().active || "", slug);
129
+ }
130
+
131
+ /** Who a token is, without it being the one this process uses: how a login is named before it is kept. */
132
+ export async function meWith(tokenValue, base = baseUrl()) {
133
+ let res;
134
+ try {
135
+ res = await fetch(`${base}/v1/me`, {
136
+ headers: { ...CLIENT, authorization: `Bearer ${tokenValue}` },
137
+ });
138
+ } catch (err) {
139
+ throw new ApiError(0, `could not reach ${base} (${err.message})`);
140
+ }
141
+ const said = await res.json().catch(() => ({}));
142
+ if (!res.ok) throw new ApiError(res.status, said.message || res.statusText);
143
+ return said;
144
+ }
145
+
146
+ export { nameFor };
147
+
37
148
  async function call(path, { method = "GET", body, auth = true, raw = false } = {}) {
38
149
  const headers = { ...CLIENT };
39
150
  if (auth) {
151
+ if (needsChoice()) throw new ApiError(409, choiceMessage());
40
152
  const t = token();
41
153
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
42
154
  headers.authorization = `Bearer ${t}`;
@@ -264,6 +376,7 @@ export const ack = (id, taken, note = "") =>
264
376
  * percent-encoded because a header cannot hold what a filename can.
265
377
  */
266
378
  export async function uploadFile(bytes, type, name = "") {
379
+ if (needsChoice()) throw new ApiError(409, choiceMessage());
267
380
  const t = token();
268
381
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
269
382
  const headers = {
package/src/mcp.js CHANGED
@@ -1322,6 +1322,59 @@ export function buildServer() {
1322
1322
  async ({ kind }) => text(template(kind ? { kind } : {})),
1323
1323
  );
1324
1324
 
1325
+ server.registerTool(
1326
+ "accounts",
1327
+ {
1328
+ title: "Accounts on this machine",
1329
+ annotations: READS,
1330
+ description:
1331
+ "The Passalong accounts signed in on this machine, which one this session is using, and " +
1332
+ "whether it still has to be chosen. With two or more and nothing chosen, every call that " +
1333
+ "needs an account is refused until use_account says which. Read-only.",
1334
+ inputSchema: {},
1335
+ },
1336
+ async () => {
1337
+ const list = api.accountList();
1338
+ return json({
1339
+ accounts: list,
1340
+ in_use: api.accountInUse(),
1341
+ must_choose: api.needsChoice(),
1342
+ ...(api.needsChoice() ? { next: api.choiceMessage() } : {}),
1343
+ });
1344
+ },
1345
+ );
1346
+
1347
+ server.registerTool(
1348
+ "use_account",
1349
+ {
1350
+ title: "Use an account for this session",
1351
+ annotations: { ...ADDS, idempotentHint: true },
1352
+ description:
1353
+ "Say which signed-in account this session acts as. Call it only with a name the PERSON " +
1354
+ "gave you: when a machine has more than one account and none is chosen, ask them which, " +
1355
+ "in plain words, then pass their answer. It lasts until this session ends and changes " +
1356
+ "nothing on disk, so it never moves the machine's default.",
1357
+ inputSchema: {
1358
+ name: z.string().describe("an account name from the accounts tool, as the person said it"),
1359
+ },
1360
+ },
1361
+ async ({ name }) => {
1362
+ try {
1363
+ api.chooseAccount(name);
1364
+ const who = await api.me();
1365
+ return json({
1366
+ using: name,
1367
+ account: who.account,
1368
+ handle: who.handle || "",
1369
+ email: who.email || "",
1370
+ teams: (who.teams || []).map((t) => t.slug),
1371
+ });
1372
+ } catch (err) {
1373
+ return fail(err);
1374
+ }
1375
+ },
1376
+ );
1377
+
1325
1378
  server.registerTool(
1326
1379
  "set_guide_status",
1327
1380
  {
package/src/passalong.js CHANGED
@@ -55,6 +55,14 @@ export async function share(markdown, { cwd = process.cwd(), to, follows } = {})
55
55
  const target = parseTarget(to);
56
56
  if (target.team) guide.meta.team = target.team;
57
57
  if (target.to) guide.meta.to = target.to;
58
+ if (
59
+ !guide.meta.team &&
60
+ (guide.meta.kind === "bug" || guide.meta.kind === "task") &&
61
+ api.loggedIn()
62
+ ) {
63
+ const team = api.currentTeam();
64
+ if (team) guide.meta.team = team;
65
+ }
58
66
  // `follows` names the guide this one came out of: an id, a share link, or a pulled .md file. An id
59
67
  // is used as it is; a link or a file is read for the id in its own frontmatter. `validate()` below
60
68
  // is what refuses a guide that names itself.
@@ -111,10 +119,11 @@ export async function fileBugs(
111
119
  if (!api.loggedIn())
112
120
  throw new PassalongError("filing a report needs sync — run `passalong login` first");
113
121
  const target = parseTarget(to);
122
+ const team = target.team || api.currentTeam();
114
123
  const { report } = await api.createReport({
115
124
  title,
116
125
  environment,
117
- team: target.team || "",
126
+ team,
118
127
  to: target.to || "",
119
128
  });
120
129
 
@@ -139,7 +148,7 @@ export async function fileBugs(
139
148
  .join(", ")}) before failing: ${err.message}`,
140
149
  );
141
150
  }
142
- return { report: { id: report.id, title, environment }, issues: filed };
151
+ return { report: { id: report.id, title, environment, team }, issues: filed };
143
152
  }
144
153
 
145
154
  /** Resolve a reference (id, share URL, or local file path) to a guide. */
package/src/store.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  mkdirSync,
11
11
  readdirSync,
12
12
  readFileSync,
13
+ renameSync,
13
14
  statSync,
14
15
  unlinkSync,
15
16
  writeFileSync,
@@ -40,10 +41,111 @@ export function readConfig() {
40
41
  export function writeConfig(patch) {
41
42
  ensure();
42
43
  const next = { ...readConfig(), ...patch };
43
- writeFileSync(CONFIG, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
44
+ // Written beside it and renamed over it, so a reader never meets a half-written file: the update
45
+ // check rewrites this from a background process, and a command that read it mid-write saw no
46
+ // accounts at all.
47
+ const tmp = `${CONFIG}.${process.pid}.tmp`;
48
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
49
+ renameSync(tmp, CONFIG);
44
50
  return next;
45
51
  }
46
52
 
53
+ // ---- accounts on this machine -----------------------------------------------------------------
54
+ //
55
+ // One machine used to hold one login: a single `token` and `api` in the config, so every repo and
56
+ // every agent acted as whoever signed in last, and a second `login` overwrote the first. They are
57
+ // now kept by name under `accounts`, with `active` naming the one a person at a terminal gets by
58
+ // default. `token`, `api` and `team` stay at the top of the file as a copy of the active account,
59
+ // because that is where every older reader — and every older CLI sharing this directory — looks.
60
+
61
+ /** Every login on this machine by name. Empty on a config from before there were several. */
62
+ export function readAccounts(cfg = readConfig()) {
63
+ return cfg.accounts && typeof cfg.accounts === "object" ? cfg.accounts : {};
64
+ }
65
+
66
+ const slug = (s) =>
67
+ String(s ?? "")
68
+ .toLowerCase()
69
+ .replace(/[^a-z0-9._-]+/g, "-")
70
+ .replace(/^-+|-+$/g, "")
71
+ .slice(0, 32);
72
+
73
+ /** What a login is called when its owner has not said: their @handle, else the start of their email. */
74
+ export function nameFor({ handle, email, account }) {
75
+ return slug(handle) || slug(String(email ?? "").split("@")[0]) || slug(account) || "account";
76
+ }
77
+
78
+ /** Make `name` the default: what a person at a terminal gets, and what older readers see. */
79
+ export function setActive(name) {
80
+ const entry = readAccounts()[name];
81
+ if (!entry) throw new Error(`no account called "${name}" on this machine`);
82
+ return writeConfig({
83
+ active: name,
84
+ token: entry.token,
85
+ api: entry.api,
86
+ team: entry.team || "",
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Keep a login under a name. A login of an account already here is updated in place rather than
92
+ * added twice; a new one takes `nameFor`, with a number on the end if that is taken. The first
93
+ * login on a machine becomes the default, and so does any that `activate` asks for.
94
+ */
95
+ export function saveAccount(entry, { activate = false } = {}) {
96
+ const cfg = readConfig();
97
+ const all = { ...readAccounts(cfg) };
98
+ const same = Object.entries(all).find(
99
+ ([, a]) => entry.account && a.account === entry.account && a.api === entry.api,
100
+ );
101
+ let name = same?.[0];
102
+ if (!name) {
103
+ const base = nameFor(entry);
104
+ name = base;
105
+ for (let i = 2; all[name]; i++) name = `${base}-${i}`;
106
+ }
107
+ all[name] = { ...(same?.[1] ?? {}), ...entry };
108
+ writeConfig({ accounts: all });
109
+ if (activate || !cfg.active || !readAccounts(cfg)[cfg.active]) setActive(name);
110
+ else if (cfg.active === name) setActive(name);
111
+ return name;
112
+ }
113
+
114
+ /** Set the team an account works in, and mirror it when that account is the default. */
115
+ export function saveTeam(name, team) {
116
+ const cfg = readConfig();
117
+ const all = readAccounts(cfg);
118
+ if (name && all[name]) {
119
+ writeConfig({ accounts: { ...all, [name]: { ...all[name], team } } });
120
+ if (cfg.active === name) writeConfig({ team });
121
+ } else writeConfig({ team });
122
+ }
123
+
124
+ /**
125
+ * Forget a login. If it was the default, the first one left takes over — said to the person by the
126
+ * caller, because a default that changes silently is the thing this exists to stop.
127
+ */
128
+ export function removeAccount(name) {
129
+ const cfg = readConfig();
130
+ const all = { ...readAccounts(cfg) };
131
+ if (!all[name]) return { removed: false, active: cfg.active || "" };
132
+ delete all[name];
133
+ writeConfig({ accounts: all });
134
+ if (cfg.active !== name) return { removed: true, active: cfg.active || "" };
135
+ const next = Object.keys(all)[0];
136
+ if (next) {
137
+ setActive(next);
138
+ return { removed: true, active: next };
139
+ }
140
+ const bare = readConfig();
141
+ delete bare.active;
142
+ delete bare.token;
143
+ delete bare.team;
144
+ delete bare.accounts;
145
+ writeFileSync(CONFIG, `${JSON.stringify(bare, null, 2)}\n`, { mode: 0o600 });
146
+ return { removed: true, active: "" };
147
+ }
148
+
47
149
  export function guidePath(id) {
48
150
  return join(GUIDES, `${id}.md`);
49
151
  }