@retasc/cli 1.23.0 → 1.24.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 +22 -0
- package/dist/api.js +1 -0
- package/dist/commands/invite.js +52 -0
- package/dist/index.js +23 -2
- package/dist/lib/format.js +9 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,28 @@ 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.24.0 (2026-08-19)
|
|
10
|
+
|
|
11
|
+
- **RTSC-667** — `retasc members invite` asks instead of demanding ids. It used to
|
|
12
|
+
require `--org-id`, and the new `--project-id` made that worse: nothing in the CLI
|
|
13
|
+
lists project ids, so a scoped invite meant fetching one from the Dash. Run it with no
|
|
14
|
+
flags in a terminal and it asks which org (only the ones you can actually invite into)
|
|
15
|
+
and which projects, with **All projects** as the first option rather than the thing you
|
|
16
|
+
get by not answering. It asks nothing when there is nothing to choose — one eligible
|
|
17
|
+
org, or fewer than two projects — and a script with no TTY behaves exactly as before.
|
|
18
|
+
The flags still work and still win.
|
|
19
|
+
- **RTSC-666** — the PROJECTS column in `retasc members list` comma-joins, so two
|
|
20
|
+
projects read as `XTRO, XTRO Marketing` rather than running together.
|
|
21
|
+
- **RTSC-664** — invite someone to one project, not the whole org. Membership was org-level,
|
|
22
|
+
so anyone you invited read every project you had. `retasc members invite` now takes
|
|
23
|
+
`--project-id <id>`, repeatable, and the invitee lands scoped to exactly those projects.
|
|
24
|
+
Omitting it still grants every project, so an existing script keeps its behaviour untouched
|
|
25
|
+
and an org that never scopes anyone never notices this shipped. `retasc members list` gains
|
|
26
|
+
a PROJECTS column naming what each code confers, with `All` for the unscoped ones — an
|
|
27
|
+
unscoped invite is the WIDEST grant, and a column that rendered it as a dash would read as
|
|
28
|
+
"none". Enforcement is server-side at every door, agent auth included: a key whose project
|
|
29
|
+
leaves its principal's scope stops authenticating on its very next call.
|
|
30
|
+
|
|
9
31
|
## 1.23.0 (2026-08-17)
|
|
10
32
|
|
|
11
33
|
- **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,52 @@
|
|
|
1
|
+
import { cliError } from "../api.js";
|
|
2
|
+
import { pickExisting, isInteractive, ask } from "./bind.js";
|
|
3
|
+
import { clean } from "../lib/text.js";
|
|
4
|
+
/** The "every project" entry in the project menu. A symbol, so it can never collide
|
|
5
|
+
* with a project id, however a future id is shaped. */
|
|
6
|
+
const ALL = Symbol("all-projects");
|
|
7
|
+
/**
|
|
8
|
+
* Which org is this invite for?
|
|
9
|
+
*
|
|
10
|
+
* Only orgs the caller can actually invite into are offered. The owner/admin gate is
|
|
11
|
+
* enforced server-side (`requireOwnerOrAdmin`), and offering a plain member an org they
|
|
12
|
+
* will be refused reads as permission — the same rule `pickExisting`'s own header
|
|
13
|
+
* states. Orgs mid-delete are dropped for the same reason: `acceptInvite` refuses them.
|
|
14
|
+
*/
|
|
15
|
+
export async function chooseInviteOrg(orgs, opts = {}) {
|
|
16
|
+
const eligible = orgs.filter((o) => !o.deleting && o.role !== "member");
|
|
17
|
+
if (eligible.length === 0) {
|
|
18
|
+
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.");
|
|
19
|
+
}
|
|
20
|
+
// One choice is not a choice. The confirmation line names the org afterwards, so
|
|
21
|
+
// nothing is silently assumed on the caller's behalf.
|
|
22
|
+
if (eligible.length === 1)
|
|
23
|
+
return eligible[0].id;
|
|
24
|
+
const interactive = opts.interactive ?? isInteractive();
|
|
25
|
+
if (!interactive) {
|
|
26
|
+
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(", ")}`);
|
|
27
|
+
}
|
|
28
|
+
const chosen = await pickExisting("Which org", eligible, (o) => (o.slug ? `${clean(o.name)} (${clean(o.slug)})` : clean(o.name)), opts.askFn ?? ask);
|
|
29
|
+
return chosen.id;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Which projects may the invitee reach? `undefined` ⇒ every project.
|
|
33
|
+
*
|
|
34
|
+
* The menu's FIRST entry is "All projects" — an explicit option, never the thing you
|
|
35
|
+
* get by not answering. Handing someone the whole org should be a choice somebody
|
|
36
|
+
* made; that is the same call the Dash's invite form makes, for the same reason.
|
|
37
|
+
*
|
|
38
|
+
* Two cases skip the question and mean "every project", which is what this command has
|
|
39
|
+
* always done when no project was named:
|
|
40
|
+
* • fewer than two projects — there is nothing to choose between
|
|
41
|
+
* • no TTY — a script that named no project keeps its behaviour, and prompting a
|
|
42
|
+
* pipeline is how a release hangs CI rather than failing it
|
|
43
|
+
*/
|
|
44
|
+
export async function chooseInviteProjects(projects, opts = {}) {
|
|
45
|
+
if (projects.length < 2)
|
|
46
|
+
return undefined;
|
|
47
|
+
const interactive = opts.interactive ?? isInteractive();
|
|
48
|
+
if (!interactive)
|
|
49
|
+
return undefined;
|
|
50
|
+
const chosen = await pickExisting("Which projects can they see", [ALL, ...projects], (p) => (p === ALL ? "All projects" : clean(p.name)), opts.askFn ?? ask);
|
|
51
|
+
return chosen === ALL ? undefined : [chosen.id];
|
|
52
|
+
}
|
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
|
-
.
|
|
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
|
-
|
|
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
|
package/dist/lib/format.js
CHANGED
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|