passalong 0.14.0 → 0.15.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/README.md +17 -1
- package/bin/passalong +113 -12
- package/package.json +1 -1
- package/src/api.js +116 -3
- package/src/mcp.js +53 -0
- package/src/passalong.js +11 -2
- package/src/store.js +96 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
|
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
|
@@ -44,6 +44,102 @@ export function writeConfig(patch) {
|
|
|
44
44
|
return next;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// ---- accounts on this machine -----------------------------------------------------------------
|
|
48
|
+
//
|
|
49
|
+
// One machine used to hold one login: a single `token` and `api` in the config, so every repo and
|
|
50
|
+
// every agent acted as whoever signed in last, and a second `login` overwrote the first. They are
|
|
51
|
+
// now kept by name under `accounts`, with `active` naming the one a person at a terminal gets by
|
|
52
|
+
// default. `token`, `api` and `team` stay at the top of the file as a copy of the active account,
|
|
53
|
+
// because that is where every older reader — and every older CLI sharing this directory — looks.
|
|
54
|
+
|
|
55
|
+
/** Every login on this machine by name. Empty on a config from before there were several. */
|
|
56
|
+
export function readAccounts(cfg = readConfig()) {
|
|
57
|
+
return cfg.accounts && typeof cfg.accounts === "object" ? cfg.accounts : {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const slug = (s) =>
|
|
61
|
+
String(s ?? "")
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
64
|
+
.replace(/^-+|-+$/g, "")
|
|
65
|
+
.slice(0, 32);
|
|
66
|
+
|
|
67
|
+
/** What a login is called when its owner has not said: their @handle, else the start of their email. */
|
|
68
|
+
export function nameFor({ handle, email, account }) {
|
|
69
|
+
return slug(handle) || slug(String(email ?? "").split("@")[0]) || slug(account) || "account";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Make `name` the default: what a person at a terminal gets, and what older readers see. */
|
|
73
|
+
export function setActive(name) {
|
|
74
|
+
const entry = readAccounts()[name];
|
|
75
|
+
if (!entry) throw new Error(`no account called "${name}" on this machine`);
|
|
76
|
+
return writeConfig({
|
|
77
|
+
active: name,
|
|
78
|
+
token: entry.token,
|
|
79
|
+
api: entry.api,
|
|
80
|
+
team: entry.team || "",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Keep a login under a name. A login of an account already here is updated in place rather than
|
|
86
|
+
* added twice; a new one takes `nameFor`, with a number on the end if that is taken. The first
|
|
87
|
+
* login on a machine becomes the default, and so does any that `activate` asks for.
|
|
88
|
+
*/
|
|
89
|
+
export function saveAccount(entry, { activate = false } = {}) {
|
|
90
|
+
const cfg = readConfig();
|
|
91
|
+
const all = { ...readAccounts(cfg) };
|
|
92
|
+
const same = Object.entries(all).find(
|
|
93
|
+
([, a]) => entry.account && a.account === entry.account && a.api === entry.api,
|
|
94
|
+
);
|
|
95
|
+
let name = same?.[0];
|
|
96
|
+
if (!name) {
|
|
97
|
+
const base = nameFor(entry);
|
|
98
|
+
name = base;
|
|
99
|
+
for (let i = 2; all[name]; i++) name = `${base}-${i}`;
|
|
100
|
+
}
|
|
101
|
+
all[name] = { ...(same?.[1] ?? {}), ...entry };
|
|
102
|
+
writeConfig({ accounts: all });
|
|
103
|
+
if (activate || !cfg.active || !readAccounts(cfg)[cfg.active]) setActive(name);
|
|
104
|
+
else if (cfg.active === name) setActive(name);
|
|
105
|
+
return name;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Set the team an account works in, and mirror it when that account is the default. */
|
|
109
|
+
export function saveTeam(name, team) {
|
|
110
|
+
const cfg = readConfig();
|
|
111
|
+
const all = readAccounts(cfg);
|
|
112
|
+
if (name && all[name]) {
|
|
113
|
+
writeConfig({ accounts: { ...all, [name]: { ...all[name], team } } });
|
|
114
|
+
if (cfg.active === name) writeConfig({ team });
|
|
115
|
+
} else writeConfig({ team });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Forget a login. If it was the default, the first one left takes over — said to the person by the
|
|
120
|
+
* caller, because a default that changes silently is the thing this exists to stop.
|
|
121
|
+
*/
|
|
122
|
+
export function removeAccount(name) {
|
|
123
|
+
const cfg = readConfig();
|
|
124
|
+
const all = { ...readAccounts(cfg) };
|
|
125
|
+
if (!all[name]) return { removed: false, active: cfg.active || "" };
|
|
126
|
+
delete all[name];
|
|
127
|
+
writeConfig({ accounts: all });
|
|
128
|
+
if (cfg.active !== name) return { removed: true, active: cfg.active || "" };
|
|
129
|
+
const next = Object.keys(all)[0];
|
|
130
|
+
if (next) {
|
|
131
|
+
setActive(next);
|
|
132
|
+
return { removed: true, active: next };
|
|
133
|
+
}
|
|
134
|
+
const bare = readConfig();
|
|
135
|
+
delete bare.active;
|
|
136
|
+
delete bare.token;
|
|
137
|
+
delete bare.team;
|
|
138
|
+
delete bare.accounts;
|
|
139
|
+
writeFileSync(CONFIG, `${JSON.stringify(bare, null, 2)}\n`, { mode: 0o600 });
|
|
140
|
+
return { removed: true, active: "" };
|
|
141
|
+
}
|
|
142
|
+
|
|
47
143
|
export function guidePath(id) {
|
|
48
144
|
return join(GUIDES, `${id}.md`);
|
|
49
145
|
}
|