@retasc/cli 1.8.0 → 1.10.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/dist/api.js +23 -0
- package/dist/commands/bind.js +264 -132
- package/dist/commands/doctor.js +41 -1
- package/dist/commands/identity.js +96 -0
- package/dist/commands/join.js +218 -0
- package/dist/commands/mcp.js +5 -3
- package/dist/index.js +40 -17
- package/dist/lib/invite.js +38 -0
- package/dist/lib/launcher.js +18 -0
- package/dist/lib/text.js +21 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -25,6 +25,12 @@ const fns = {
|
|
|
25
25
|
billingStatus: makeFunctionReference("manage:billingStatus"),
|
|
26
26
|
chargeHistory: makeFunctionReference("manage:chargeHistory"),
|
|
27
27
|
orgPayments: makeFunctionReference("manage:orgPayments"),
|
|
28
|
+
// Imported-identity claim (RTSC-433/473), wired for `join` by RTSC-492 and shared with
|
|
29
|
+
// RTSC-477's standalone `retasc identity`. Member-gated already — nothing was widened
|
|
30
|
+
// server-side for the CLI, and nothing should be.
|
|
31
|
+
claimableGhosts: makeFunctionReference("ghosts:claimableGhosts"),
|
|
32
|
+
claimGhost: makeFunctionReference("ghosts:claimGhost"),
|
|
33
|
+
dismissGhostPrompt: makeFunctionReference("ghosts:dismissGhostPrompt"),
|
|
28
34
|
};
|
|
29
35
|
function client() {
|
|
30
36
|
const cfg = loadConfig();
|
|
@@ -66,6 +72,20 @@ export function formatError(e) {
|
|
|
66
72
|
.trim();
|
|
67
73
|
return { message };
|
|
68
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Fail the same way the backend does (RTSC-492).
|
|
77
|
+
*
|
|
78
|
+
* `formatError` truncates a bare Error to its FIRST line, so a multi-line message loses
|
|
79
|
+
* everything after the diagnosis — including the command that fixes it. Some of the stops
|
|
80
|
+
* the CLI has to make are its own (an org with no projects the caller may not create),
|
|
81
|
+
* with no server call to produce a `userError`, and they deserve the same code + message +
|
|
82
|
+
* hint rendering as one that came off the wire.
|
|
83
|
+
*/
|
|
84
|
+
export function cliError(code, message, hint) {
|
|
85
|
+
const e = new Error(`${code}: ${message}`);
|
|
86
|
+
e.data = { code, message, hint };
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
69
89
|
// Does this error mean "the access token is missing/expired", i.e. a refresh
|
|
70
90
|
// might fix it? The access-token JWT lives ~1h, so any long-lived login trips
|
|
71
91
|
// this. Two shapes surface: our server functions throw `UNAUTHENTICATED …`
|
|
@@ -139,4 +159,7 @@ export const api = {
|
|
|
139
159
|
billingStatus: (args) => withAuth(() => client().query(fns.billingStatus, args)),
|
|
140
160
|
chargeHistory: (args) => withAuth(() => client().query(fns.chargeHistory, args)),
|
|
141
161
|
orgPayments: (args) => withAuth(() => client().action(fns.orgPayments, args)),
|
|
162
|
+
claimableGhosts: (args) => withAuth(() => client().query(fns.claimableGhosts, args)),
|
|
163
|
+
claimGhost: (args) => withAuth(() => client().mutation(fns.claimGhost, args)),
|
|
164
|
+
dismissGhostPrompt: (args) => withAuth(() => client().mutation(fns.dismissGhostPrompt, args)),
|
|
142
165
|
};
|
package/dist/commands/bind.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { stdin, stdout } from "node:process";
|
|
3
|
-
import { api } from "../api.js";
|
|
3
|
+
import { api, cliError } from "../api.js";
|
|
4
4
|
import { deviceLogin } from "../auth.js";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
6
|
import { installMarker } from "./mcp.js";
|
|
7
7
|
import { readLocalBinding, resolveBinding } from "../lib/binding.js";
|
|
8
8
|
import { getBinding, setBinding, newWorkspaceId } from "../lib/keystore.js";
|
|
9
|
-
|
|
9
|
+
import { resolveLauncher, launcherNote, selfCommand } from "../lib/launcher.js";
|
|
10
|
+
import { clean } from "../lib/text.js";
|
|
11
|
+
import { VERSION } from "../version.js";
|
|
12
|
+
export function isInteractive() {
|
|
10
13
|
return Boolean(stdin.isTTY && stdout.isTTY);
|
|
11
14
|
}
|
|
12
|
-
async function ask(question) {
|
|
15
|
+
export async function ask(question) {
|
|
13
16
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
14
17
|
try {
|
|
15
18
|
// readline's question() NEVER settles when stdin hits EOF (Ctrl-D, or a
|
|
@@ -24,7 +27,7 @@ async function ask(question) {
|
|
|
24
27
|
rl.close();
|
|
25
28
|
}
|
|
26
29
|
}
|
|
27
|
-
async function confirm(question, assumeYes) {
|
|
30
|
+
export async function confirm(question, assumeYes) {
|
|
28
31
|
if (assumeYes)
|
|
29
32
|
return true;
|
|
30
33
|
if (!isInteractive())
|
|
@@ -33,8 +36,10 @@ async function confirm(question, assumeYes) {
|
|
|
33
36
|
return a === "y" || a === "yes";
|
|
34
37
|
}
|
|
35
38
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
39
|
+
* Render a numbered menu and read one answer, optionally with a trailing extra option.
|
|
40
|
+
*
|
|
41
|
+
* Returns the chosen item, or `undefined` when the trailing option was taken. When there
|
|
42
|
+
* is no trailing option, `undefined` is impossible — every valid answer names an item.
|
|
38
43
|
*
|
|
39
44
|
* Invalid input re-prompts rather than falling through: undefined used to mean
|
|
40
45
|
* BOTH "chose create-new" and "typed garbage", so a typo like `1)` silently
|
|
@@ -44,26 +49,55 @@ async function confirm(question, assumeYes) {
|
|
|
44
49
|
* "4.0", which would route those straight to create-new — the same
|
|
45
50
|
* non-canonical-input-hits-the-destructive-branch bug in a new coat.
|
|
46
51
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
async function choose(label, items, render, askFn, extra) {
|
|
53
|
+
// An empty label means the caller already printed its own heading, and a menu is being
|
|
54
|
+
// appended to it — used by the identity prompt, whose lede is two lines of its own.
|
|
55
|
+
if (label)
|
|
56
|
+
console.log(`\n${label}:`);
|
|
51
57
|
items.forEach((it, i) => console.log(` ${i + 1}) ${render(it)}`));
|
|
52
|
-
const
|
|
53
|
-
|
|
58
|
+
const extraSlot = items.length + 1;
|
|
59
|
+
if (extra)
|
|
60
|
+
console.log(` ${extraSlot}) ${extra}`);
|
|
61
|
+
const highest = extra ? extraSlot : items.length;
|
|
54
62
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
55
63
|
const a = await askFn("Choose a number: ");
|
|
56
64
|
if (/^\d+$/.test(a)) {
|
|
57
65
|
const n = Number(a);
|
|
58
66
|
if (n >= 1 && n <= items.length)
|
|
59
67
|
return items[n - 1];
|
|
60
|
-
if (n ===
|
|
68
|
+
if (extra && n === extraSlot)
|
|
61
69
|
return undefined;
|
|
62
70
|
}
|
|
63
|
-
console.log(`Please enter a number between 1 and ${
|
|
71
|
+
console.log(`Please enter a number between 1 and ${highest}.`);
|
|
64
72
|
}
|
|
65
73
|
throw new Error("no valid choice — aborting");
|
|
66
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Pick from a list interactively. Returns the chosen item, or undefined when the
|
|
77
|
+
* user explicitly picks the trailing "create new" option.
|
|
78
|
+
*/
|
|
79
|
+
export async function pick(label, items, render, askFn = ask) {
|
|
80
|
+
if (!items.length)
|
|
81
|
+
return undefined;
|
|
82
|
+
return choose(label, items, render, askFn, "+ create new");
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Pick from a list with NO create option (RTSC-492).
|
|
86
|
+
*
|
|
87
|
+
* `pick` appends "+ create new" unconditionally, which is right for `bind` — the person
|
|
88
|
+
* running it is setting up their own org and is its owner. It is wrong for someone who
|
|
89
|
+
* just accepted an invite: `createProject` is `requireOwner`, so offering the slot to a
|
|
90
|
+
* member is offering them a refusal. An option that can only fail is worse than no option,
|
|
91
|
+
* because it reads as permission.
|
|
92
|
+
*
|
|
93
|
+
* Never returns undefined, so the caller has no "or else" branch to get wrong. An empty
|
|
94
|
+
* list is the caller's problem to explain, not something to render as a menu of nothing.
|
|
95
|
+
*/
|
|
96
|
+
export async function pickExisting(label, items, render, askFn = ask) {
|
|
97
|
+
if (!items.length)
|
|
98
|
+
throw new Error("nothing to choose from");
|
|
99
|
+
return (await choose(label, items, render, askFn, null));
|
|
100
|
+
}
|
|
67
101
|
/**
|
|
68
102
|
* The message to show when `bind` aborts mid-run, or null when there's nothing
|
|
69
103
|
* to say. `bind` commits a new org server-side before the project step; if a
|
|
@@ -83,64 +117,225 @@ export function strandRecoveryHint(args) {
|
|
|
83
117
|
`but has no project or key yet — re-run to finish:\n` +
|
|
84
118
|
` retasc bind --org-id ${orgId}`);
|
|
85
119
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const cfg = loadConfig();
|
|
104
|
-
const cwd = process.cwd();
|
|
105
|
-
// --- loud on re-bind -------------------------------------------------------
|
|
120
|
+
/**
|
|
121
|
+
* "This folder is already bound — replace it?" (RTSC-262/RTSC-91)
|
|
122
|
+
*
|
|
123
|
+
* Extracted from `bindAction` so `join` inherits it verbatim rather than growing a second,
|
|
124
|
+
* quieter re-bind path (RTSC-492). Silently rebinding someone's working folder is the one
|
|
125
|
+
* outcome neither command may produce, and two implementations of that rule is one too
|
|
126
|
+
* many.
|
|
127
|
+
*
|
|
128
|
+
* `proceed: false` means STOP — either the caller declined, or the requested binding is
|
|
129
|
+
* already in place and there is nothing to do. The exit code for the non-interactive
|
|
130
|
+
* refusal is set here, at the point that knows why.
|
|
131
|
+
*
|
|
132
|
+
* Both callers run this before doing any work that commits state: `bind` before it can
|
|
133
|
+
* create an org, `join` before it mints a key. Neither runs it before the invite is
|
|
134
|
+
* redeemed, because joining an org is not a property of this folder.
|
|
135
|
+
*/
|
|
136
|
+
export async function rebindGuard(args) {
|
|
106
137
|
// markerOnly (a cloned repo's committed marker, or an entry the CLI can't
|
|
107
138
|
// read a key from) never gates: there is no usable binding to "replace", and
|
|
108
139
|
// minting a key under the existing marker id is exactly what bind is FOR.
|
|
109
|
-
const existing = readLocalBinding(cwd);
|
|
110
|
-
if (existing
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
140
|
+
const existing = readLocalBinding(args.cwd);
|
|
141
|
+
if (!existing || existing.markerOnly)
|
|
142
|
+
return { proceed: true, existing: existing ?? null };
|
|
143
|
+
// Idempotent converge (RTSC-262): re-running with the SAME target is success, not a
|
|
144
|
+
// refusal — a provisioning script must be able to run `retasc bind --org-id X
|
|
145
|
+
// --project-id Y` repeatedly without churning keys.
|
|
146
|
+
if (existing.workspaceId && args.orgId && args.projectId) {
|
|
147
|
+
const cur = getBinding(existing.workspaceId);
|
|
148
|
+
if (cur && cur.orgId === args.orgId && cur.projectId === args.projectId) {
|
|
149
|
+
console.log(`Already bound to the requested org/project (${cur.prefix ?? cur.projectId}). Nothing to do.`);
|
|
150
|
+
return { proceed: false, existing };
|
|
120
151
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
152
|
+
}
|
|
153
|
+
let where = "an existing Retasc binding";
|
|
154
|
+
try {
|
|
155
|
+
const b = await resolveBinding(existing.url || args.mcpUrl, existing.key);
|
|
156
|
+
where = `org "${clean(b.org.name)}" / project ${clean(b.project.prefix)}`;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* key may be stale/revoked — still warn before replacing */
|
|
160
|
+
}
|
|
161
|
+
console.log(`This folder is already bound to ${where}.`);
|
|
162
|
+
if (await confirm("Replace it?", args.yes))
|
|
163
|
+
return { proceed: true, existing };
|
|
164
|
+
// RTSC-262: with no TTY, confirm() answers "no" on its own — and the
|
|
165
|
+
// widened binding lookup makes an existing binding the COMMON case. A
|
|
166
|
+
// provisioning script must not be told success (exit 0) for a no-op.
|
|
167
|
+
if (!isInteractive()) {
|
|
168
|
+
console.error("✗ refusing to replace the existing binding (a DIFFERENT org/project) non-interactively — pass --yes.");
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
console.log("Left unchanged.");
|
|
173
|
+
}
|
|
174
|
+
return { proceed: false, existing };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Everything from "which project" to a working folder: pick the project, make `retasc`
|
|
178
|
+
* durable, mint a key for that (org, project), write the binding, and wire the marker.
|
|
179
|
+
*
|
|
180
|
+
* Shared by `bind` and `join` (RTSC-492) rather than copied. The two commands differ only
|
|
181
|
+
* in how they arrive at an org and in whether the caller may create a project; the tail is
|
|
182
|
+
* the same act, and two copies of it would drift — the marker-writing half in particular
|
|
183
|
+
* has already had to be fixed once (RTSC-493) and must never need fixing twice.
|
|
184
|
+
*
|
|
185
|
+
* ORDER. The durable-`retasc` step runs BEFORE the mint, not as a side effect of writing
|
|
186
|
+
* the marker afterwards. A cold `npm i -g` takes tens of seconds and can fail outright; a
|
|
187
|
+
* key minted before it is a live credential stranded behind a step that may never finish.
|
|
188
|
+
* Resolving first also means the launcher is known to every message printed after it.
|
|
189
|
+
*/
|
|
190
|
+
export async function completeWorkspaceSetup(args) {
|
|
191
|
+
const { orgId, opts, existing } = args;
|
|
192
|
+
const cfg = loadConfig();
|
|
193
|
+
const cwd = process.cwd();
|
|
194
|
+
const org = args.orgLabel ? `"${clean(args.orgLabel)}"` : "this org";
|
|
195
|
+
// --- resolve project (pick / flag / create) --------------------------------
|
|
196
|
+
let projectId = opts.projectId;
|
|
197
|
+
let prefix;
|
|
198
|
+
if (!projectId && opts.project && opts.prefix) {
|
|
199
|
+
if (!args.canCreateProject) {
|
|
200
|
+
cliError("FORBIDDEN", "Only an owner can create a project.", `Ask an owner to add one, then pass --project-id <id>.`);
|
|
125
201
|
}
|
|
126
|
-
|
|
127
|
-
|
|
202
|
+
const p = (await api.createProject({ orgId, name: opts.project, prefix: opts.prefix }));
|
|
203
|
+
projectId = p.projectId;
|
|
204
|
+
prefix = p.prefix;
|
|
205
|
+
console.log(`✓ Created project ${p.prefix}.`);
|
|
206
|
+
}
|
|
207
|
+
if (!projectId) {
|
|
208
|
+
const { projects } = (await api.listProjects({ orgId }));
|
|
209
|
+
const list = projects ?? [];
|
|
210
|
+
if (!args.canCreateProject) {
|
|
211
|
+
// A member cannot create one, so an empty org is a dead end here — and it has to say
|
|
212
|
+
// what to ask for, by name. A bare FORBIDDEN from `createProject` would be both
|
|
213
|
+
// wrong (they never asked to create anything) and unactionable.
|
|
214
|
+
if (!list.length) {
|
|
215
|
+
cliError("NO_PROJECTS", `Org ${org} has no projects yet, and only an owner can create one.`, `Ask an owner to add a project, then finish this folder with: ${selfCommand(VERSION)} bind --org-id ${orgId}`);
|
|
216
|
+
}
|
|
217
|
+
// One project is not a choice. Asking anyway would be the only question in the
|
|
218
|
+
// common invite path, for an answer that was never in doubt.
|
|
219
|
+
if (list.length === 1) {
|
|
220
|
+
projectId = list[0].id;
|
|
221
|
+
prefix = list[0].prefix;
|
|
222
|
+
}
|
|
223
|
+
else if (isInteractive()) {
|
|
224
|
+
const chosen = await pickExisting("Select a project", list, (p) => `${clean(p.prefix)} — ${clean(p.name)}`);
|
|
225
|
+
projectId = chosen.id;
|
|
226
|
+
prefix = chosen.prefix;
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
cliError("AMBIGUOUS", `Org ${org} has ${list.length} projects, so one has to be named.`, `Pass --project-id <id> (or run interactively): ${list.map((p) => `${p.prefix}=${p.id}`).join(", ")}`);
|
|
230
|
+
}
|
|
128
231
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (!isInteractive()) {
|
|
135
|
-
console.error("✗ refusing to replace the existing binding (a DIFFERENT org/project) non-interactively — pass --yes.");
|
|
136
|
-
process.exitCode = 1;
|
|
232
|
+
else if (isInteractive()) {
|
|
233
|
+
const chosen = await pick("Select a project", list, (p) => `${clean(p.prefix)} — ${clean(p.name)}`);
|
|
234
|
+
if (chosen) {
|
|
235
|
+
projectId = chosen.id;
|
|
236
|
+
prefix = chosen.prefix;
|
|
137
237
|
}
|
|
138
238
|
else {
|
|
139
|
-
|
|
239
|
+
const name = await ask("New project name: ");
|
|
240
|
+
const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
|
|
241
|
+
if (!name || !pfx)
|
|
242
|
+
throw new Error("project name and prefix required");
|
|
243
|
+
const p = (await api.createProject({ orgId, name, prefix: pfx }));
|
|
244
|
+
projectId = p.projectId;
|
|
245
|
+
prefix = p.prefix;
|
|
246
|
+
console.log(`✓ Created project ${p.prefix}.`);
|
|
140
247
|
}
|
|
141
|
-
return;
|
|
142
248
|
}
|
|
249
|
+
else if (list.length === 1) {
|
|
250
|
+
projectId = list[0].id;
|
|
251
|
+
prefix = list[0].prefix;
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
throw new Error("no project selected — pass --project-id <id>, or --project <name> --prefix <PFX>.");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// --- make `retasc` durable BEFORE anything is committed (RTSC-493) ---------
|
|
258
|
+
// The marker names a command something else spawns on every agent start, so it has to
|
|
259
|
+
// name one proved to run on this machine. Resolved (and announced) here so the install
|
|
260
|
+
// can't land after a key exists, and so every later message knows what to tell the user
|
|
261
|
+
// to type.
|
|
262
|
+
const launcher = resolveLauncher({ version: VERSION });
|
|
263
|
+
const note = launcherNote(launcher);
|
|
264
|
+
if (note)
|
|
265
|
+
console.log(note);
|
|
266
|
+
// --- mint a key for THIS (org, project) and wire the watchdog into THIS folder
|
|
267
|
+
const minted = (await api.mintKey({
|
|
268
|
+
orgId,
|
|
269
|
+
projectId: projectId,
|
|
270
|
+
agentName: opts.agent,
|
|
271
|
+
runtime: opts.runtime ?? "claude-code",
|
|
272
|
+
keyName: prefix ? `${prefix} key` : undefined,
|
|
273
|
+
}));
|
|
274
|
+
console.log(`✓ Minted key for this workspace (${minted.key.slice(0, 14)}…).\n`);
|
|
275
|
+
// RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
|
|
276
|
+
// keyed by a workspace id; reuse this folder's existing id (so a re-bind, or a
|
|
277
|
+
// teammate's committed marker, keeps the same id) or mint a fresh one.
|
|
278
|
+
const workspaceId = existing?.workspaceId ?? newWorkspaceId();
|
|
279
|
+
setBinding(workspaceId, {
|
|
280
|
+
orgId,
|
|
281
|
+
projectId: projectId,
|
|
282
|
+
key: minted.key,
|
|
283
|
+
url: cfg.mcpUrl,
|
|
284
|
+
prefix,
|
|
285
|
+
orgName: args.orgLabel,
|
|
286
|
+
boundPath: cwd,
|
|
287
|
+
createdAt: Date.now(),
|
|
288
|
+
});
|
|
289
|
+
args.onBound?.();
|
|
290
|
+
// Per-folder only (local scope), always watchdog. The marker carries only the
|
|
291
|
+
// workspace id — no secret — so ./.mcp.json is safe to commit.
|
|
292
|
+
installMarker({ workspaceId, scope: "local", launcher });
|
|
293
|
+
// Confirm the binding the same way the agent will see it.
|
|
294
|
+
try {
|
|
295
|
+
const b = await resolveBinding(cfg.mcpUrl, minted.key);
|
|
296
|
+
console.log(`\n✓ This folder is bound to org "${clean(b.org.name)}" / project ${clean(b.project.prefix)}.\n` +
|
|
297
|
+
` Agents launched here can only ever read or write ${clean(b.project.prefix)}.`);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* binding written; whoami confirmation is best-effort */
|
|
143
301
|
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Sign in first when there is no session at all (RTSC-481, extended to `join` by RTSC-492).
|
|
305
|
+
*
|
|
306
|
+
* `withAuth` deliberately refuses to start a device flow for someone who was
|
|
307
|
+
* never signed in: on an arbitrary call that would be a surprise browser prompt
|
|
308
|
+
* under a misleading "session expired". That reasoning holds for every OTHER
|
|
309
|
+
* command and is left alone. It does not hold for the two commands that are the WHOLE
|
|
310
|
+
* instruction someone is handed — `bind` for a new owner, `join` for an invited teammate —
|
|
311
|
+
* because "run this, but run the other one first" is not a single instruction. So the
|
|
312
|
+
* sign-in is explicit, announced, and only when a human is present to authorize it.
|
|
313
|
+
*/
|
|
314
|
+
export async function ensureSignedIn() {
|
|
315
|
+
if (loadConfig().token)
|
|
316
|
+
return;
|
|
317
|
+
if (!isInteractive()) {
|
|
318
|
+
throw new Error("Not signed in. Run `retasc login` first (no TTY here for the device flow).");
|
|
319
|
+
}
|
|
320
|
+
console.error("Not signed in yet — authenticating with GitHub first…");
|
|
321
|
+
await deviceLogin();
|
|
322
|
+
}
|
|
323
|
+
export async function bindAction(opts) {
|
|
324
|
+
await ensureSignedIn();
|
|
325
|
+
const cfg = loadConfig();
|
|
326
|
+
// --- loud on re-bind -------------------------------------------------------
|
|
327
|
+
// Runs before the org step on purpose: `--org-name` commits an org server-side, and a
|
|
328
|
+
// run that is about to be refused must not leave one behind (RTSC-297).
|
|
329
|
+
const guard = await rebindGuard({
|
|
330
|
+
cwd: process.cwd(),
|
|
331
|
+
mcpUrl: cfg.mcpUrl,
|
|
332
|
+
orgId: opts.orgId,
|
|
333
|
+
projectId: opts.projectId,
|
|
334
|
+
yes: opts.yes,
|
|
335
|
+
});
|
|
336
|
+
if (!guard.proceed)
|
|
337
|
+
return;
|
|
338
|
+
const existing = guard.existing;
|
|
144
339
|
// --- resolve org (pick / flag / create) ------------------------------------
|
|
145
340
|
// Track whether THIS run created the org (vs. selected a pre-existing one or
|
|
146
341
|
// took one via --org-id). An org is created and committed server-side BEFORE
|
|
@@ -187,79 +382,16 @@ export async function bindAction(opts) {
|
|
|
187
382
|
// the org this run, name it and print the resume command (RTSC-297).
|
|
188
383
|
let bindingWritten = false;
|
|
189
384
|
try {
|
|
190
|
-
|
|
191
|
-
let projectId = opts.projectId;
|
|
192
|
-
let prefix;
|
|
193
|
-
if (!projectId && opts.project && opts.prefix) {
|
|
194
|
-
const p = (await api.createProject({ orgId: orgId, name: opts.project, prefix: opts.prefix }));
|
|
195
|
-
projectId = p.projectId;
|
|
196
|
-
prefix = p.prefix;
|
|
197
|
-
console.log(`✓ Created project ${p.prefix}.`);
|
|
198
|
-
}
|
|
199
|
-
if (!projectId) {
|
|
200
|
-
const { projects } = (await api.listProjects({ orgId: orgId }));
|
|
201
|
-
const list = projects ?? [];
|
|
202
|
-
if (isInteractive()) {
|
|
203
|
-
const chosen = await pick("Select a project", list, (p) => `${p.prefix} — ${p.name}`);
|
|
204
|
-
if (chosen) {
|
|
205
|
-
projectId = chosen.id;
|
|
206
|
-
prefix = chosen.prefix;
|
|
207
|
-
}
|
|
208
|
-
else {
|
|
209
|
-
const name = await ask("New project name: ");
|
|
210
|
-
const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
|
|
211
|
-
if (!name || !pfx)
|
|
212
|
-
throw new Error("project name and prefix required");
|
|
213
|
-
const p = (await api.createProject({ orgId: orgId, name, prefix: pfx }));
|
|
214
|
-
projectId = p.projectId;
|
|
215
|
-
prefix = p.prefix;
|
|
216
|
-
console.log(`✓ Created project ${p.prefix}.`);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
else if (list.length === 1) {
|
|
220
|
-
projectId = list[0].id;
|
|
221
|
-
prefix = list[0].prefix;
|
|
222
|
-
}
|
|
223
|
-
else {
|
|
224
|
-
throw new Error("no project selected — pass --project-id <id>, or --project <name> --prefix <PFX>.");
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
// --- mint a key for THIS (org, project) and wire the watchdog into THIS folder
|
|
228
|
-
const minted = (await api.mintKey({
|
|
229
|
-
orgId: orgId,
|
|
230
|
-
projectId: projectId,
|
|
231
|
-
agentName: opts.agent,
|
|
232
|
-
runtime: opts.runtime ?? "claude-code",
|
|
233
|
-
keyName: prefix ? `${prefix} key` : undefined,
|
|
234
|
-
}));
|
|
235
|
-
console.log(`✓ Minted key for this workspace (${minted.key.slice(0, 14)}…).\n`);
|
|
236
|
-
// RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
|
|
237
|
-
// keyed by a workspace id; reuse this folder's existing id (so a re-bind, or a
|
|
238
|
-
// teammate's committed marker, keeps the same id) or mint a fresh one.
|
|
239
|
-
const workspaceId = existing?.workspaceId ?? newWorkspaceId();
|
|
240
|
-
setBinding(workspaceId, {
|
|
385
|
+
await completeWorkspaceSetup({
|
|
241
386
|
orgId: orgId,
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
387
|
+
opts,
|
|
388
|
+
existing,
|
|
389
|
+
// `bind` is the OWNER's command — creating a project is the point of its picker.
|
|
390
|
+
canCreateProject: true,
|
|
391
|
+
onBound: () => {
|
|
392
|
+
bindingWritten = true; // strand window closed — the org is now reachable
|
|
393
|
+
},
|
|
249
394
|
});
|
|
250
|
-
bindingWritten = true; // strand window closed — the org is now reachable
|
|
251
|
-
// Per-folder only (local scope), always watchdog. The marker carries only the
|
|
252
|
-
// workspace id — no secret — so ./.mcp.json is safe to commit.
|
|
253
|
-
installMarker({ workspaceId, scope: "local" });
|
|
254
|
-
// Confirm the binding the same way the agent will see it.
|
|
255
|
-
try {
|
|
256
|
-
const b = await resolveBinding(cfg.mcpUrl, minted.key);
|
|
257
|
-
console.log(`\n✓ This folder is bound to org "${b.org.name}" / project ${b.project.prefix}.\n` +
|
|
258
|
-
` Agents launched here can only ever read or write ${b.project.prefix}.`);
|
|
259
|
-
}
|
|
260
|
-
catch {
|
|
261
|
-
/* binding written; whoami confirmation is best-effort */
|
|
262
|
-
}
|
|
263
395
|
}
|
|
264
396
|
catch (err) {
|
|
265
397
|
const hint = strandRecoveryHint({ createdOrgThisRun, orgId, bindingWritten });
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,6 +2,7 @@ import { loadConfig } from "../config.js";
|
|
|
2
2
|
import { claudeConfigPath, isNetworkError, readGlobalBinding, readLocalBinding, readShadowedBinding, resolveBinding, sameIdentity, } from "../lib/binding.js";
|
|
3
3
|
import { getBinding } from "../lib/keystore.js";
|
|
4
4
|
import { runsOk } from "../lib/launcher.js";
|
|
5
|
+
import { clean } from "../lib/text.js";
|
|
5
6
|
// RTSC-91 (DESIGN §13): `retasc doctor` — confirm THIS folder is correctly and
|
|
6
7
|
// safely bound. The question a human actually has is "which org/project does
|
|
7
8
|
// this folder talk to?", so the healthy answer is ONE line naming them.
|
|
@@ -20,7 +21,8 @@ const bad = (m) => console.log(` ✗ ${m}`);
|
|
|
20
21
|
// Server- and config-derived strings end up inside doctor's verdict lines; a
|
|
21
22
|
// hostile endpoint (reachable via a config-supplied legacy url) must not be
|
|
22
23
|
// able to smuggle ANSI escapes into the very output people trust for ✓/✗.
|
|
23
|
-
|
|
24
|
+
// Shared with join/bind — see lib/text.ts for why anything off the wire is sanitized
|
|
25
|
+
// before it reaches a terminal (RTSC-492).
|
|
24
26
|
/**
|
|
25
27
|
* RTSC-493: can the registered entry actually be STARTED on this machine?
|
|
26
28
|
*
|
|
@@ -43,6 +45,39 @@ export function launcherVerdict(local, probe = runsOk) {
|
|
|
43
45
|
shown: clean([local.command, ...probeArgs].join(" ")),
|
|
44
46
|
};
|
|
45
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* RTSC-498: macOS is the only platform this CLI is tested on. That was true and written
|
|
50
|
+
* down nowhere, which left someone on Linux or Windows unable to tell their own mistake
|
|
51
|
+
* from a bug from a platform we never targeted.
|
|
52
|
+
*
|
|
53
|
+
* It says so HERE and not in the README because the README is read by everyone, and for
|
|
54
|
+
* the majority on macOS a standing disclaimer about platforms they aren't using is pure
|
|
55
|
+
* noise. `doctor` is the one surface that already knows which machine it's on, so the
|
|
56
|
+
* people it concerns are exactly the people who see it. On darwin this returns null and
|
|
57
|
+
* nothing is printed.
|
|
58
|
+
*
|
|
59
|
+
* Deliberately NOT a ✗ or a ! — this is context, not a fault in the folder being checked,
|
|
60
|
+
* and it must not read as "your setup is broken".
|
|
61
|
+
*/
|
|
62
|
+
export function platformNote(platform = process.platform) {
|
|
63
|
+
if (platform === "darwin")
|
|
64
|
+
return null;
|
|
65
|
+
// Windows and Linux are the two we've actually committed to reaching. Everything else
|
|
66
|
+
// (freebsd, aix, sunos…) is untested AND unplanned, so it must not be told its support
|
|
67
|
+
// is "on the list" — that would be exactly the unearned promise this note exists to
|
|
68
|
+
// avoid making. It still gets the note; it just doesn't get the roadmap sentence.
|
|
69
|
+
const planned = platform === "win32" ? "Windows" : platform === "linux" ? "Linux" : null;
|
|
70
|
+
// `clean` for the same reason every other interpolation in this file uses it: nothing
|
|
71
|
+
// reaches a verdict line able to smuggle ANSI escapes. `process.platform` is a Node
|
|
72
|
+
// build-time constant today, but this is an exported function with a caller-supplied
|
|
73
|
+
// parameter, and the safe version costs nothing.
|
|
74
|
+
const name = planned ?? clean(platform);
|
|
75
|
+
const note = `Note: ${name} is not a platform we test. macOS is the only one we support today.\n` +
|
|
76
|
+
` Much of the CLI is plain Node and should work fine here, but nothing on this\n` +
|
|
77
|
+
` platform is exercised, so a failure isn't a regression against a promise we\n` +
|
|
78
|
+
` made.`;
|
|
79
|
+
return planned ? `${note} Proper ${planned} support is on the list, as bandwidth allows.` : note;
|
|
80
|
+
}
|
|
46
81
|
function checkLauncher(local) {
|
|
47
82
|
const v = launcherVerdict(local);
|
|
48
83
|
if (!v)
|
|
@@ -164,4 +199,9 @@ export async function doctorAction() {
|
|
|
164
199
|
` own, so issues can land in the wrong project.\n` +
|
|
165
200
|
` Fix: claude mcp remove -s user retasc`);
|
|
166
201
|
}
|
|
202
|
+
// 3) Ambient context, not a verdict on this folder — so it trails the checks
|
|
203
|
+
// rather than framing them, and on macOS it prints nothing at all.
|
|
204
|
+
const note = platformNote();
|
|
205
|
+
if (note)
|
|
206
|
+
console.log(`\n${note}`);
|
|
167
207
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { api } from "../api.js";
|
|
2
|
+
import { clean } from "../lib/text.js";
|
|
3
|
+
import { isInteractive } from "./bind.js";
|
|
4
|
+
import { identityLoop } from "./join.js";
|
|
5
|
+
/**
|
|
6
|
+
* Which org's placeholders to offer.
|
|
7
|
+
*
|
|
8
|
+
* Same rule as `retasc billing`: explicit `--org-id` wins, one membership needs no
|
|
9
|
+
* question, several must be named. Deliberately NOT the folder binding — that resolves
|
|
10
|
+
* through an agent key, and this is a question only a signed-in human can answer, so
|
|
11
|
+
* reading identity off the folder would name an org this person might not even be in.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveOrg(orgs, orgId) {
|
|
14
|
+
// Org names are human-typed and land in a terminal that acts on escape sequences, the
|
|
15
|
+
// same rule the ghost rows follow. `clean` here rather than at each interpolation so a
|
|
16
|
+
// later line added to this list cannot forget it.
|
|
17
|
+
const list = () => orgs.map((o) => ` ${o.id} ${clean(o.name)}${o.slug ? ` (${clean(o.slug)})` : ""}`);
|
|
18
|
+
if (orgId) {
|
|
19
|
+
const found = orgs.find((o) => o.id === orgId);
|
|
20
|
+
// REFUSE an id we can't see, rather than passing it through for the server to reject.
|
|
21
|
+
// The server does not reject it: `memberOf` returns null for a non-member AND for a
|
|
22
|
+
// suspended one, and `claimableGhosts` maps null to `{asked: true}` — the exact shape a
|
|
23
|
+
// permanent decline returns. Passing through would make this command answer "you've
|
|
24
|
+
// already said none of these are you" to someone who simply typed the wrong id, which
|
|
25
|
+
// is a false statement about their own history and the one thing it must never say.
|
|
26
|
+
if (!found) {
|
|
27
|
+
throw new Error(`You're not an active member of ${clean(orgId)} — or that isn't an org id.` +
|
|
28
|
+
(orgs.length ? `\nOrgs you can ask about:\n${list().join("\n")}` : ""));
|
|
29
|
+
}
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
if (orgs.length === 0)
|
|
33
|
+
throw new Error("You're not a member of any org yet.");
|
|
34
|
+
if (orgs.length > 1) {
|
|
35
|
+
throw new Error(`Several orgs — pass --org-id <id>:\n${list().join("\n")}`);
|
|
36
|
+
}
|
|
37
|
+
return orgs[0];
|
|
38
|
+
}
|
|
39
|
+
/** What to print once the loop is done. `join` prints nothing; this command must. */
|
|
40
|
+
function report(outcome, orgLabel) {
|
|
41
|
+
switch (outcome) {
|
|
42
|
+
case "claimed":
|
|
43
|
+
case "unavailable":
|
|
44
|
+
// Both already said their piece, line by line, as they happened.
|
|
45
|
+
return;
|
|
46
|
+
case "none":
|
|
47
|
+
// The common answer, and a genuinely good one — say it plainly rather than exiting
|
|
48
|
+
// silently, which reads like the command failed to run.
|
|
49
|
+
console.log(`Nothing waiting for you in ${orgLabel}.`);
|
|
50
|
+
console.log("Run this again after a migration — each import brings its own people across.");
|
|
51
|
+
return;
|
|
52
|
+
case "answered":
|
|
53
|
+
// The permanent decline. Worth its own wording: from in here it is indistinguishable
|
|
54
|
+
// from "none", and someone who declined during an earlier import and now has a real
|
|
55
|
+
// placeholder from a NEW one would otherwise read "nothing waiting" as the truth.
|
|
56
|
+
console.log(`You've already said none of the imported people in ${orgLabel} are you.`);
|
|
57
|
+
console.log("That answer covers the whole org, including later migrations.");
|
|
58
|
+
console.log("If a newer import did carry you across, an owner can link it from the Team page.");
|
|
59
|
+
return;
|
|
60
|
+
case "declined":
|
|
61
|
+
console.log("Noted — you won't be asked again.");
|
|
62
|
+
return;
|
|
63
|
+
case "left":
|
|
64
|
+
console.log("Nothing linked. Run `retasc identity` again whenever you want to look.");
|
|
65
|
+
return;
|
|
66
|
+
case "skipped":
|
|
67
|
+
// Only reachable non-interactively; the interactive guard below catches that first.
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function identityAction(opts, deps = {}) {
|
|
72
|
+
const d = {
|
|
73
|
+
me: () => api.me(),
|
|
74
|
+
// One implementation of the irreversible question, shared with `join`. Two surfaces
|
|
75
|
+
// asking it in two different voices is how someone ends up answering the one they trust
|
|
76
|
+
// less — the same reason its wording already mirrors the Dash notice.
|
|
77
|
+
loop: (orgId, orgLabel) => identityLoop(orgId, {}, orgLabel),
|
|
78
|
+
interactive: isInteractive,
|
|
79
|
+
...deps,
|
|
80
|
+
};
|
|
81
|
+
// Refuse up front rather than exiting 0 in silence. `identityLoop` returns "skipped"
|
|
82
|
+
// without a TTY — correct inside `join`, where the folder setup is the point and the
|
|
83
|
+
// question is optional, and wrong here, where the question IS the command.
|
|
84
|
+
if (!d.interactive()) {
|
|
85
|
+
throw new Error("`retasc identity` needs an interactive terminal — linking an imported person is irreversible, so it always asks first.");
|
|
86
|
+
}
|
|
87
|
+
const me = await d.me();
|
|
88
|
+
const org = resolveOrg(me.orgs, opts.orgId);
|
|
89
|
+
// The NAME alone, not "name (slug)". It lands mid-sentence in both surfaces — "When Acme
|
|
90
|
+
// migrated…" and "Nothing waiting for you in Acme." The slug only earns its place in the
|
|
91
|
+
// several-orgs error, where it is there to be copied.
|
|
92
|
+
const orgLabel = clean(org.name);
|
|
93
|
+
const outcome = await d.loop(org.id, orgLabel);
|
|
94
|
+
report(outcome, orgLabel);
|
|
95
|
+
return outcome;
|
|
96
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { api, formatError } from "../api.js";
|
|
2
|
+
import { loadConfig } from "../config.js";
|
|
3
|
+
import { parseInviteCode } from "../lib/invite.js";
|
|
4
|
+
import { selfCommand } from "../lib/launcher.js";
|
|
5
|
+
import { clean } from "../lib/text.js";
|
|
6
|
+
import { VERSION } from "../version.js";
|
|
7
|
+
import { ask, confirm, ensureSignedIn, isInteractive, pickExisting, rebindGuard, completeWorkspaceSetup, } from "./bind.js";
|
|
8
|
+
/** A ghost whose origin predates `sourceLabel`. Never invent a tool we don't know. */
|
|
9
|
+
const UNKNOWN_SOURCE = "another tool";
|
|
10
|
+
/**
|
|
11
|
+
* A stop that cannot loop forever.
|
|
12
|
+
*
|
|
13
|
+
* The loop is driven by a server list that shrinks on every claim, so it terminates on its
|
|
14
|
+
* own — but it is a `while (true)` around a network call, and a backend that ever returned
|
|
15
|
+
* an unchanged list would spin a human's terminal rather than fail. Well above any real
|
|
16
|
+
* number of migrated tools.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_IDENTITY_ROUNDS = 10;
|
|
19
|
+
/** How one row reads: the person, and the tool they were carried in from. */
|
|
20
|
+
function ghostRow(g) {
|
|
21
|
+
// BOTH fields are third-party text: the name was typed by someone in ClickUp/Jira/Asana
|
|
22
|
+
// and carried across verbatim, and `sourceLabel` is the adapter's own record of where.
|
|
23
|
+
// They print into a terminal that acts on escape sequences, right above a prompt.
|
|
24
|
+
return `${clean(g.name)} from ${clean(g.sourceLabel ?? UNKNOWN_SOURCE)}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* "Which of these imported people is you?" — the CLI half of RTSC-433/473.
|
|
28
|
+
*
|
|
29
|
+
* Mirrors the Dash notice (`dash/src/components/GhostClaim.tsx`) deliberately, down to the
|
|
30
|
+
* confirm wording in `dash/src/lib/ghostPrompt.ts`. Two surfaces asking the same
|
|
31
|
+
* irreversible question in two different voices is how someone ends up answering the one
|
|
32
|
+
* they trust less.
|
|
33
|
+
*
|
|
34
|
+
* Never fatal. Everything here is about attribution; the bind that follows is what makes
|
|
35
|
+
* the folder work, and a failure to offer a claim must not cost someone their workspace.
|
|
36
|
+
*/
|
|
37
|
+
export async function identityLoop(orgId, opts, orgLabel,
|
|
38
|
+
// Injected so the question itself can be pinned by tests. It is asked once per human per
|
|
39
|
+
// org and it is irreversible, so "we ran it live and it looked right" is not evidence —
|
|
40
|
+
// the live orgs available to us have all already answered, which exercises only the
|
|
41
|
+
// silent path. What has to be provable is the loud one.
|
|
42
|
+
deps = {}) {
|
|
43
|
+
const d = {
|
|
44
|
+
claimableGhosts: api.claimableGhosts,
|
|
45
|
+
claimGhost: api.claimGhost,
|
|
46
|
+
dismissGhostPrompt: api.dismissGhostPrompt,
|
|
47
|
+
askFn: ask,
|
|
48
|
+
confirmFn: confirm,
|
|
49
|
+
interactive: isInteractive,
|
|
50
|
+
...deps,
|
|
51
|
+
};
|
|
52
|
+
// `--yes` may SKIP this question. It may never answer it: claiming pulls another human's
|
|
53
|
+
// authorship AND their dispatch lane onto your account, irreversibly, and there is no
|
|
54
|
+
// CLI path back. A scripted run has nobody to be wrong on behalf of.
|
|
55
|
+
if (!d.interactive() || opts.yes)
|
|
56
|
+
return "skipped";
|
|
57
|
+
// Whether anything was linked BEFORE the round that ends the loop. A claim is followed by
|
|
58
|
+
// another round (a second migrated tool may still be offerable), and that round normally
|
|
59
|
+
// ends in "nothing left" — which must not erase the claim that just happened.
|
|
60
|
+
let claimedAny = false;
|
|
61
|
+
for (let round = 0; round < MAX_IDENTITY_ROUNDS; round++) {
|
|
62
|
+
let ghosts;
|
|
63
|
+
try {
|
|
64
|
+
const res = await d.claimableGhosts({ orgId });
|
|
65
|
+
// Two different endings the server returns on one shape. "Answered" is this human's
|
|
66
|
+
// permanent org-wide decline; "none" is simply an empty list, and they can be asked
|
|
67
|
+
// again after the next migration. `join` treats both as silence; `identity` does not.
|
|
68
|
+
if (res.asked)
|
|
69
|
+
return claimedAny ? "claimed" : "answered";
|
|
70
|
+
if (!res.ghosts.length)
|
|
71
|
+
return claimedAny ? "claimed" : "none";
|
|
72
|
+
ghosts = res.ghosts;
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
const { message } = formatError(e);
|
|
76
|
+
console.error(` ! Couldn't check for imported history (${message}). Carrying on.`);
|
|
77
|
+
return claimedAny ? "claimed" : "unavailable";
|
|
78
|
+
}
|
|
79
|
+
console.log(round === 0
|
|
80
|
+
? `\nWhen ${orgLabel ?? "this org"} migrated, some people were carried across.\n` +
|
|
81
|
+
"One of them may be you:"
|
|
82
|
+
: "\nAnything else here you recognise?");
|
|
83
|
+
// "Don't ask again" is not decoration. Declining writes `ghostPromptDismissedAt` on the
|
|
84
|
+
// member row and `claimGhost` refuses for good afterwards, org-wide — so a bare "none
|
|
85
|
+
// of these are me" would read as "none of these ClickUp ones" and quietly close the
|
|
86
|
+
// door on a second tool's placeholder too.
|
|
87
|
+
// Compared by REFERENCE below, not by a magic id value: a sentinel that is merely a
|
|
88
|
+
// row with an empty id would let any future ghost with a falsy id read as a decline —
|
|
89
|
+
// and a decline is permanent.
|
|
90
|
+
const NONE = { id: "", name: "None of these are me (don't ask again)", sourceLabel: null };
|
|
91
|
+
// No label: the heading above is the prompt, and `choose` would print a second one.
|
|
92
|
+
const chosen = await pickExisting("", [...ghosts, NONE], (g) => (g === NONE ? g.name : ghostRow(g)), d.askFn);
|
|
93
|
+
if (chosen === NONE) {
|
|
94
|
+
try {
|
|
95
|
+
await d.dismissGhostPrompt({ orgId });
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
console.error(` ! ${formatError(e).message}`);
|
|
99
|
+
// NOT "declined". A caller answers that with "you won't be asked again", and the
|
|
100
|
+
// write is the only thing that makes it true — the flag never landed, so they WILL
|
|
101
|
+
// be asked again. The error is already on stderr; `unavailable` adds no second
|
|
102
|
+
// sentence on top of it rather than a reassuring false one.
|
|
103
|
+
return "unavailable";
|
|
104
|
+
}
|
|
105
|
+
return "declined";
|
|
106
|
+
}
|
|
107
|
+
// Confirm before claiming — it is irreversible and it moves dispatch routing, so it
|
|
108
|
+
// must not be a single keystroke on a row in a list. Same qualitative wording as the
|
|
109
|
+
// Dash: counting "12 issues, 4 comments" would mean an org-wide scan on exactly the
|
|
110
|
+
// orgs that just ingested a migration.
|
|
111
|
+
const from = clean(chosen.sourceLabel ?? UNKNOWN_SOURCE);
|
|
112
|
+
const question = `\n Link "${clean(chosen.name)}" to your account? Everything they wrote, commented on,\n` +
|
|
113
|
+
` or were assigned in ${from} becomes yours. This can't be undone.`;
|
|
114
|
+
if (!(await d.confirmFn(question))) {
|
|
115
|
+
// NOT a dismissal. They declined THIS row, which is not the same as "none of these
|
|
116
|
+
// are me" — and dismissing is permanent, so it only ever happens when said outright.
|
|
117
|
+
console.log(" Left unlinked.");
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const res = await d.claimGhost({ orgId, memberId: chosen.id });
|
|
122
|
+
console.log(`✓ Linked ${clean(res.name)}.`);
|
|
123
|
+
claimedAny = true;
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
// SOURCE_ALREADY_CLAIMED, IMPORT_RUNNING, NOT_CLAIMABLE — all readable, all reachable
|
|
127
|
+
// by racing the Dash, and none of them a reason to abandon the setup.
|
|
128
|
+
const { code, message, hint } = formatError(e);
|
|
129
|
+
console.error(` ✗ ${code ? `${code}: ` : ""}${message}`);
|
|
130
|
+
if (hint)
|
|
131
|
+
console.error(` → ${hint}`);
|
|
132
|
+
return claimedAny ? "claimed" : "unavailable";
|
|
133
|
+
}
|
|
134
|
+
// Claiming one ClickUp identity removes EVERY remaining ClickUp row (`claimableGhosts`
|
|
135
|
+
// filters by spent source), so the next round can only ever offer a different tool.
|
|
136
|
+
}
|
|
137
|
+
// Round budget spent. Only reachable by declining individual rows over and over — the
|
|
138
|
+
// list is unchanged each time, so nothing was linked and nothing was answered for good.
|
|
139
|
+
return claimedAny ? "claimed" : "left";
|
|
140
|
+
}
|
|
141
|
+
export async function joinAction(link, opts) {
|
|
142
|
+
// Client-side, before anything else: the code is what the server matches, and someone
|
|
143
|
+
// who pasted the wrong thing should hear it here rather than as "not recognized".
|
|
144
|
+
const code = parseInviteCode(link);
|
|
145
|
+
await ensureSignedIn();
|
|
146
|
+
// --- 2. redeem ------------------------------------------------------------
|
|
147
|
+
// Any failure here stops the run. A dead invite means there is no org to bind to, and
|
|
148
|
+
// binding this folder to nothing would be worse than stopping.
|
|
149
|
+
const res = (await api.acceptInvite({ code }));
|
|
150
|
+
const orgLabel = res.slug ? clean(res.slug) : undefined;
|
|
151
|
+
const where = orgLabel ? ` "${orgLabel}"` : "";
|
|
152
|
+
if (res.alreadyMember) {
|
|
153
|
+
// Not a failure: the code stays unconsumed by design, so the person it was meant for
|
|
154
|
+
// can still use it. Say so and carry on into the folder half.
|
|
155
|
+
console.log(`• You're already a member of org${where} (${res.role}).`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
console.log(`✓ Joined org${where} as a ${res.role}.`);
|
|
159
|
+
}
|
|
160
|
+
if (opts.bind === false) {
|
|
161
|
+
// `--no-bind` is redeem-only, exactly as `join` behaved before RTSC-492 — including
|
|
162
|
+
// asking nothing, so anything scripted against it sees the same output and the same
|
|
163
|
+
// exit code. The identity prompt still reaches them through the Dash.
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// --- 3. identity ----------------------------------------------------------
|
|
167
|
+
// BEFORE anything about this folder, including the re-bind guard. Ghosts are rows in
|
|
168
|
+
// `members`, which carries an `orgId` and no `projectId`: the question belongs to the org
|
|
169
|
+
// just joined, and this is the only time `join` asks it. Gating it behind the folder half
|
|
170
|
+
// would mean someone whose folder is already bound elsewhere — who declines the re-bind —
|
|
171
|
+
// silently never sees it, and their imported history stays stranded.
|
|
172
|
+
await identityLoop(res.orgId, opts, orgLabel);
|
|
173
|
+
// --- the folder half ------------------------------------------------------
|
|
174
|
+
const cfg = loadConfig();
|
|
175
|
+
const self = selfCommand(VERSION);
|
|
176
|
+
const resume = `${self} bind --org-id ${res.orgId}`;
|
|
177
|
+
const guard = await rebindGuard({
|
|
178
|
+
cwd: process.cwd(),
|
|
179
|
+
mcpUrl: cfg.mcpUrl,
|
|
180
|
+
orgId: res.orgId,
|
|
181
|
+
projectId: opts.projectId,
|
|
182
|
+
yes: opts.yes,
|
|
183
|
+
});
|
|
184
|
+
if (!guard.proceed) {
|
|
185
|
+
// The membership landed either way — never let a declined or no-op re-bind read as a
|
|
186
|
+
// failed join. `rebindGuard` has already said what it did with the folder, INCLUDING
|
|
187
|
+
// setting a non-zero exit code for the non-interactive refusal, so don't paper over
|
|
188
|
+
// that with a line that reads like everything is finished.
|
|
189
|
+
if (process.exitCode)
|
|
190
|
+
console.error(` You did join org${where}; only the folder was left alone.`);
|
|
191
|
+
else
|
|
192
|
+
console.log(` You're a member of org${where}. Nothing else to do here.`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await completeWorkspaceSetup({
|
|
197
|
+
orgId: res.orgId,
|
|
198
|
+
orgLabel,
|
|
199
|
+
opts,
|
|
200
|
+
existing: guard.existing,
|
|
201
|
+
// `createProject` is `requireOwner`, and an invite only ever grants `member`. Offering
|
|
202
|
+
// "+ create new" here would be offering a refusal dressed as a choice.
|
|
203
|
+
canCreateProject: false,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
// Say what SUCCEEDED before reporting the failure, and name the command that resumes.
|
|
208
|
+
// The membership (and any identity claim) already landed and cannot be re-done: the
|
|
209
|
+
// invite is consumed now, so re-running `join` with the same link would report
|
|
210
|
+
// "already used" and read like the whole thing failed.
|
|
211
|
+
console.error(`\n You did join org${where} — only this folder's setup didn't finish.`);
|
|
212
|
+
console.error(` Resume with: ${resume}`);
|
|
213
|
+
throw e;
|
|
214
|
+
}
|
|
215
|
+
if (isInteractive()) {
|
|
216
|
+
console.log("\nStart your agent in this folder and it'll pull from the queue.");
|
|
217
|
+
}
|
|
218
|
+
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { resolveLauncher, launcherNote, portableLauncher } from "../lib/launcher.js";
|
|
4
|
+
import { resolveLauncher, launcherNote, portableLauncher, } from "../lib/launcher.js";
|
|
5
5
|
import { VERSION } from "../version.js";
|
|
6
6
|
export const SERVER_NAME = "retasc";
|
|
7
7
|
/** Normalize a user-supplied scope string. `user` (global) is refused and
|
|
@@ -211,8 +211,10 @@ export function installMarker(opts) {
|
|
|
211
211
|
const scope = opts.scope ?? "local";
|
|
212
212
|
// RTSC-493: resolve BEFORE writing anything. The marker names a command something else
|
|
213
213
|
// will spawn later, so it has to name one that has been proved to run on this machine.
|
|
214
|
-
const resolved = resolveLauncher({ version: VERSION, install: opts.install });
|
|
215
|
-
|
|
214
|
+
const resolved = opts.launcher ?? resolveLauncher({ version: VERSION, install: opts.install });
|
|
215
|
+
// Only announce a resolve we did ourselves — a caller that passed one in already printed
|
|
216
|
+
// its note at the point in ITS sequence where the install actually happened.
|
|
217
|
+
const note = opts.launcher ? null : launcherNote(resolved);
|
|
216
218
|
if (note)
|
|
217
219
|
console.log(note);
|
|
218
220
|
// `local` scope lands in this machine's own Claude config, where the absolute path is
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { installMcp, normalizeScope } from "./commands/mcp.js";
|
|
|
6
6
|
import { installGate } from "./commands/gate.js";
|
|
7
7
|
import { claimAction } from "./commands/claim.js";
|
|
8
8
|
import { bindAction } from "./commands/bind.js";
|
|
9
|
+
import { joinAction } from "./commands/join.js";
|
|
10
|
+
import { identityAction } from "./commands/identity.js";
|
|
9
11
|
import { doctorAction } from "./commands/doctor.js";
|
|
10
12
|
import { billingAction } from "./commands/billing.js";
|
|
11
13
|
import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
|
|
@@ -320,7 +322,11 @@ members
|
|
|
320
322
|
const res = (await api.createInvite({ orgId: opts.orgId, expiresInDays: opts.expiresDays }));
|
|
321
323
|
console.log(`✓ Invite code: ${res.code}`);
|
|
322
324
|
console.log(` Expires ${new Date(res.expiresAt).toISOString().slice(0, 10)}. Single-use — shown once.`);
|
|
323
|
-
|
|
325
|
+
// RTSC-492: one command, and one that needs nothing installed first. `join` signs
|
|
326
|
+
// them in, binds the folder and wires their agent, so this must not tell them to run
|
|
327
|
+
// `retasc login` first — nor name a `retasc` binary they don't have yet.
|
|
328
|
+
console.log(` Share it. In the folder their agent works in, they run:`);
|
|
329
|
+
console.log(` npx @retasc/cli join ${res.code}`);
|
|
324
330
|
}
|
|
325
331
|
catch (e) {
|
|
326
332
|
fail(e);
|
|
@@ -354,25 +360,42 @@ members
|
|
|
354
360
|
fail(e);
|
|
355
361
|
}
|
|
356
362
|
});
|
|
363
|
+
// RTSC-492 — the WHOLE of an invited teammate's setup, in one command run from the folder
|
|
364
|
+
// their agent will work in: sign in, redeem, claim any imported history, pick the project,
|
|
365
|
+
// make `retasc` durable, mint a key, bind the folder and wire the MCP marker. It signs
|
|
366
|
+
// itself in (no `requireLogin` gate) because "run this, but run the other one first" is
|
|
367
|
+
// not a single instruction.
|
|
357
368
|
program
|
|
358
369
|
.command("join")
|
|
359
|
-
.description("
|
|
360
|
-
.argument("<
|
|
361
|
-
.
|
|
370
|
+
.description("Join an org from an invite link and set this folder up completely — one command.")
|
|
371
|
+
.argument("<link>", "The invite link you were given (or just the rtscinv_… code)")
|
|
372
|
+
.option("--no-bind", "Redeem only — don't set this folder up")
|
|
373
|
+
.option("--project-id <id>", "Which project to bind to (skips the picker)")
|
|
374
|
+
.option("--agent <name>", "Agent member name (default: auto)")
|
|
375
|
+
.option("--runtime <runtime>", "Agent runtime", "claude-code")
|
|
376
|
+
// RTSC-477 — name the way back. `--yes` skips the identity question and must never answer
|
|
377
|
+
// it, so the flag that causes the gap is the right place to say how to close it.
|
|
378
|
+
.option("-y, --yes", "Don't prompt to replace an existing binding, and skip the identity question (ask it later with `retasc identity`)")
|
|
379
|
+
.allowExcessArguments(false)
|
|
380
|
+
.action(async (link, opts) => {
|
|
381
|
+
await joinAction(link, opts).catch(fail);
|
|
382
|
+
});
|
|
383
|
+
// RTSC-477 — the same question `join` asks, on demand. `join` fires once, at the moment you
|
|
384
|
+
// accept an invite; placeholders arrive with EVERY migration, and claiming is per source, so
|
|
385
|
+
// the question recurs and needed a surface that recurs with it.
|
|
386
|
+
//
|
|
387
|
+
// No `-y/--yes`, and no `identity claim <name>` subcommand, on purpose: a claim is
|
|
388
|
+
// irreversible and moves dispatch routing, so it is never answered on a script's behalf.
|
|
389
|
+
// `allowExcessArguments(false)` therefore rejects `retasc identity claim …` outright rather
|
|
390
|
+
// than ignoring the words and prompting anyway.
|
|
391
|
+
program
|
|
392
|
+
.command("identity")
|
|
393
|
+
.description("Link imported history to your account: shows the people a migration carried into this org and asks which one is you.")
|
|
394
|
+
.option("--org-id <id>", "Which org (defaults to your only one).")
|
|
395
|
+
.allowExcessArguments(false)
|
|
396
|
+
.action(async (opts) => {
|
|
362
397
|
requireLogin();
|
|
363
|
-
|
|
364
|
-
const res = (await api.acceptInvite({ code }));
|
|
365
|
-
const where = res.slug ? ` "${res.slug}"` : "";
|
|
366
|
-
if (res.alreadyMember) {
|
|
367
|
-
console.log(`• You're already a member of org${where} (${res.role}). Nothing to do.`);
|
|
368
|
-
}
|
|
369
|
-
else {
|
|
370
|
-
console.log(`✓ Joined org${where} as ${res.role}. It'll show up in \`retasc whoami\`.`);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
catch (e) {
|
|
374
|
-
fail(e);
|
|
375
|
-
}
|
|
398
|
+
await identityAction({ orgId: opts.orgId }).catch(fail);
|
|
376
399
|
});
|
|
377
400
|
// --- mcp wiring ------------------------------------------------------------
|
|
378
401
|
const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// RTSC-492 — turn whatever the person was handed into the code the server matches.
|
|
2
|
+
//
|
|
3
|
+
// An invite reaches someone as a LINK (`https://dash.retasc.com/join#rtscinv_…`), and a
|
|
4
|
+
// link is what they paste. `acceptInvite` matches an exact string, and it should stay that
|
|
5
|
+
// way: normalising a paste is a property of the edge that accepts typed input, not of the
|
|
6
|
+
// mutation that grants membership. So the unwrapping happens here.
|
|
7
|
+
/** Every invite code the product has ever minted starts with this. */
|
|
8
|
+
const INVITE_PREFIX = "rtscinv_";
|
|
9
|
+
/**
|
|
10
|
+
* The invite code inside a link, a bare code, or a code someone pasted with prose
|
|
11
|
+
* around it.
|
|
12
|
+
*
|
|
13
|
+
* One rule rather than a parser: find the first token that LOOKS like an invite code,
|
|
14
|
+
* anywhere in the input. That covers the fragment (`…/join#rtscinv_x`), a query string
|
|
15
|
+
* (`?code=rtscinv_x`), a path segment, an email client's `<…>` wrapper and a trailing
|
|
16
|
+
* period from a sentence, without needing to know which of those the sender used — and
|
|
17
|
+
* without the URL-shape guessing that gets one of them wrong.
|
|
18
|
+
*
|
|
19
|
+
* A token that isn't our shape is still passed through UNCHANGED as long as it can't be a
|
|
20
|
+
* link. A future code format then keeps working with no CLI release, and the server's
|
|
21
|
+
* "That invite code isn't recognized" stays the authority on what's valid. Something
|
|
22
|
+
* link-shaped with no code in it is the one case we refuse locally, because forwarding a
|
|
23
|
+
* URL as a code can only ever produce that same message with none of the diagnosis.
|
|
24
|
+
*/
|
|
25
|
+
export function parseInviteCode(input) {
|
|
26
|
+
const raw = String(input ?? "").trim();
|
|
27
|
+
// Hex today, but accept the wider token charset a future format might use rather than
|
|
28
|
+
// pinning this to `[0-9a-f]{48}` and having to ship a release to read a new code.
|
|
29
|
+
const match = raw.match(new RegExp(`${INVITE_PREFIX}[A-Za-z0-9_-]+`));
|
|
30
|
+
if (match)
|
|
31
|
+
return match[0];
|
|
32
|
+
const bare = raw.replace(/^[<"'(]+|[>"'.,)]+$/g, "");
|
|
33
|
+
if (bare && !/[\s/:]/.test(bare))
|
|
34
|
+
return bare;
|
|
35
|
+
throw new Error(raw
|
|
36
|
+
? "That doesn't contain an invite code. Paste the whole invite link, or just the `rtscinv_…` code."
|
|
37
|
+
: "No invite code given. Pass the invite link, or just the `rtscinv_…` code.");
|
|
38
|
+
}
|
package/dist/lib/launcher.js
CHANGED
|
@@ -78,6 +78,24 @@ export function portableLauncher(r, version) {
|
|
|
78
78
|
return r.launcher;
|
|
79
79
|
return { command: "npx", args: ["-y", `${PKG}@${version}`] };
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* How to spell "run this CLI again", for a message printed BEFORE anything has been made
|
|
83
|
+
* durable (RTSC-492).
|
|
84
|
+
*
|
|
85
|
+
* `join` can stop for reasons that have nothing to do with us — an org with no projects,
|
|
86
|
+
* a mint that failed — and every one of those has to name the command that resumes. Until
|
|
87
|
+
* `resolveLauncher` has run there may be no `retasc` on this machine at all, so telling
|
|
88
|
+
* someone to type `retasc bind …` would be telling them to type a command that does not
|
|
89
|
+
* exist. That is the same defect as a marker naming a missing binary, just aimed at the
|
|
90
|
+
* human instead of the agent.
|
|
91
|
+
*
|
|
92
|
+
* Decided from argv rather than by probing: it costs no subprocess, and it names the form
|
|
93
|
+
* they DEMONSTRABLY have — they just used it. npx unpacks the package into its own `_npx`
|
|
94
|
+
* cache and puts nothing on PATH, so a script path under that cache is proof of an npx run.
|
|
95
|
+
*/
|
|
96
|
+
export function selfCommand(version, argv1 = process.argv[1] ?? "") {
|
|
97
|
+
return /[\\/]_npx[\\/]/.test(argv1) ? `npx -y ${PKG}@${version}` : "retasc";
|
|
98
|
+
}
|
|
81
99
|
/** Install (or upgrade to) an exact version globally. Returns null on success. */
|
|
82
100
|
function installGlobal(version) {
|
|
83
101
|
// A cold global install takes seconds with no output of its own. Silence here reads as
|
package/dist/lib/text.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip control characters from a string before printing it (RTSC-261, RTSC-492).
|
|
3
|
+
*
|
|
4
|
+
* Anything that came off the wire is untrusted output: it reaches `console.log` in a
|
|
5
|
+
* terminal that acts on escape sequences, so a value carrying `\x1b[…` can move the cursor,
|
|
6
|
+
* clear the line, or overwrite text the user is reading. That matters most where the text
|
|
7
|
+
* sits next to a decision — a name in a confirm prompt could be dressed up to look like the
|
|
8
|
+
* prompt itself.
|
|
9
|
+
*
|
|
10
|
+
* The riskiest strings in the product are the ones this exists for: an imported member's
|
|
11
|
+
* display name is chosen in ClickUp/Jira/Asana by someone who is not our user, carried
|
|
12
|
+
* across by a migration verbatim, and then shown to a human about to make an irreversible
|
|
13
|
+
* choice about it.
|
|
14
|
+
*
|
|
15
|
+
* Mirrors `sanitize` in `convex/lib/userError.ts` and the inline cleanup in `formatError`.
|
|
16
|
+
* The three are one rule; this is the copy the CLI's own printing paths share.
|
|
17
|
+
*/
|
|
18
|
+
export function clean(s) {
|
|
19
|
+
// eslint-disable-next-line no-control-regex
|
|
20
|
+
return String(s).replace(/[\x00-\x1f\x7f\u0085\u2028\u2029]/g, " ");
|
|
21
|
+
}
|
package/package.json
CHANGED