@retasc/cli 1.7.2 → 1.9.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 +265 -116
- package/dist/commands/claim.js +3 -0
- package/dist/commands/doctor.js +81 -1
- package/dist/commands/join.js +200 -0
- package/dist/commands/mcp.js +46 -24
- package/dist/index.js +23 -27
- package/dist/lib/binding.js +7 -2
- package/dist/lib/invite.js +38 -0
- package/dist/lib/launcher.js +200 -0
- package/dist/lib/text.js +21 -0
- package/dist/version.js +11 -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,14 +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
|
+
import { deviceLogin } from "../auth.js";
|
|
4
5
|
import { loadConfig } from "../config.js";
|
|
5
6
|
import { installMarker } from "./mcp.js";
|
|
6
7
|
import { readLocalBinding, resolveBinding } from "../lib/binding.js";
|
|
7
8
|
import { getBinding, setBinding, newWorkspaceId } from "../lib/keystore.js";
|
|
8
|
-
|
|
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() {
|
|
9
13
|
return Boolean(stdin.isTTY && stdout.isTTY);
|
|
10
14
|
}
|
|
11
|
-
async function ask(question) {
|
|
15
|
+
export async function ask(question) {
|
|
12
16
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
13
17
|
try {
|
|
14
18
|
// readline's question() NEVER settles when stdin hits EOF (Ctrl-D, or a
|
|
@@ -23,7 +27,7 @@ async function ask(question) {
|
|
|
23
27
|
rl.close();
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
|
-
async function confirm(question, assumeYes) {
|
|
30
|
+
export async function confirm(question, assumeYes) {
|
|
27
31
|
if (assumeYes)
|
|
28
32
|
return true;
|
|
29
33
|
if (!isInteractive())
|
|
@@ -32,8 +36,10 @@ async function confirm(question, assumeYes) {
|
|
|
32
36
|
return a === "y" || a === "yes";
|
|
33
37
|
}
|
|
34
38
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
37
43
|
*
|
|
38
44
|
* Invalid input re-prompts rather than falling through: undefined used to mean
|
|
39
45
|
* BOTH "chose create-new" and "typed garbage", so a typo like `1)` silently
|
|
@@ -43,26 +49,55 @@ async function confirm(question, assumeYes) {
|
|
|
43
49
|
* "4.0", which would route those straight to create-new — the same
|
|
44
50
|
* non-canonical-input-hits-the-destructive-branch bug in a new coat.
|
|
45
51
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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}:`);
|
|
50
57
|
items.forEach((it, i) => console.log(` ${i + 1}) ${render(it)}`));
|
|
51
|
-
const
|
|
52
|
-
|
|
58
|
+
const extraSlot = items.length + 1;
|
|
59
|
+
if (extra)
|
|
60
|
+
console.log(` ${extraSlot}) ${extra}`);
|
|
61
|
+
const highest = extra ? extraSlot : items.length;
|
|
53
62
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
54
63
|
const a = await askFn("Choose a number: ");
|
|
55
64
|
if (/^\d+$/.test(a)) {
|
|
56
65
|
const n = Number(a);
|
|
57
66
|
if (n >= 1 && n <= items.length)
|
|
58
67
|
return items[n - 1];
|
|
59
|
-
if (n ===
|
|
68
|
+
if (extra && n === extraSlot)
|
|
60
69
|
return undefined;
|
|
61
70
|
}
|
|
62
|
-
console.log(`Please enter a number between 1 and ${
|
|
71
|
+
console.log(`Please enter a number between 1 and ${highest}.`);
|
|
63
72
|
}
|
|
64
73
|
throw new Error("no valid choice — aborting");
|
|
65
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
|
+
}
|
|
66
101
|
/**
|
|
67
102
|
* The message to show when `bind` aborts mid-run, or null when there's nothing
|
|
68
103
|
* to say. `bind` commits a new org server-side before the project step; if a
|
|
@@ -82,48 +117,225 @@ export function strandRecoveryHint(args) {
|
|
|
82
117
|
`but has no project or key yet — re-run to finish:\n` +
|
|
83
118
|
` retasc bind --org-id ${orgId}`);
|
|
84
119
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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) {
|
|
89
137
|
// markerOnly (a cloned repo's committed marker, or an entry the CLI can't
|
|
90
138
|
// read a key from) never gates: there is no usable binding to "replace", and
|
|
91
139
|
// minting a key under the existing marker id is exactly what bind is FOR.
|
|
92
|
-
const existing = readLocalBinding(cwd);
|
|
93
|
-
if (existing
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
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 };
|
|
103
151
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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>.`);
|
|
108
201
|
}
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
}
|
|
111
231
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (!isInteractive()) {
|
|
118
|
-
console.error("✗ refusing to replace the existing binding (a DIFFERENT org/project) non-interactively — pass --yes.");
|
|
119
|
-
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;
|
|
120
237
|
}
|
|
121
238
|
else {
|
|
122
|
-
|
|
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}.`);
|
|
123
247
|
}
|
|
124
|
-
return;
|
|
125
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)}.`);
|
|
126
298
|
}
|
|
299
|
+
catch {
|
|
300
|
+
/* binding written; whoami confirmation is best-effort */
|
|
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;
|
|
127
339
|
// --- resolve org (pick / flag / create) ------------------------------------
|
|
128
340
|
// Track whether THIS run created the org (vs. selected a pre-existing one or
|
|
129
341
|
// took one via --org-id). An org is created and committed server-side BEFORE
|
|
@@ -170,79 +382,16 @@ export async function bindAction(opts) {
|
|
|
170
382
|
// the org this run, name it and print the resume command (RTSC-297).
|
|
171
383
|
let bindingWritten = false;
|
|
172
384
|
try {
|
|
173
|
-
|
|
174
|
-
let projectId = opts.projectId;
|
|
175
|
-
let prefix;
|
|
176
|
-
if (!projectId && opts.project && opts.prefix) {
|
|
177
|
-
const p = (await api.createProject({ orgId: orgId, name: opts.project, prefix: opts.prefix }));
|
|
178
|
-
projectId = p.projectId;
|
|
179
|
-
prefix = p.prefix;
|
|
180
|
-
console.log(`✓ Created project ${p.prefix}.`);
|
|
181
|
-
}
|
|
182
|
-
if (!projectId) {
|
|
183
|
-
const { projects } = (await api.listProjects({ orgId: orgId }));
|
|
184
|
-
const list = projects ?? [];
|
|
185
|
-
if (isInteractive()) {
|
|
186
|
-
const chosen = await pick("Select a project", list, (p) => `${p.prefix} — ${p.name}`);
|
|
187
|
-
if (chosen) {
|
|
188
|
-
projectId = chosen.id;
|
|
189
|
-
prefix = chosen.prefix;
|
|
190
|
-
}
|
|
191
|
-
else {
|
|
192
|
-
const name = await ask("New project name: ");
|
|
193
|
-
const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
|
|
194
|
-
if (!name || !pfx)
|
|
195
|
-
throw new Error("project name and prefix required");
|
|
196
|
-
const p = (await api.createProject({ orgId: orgId, name, prefix: pfx }));
|
|
197
|
-
projectId = p.projectId;
|
|
198
|
-
prefix = p.prefix;
|
|
199
|
-
console.log(`✓ Created project ${p.prefix}.`);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
else if (list.length === 1) {
|
|
203
|
-
projectId = list[0].id;
|
|
204
|
-
prefix = list[0].prefix;
|
|
205
|
-
}
|
|
206
|
-
else {
|
|
207
|
-
throw new Error("no project selected — pass --project-id <id>, or --project <name> --prefix <PFX>.");
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
// --- mint a key for THIS (org, project) and wire the watchdog into THIS folder
|
|
211
|
-
const minted = (await api.mintKey({
|
|
212
|
-
orgId: orgId,
|
|
213
|
-
projectId: projectId,
|
|
214
|
-
agentName: opts.agent,
|
|
215
|
-
runtime: opts.runtime ?? "claude-code",
|
|
216
|
-
keyName: prefix ? `${prefix} key` : undefined,
|
|
217
|
-
}));
|
|
218
|
-
console.log(`✓ Minted key for this workspace (${minted.key.slice(0, 14)}…).\n`);
|
|
219
|
-
// RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
|
|
220
|
-
// keyed by a workspace id; reuse this folder's existing id (so a re-bind, or a
|
|
221
|
-
// teammate's committed marker, keeps the same id) or mint a fresh one.
|
|
222
|
-
const workspaceId = existing?.workspaceId ?? newWorkspaceId();
|
|
223
|
-
setBinding(workspaceId, {
|
|
385
|
+
await completeWorkspaceSetup({
|
|
224
386
|
orgId: orgId,
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
+
},
|
|
232
394
|
});
|
|
233
|
-
bindingWritten = true; // strand window closed — the org is now reachable
|
|
234
|
-
// Per-folder only (local scope), always watchdog. The marker carries only the
|
|
235
|
-
// workspace id — no secret — so ./.mcp.json is safe to commit.
|
|
236
|
-
installMarker({ workspaceId, scope: "local" });
|
|
237
|
-
// Confirm the binding the same way the agent will see it.
|
|
238
|
-
try {
|
|
239
|
-
const b = await resolveBinding(cfg.mcpUrl, minted.key);
|
|
240
|
-
console.log(`\n✓ This folder is bound to org "${b.org.name}" / project ${b.project.prefix}.\n` +
|
|
241
|
-
` Agents launched here can only ever read or write ${b.project.prefix}.`);
|
|
242
|
-
}
|
|
243
|
-
catch {
|
|
244
|
-
/* binding written; whoami confirmation is best-effort */
|
|
245
|
-
}
|
|
246
395
|
}
|
|
247
396
|
catch (err) {
|
|
248
397
|
const hint = strandRecoveryHint({ createdOrgThisRun, orgId, bindingWritten });
|
package/dist/commands/claim.js
CHANGED
|
@@ -193,6 +193,9 @@ export async function claimAction(opts) {
|
|
|
193
193
|
note(`✓ Claimed ${issueId}${title ? ` — ${title}` : ""}`);
|
|
194
194
|
note(` worktree: ${plan.path}`);
|
|
195
195
|
note(` branch: ${plan.branch}`);
|
|
196
|
+
// RTSC-446: a fresh worktree checks out tracked files only — deps/artifacts are absent.
|
|
197
|
+
note(` Fresh worktree: only tracked files. Run the repo's usual setup (what docs/CI`);
|
|
198
|
+
note(` run on a fresh checkout) before your first build or test.`);
|
|
196
199
|
finish(plan.path, plan.branch, issueId, title, claim.claimToken, opts);
|
|
197
200
|
}
|
|
198
201
|
/** Emit machine output / drop into the worktree per the chosen flags. */
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
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
|
+
import { runsOk } from "../lib/launcher.js";
|
|
5
|
+
import { clean } from "../lib/text.js";
|
|
4
6
|
// RTSC-91 (DESIGN §13): `retasc doctor` — confirm THIS folder is correctly and
|
|
5
7
|
// safely bound. The question a human actually has is "which org/project does
|
|
6
8
|
// this folder talk to?", so the healthy answer is ONE line naming them.
|
|
@@ -19,7 +21,76 @@ const bad = (m) => console.log(` ✗ ${m}`);
|
|
|
19
21
|
// Server- and config-derived strings end up inside doctor's verdict lines; a
|
|
20
22
|
// hostile endpoint (reachable via a config-supplied legacy url) must not be
|
|
21
23
|
// able to smuggle ANSI escapes into the very output people trust for ✓/✗.
|
|
22
|
-
|
|
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).
|
|
26
|
+
/**
|
|
27
|
+
* RTSC-493: can the registered entry actually be STARTED on this machine?
|
|
28
|
+
*
|
|
29
|
+
* Every other check here asks whether the binding is right. This one asks whether it can
|
|
30
|
+
* run at all, which is a different failure and an invisible one: the entry names a
|
|
31
|
+
* command, something else spawns it later, and when that command isn't there the only
|
|
32
|
+
* symptom is an agent with no Retasc tools. Nothing points back here.
|
|
33
|
+
*
|
|
34
|
+
* Folders bound before the fix carry a bare `retasc` that npx never installed, and
|
|
35
|
+
* re-binding is what repairs them, so this is the surface that has to say so.
|
|
36
|
+
*/
|
|
37
|
+
export function launcherVerdict(local, probe = runsOk) {
|
|
38
|
+
if (!local.command)
|
|
39
|
+
return null; // HTTP transport spawns nothing
|
|
40
|
+
// The stored args end in `mcp-proxy` (the subcommand). Probe the LAUNCHER, so drop it.
|
|
41
|
+
const args = local.args ?? [];
|
|
42
|
+
const probeArgs = args[args.length - 1] === "mcp-proxy" ? args.slice(0, -1) : args;
|
|
43
|
+
return {
|
|
44
|
+
startable: Boolean(probe(local.command, probeArgs)),
|
|
45
|
+
shown: clean([local.command, ...probeArgs].join(" ")),
|
|
46
|
+
};
|
|
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
|
+
}
|
|
81
|
+
function checkLauncher(local) {
|
|
82
|
+
const v = launcherVerdict(local);
|
|
83
|
+
if (!v)
|
|
84
|
+
return;
|
|
85
|
+
if (v.startable) {
|
|
86
|
+
ok(`your agent can start Retasc (${v.shown}).`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
bad(`your agent CANNOT start Retasc — this folder is registered to run "${v.shown}",\n` +
|
|
90
|
+
` which doesn't run on this machine. The binding itself is fine; the command is missing.\n` +
|
|
91
|
+
` This is what a bind through npx used to leave behind.\n` +
|
|
92
|
+
` Fix: npm install -g @retasc/cli then re-run \`retasc bind\` here.`);
|
|
93
|
+
}
|
|
23
94
|
export async function doctorAction() {
|
|
24
95
|
const cfg = loadConfig();
|
|
25
96
|
const cwd = process.cwd();
|
|
@@ -69,6 +140,10 @@ export async function doctorAction() {
|
|
|
69
140
|
bad(`key not accepted by the server: ${msg}. Re-run \`retasc bind\`.`);
|
|
70
141
|
}
|
|
71
142
|
}
|
|
143
|
+
// Directly under the headline, because "bound to org X" reads as an all-clear and
|
|
144
|
+
// this is the one way it can be true and the folder still not work (RTSC-493).
|
|
145
|
+
// Local and independent of the server, so it runs even when the network is down.
|
|
146
|
+
checkLauncher(local);
|
|
72
147
|
// Caveats below the headline — each is a real risk, none is the common case.
|
|
73
148
|
if (local.workspaceId) {
|
|
74
149
|
// The id resolved, but if it was bound at a different folder, this marker
|
|
@@ -124,4 +199,9 @@ export async function doctorAction() {
|
|
|
124
199
|
` own, so issues can land in the wrong project.\n` +
|
|
125
200
|
` Fix: claude mcp remove -s user retasc`);
|
|
126
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}`);
|
|
127
207
|
}
|