@retasc/cli 1.7.0 → 1.7.2

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.
@@ -63,6 +63,25 @@ export async function pick(label, items, render, askFn = ask) {
63
63
  }
64
64
  throw new Error("no valid choice — aborting");
65
65
  }
66
+ /**
67
+ * The message to show when `bind` aborts mid-run, or null when there's nothing
68
+ * to say. `bind` commits a new org server-side before the project step; if a
69
+ * later step throws, that org is stranded (no project, no key, no binding) and
70
+ * unreachable from the CLI. There's no CLI-callable org delete, so we surface
71
+ * the id and the resume command instead (RTSC-297, Option 2).
72
+ *
73
+ * It fires ONLY for an org THIS run created that never got bound. A merely
74
+ * SELECTED org (or one already bound before the failure) is not ours to
75
+ * reclaim, and returns null — nothing extra is printed.
76
+ */
77
+ export function strandRecoveryHint(args) {
78
+ const { createdOrgThisRun, orgId, bindingWritten } = args;
79
+ if (!createdOrgThisRun || !orgId || bindingWritten)
80
+ return null;
81
+ return (`\n✗ Aborted before this folder was bound. A new org was created (${orgId}) ` +
82
+ `but has no project or key yet — re-run to finish:\n` +
83
+ ` retasc bind --org-id ${orgId}`);
84
+ }
66
85
  export async function bindAction(opts) {
67
86
  const cfg = loadConfig();
68
87
  const cwd = process.cwd();
@@ -106,10 +125,20 @@ export async function bindAction(opts) {
106
125
  }
107
126
  }
108
127
  // --- resolve org (pick / flag / create) ------------------------------------
128
+ // Track whether THIS run created the org (vs. selected a pre-existing one or
129
+ // took one via --org-id). An org is created and committed server-side BEFORE
130
+ // the project step runs, so any abort in the project/mint/bind steps below
131
+ // strands it: no project, no key, no local binding — invisible from the CLI,
132
+ // and (orgs land on the billing rail) accumulating against the account on
133
+ // repeated aborts. There's no CLI-callable org delete, so instead we name the
134
+ // stranded org in the error and print the command to resume (RTSC-297). A
135
+ // merely-SELECTED org must never trigger this — it isn't ours to reclaim.
109
136
  let orgId = opts.orgId;
137
+ let createdOrgThisRun = false;
110
138
  if (!orgId && opts.orgName) {
111
139
  const org = (await api.createOrg({ name: opts.orgName }));
112
140
  orgId = org.orgId;
141
+ createdOrgThisRun = true;
113
142
  console.log(`✓ Created org "${opts.orgName}".`);
114
143
  }
115
144
  if (!orgId) {
@@ -125,6 +154,7 @@ export async function bindAction(opts) {
125
154
  if (!name)
126
155
  throw new Error("org name required");
127
156
  orgId = (await api.createOrg({ name })).orgId;
157
+ createdOrgThisRun = true;
128
158
  console.log(`✓ Created org "${name}".`);
129
159
  }
130
160
  }
@@ -135,76 +165,89 @@ export async function bindAction(opts) {
135
165
  throw new Error("no org selected — pass --org-id <id> or --org-name <name> (or run interactively).");
136
166
  }
137
167
  }
138
- // --- resolve project (pick / flag / create) --------------------------------
139
- let projectId = opts.projectId;
140
- let prefix;
141
- if (!projectId && opts.project && opts.prefix) {
142
- const p = (await api.createProject({ orgId: orgId, name: opts.project, prefix: opts.prefix }));
143
- projectId = p.projectId;
144
- prefix = p.prefix;
145
- console.log(`✓ Created project ${p.prefix}.`);
146
- }
147
- if (!projectId) {
148
- const { projects } = (await api.listProjects({ orgId: orgId }));
149
- const list = projects ?? [];
150
- if (isInteractive()) {
151
- const chosen = await pick("Select a project", list, (p) => `${p.prefix} — ${p.name}`);
152
- if (chosen) {
153
- projectId = chosen.id;
154
- prefix = chosen.prefix;
168
+ // Everything from here to the binding write is the strand window: the org
169
+ // exists but the workspace isn't bound yet. If we abort in it after creating
170
+ // the org this run, name it and print the resume command (RTSC-297).
171
+ let bindingWritten = false;
172
+ try {
173
+ // --- resolve project (pick / flag / create) --------------------------------
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;
155
205
  }
156
206
  else {
157
- const name = await ask("New project name: ");
158
- const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
159
- if (!name || !pfx)
160
- throw new Error("project name and prefix required");
161
- const p = (await api.createProject({ orgId: orgId, name, prefix: pfx }));
162
- projectId = p.projectId;
163
- prefix = p.prefix;
164
- console.log(`✓ Created project ${p.prefix}.`);
207
+ throw new Error("no project selected — pass --project-id <id>, or --project <name> --prefix <PFX>.");
165
208
  }
166
209
  }
167
- else if (list.length === 1) {
168
- projectId = list[0].id;
169
- prefix = list[0].prefix;
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, {
224
+ orgId: orgId,
225
+ projectId: projectId,
226
+ key: minted.key,
227
+ url: cfg.mcpUrl,
228
+ prefix,
229
+ orgName: undefined,
230
+ boundPath: cwd,
231
+ createdAt: Date.now(),
232
+ });
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}.`);
170
242
  }
171
- else {
172
- throw new Error("no project selected pass --project-id <id>, or --project <name> --prefix <PFX>.");
243
+ catch {
244
+ /* binding written; whoami confirmation is best-effort */
173
245
  }
174
246
  }
175
- // --- mint a key for THIS (org, project) and wire the watchdog into THIS folder
176
- const minted = (await api.mintKey({
177
- orgId: orgId,
178
- projectId: projectId,
179
- agentName: opts.agent,
180
- runtime: opts.runtime ?? "claude-code",
181
- keyName: prefix ? `${prefix} key` : undefined,
182
- }));
183
- console.log(`✓ Minted key for this workspace (${minted.key.slice(0, 14)}…).\n`);
184
- // RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
185
- // keyed by a workspace id; reuse this folder's existing id (so a re-bind, or a
186
- // teammate's committed marker, keeps the same id) or mint a fresh one.
187
- const workspaceId = existing?.workspaceId ?? newWorkspaceId();
188
- setBinding(workspaceId, {
189
- orgId: orgId,
190
- projectId: projectId,
191
- key: minted.key,
192
- url: cfg.mcpUrl,
193
- prefix,
194
- orgName: undefined,
195
- boundPath: cwd,
196
- createdAt: Date.now(),
197
- });
198
- // Per-folder only (local scope), always watchdog. The marker carries only the
199
- // workspace id — no secret — so ./.mcp.json is safe to commit.
200
- installMarker({ workspaceId, scope: "local" });
201
- // Confirm the binding the same way the agent will see it.
202
- try {
203
- const b = await resolveBinding(cfg.mcpUrl, minted.key);
204
- console.log(`\n✓ This folder is bound to org "${b.org.name}" / project ${b.project.prefix}.\n` +
205
- ` Agents launched here can only ever read or write ${b.project.prefix}.`);
206
- }
207
- catch {
208
- /* binding written; whoami confirmation is best-effort */
247
+ catch (err) {
248
+ const hint = strandRecoveryHint({ createdOrgThisRun, orgId, bindingWritten });
249
+ if (hint)
250
+ console.error(hint);
251
+ throw err;
209
252
  }
210
253
  }
@@ -40,6 +40,15 @@ export function applyObservation(leases, o) {
40
40
  const id = typeof o.args?.identifier === "string" ? o.args.identifier : undefined;
41
41
  if (id && o.toolName === "release_issue")
42
42
  leases.delete(id);
43
+ // RTSC-321: moving to `review` (RTSC-257) hands the lease back server-side (the
44
+ // author's work is done, pending a reviewer), so stop heartbeating it — like a
45
+ // release, NOT a terminal close. It must NOT reap: `terminalCloseId` stays
46
+ // done|canceled only, so the branch/worktree survives for the review + PR. (The
47
+ // reviewer's send-back review→todo also releases server-side but is undetectable
48
+ // from args alone — doing→todo does NOT release — so that case self-heals via the
49
+ // failed-heartbeat path instead.)
50
+ if (id && o.toolName === "save_issue" && o.args?.status === "review")
51
+ leases.delete(id);
43
52
  const closeId = terminalCloseId(o);
44
53
  if (closeId)
45
54
  leases.delete(closeId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Retasc CLI — sign in with GitHub, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {