@retasc/cli 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,35 @@ release commits and the issues they reference.
6
6
 
7
7
  Dates are the npm publish date. Each entry names the RTSC issue behind it.
8
8
 
9
+ ## 1.18.0 (2026-08-02)
10
+
11
+ - **RTSC-530** — setting up from scratch now asks where your work comes from, and imports it
12
+ in the same command.
13
+
14
+ `retasc bind` used to offer one thing at the project step: name a new project. So someone
15
+ arriving from Jira had to invent a project they didn't want, run `retasc import`
16
+ afterwards to get the one they did, and leave the empty one behind — and projects can't be
17
+ deleted individually.
18
+
19
+ ```
20
+ Where does your work come from?
21
+ 1) Linear
22
+ 2) Jira
23
+ 3) Asana
24
+ 4) ClickUp
25
+ 5) Shortcut
26
+ 6) Start from scratch (name a project; your agents file into it)
27
+ ```
28
+
29
+ Pick a tracker and the import runs right there, then the folder binds to the project it
30
+ created. Pick "start from scratch" and it behaves exactly as before. This is the same fork
31
+ the Dash has always offered; importing isn't a separate errand, it's one of the ways a
32
+ first project comes into existence.
33
+
34
+ Only when the org has no projects — binding a second folder in an existing org is still
35
+ just picking from the list. Declining at the import confirmation falls back to naming a
36
+ project rather than abandoning setup: you still asked to bind the folder.
37
+
9
38
  ## 1.17.0 (2026-08-02)
10
39
 
11
40
  - **RTSC-529** — the column mapping is a numbered picker, like every other prompt.
@@ -208,6 +208,59 @@ function remember(globalInstall) {
208
208
  patchConfig({ globalInstall });
209
209
  return globalInstall;
210
210
  }
211
+ /** Name and create a project by hand — the "start from scratch" ending. */
212
+ async function nameAProject(orgId) {
213
+ const name = await ask("New project name: ");
214
+ const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
215
+ if (!name || !pfx)
216
+ throw new Error("project name and prefix required");
217
+ const p = (await api.createProject({ orgId, name, prefix: pfx }));
218
+ console.log(`✓ Created project ${p.prefix}.`);
219
+ return { projectId: p.projectId, prefix: p.prefix };
220
+ }
221
+ /**
222
+ * "Where does your work come from?" — the first project, for an org that has none.
223
+ *
224
+ * RTSC-530. Mirrors the Dash's `ProjectSetup`: the trackers first, then "start from
225
+ * scratch" as the last row rather than a footer escape hatch. The Dash records why it is a
226
+ * ROW — as a button it read as a way out of the five real options, which is the wrong
227
+ * framing when most people arriving have work to bring.
228
+ *
229
+ * The import flow is CALLED, not re-implemented (`runImportFlow`). A second copy of the
230
+ * auth prompt, target picker and column mapping is how the two drift, and the mapping is
231
+ * the part that decides where a whole backlog lands.
232
+ *
233
+ * Imported dynamically: `commands/import.ts` imports `pickExisting` from here, so a static
234
+ * import would close a cycle. Same pattern `join` uses for `identityLoop`.
235
+ */
236
+ async function firstProject(orgId, orgLabel) {
237
+ let sources = [];
238
+ try {
239
+ sources = (await api.listImportSources());
240
+ }
241
+ catch {
242
+ // Never block setting up a workspace on the importer list being reachable. A person
243
+ // who just wants an empty project should not be stopped by a source catalogue.
244
+ sources = [];
245
+ }
246
+ if (!sources.length)
247
+ return nameAProject(orgId);
248
+ const SCRATCH = { source: "", label: "Start from scratch (name a project; your agents file into it)" };
249
+ const picked = await pickExisting("Where does your work come from?", [...sources, SCRATCH], (x) => clean(x.label));
250
+ // Compared by REFERENCE, so a source that ever arrives with an empty id cannot be
251
+ // mistaken for the scratch row.
252
+ if (picked === SCRATCH)
253
+ return nameAProject(orgId);
254
+ const { runImportFlow } = await import("./import.js");
255
+ const done = await runImportFlow({ orgId, orgLabel, source: picked.source });
256
+ // Declining at the import confirmation is not an error, and must not abandon setup —
257
+ // they still asked to bind this folder. Fall back to the other branch of the same fork.
258
+ if (!done) {
259
+ console.log("\nNo import. Let's make an empty project instead.");
260
+ return nameAProject(orgId);
261
+ }
262
+ return done;
263
+ }
211
264
  /**
212
265
  * Everything from "which project" to a working folder: pick the project, make `retasc`
213
266
  * durable, mint a key for that (org, project), write the binding, and wire the marker.
@@ -264,6 +317,19 @@ export async function completeWorkspaceSetup(args) {
264
317
  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(", ")}`);
265
318
  }
266
319
  }
320
+ else if (isInteractive() && list.length === 0) {
321
+ // RTSC-530 — the FROM-SCRATCH case, and the Dash's question rather than ours.
322
+ //
323
+ // `Onboarding.tsx` asks "Where does your work come from?" and offers the five
324
+ // trackers AND "start from scratch" as one fork, because importing is not a separate
325
+ // act: it is one of the ways a first project comes into existence. The CLI used to
326
+ // ask only "New project name:", so someone arriving from Jira had to invent a project
327
+ // they did not want, run `retasc import` afterwards, and leave an empty one behind
328
+ // that cannot be deleted (org-granularity delete only).
329
+ const made = await firstProject(orgId, org);
330
+ projectId = made.projectId;
331
+ prefix = made.prefix;
332
+ }
267
333
  else if (isInteractive()) {
268
334
  const chosen = await pick("Select a project", list, (p) => `${clean(p.prefix)} — ${clean(p.name)}`);
269
335
  if (chosen) {
@@ -271,14 +337,9 @@ export async function completeWorkspaceSetup(args) {
271
337
  prefix = chosen.prefix;
272
338
  }
273
339
  else {
274
- const name = await ask("New project name: ");
275
- const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
276
- if (!name || !pfx)
277
- throw new Error("project name and prefix required");
278
- const p = (await api.createProject({ orgId, name, prefix: pfx }));
279
- projectId = p.projectId;
280
- prefix = p.prefix;
281
- console.log(`✓ Created project ${p.prefix}.`);
340
+ const made = await nameAProject(orgId);
341
+ projectId = made.projectId;
342
+ prefix = made.prefix;
282
343
  }
283
344
  }
284
345
  else if (list.length === 1) {
@@ -312,25 +312,21 @@ function followProgress(orgId) {
312
312
  stdout.write("\r\x1b[2K");
313
313
  };
314
314
  }
315
- export async function importAction(opts) {
316
- if (!isInteractive() && !opts.yes) {
317
- cliError("NEEDS_TERMINAL", "Importing asks what each of your columns means, so it needs a terminal.", "Run it interactively, or use the Dash.");
318
- }
319
- // --- which org ------------------------------------------------------------
320
- const me = (await api.me());
321
- const orgs = me.orgs ?? [];
322
- let orgId = opts.orgId;
323
- if (!orgId) {
324
- if (orgs.length === 0)
325
- cliError("NO_ORG", "You're not a member of any org yet.");
326
- else if (orgs.length === 1)
327
- orgId = orgs[0].id;
328
- else {
329
- const chosen = await pickExisting("Import into which org", orgs, (o) => `${clean(o.name)}${o.slug ? ` (${clean(o.slug)})` : ""}`);
330
- orgId = chosen.id;
331
- }
332
- }
333
- const orgLabel = clean(orgs.find((o) => o.id === orgId)?.name ?? "this org");
315
+ /**
316
+ * The whole import, from source to finished run, returning the project it landed in.
317
+ *
318
+ * RTSC-530 — split out of `importAction` so `retasc bind` can offer "bring your tracker
319
+ * across" as one of the ways a FIRST project comes into existence, the way the Dash's
320
+ * project step does. It calls this; it does not resemble it. Two copies of the auth prompt,
321
+ * target picker and column mapping is how the two drift, and the mapping is the part that
322
+ * decides where a whole backlog lands.
323
+ *
324
+ * Returns null when the human declines at the confirmation, so the caller can fall back
325
+ * rather than treat a deliberate "no" as a failure.
326
+ */
327
+ export async function runImportFlow(opts) {
328
+ const { orgId, orgLabel } = opts;
329
+ // --- which tracker --------------------------------------------------------
334
330
  // --- which tracker --------------------------------------------------------
335
331
  const sources = (await api.listImportSources());
336
332
  if (!sources.length)
@@ -343,7 +339,7 @@ export async function importAction(opts) {
343
339
  console.log(`\nConnect to ${clean(src.label)}:`);
344
340
  const auth = await collectAuth(src);
345
341
  // --- which team/project/workspace -----------------------------------------
346
- const { targets } = (await api.listImportTargets({ orgId: orgId, source: src.source, auth }));
342
+ const { targets } = (await api.listImportTargets({ orgId, source: src.source, auth }));
347
343
  if (!targets.length) {
348
344
  cliError("NO_TARGETS", `That ${clean(src.label)} account has no ${clean(src.targetNoun)} we can import.`, "Check the credentials belong to the right account.");
349
345
  }
@@ -355,13 +351,13 @@ export async function importAction(opts) {
355
351
  let reviewerByStatus;
356
352
  if (src.supportsStatusMapping) {
357
353
  const { statuses } = (await api.listImportStatuses({
358
- orgId: orgId,
354
+ orgId,
359
355
  source: src.source,
360
356
  auth,
361
357
  targetRef: target.id,
362
358
  }));
363
359
  if (statuses.length) {
364
- const reviewers = (await api.listReviewCandidates({ orgId: orgId }));
360
+ const reviewers = (await api.listReviewCandidates({ orgId }));
365
361
  const mapped = await mapStatuses(statuses, reviewers, ask);
366
362
  statusMap = mapped.statusMap;
367
363
  reviewerByStatus = Object.keys(mapped.reviewerByStatus).length
@@ -379,13 +375,13 @@ export async function importAction(opts) {
379
375
  // gets re-run casually. Read from `importHistory` (permanent) rather than `latestImport`
380
376
  // (live progress, swept after 24h), so this still fires for someone who imported last
381
377
  // week and has been working in Retasc since — the person with the most to lose.
382
- const history = (await api.importHistory({ orgId: orgId }));
378
+ const history = (await api.importHistory({ orgId }));
383
379
  const warning = reimportWarning(history, src.source, src.label);
384
380
  if (warning)
385
381
  console.log(warning);
386
382
  if (!opts.yes && !(await confirm("This can't be undone. Go ahead?"))) {
387
383
  console.log("Nothing imported.");
388
- return;
384
+ return null;
389
385
  }
390
386
  // --- run -------------------------------------------------------------------
391
387
  console.log("\nImporting…\n");
@@ -396,7 +392,7 @@ export async function importAction(opts) {
396
392
  let res;
397
393
  try {
398
394
  res = (await api.runImport({
399
- orgId: orgId,
395
+ orgId,
400
396
  source: src.source,
401
397
  auth,
402
398
  target: { id: target.id, key: target.key, name: target.name },
@@ -429,4 +425,26 @@ export async function importAction(opts) {
429
425
  console.log("");
430
426
  const { identityLoop } = await import("./join.js");
431
427
  await identityLoop(orgId, {}, orgLabel);
428
+ return { projectId: res.projectId, prefix: target.key };
429
+ }
430
+ /** `retasc import` — the standalone command. Resolves the org, then runs the flow above. */
431
+ export async function importAction(opts) {
432
+ if (!isInteractive() && !opts.yes) {
433
+ cliError("NEEDS_TERMINAL", "Importing asks what each of your columns means, so it needs a terminal.", "Run it interactively, or use the Dash.");
434
+ }
435
+ const me = (await api.me());
436
+ const orgs = me.orgs ?? [];
437
+ let orgId = opts.orgId;
438
+ if (!orgId) {
439
+ if (orgs.length === 0)
440
+ cliError("NO_ORG", "You're not a member of any org yet.");
441
+ else if (orgs.length === 1)
442
+ orgId = orgs[0].id;
443
+ else {
444
+ const chosen = await pickExisting("Import into which org", orgs, (o) => `${clean(o.name)}${o.slug ? ` (${clean(o.slug)})` : ""}`);
445
+ orgId = chosen.id;
446
+ }
447
+ }
448
+ const orgLabel = clean(orgs.find((o) => o.id === orgId)?.name ?? "this org");
449
+ await runImportFlow({ orgId: orgId, orgLabel, source: opts.source, yes: opts.yes });
432
450
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Retasc CLI \u2014 the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {