@retasc/cli 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,39 @@ release commits and the issues they reference.
6
6
 
7
7
  Dates are the npm publish date. Each entry names the RTSC issue behind it.
8
8
 
9
+ ## 1.25.0 (2026-08-19)
10
+
11
+ - **RTSC-669** — the invite project picker takes several projects. 1.24.0 shipped it
12
+ single-select, which left "two of these four" expressible nowhere: the workaround —
13
+ repeating `--project-id` — needs ids that nothing in the CLI lists, which is the gap
14
+ the picker existed to close. Answer `2,4` (or `2 4`) and the invite grants exactly
15
+ those. Naming every project collapses to all projects, so the grant follows the org as
16
+ it grows rather than freezing today's list. `1) All projects` can't be combined with
17
+ individual ones: that's a contradiction about the widest grant there is, so it re-asks
18
+ instead of guessing which half you meant.
19
+
20
+ ## 1.24.0 (2026-08-19)
21
+
22
+ - **RTSC-667** — `retasc members invite` asks instead of demanding ids. It used to
23
+ require `--org-id`, and the new `--project-id` made that worse: nothing in the CLI
24
+ lists project ids, so a scoped invite meant fetching one from the Dash. Run it with no
25
+ flags in a terminal and it asks which org (only the ones you can actually invite into)
26
+ and which projects, with **All projects** as the first option rather than the thing you
27
+ get by not answering. It asks nothing when there is nothing to choose — one eligible
28
+ org, or fewer than two projects — and a script with no TTY behaves exactly as before.
29
+ The flags still work and still win.
30
+ - **RTSC-666** — the PROJECTS column in `retasc members list` comma-joins, so two
31
+ projects read as `XTRO, XTRO Marketing` rather than running together.
32
+ - **RTSC-664** — invite someone to one project, not the whole org. Membership was org-level,
33
+ so anyone you invited read every project you had. `retasc members invite` now takes
34
+ `--project-id <id>`, repeatable, and the invitee lands scoped to exactly those projects.
35
+ Omitting it still grants every project, so an existing script keeps its behaviour untouched
36
+ and an org that never scopes anyone never notices this shipped. `retasc members list` gains
37
+ a PROJECTS column naming what each code confers, with `All` for the unscoped ones — an
38
+ unscoped invite is the WIDEST grant, and a column that rendered it as a dash would read as
39
+ "none". Enforcement is server-side at every door, agent auth included: a key whose project
40
+ leaves its principal's scope stops authenticating on its very next call.
41
+
9
42
  ## 1.23.0 (2026-08-17)
10
43
 
11
44
  - **RTSC-660** — the proxy attaches files for you. Attaching a file was the one Retasc write
package/dist/api.js CHANGED
@@ -180,6 +180,7 @@ export const api = {
180
180
  mintKey: (args) => withAuth(() => client().action(fns.mintKey, args)),
181
181
  rotateKey: (args) => withAuth(() => client().action(fns.rotateKey, args)),
182
182
  revokeKey: (args) => withAuth(() => client().mutation(fns.revokeKey, args)),
183
+ // `projectIds` omitted ⇒ every project (RTSC-664).
183
184
  createInvite: (args) => withAuth(() => client().action(fns.createInvite, args)),
184
185
  acceptInvite: (args) => withAuth(() => client().mutation(fns.acceptInvite, args)),
185
186
  listInvites: (args) => withAuth(() => client().query(fns.listInvites, args)),
@@ -0,0 +1,121 @@
1
+ import { cliError } from "../api.js";
2
+ import { pickExisting, isInteractive, ask } from "./bind.js";
3
+ import { clean } from "../lib/text.js";
4
+ /**
5
+ * Parse an answer to the multi-select prompt into 1-based positions (RTSC-669).
6
+ *
7
+ * Exported for its tests: this is the whole of the input rule, and the rule is the part
8
+ * that goes wrong. Returns null for anything that isn't a clean selection, so every
9
+ * rejection re-prompts rather than resolving to a subset of what was typed — picking
10
+ * SOME of the projects somebody listed is worse than picking none, because they would
11
+ * never see which ones went missing.
12
+ *
13
+ * `max` is the highest offerable position.
14
+ */
15
+ export function parseSelection(answer, max) {
16
+ const tokens = answer.split(/[\s,]+/).filter(Boolean);
17
+ if (!tokens.length)
18
+ return null;
19
+ const out = [];
20
+ for (const t of tokens) {
21
+ // Digits ONLY. `Number()` alone accepts "0x4", "4e0", "+4" and "4.0", and RTSC-269
22
+ // is the record of what happens when a non-canonical spelling reaches a branch it
23
+ // was never meant to: a typo picked the destructive option instead of re-asking.
24
+ if (!/^\d+$/.test(t))
25
+ return null;
26
+ const n = Number(t);
27
+ if (n < 1 || n > max)
28
+ return null;
29
+ if (!out.includes(n))
30
+ out.push(n); // "2,2" is a typo, not a double selection
31
+ }
32
+ return out;
33
+ }
34
+ /**
35
+ * Which org is this invite for?
36
+ *
37
+ * Only orgs the caller can actually invite into are offered. The owner/admin gate is
38
+ * enforced server-side (`requireOwnerOrAdmin`), and offering a plain member an org they
39
+ * will be refused reads as permission — the same rule `pickExisting`'s own header
40
+ * states. Orgs mid-delete are dropped for the same reason: `acceptInvite` refuses them.
41
+ */
42
+ export async function chooseInviteOrg(orgs, opts = {}) {
43
+ const eligible = orgs.filter((o) => !o.deleting && o.role !== "member");
44
+ if (eligible.length === 0) {
45
+ cliError("FORBIDDEN", "You can't invite anyone: you're not an owner or admin of any org.", "Ask an owner of the org you want to invite into to send the invite, or to make you an admin.");
46
+ }
47
+ // One choice is not a choice. The confirmation line names the org afterwards, so
48
+ // nothing is silently assumed on the caller's behalf.
49
+ if (eligible.length === 1)
50
+ return eligible[0].id;
51
+ const interactive = opts.interactive ?? isInteractive();
52
+ if (!interactive) {
53
+ cliError("AMBIGUOUS", `You're an owner or admin of ${eligible.length} orgs, so one has to be named.`, `Pass --org-id <id>: ${eligible.map((o) => `${clean(o.name)}=${o.id}`).join(", ")}`);
54
+ }
55
+ const chosen = await pickExisting("Which org", eligible, (o) => (o.slug ? `${clean(o.name)} (${clean(o.slug)})` : clean(o.name)), opts.askFn ?? ask);
56
+ return chosen.id;
57
+ }
58
+ /**
59
+ * Which projects may the invitee reach? `undefined` ⇒ every project.
60
+ *
61
+ * MULTI-select (RTSC-669), because the data model always was: `projectIds` is an array
62
+ * and the Dash modal has picked any combination since RTSC-665. Shipping a single-select
63
+ * picker here left "two of these four" expressible nowhere — the documented workaround,
64
+ * repeating `--project-id`, needs ids that nothing in the CLI lists, which is the very
65
+ * gap the picker was added to close.
66
+ *
67
+ * Hand-rolled rather than widened out of `choose`/`pickExisting`: those are single-select
68
+ * by contract and `bind`, `join` and the identity prompt all rely on it. Teaching them to
69
+ * return "one or many" would put that ambiguity into every picker in the product to serve
70
+ * one caller.
71
+ *
72
+ * The menu's FIRST entry is "All projects" — an explicit option, never the thing you
73
+ * get by not answering, and never combinable with the others (that is a contradiction
74
+ * about the widest grant there is, so it re-prompts rather than resolving). Handing
75
+ * someone the whole org should be a choice somebody made; that is the same call the
76
+ * Dash's invite form makes, for the same reason.
77
+ *
78
+ * Two cases skip the question and mean "every project", which is what this command has
79
+ * always done when no project was named:
80
+ * • fewer than two projects — there is nothing to choose between
81
+ * • no TTY — a script that named no project keeps its behaviour, and prompting a
82
+ * pipeline is how a release hangs CI rather than failing it
83
+ */
84
+ export async function chooseInviteProjects(projects, opts = {}) {
85
+ if (projects.length < 2)
86
+ return undefined;
87
+ const interactive = opts.interactive ?? isInteractive();
88
+ if (!interactive)
89
+ return undefined;
90
+ const askFn = opts.askFn ?? ask;
91
+ console.log("\nWhich projects can they see:");
92
+ console.log(" 1) All projects");
93
+ projects.forEach((p, i) => console.log(` ${i + 2}) ${clean(p.name)}`));
94
+ const max = projects.length + 1;
95
+ // Three attempts then abort, the cap `choose` uses: an EOF stdin returns "" forever,
96
+ // and a prompt that re-asks forever is a hang rather than a failure.
97
+ for (let attempt = 0; attempt < 3; attempt++) {
98
+ const picked = parseSelection(await askFn("Choose a number, or several (e.g. 2,4): "), max);
99
+ if (!picked) {
100
+ console.log(`Please enter numbers between 1 and ${max}, separated by commas.`);
101
+ continue;
102
+ }
103
+ const wantsAll = picked.includes(1);
104
+ if (wantsAll && picked.length > 1) {
105
+ // Not resolvable either way, so it must not be resolved. "All projects AND
106
+ // Marketing" is a contradiction about the widest grant in the product, and
107
+ // guessing which half they meant is how someone hands over an org by accident.
108
+ console.log("1) All projects can't be combined with individual projects. Pick one or the other.");
109
+ continue;
110
+ }
111
+ if (wantsAll)
112
+ return undefined;
113
+ const chosen = picked.map((n) => projects[n - 2]);
114
+ // Naming every project IS "all projects", and storing it as a list would freeze the
115
+ // grant at today's set — a project added next month would be missing from a scope
116
+ // its author chose as everything. `normalizeInviteScope` collapses this server-side
117
+ // too; doing it here as well keeps the confirmation honest about what was granted.
118
+ return chosen.length === projects.length ? undefined : chosen.map((p) => p.id);
119
+ }
120
+ throw new Error("no valid choice — aborting");
121
+ }
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { installGate, resolveGatePrefix } from "./commands/gate.js";
7
7
  import { claimAction } from "./commands/claim.js";
8
8
  import { bindAction, setupFromToken } from "./commands/bind.js";
9
9
  import { joinAction } from "./commands/join.js";
10
+ import { chooseInviteOrg, chooseInviteProjects } from "./commands/invite.js";
10
11
  import { identityAction } from "./commands/identity.js";
11
12
  import { importAction } from "./commands/import.js";
12
13
  import { doctorAction } from "./commands/doctor.js";
@@ -357,12 +358,32 @@ const members = program.command("members").description("Invite people to an org
357
358
  members
358
359
  .command("invite")
359
360
  .description("Mint a single-use invite code for an org (owner or admin). Shown once.")
360
- .requiredOption("--org-id <id>")
361
+ // RTSC-667 — no longer REQUIRED. Omit it in a terminal and you are asked which org,
362
+ // from the ones you can actually invite into. It stays available because a script
363
+ // has nobody to ask.
364
+ .option("--org-id <id>", "Which org (asked if omitted)")
361
365
  .option("--expires-days <n>", "Days until the code expires (1–30, default 7)", (v) => parseInt(v, 10))
366
+ // RTSC-664. Repeatable, and OMITTED MEANS EVERY PROJECT — the behaviour this
367
+ // command has always had, so an existing script keeps working untouched. RTSC-667
368
+ // adds the question a human gets instead, with "All projects" as an explicit first
369
+ // option rather than the thing you get by not answering.
370
+ .option("--project-id <id>", "Limit the invitee to this project (repeatable; asked if omitted, all if skipped)", (v, prev) => [...prev, v], [])
362
371
  .action(async (opts) => {
363
372
  requireLogin();
364
373
  try {
365
- const res = (await api.createInvite({ orgId: opts.orgId, expiresInDays: opts.expiresDays }));
374
+ // Ask only for what the command line didn't answer, and fetch only for what we
375
+ // are about to ask: naming --org-id must not cost a round trip for a question
376
+ // nobody sees.
377
+ const orgId = opts.orgId ?? (await chooseInviteOrg(((await api.me()).orgs ?? [])));
378
+ const named = opts.projectId ?? [];
379
+ const projectIds = named.length
380
+ ? named
381
+ : await chooseInviteProjects((await api.listProjects({ orgId })).projects ?? []);
382
+ const res = (await api.createInvite({
383
+ orgId,
384
+ expiresInDays: opts.expiresDays,
385
+ ...(projectIds ? { projectIds } : {}),
386
+ }));
366
387
  console.log(`✓ Invite code: ${res.code}`);
367
388
  console.log(` Expires ${new Date(res.expiresAt).toISOString().slice(0, 10)}. Single-use — shown once.`);
368
389
  // RTSC-492: one command, and one that needs nothing installed first. `join` signs
@@ -145,9 +145,17 @@ export function keyListView(rows, now = Date.now()) {
145
145
  : body;
146
146
  }
147
147
  export function inviteListView(rows) {
148
- return table(["CODE", "ROLE", "STATUS", "INVITED BY", "EXPIRES"], rows.map((i) => [
148
+ return table(["CODE", "ROLE", "PROJECTS", "STATUS", "INVITED BY", "EXPIRES"], rows.map((i) => [
149
149
  clean(i.displayPrefix ?? "—"),
150
150
  clean(i.role ?? "—"),
151
+ // "All" rather than a dash: an unscoped invite grants MORE than a scoped one,
152
+ // and a column that renders the widest grant as an em dash reads as "none".
153
+ //
154
+ // RTSC-666 — comma-joined, not space-joined. A space was unambiguous while the
155
+ // server sent PREFIXES, which never contain one; it sends names now, so two
156
+ // projects rendered as "XTRO XTRO Marketing" — three words, or one badly named
157
+ // thing, but not two projects. Matches how the Dash joins the same list.
158
+ i.projects == null ? "All" : clean(i.projects.join(", ") || "—"),
151
159
  clean(i.status ?? "—"),
152
160
  clean(i.invitedBy ?? "—"),
153
161
  // Only a LIVE invite has a deadline worth reading. On a spent one the date is
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.23.0",
4
- "description": "Retasc CLI the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
3
+ "version": "1.25.0",
4
+ "description": "Retasc CLI \u2014 the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "retasc": "dist/index.js"