@motir/cli 0.1.0 → 0.1.1
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/README.md +53 -5
- package/dist/index.js +583 -63
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,9 +43,46 @@ pnpm --filter @motir/cli build # produces dist/index.js (the `motir` binary
|
|
|
43
43
|
node packages/cli/dist/index.js --help
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
##
|
|
46
|
+
## Authenticate
|
|
47
47
|
|
|
48
48
|
```sh
|
|
49
|
+
motir login
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`motir login` is a **device grant**: it prints a short code and a URL, opens
|
|
53
|
+
Motir in your browser, and waits for you to approve it there. Codes last 15
|
|
54
|
+
minutes and nothing is written to disk until you approve. It is **headless by
|
|
55
|
+
construction** — the code and URL are printed whether or not a browser opens, so
|
|
56
|
+
an SSH session or a container uses the same command; `--no-browser` skips the
|
|
57
|
+
launch attempt outright.
|
|
58
|
+
|
|
59
|
+
The approval mints a CLI-scoped PAT: scopes `read`, `work_items:write`,
|
|
60
|
+
`integration` (fixed — the grant can neither widen nor narrow them), 90-day
|
|
61
|
+
expiry, labelled `CLI · <hostname>`. It lands in `~/.config/motir/config.json`,
|
|
62
|
+
`chmod 600`, keyed by server URL.
|
|
63
|
+
|
|
64
|
+
There are three credential tiers, and `motir login` is the middle one:
|
|
65
|
+
|
|
66
|
+
| Tier | How | For |
|
|
67
|
+
| -------------------------- | ---------------------------------- | ------------------------------- |
|
|
68
|
+
| `MOTIR_TOKEN` | export it — no login step, no file | CI, containers, read-only boxes |
|
|
69
|
+
| `motir login` | browser approval | a person at a terminal |
|
|
70
|
+
| `motir auth login --token` | paste a PAT you already hold | scripts, older servers |
|
|
71
|
+
|
|
72
|
+
`MOTIR_TOKEN` is honoured by **every** command (paired with `MOTIR_SERVER` when
|
|
73
|
+
there is no `.motir.json` to walk up to) and outranks a stored credential.
|
|
74
|
+
|
|
75
|
+
**Disconnecting** is two different things: `motir logout` forgets the local copy
|
|
76
|
+
on this machine, while **revoking the token in Settings → Account → API tokens**
|
|
77
|
+
is the server-side kill switch. Full guide: [`docs/cli.md` §
|
|
78
|
+
Authenticate](../../docs/cli.md#authenticate).
|
|
79
|
+
|
|
80
|
+
## Setup commands
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
motir login [--server <url>] [--no-browser] # connect this terminal
|
|
84
|
+
motir logout [--server <url>] # forget the local credential
|
|
85
|
+
|
|
49
86
|
motir auth login [--server <url>] [--token <pat>] # validate + store a PAT
|
|
50
87
|
motir auth status [--server <url>] # server, token prefix, owning user
|
|
51
88
|
motir auth logout [--server <url>] # forget the stored token
|
|
@@ -67,10 +104,19 @@ motir ready [--kinds <list>] [--assignee <id|me|unassigned>] [--json]
|
|
|
67
104
|
motir status [--json] # ready / in-flight + active sprint
|
|
68
105
|
motir sprints [--state <planned|active|complete>] [--json]
|
|
69
106
|
motir sprint [ref] [--kinds <list>] [--json] # defaults to the ACTIVE sprint
|
|
70
|
-
motir show <key> [--json]
|
|
107
|
+
motir show <key> [--activity|--comments] [--json] # one item, in full
|
|
71
108
|
motir open <key> [--print] # …in the browser
|
|
72
109
|
```
|
|
73
110
|
|
|
111
|
+
**`motir show --activity` / `--comments`** add the DISCUSSION the aggregate does
|
|
112
|
+
not carry: `--activity` interleaves comment threads with the change trail,
|
|
113
|
+
`--comments` prints the threads alone. Both are opt-in — the stream is a second
|
|
114
|
+
tool call, so a plain `show` stays one round-trip no matter how loud a card got —
|
|
115
|
+
and comment bodies print **in full**, never excerpted. ONE page is printed and
|
|
116
|
+
never drained: when more remains the footer says how much and points at `motir
|
|
117
|
+
open <key>`; with `--json` the activity page rides along **unaltered** under an
|
|
118
|
+
`activity` key. Passing both flags is refused rather than silently resolved.
|
|
119
|
+
|
|
74
120
|
`motir sprint`'s **`ref` resolves in order**: omitted → the active sprint; then a
|
|
75
121
|
sprint **id**; then an **exact name**; then a **name prefix** — each
|
|
76
122
|
case-insensitive, first rule yielding exactly one sprint wins. An ambiguous
|
|
@@ -154,9 +200,11 @@ the item in the session exclude list so the next `motir next` moves past it;
|
|
|
154
200
|
> `in_progress` status, so if the store cannot be written the CLI warns once and
|
|
155
201
|
> the run continues rather than aborting.
|
|
156
202
|
|
|
157
|
-
**Closing out.** After you merge, `motir done <key>`.
|
|
158
|
-
|
|
159
|
-
|
|
203
|
+
**Closing out.** After you merge, `motir done <key>`. Since MOTIR-1625 the
|
|
204
|
+
default workflow carries a direct `in_progress → done` edge, so this is one legal
|
|
205
|
+
hop whether or not the item ever reached In Review. `motir done --via in_review
|
|
206
|
+
<key>` still walks it through review explicitly — useful on a custom workflow
|
|
207
|
+
that has no direct edge, or when you want the review hop on the record. An
|
|
160
208
|
illegal flip surfaces the server's own allowed-targets error verbatim. A merged
|
|
161
209
|
**session** PR closes out in bulk with `motir done --session <branch>`.
|
|
162
210
|
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { Command as Command2 } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
|
-
var CLI_VERSION = "0.1.
|
|
7
|
+
var CLI_VERSION = "0.1.1";
|
|
8
8
|
|
|
9
9
|
// src/mcpClient.ts
|
|
10
10
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
@@ -220,6 +220,12 @@ var MotirClient = class {
|
|
|
220
220
|
whoami() {
|
|
221
221
|
return this.callStructured("whoami");
|
|
222
222
|
}
|
|
223
|
+
/** The token workspace's browsable projects (MOTIR-1879). Takes no arguments:
|
|
224
|
+
* the workspace is the one the PAT is bound to. An empty workspace is an
|
|
225
|
+
* EMPTY LIST, not an error. */
|
|
226
|
+
listProjects() {
|
|
227
|
+
return this.callStructured("list_projects");
|
|
228
|
+
}
|
|
223
229
|
listReady(args) {
|
|
224
230
|
return this.callStructured("list_ready", { ...args });
|
|
225
231
|
}
|
|
@@ -229,6 +235,21 @@ var MotirClient = class {
|
|
|
229
235
|
getWorkItem(key) {
|
|
230
236
|
return this.callStructured("get_work_item", { key });
|
|
231
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* ONE page of a work item's discussion + change trail (MOTIR-1999).
|
|
240
|
+
*
|
|
241
|
+
* A SECOND call, made only when `motir show` is asked for it: the default read
|
|
242
|
+
* stays one round-trip, so a card with two hundred comments never slows down
|
|
243
|
+
* `show` or the dispatch path that leans on it.
|
|
244
|
+
*
|
|
245
|
+
* `cursor` and `order` are pass-through in both directions — the `all` cursor
|
|
246
|
+
* is an OPAQUE composite carrying both sources' positions, so the CLI must
|
|
247
|
+
* never construct, parse or merge one. Omitting `order` leaves each view on
|
|
248
|
+
* its own shipped default rather than the client inventing one.
|
|
249
|
+
*/
|
|
250
|
+
getWorkItemActivity(args) {
|
|
251
|
+
return this.callStructured("get_work_item_activity", { ...args });
|
|
252
|
+
}
|
|
232
253
|
transitionStatus(args) {
|
|
233
254
|
return this.callStructured("transition_status", { ...args });
|
|
234
255
|
}
|
|
@@ -351,25 +372,25 @@ function isInteractive() {
|
|
|
351
372
|
function promptLine(question, defaultValue) {
|
|
352
373
|
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
353
374
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
354
|
-
return new Promise((
|
|
375
|
+
return new Promise((resolve4) => {
|
|
355
376
|
rl.question(`${question}${suffix}: `, (answer) => {
|
|
356
377
|
rl.close();
|
|
357
378
|
const trimmed = answer.trim();
|
|
358
|
-
|
|
379
|
+
resolve4(trimmed.length > 0 ? trimmed : defaultValue ?? "");
|
|
359
380
|
});
|
|
360
381
|
});
|
|
361
382
|
}
|
|
362
383
|
function promptLineOrNull(question) {
|
|
363
384
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
364
|
-
return new Promise((
|
|
385
|
+
return new Promise((resolve4) => {
|
|
365
386
|
let answered = false;
|
|
366
387
|
rl.question(`${question}`, (answer) => {
|
|
367
388
|
answered = true;
|
|
368
389
|
rl.close();
|
|
369
|
-
|
|
390
|
+
resolve4(answer);
|
|
370
391
|
});
|
|
371
392
|
rl.on("close", () => {
|
|
372
|
-
if (!answered)
|
|
393
|
+
if (!answered) resolve4(null);
|
|
373
394
|
});
|
|
374
395
|
});
|
|
375
396
|
}
|
|
@@ -378,11 +399,11 @@ function promptSecret(question) {
|
|
|
378
399
|
rl._writeToOutput = () => {
|
|
379
400
|
};
|
|
380
401
|
process.stderr.write(`${question}: `);
|
|
381
|
-
return new Promise((
|
|
402
|
+
return new Promise((resolve4) => {
|
|
382
403
|
rl.question("", (answer) => {
|
|
383
404
|
rl.close();
|
|
384
405
|
process.stderr.write("\n");
|
|
385
|
-
|
|
406
|
+
resolve4(answer.trim());
|
|
386
407
|
});
|
|
387
408
|
});
|
|
388
409
|
}
|
|
@@ -533,6 +554,307 @@ async function authLogout(opts) {
|
|
|
533
554
|
}
|
|
534
555
|
}
|
|
535
556
|
|
|
557
|
+
// src/commands/login.ts
|
|
558
|
+
import { hostname } from "os";
|
|
559
|
+
import { setTimeout as delay } from "timers/promises";
|
|
560
|
+
|
|
561
|
+
// src/browser.ts
|
|
562
|
+
import { spawn } from "child_process";
|
|
563
|
+
function launcher(platform) {
|
|
564
|
+
if (platform === "darwin") return { cmd: "open", args: [] };
|
|
565
|
+
if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
|
|
566
|
+
return { cmd: "xdg-open", args: [] };
|
|
567
|
+
}
|
|
568
|
+
function hasDisplay(env, platform) {
|
|
569
|
+
if (platform === "darwin" || platform === "win32") return true;
|
|
570
|
+
return Boolean(env["DISPLAY"] || env["WAYLAND_DISPLAY"]);
|
|
571
|
+
}
|
|
572
|
+
function openUrl(url, opts = {}) {
|
|
573
|
+
const platform = opts.platform ?? process.platform;
|
|
574
|
+
const env = opts.env ?? process.env;
|
|
575
|
+
if (!hasDisplay(env, platform)) return Promise.resolve(false);
|
|
576
|
+
const { cmd, args } = launcher(platform);
|
|
577
|
+
return new Promise((resolve4) => {
|
|
578
|
+
try {
|
|
579
|
+
const child = spawn(cmd, [...args, url], { stdio: "ignore", detached: true });
|
|
580
|
+
child.on("error", () => resolve4(false));
|
|
581
|
+
child.unref();
|
|
582
|
+
setTimeout(() => resolve4(true), 0);
|
|
583
|
+
} catch {
|
|
584
|
+
resolve4(false);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/projectLink.ts
|
|
590
|
+
import { homedir as homedir2 } from "os";
|
|
591
|
+
import { resolve as resolve2 } from "path";
|
|
592
|
+
function describeProject(resolved, workspace) {
|
|
593
|
+
const where = resolved.sole ? `the only project in workspace ${workspace}` : `in workspace ${workspace}`;
|
|
594
|
+
return `${resolved.project.key} \u2014 ${resolved.project.name} (${where})`;
|
|
595
|
+
}
|
|
596
|
+
function nextLinkCommand(projectKey) {
|
|
597
|
+
return projectKey ? `motir link --project ${projectKey}` : "motir link";
|
|
598
|
+
}
|
|
599
|
+
async function resolveProject(client, workspace, serverUrl) {
|
|
600
|
+
const { projects } = await client.listProjects();
|
|
601
|
+
if (projects.length === 0) {
|
|
602
|
+
throw new CliError(`The workspace ${workspace} has no projects yet.`, {
|
|
603
|
+
hint: `Create one at ${serverUrl}, then run \`${nextLinkCommand()}\` again.`
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
const only = projects[0];
|
|
607
|
+
if (projects.length === 1 && only) return { project: only, sole: true };
|
|
608
|
+
const keys = projects.map((p) => p.key);
|
|
609
|
+
if (!isInteractive()) {
|
|
610
|
+
throw new CliError(`The workspace ${workspace} has ${projects.length} projects.`, {
|
|
611
|
+
hint: `Pass --project <key> \u2014 one of: ${keys.join(", ")}.`
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
info(`Projects in ${workspace}:`);
|
|
615
|
+
projects.forEach((p, index) => info(` ${index + 1}) ${p.key} \u2014 ${p.name}`));
|
|
616
|
+
const answer = await promptLine(`Project [1-${projects.length}, or a key]`);
|
|
617
|
+
const picked = pickProject(projects, answer);
|
|
618
|
+
if (!picked) {
|
|
619
|
+
throw new CliError(`"${answer}" is not one of this workspace\u2019s projects.`, {
|
|
620
|
+
hint: `Choose 1-${projects.length}, or pass --project <key> \u2014 one of: ${keys.join(", ")}.`
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return { project: picked, sole: false };
|
|
624
|
+
}
|
|
625
|
+
function pickProject(projects, answer) {
|
|
626
|
+
const trimmed = answer.trim();
|
|
627
|
+
if (/^\d+$/.test(trimmed)) return projects[Number(trimmed) - 1];
|
|
628
|
+
return projects.find((p) => p.key.toLowerCase() === trimmed.toLowerCase());
|
|
629
|
+
}
|
|
630
|
+
async function autoLinkAfterLogin(input) {
|
|
631
|
+
const cwd = resolve2(input.cwd ?? process.cwd());
|
|
632
|
+
const home = resolve2(input.home ?? homedir2());
|
|
633
|
+
let existing;
|
|
634
|
+
try {
|
|
635
|
+
existing = findLink(cwd);
|
|
636
|
+
} catch {
|
|
637
|
+
info(`Found a .motir.json that could not be read \u2014 leaving it alone.`);
|
|
638
|
+
info(`Run \`${nextLinkCommand()}\` to rewrite it once it is fixed.`);
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
if (existing) {
|
|
642
|
+
info(
|
|
643
|
+
`Already linked: ${existing.dir} \u2192 ${existing.config.workspace}/${existing.config.project}.`
|
|
644
|
+
);
|
|
645
|
+
info(`Run \`${nextLinkCommand(existing.config.project)}\` here to change it.`);
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
if (cwd === home) {
|
|
649
|
+
info(`Not linking your home directory \u2014 a link there would bind every folder under it.`);
|
|
650
|
+
info(`cd to your workspace root and run \`${nextLinkCommand()}\`.`);
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
let projects;
|
|
654
|
+
try {
|
|
655
|
+
projects = await input.listProjects();
|
|
656
|
+
} catch {
|
|
657
|
+
info(`Run \`${nextLinkCommand()}\` in your workspace root to bind it to a project.`);
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
const only = projects[0];
|
|
661
|
+
if (projects.length !== 1 || !only) {
|
|
662
|
+
if (projects.length === 0) {
|
|
663
|
+
info(`No projects in workspace ${input.workspace} yet \u2014 create one at ${input.serverUrl},`);
|
|
664
|
+
info(`then run \`${nextLinkCommand()}\` in your workspace root.`);
|
|
665
|
+
} else {
|
|
666
|
+
const keys = projects.map((p) => p.key).join(", ");
|
|
667
|
+
info(`${projects.length} projects in workspace ${input.workspace}: ${keys}.`);
|
|
668
|
+
info(`Run \`${nextLinkCommand("<key>")}\` in your workspace root to pick one.`);
|
|
669
|
+
}
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
const config = {
|
|
673
|
+
serverUrl: input.serverUrl,
|
|
674
|
+
workspace: input.workspace,
|
|
675
|
+
project: only.key
|
|
676
|
+
};
|
|
677
|
+
let path;
|
|
678
|
+
try {
|
|
679
|
+
path = writeLink(cwd, config);
|
|
680
|
+
} catch {
|
|
681
|
+
info(`Could not write a link in ${cwd}.`);
|
|
682
|
+
info(`Run \`${nextLinkCommand(only.key)}\` from a writable workspace root.`);
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
info(`Linked ${cwd} \u2192 ${describeProject({ project: only, sole: true }, input.workspace)}`);
|
|
686
|
+
info(`Wrote ${path} \u2014 move or delete it to bind a different folder.`);
|
|
687
|
+
return path;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// src/deviceAuth.ts
|
|
691
|
+
var CLI_CLIENT_ID = "motir-cli";
|
|
692
|
+
var DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
693
|
+
var DEVICE_START_PATH = "/api/cli/device/start";
|
|
694
|
+
var DEVICE_TOKEN_PATH = "/api/cli/device/token";
|
|
695
|
+
async function readJson(res) {
|
|
696
|
+
try {
|
|
697
|
+
return await res.json();
|
|
698
|
+
} catch {
|
|
699
|
+
return void 0;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
async function postJson(url, body, serverUrl) {
|
|
703
|
+
try {
|
|
704
|
+
return await fetch(url, {
|
|
705
|
+
method: "POST",
|
|
706
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
707
|
+
body: JSON.stringify(body)
|
|
708
|
+
});
|
|
709
|
+
} catch {
|
|
710
|
+
throw new CliError(`Could not reach ${serverUrl}.`, {
|
|
711
|
+
hint: "Check the URL (--server <url> or MOTIR_SERVER) and that the server is running."
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
function oauthErrorCode(body) {
|
|
716
|
+
const error = body?.error;
|
|
717
|
+
return typeof error === "string" ? error : void 0;
|
|
718
|
+
}
|
|
719
|
+
async function startDeviceGrant(input) {
|
|
720
|
+
const res = await postJson(
|
|
721
|
+
input.serverUrl + DEVICE_START_PATH,
|
|
722
|
+
{ hostname: input.hostname },
|
|
723
|
+
input.serverUrl
|
|
724
|
+
);
|
|
725
|
+
const body = await readJson(res);
|
|
726
|
+
if (!res.ok) {
|
|
727
|
+
throw new CliError(`${input.serverUrl} refused to start a login (HTTP ${res.status}).`, {
|
|
728
|
+
hint: "If this is a self-hosted Motir, it may predate `motir login` \u2014 use `motir auth login --token <pat>` instead."
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
const grant = body;
|
|
732
|
+
if (typeof grant?.device_code !== "string" || typeof grant.user_code !== "string") {
|
|
733
|
+
throw new CliError(`${input.serverUrl} did not return a device grant.`, {
|
|
734
|
+
hint: "Check that --server points at a Motir server (it should serve /api/cli/device/start)."
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
return grant;
|
|
738
|
+
}
|
|
739
|
+
async function pollDeviceGrant(input) {
|
|
740
|
+
const res = await postJson(
|
|
741
|
+
input.serverUrl + DEVICE_TOKEN_PATH,
|
|
742
|
+
{
|
|
743
|
+
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
744
|
+
device_code: input.deviceCode,
|
|
745
|
+
client_id: CLI_CLIENT_ID
|
|
746
|
+
},
|
|
747
|
+
input.serverUrl
|
|
748
|
+
);
|
|
749
|
+
const body = await readJson(res);
|
|
750
|
+
if (res.ok) {
|
|
751
|
+
const credential = body;
|
|
752
|
+
if (typeof credential?.access_token !== "string") {
|
|
753
|
+
throw new CliError(`${input.serverUrl} approved the login but returned no token.`);
|
|
754
|
+
}
|
|
755
|
+
return { state: "granted", credential };
|
|
756
|
+
}
|
|
757
|
+
switch (oauthErrorCode(body)) {
|
|
758
|
+
case "authorization_pending":
|
|
759
|
+
return { state: "pending" };
|
|
760
|
+
case "slow_down":
|
|
761
|
+
return { state: "slow_down" };
|
|
762
|
+
case "server_error":
|
|
763
|
+
return { state: "retry" };
|
|
764
|
+
case "access_denied":
|
|
765
|
+
throw new CliError("Approval was denied. No credential was written.", {
|
|
766
|
+
hint: "Run `motir login` again if that was not you \u2014 and revoke nothing: nothing was created."
|
|
767
|
+
});
|
|
768
|
+
case "expired_token":
|
|
769
|
+
throw new CliError("The code expired before it was approved.", {
|
|
770
|
+
hint: "Codes last 15 minutes. Run `motir login` again for a fresh one."
|
|
771
|
+
});
|
|
772
|
+
default:
|
|
773
|
+
throw new CliError(`The login could not be completed (HTTP ${res.status}).`, {
|
|
774
|
+
hint: "Run `motir login` again to start a fresh grant."
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/commands/login.ts
|
|
780
|
+
var SLOW_DOWN_STEP_MS = 5e3;
|
|
781
|
+
var MIN_POLL_SECONDS = 1;
|
|
782
|
+
var MIN_BUDGET_SECONDS = 60;
|
|
783
|
+
async function listProjectsWith(input) {
|
|
784
|
+
const client = new MotirClient(input);
|
|
785
|
+
try {
|
|
786
|
+
await client.connect();
|
|
787
|
+
const { projects } = await client.listProjects();
|
|
788
|
+
return projects;
|
|
789
|
+
} finally {
|
|
790
|
+
await client.close();
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function groupUserCode(code) {
|
|
794
|
+
if (code.includes("-")) return code;
|
|
795
|
+
const groups = code.match(/.{1,4}/g);
|
|
796
|
+
return groups ? groups.join("-") : code;
|
|
797
|
+
}
|
|
798
|
+
function printGrant(grant) {
|
|
799
|
+
info("");
|
|
800
|
+
info(` Your code: ${groupUserCode(grant.user_code)}`);
|
|
801
|
+
info(` Open: ${grant.verification_uri}`);
|
|
802
|
+
info("");
|
|
803
|
+
}
|
|
804
|
+
function storeCredential(serverUrl, credential) {
|
|
805
|
+
try {
|
|
806
|
+
setCredential(serverUrl, { token: credential.access_token, user: credential.user });
|
|
807
|
+
} catch {
|
|
808
|
+
throw new CliError(`Could not write the credential to ${configPath()}.`, {
|
|
809
|
+
hint: `Make that directory writable, point MOTIR_CONFIG_HOME at one that is, or set ${TOKEN_ENV_VAR} instead \u2014 the environment tier is never written to disk.`
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
async function awaitApproval(input) {
|
|
814
|
+
let intervalMs = Math.max(input.grant.interval, MIN_POLL_SECONDS) * 1e3;
|
|
815
|
+
const budgetMs = Math.max(input.grant.expires_in, MIN_BUDGET_SECONDS) * 1e3;
|
|
816
|
+
let waitedMs = 0;
|
|
817
|
+
for (; ; ) {
|
|
818
|
+
await input.sleep(intervalMs);
|
|
819
|
+
waitedMs += intervalMs;
|
|
820
|
+
const result = await pollDeviceGrant({
|
|
821
|
+
serverUrl: input.serverUrl,
|
|
822
|
+
deviceCode: input.grant.device_code
|
|
823
|
+
});
|
|
824
|
+
if (result.state === "granted") return result.credential;
|
|
825
|
+
if (result.state === "slow_down") intervalMs += SLOW_DOWN_STEP_MS;
|
|
826
|
+
if (waitedMs >= budgetMs) {
|
|
827
|
+
throw new CliError("Timed out waiting for approval.", {
|
|
828
|
+
hint: "Nothing was written. Run `motir login` again when you are ready to approve it."
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
async function loginCommand(opts, deps = {}) {
|
|
834
|
+
const sleep = deps.sleep ?? delay;
|
|
835
|
+
const open = deps.openUrl ?? openUrl;
|
|
836
|
+
const serverUrl = resolveServerUrl(opts.server);
|
|
837
|
+
const grant = await startDeviceGrant({ serverUrl, hostname: hostname() });
|
|
838
|
+
printGrant(grant);
|
|
839
|
+
const opened = opts.browser === false ? false : await open(grant.verification_uri_complete);
|
|
840
|
+
if (opened) info("Opened your browser \u2014 approve the request there.");
|
|
841
|
+
else info("Open that URL on any device, sign in, and enter the code.");
|
|
842
|
+
info("Waiting for approval\u2026");
|
|
843
|
+
const credential = await awaitApproval({ serverUrl, grant, sleep });
|
|
844
|
+
storeCredential(serverUrl, credential);
|
|
845
|
+
info(
|
|
846
|
+
`Logged in as ${credential.user.email} on ${serverUrl} (workspace ${credential.workspace.name}).`
|
|
847
|
+
);
|
|
848
|
+
const listProjects = deps.listProjects ?? listProjectsWith;
|
|
849
|
+
await autoLinkAfterLogin({
|
|
850
|
+
serverUrl,
|
|
851
|
+
workspace: credential.workspace.slug,
|
|
852
|
+
listProjects: () => listProjects({ serverUrl, token: credential.access_token }),
|
|
853
|
+
...deps.cwd === void 0 ? {} : { cwd: deps.cwd },
|
|
854
|
+
...deps.home === void 0 ? {} : { home: deps.home }
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
536
858
|
// src/commands/link.ts
|
|
537
859
|
function tokenFor(serverUrl) {
|
|
538
860
|
const cred = resolveCredential(serverUrl);
|
|
@@ -575,15 +897,17 @@ async function linkCommand(opts) {
|
|
|
575
897
|
hint: "Pass --workspace <slug>."
|
|
576
898
|
});
|
|
577
899
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
project =
|
|
900
|
+
const asserted = opts.project ?? existing?.config.project;
|
|
901
|
+
let project;
|
|
902
|
+
let chosen = null;
|
|
903
|
+
if (asserted) {
|
|
904
|
+
await assertProjectAccessible(client, asserted);
|
|
905
|
+
project = asserted;
|
|
906
|
+
} else {
|
|
907
|
+
const resolved = await resolveProject(client, workspace, serverUrl);
|
|
908
|
+
project = resolved.project.key;
|
|
909
|
+
chosen = describeProject(resolved, workspace);
|
|
584
910
|
}
|
|
585
|
-
if (!project) throw new CliError("A project key is required.");
|
|
586
|
-
await assertProjectAccessible(client, project);
|
|
587
911
|
const config = {
|
|
588
912
|
serverUrl,
|
|
589
913
|
workspace,
|
|
@@ -594,6 +918,7 @@ async function linkCommand(opts) {
|
|
|
594
918
|
const targetDir = existing?.dir ?? cwd;
|
|
595
919
|
const path = writeLink(targetDir, withRepo);
|
|
596
920
|
info(`Linked ${targetDir} \u2192 ${workspace}/${project} on ${serverUrl}`);
|
|
921
|
+
if (chosen) info(`Chose ${chosen}.`);
|
|
597
922
|
info(`Wrote ${path}`);
|
|
598
923
|
showLink(targetDir, withRepo);
|
|
599
924
|
} finally {
|
|
@@ -980,6 +1305,160 @@ function renderReadinessLine(readiness) {
|
|
|
980
1305
|
function renderLineage(detail) {
|
|
981
1306
|
return [...detail.ancestors.map((a) => a.identifier), detail.item.identifier].join(" \u203A ");
|
|
982
1307
|
}
|
|
1308
|
+
var NO_VALUE = "\u2014";
|
|
1309
|
+
var NO_COMMENTS_LINE = "No comments yet.";
|
|
1310
|
+
var NO_ACTIVITY_LINE = "No activity yet.";
|
|
1311
|
+
var COMMENT_PREFIX = "[comment] ";
|
|
1312
|
+
var REPLY_PREFIX = " \u21B3 reply ";
|
|
1313
|
+
var CHANGE_PREFIX = "[change] ";
|
|
1314
|
+
var MINUTE = 6e4;
|
|
1315
|
+
var HOUR = 60 * MINUTE;
|
|
1316
|
+
var DAY = 24 * HOUR;
|
|
1317
|
+
var MONTH = 30 * DAY;
|
|
1318
|
+
var YEAR = 365 * DAY;
|
|
1319
|
+
function plural(count, noun) {
|
|
1320
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
1321
|
+
}
|
|
1322
|
+
function relativeTime(iso, now) {
|
|
1323
|
+
const then = Date.parse(iso);
|
|
1324
|
+
if (Number.isNaN(then)) return iso;
|
|
1325
|
+
const delta = now.getTime() - then;
|
|
1326
|
+
if (delta < MINUTE) return "just now";
|
|
1327
|
+
if (delta < HOUR) return `${plural(Math.floor(delta / MINUTE), "minute")} ago`;
|
|
1328
|
+
if (delta < DAY) return `${plural(Math.floor(delta / HOUR), "hour")} ago`;
|
|
1329
|
+
if (delta < MONTH) return `${plural(Math.floor(delta / DAY), "day")} ago`;
|
|
1330
|
+
if (delta < YEAR) return `${plural(Math.floor(delta / MONTH), "month")} ago`;
|
|
1331
|
+
return `${plural(Math.floor(delta / YEAR), "year")} ago`;
|
|
1332
|
+
}
|
|
1333
|
+
function activityValueText(value) {
|
|
1334
|
+
if (value === null || value === void 0) return NO_VALUE;
|
|
1335
|
+
if (typeof value === "string") return value;
|
|
1336
|
+
switch (value.type) {
|
|
1337
|
+
case "none":
|
|
1338
|
+
return NO_VALUE;
|
|
1339
|
+
case "text":
|
|
1340
|
+
return value.text ?? NO_VALUE;
|
|
1341
|
+
case "status":
|
|
1342
|
+
return value.label ?? value.key ?? NO_VALUE;
|
|
1343
|
+
case "user":
|
|
1344
|
+
return value.name ?? value.userId ?? NO_VALUE;
|
|
1345
|
+
case "date":
|
|
1346
|
+
return value.date ?? NO_VALUE;
|
|
1347
|
+
case "sprint":
|
|
1348
|
+
return value.name ?? value.sprintId ?? NO_VALUE;
|
|
1349
|
+
case "issue":
|
|
1350
|
+
return value.identifier ?? value.workItemId ?? NO_VALUE;
|
|
1351
|
+
default:
|
|
1352
|
+
return value.label ?? value.name ?? value.identifier ?? value.text ?? value.key ?? NO_VALUE;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
function activityPartText(part) {
|
|
1356
|
+
switch (part.kind) {
|
|
1357
|
+
case "created":
|
|
1358
|
+
return "created the item";
|
|
1359
|
+
case "archived":
|
|
1360
|
+
return "archived the item";
|
|
1361
|
+
case "unarchived":
|
|
1362
|
+
return "restored the item";
|
|
1363
|
+
case "field":
|
|
1364
|
+
return `changed ${part.field}: ${activityValueText(part.from)} \u2192 ${activityValueText(part.to)}`;
|
|
1365
|
+
// The trail records THAT a body field changed, never its text (the DTO
|
|
1366
|
+
// carries none) — said plainly, so it never reads as a truncation.
|
|
1367
|
+
case "fieldEdited":
|
|
1368
|
+
return `edited ${part.field} (body not shown \u2014 the history trail records no text)`;
|
|
1369
|
+
case "link":
|
|
1370
|
+
return `${part.op} ${part.linkKind} link \u2192 ${activityValueText(part.target)}`;
|
|
1371
|
+
case "collection":
|
|
1372
|
+
return `${part.op} ${part.field}: ${(part.items ?? []).join(", ")}`;
|
|
1373
|
+
case "commentDeleted": {
|
|
1374
|
+
const replies = part.replyCount ?? 0;
|
|
1375
|
+
return `deleted a comment by ${activityValueText(part.author)} (${replies} ${replies === 1 ? "reply" : "replies"})`;
|
|
1376
|
+
}
|
|
1377
|
+
case "generic":
|
|
1378
|
+
return `${part.key}: ${activityValueText(part.from)} \u2192 ${activityValueText(part.to)}`;
|
|
1379
|
+
default:
|
|
1380
|
+
if (part.from === void 0 && part.to === void 0) {
|
|
1381
|
+
return `${part.kind} (unknown change kind \u2014 update the CLI to read it in full)`;
|
|
1382
|
+
}
|
|
1383
|
+
return `${part.kind}: ${activityValueText(part.from)} \u2192 ${activityValueText(part.to)}`;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
function attribution(who, iso, now) {
|
|
1387
|
+
return `${who ?? "former member"} \xB7 ${relativeTime(iso, now)} (${iso})`;
|
|
1388
|
+
}
|
|
1389
|
+
function renderHistoryEntry(entry, now) {
|
|
1390
|
+
const parts = entry.parts.map(activityPartText).join("; ");
|
|
1391
|
+
return `${CHANGE_PREFIX}${attribution(entry.actor.name, entry.changedAt, now)} \u2014 ${parts}`;
|
|
1392
|
+
}
|
|
1393
|
+
function renderComment(comment, prefix, now) {
|
|
1394
|
+
const edited = comment.editedAt === null ? "" : " (edited)";
|
|
1395
|
+
const indent = " ".repeat(prefix.length);
|
|
1396
|
+
const header = `${prefix}${attribution(comment.author.name, comment.createdAt, now)}${edited}`;
|
|
1397
|
+
const body = comment.bodyMd.split("\n").map((line) => line === "" ? "" : `${indent}${line}`).join("\n");
|
|
1398
|
+
return `${header}
|
|
1399
|
+
${body}`;
|
|
1400
|
+
}
|
|
1401
|
+
function renderCommentThread(thread, now) {
|
|
1402
|
+
return [
|
|
1403
|
+
renderComment(thread, COMMENT_PREFIX, now),
|
|
1404
|
+
...thread.replies.map((reply) => renderComment(reply, REPLY_PREFIX, now))
|
|
1405
|
+
].join("\n");
|
|
1406
|
+
}
|
|
1407
|
+
function commentsIn(threads) {
|
|
1408
|
+
return threads.reduce((sum, thread) => sum + 1 + thread.replies.length, 0);
|
|
1409
|
+
}
|
|
1410
|
+
function shownOfTotal(shown, total, noun) {
|
|
1411
|
+
return shown === total ? plural(total, noun) : `${shown} of ${plural(total, noun)}`;
|
|
1412
|
+
}
|
|
1413
|
+
function moreLine(remaining, identifier) {
|
|
1414
|
+
const left = remaining.filter(([count]) => count > 0).map(([count, noun]) => plural(count, noun));
|
|
1415
|
+
const what = left.length > 0 ? `${left.join(" and ")} not on this page` : "this page may be short \u2014 the stream is not exhausted";
|
|
1416
|
+
return `MORE \u2014 ${what}. \`motir show\` prints ONE page and never drains the stream; read the rest in Motir: \`motir open ${identifier}\`.`;
|
|
1417
|
+
}
|
|
1418
|
+
function activitySection(heading, counts, blocks, emptyLine, more) {
|
|
1419
|
+
const body = blocks.length === 0 ? [emptyLine] : blocks;
|
|
1420
|
+
return [`${heading}
|
|
1421
|
+
${counts}`, body.join("\n\n"), ...more === null ? [] : [more]].join(
|
|
1422
|
+
"\n\n"
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
function renderActivityStream(view, page, identifier, now) {
|
|
1426
|
+
if (view === "comments") {
|
|
1427
|
+
const p2 = page;
|
|
1428
|
+
const shown = commentsIn(p2.threads);
|
|
1429
|
+
const direction = p2.order === "asc" ? "oldest first" : "newest first";
|
|
1430
|
+
return activitySection(
|
|
1431
|
+
"COMMENTS",
|
|
1432
|
+
`${shownOfTotal(shown, p2.totalCount, "comment")}, ${direction}`,
|
|
1433
|
+
p2.threads.map((thread) => renderCommentThread(thread, now)),
|
|
1434
|
+
NO_COMMENTS_LINE,
|
|
1435
|
+
p2.nextCursor === null ? null : moreLine([[p2.totalCount - shown, "comment"]], identifier)
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
const p = page;
|
|
1439
|
+
const shownComments = commentsIn(
|
|
1440
|
+
p.entries.flatMap((entry) => entry.type === "comment" ? [entry.thread] : [])
|
|
1441
|
+
);
|
|
1442
|
+
const shownChanges = p.entries.filter((entry) => entry.type === "history").length;
|
|
1443
|
+
return activitySection(
|
|
1444
|
+
"ACTIVITY",
|
|
1445
|
+
[
|
|
1446
|
+
shownOfTotal(shownComments, p.totalComments, "comment"),
|
|
1447
|
+
shownOfTotal(shownChanges, p.totalChanges, "change")
|
|
1448
|
+
].join(" \xB7 "),
|
|
1449
|
+
p.entries.map(
|
|
1450
|
+
(entry) => entry.type === "comment" ? renderCommentThread(entry.thread, now) : renderHistoryEntry(entry.entry, now)
|
|
1451
|
+
),
|
|
1452
|
+
NO_ACTIVITY_LINE,
|
|
1453
|
+
p.nextCursor === null ? null : moreLine(
|
|
1454
|
+
[
|
|
1455
|
+
[p.totalComments - shownComments, "comment"],
|
|
1456
|
+
[p.totalChanges - shownChanges, "change"]
|
|
1457
|
+
],
|
|
1458
|
+
identifier
|
|
1459
|
+
)
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
983
1462
|
function renderWorkItemDetail(detail, titleWidth = 60) {
|
|
984
1463
|
const sections = [
|
|
985
1464
|
renderItemHeader(detail.item),
|
|
@@ -1056,34 +1535,6 @@ async function collectSprintItems(client, projectKey, sprintId, filter = {}) {
|
|
|
1056
1535
|
return { items, total };
|
|
1057
1536
|
}
|
|
1058
1537
|
|
|
1059
|
-
// src/browser.ts
|
|
1060
|
-
import { spawn } from "child_process";
|
|
1061
|
-
function launcher(platform) {
|
|
1062
|
-
if (platform === "darwin") return { cmd: "open", args: [] };
|
|
1063
|
-
if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
|
|
1064
|
-
return { cmd: "xdg-open", args: [] };
|
|
1065
|
-
}
|
|
1066
|
-
function hasDisplay(env, platform) {
|
|
1067
|
-
if (platform === "darwin" || platform === "win32") return true;
|
|
1068
|
-
return Boolean(env["DISPLAY"] || env["WAYLAND_DISPLAY"]);
|
|
1069
|
-
}
|
|
1070
|
-
function openUrl(url, opts = {}) {
|
|
1071
|
-
const platform = opts.platform ?? process.platform;
|
|
1072
|
-
const env = opts.env ?? process.env;
|
|
1073
|
-
if (!hasDisplay(env, platform)) return Promise.resolve(false);
|
|
1074
|
-
const { cmd, args } = launcher(platform);
|
|
1075
|
-
return new Promise((resolve3) => {
|
|
1076
|
-
try {
|
|
1077
|
-
const child = spawn(cmd, [...args, url], { stdio: "ignore", detached: true });
|
|
1078
|
-
child.on("error", () => resolve3(false));
|
|
1079
|
-
child.unref();
|
|
1080
|
-
setTimeout(() => resolve3(true), 0);
|
|
1081
|
-
} catch {
|
|
1082
|
-
resolve3(false);
|
|
1083
|
-
}
|
|
1084
|
-
});
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
1538
|
// src/commands/read.ts
|
|
1088
1539
|
var WORK_ITEM_KINDS = /* @__PURE__ */ new Set(["epic", "story", "task", "bug", "subtask"]);
|
|
1089
1540
|
function parseKinds(raw) {
|
|
@@ -1194,14 +1645,33 @@ function parseItemKey(raw, command) {
|
|
|
1194
1645
|
}
|
|
1195
1646
|
return key;
|
|
1196
1647
|
}
|
|
1648
|
+
function activityView(opts) {
|
|
1649
|
+
if (opts.activity && opts.comments) {
|
|
1650
|
+
throw new CliError("`--activity` and `--comments` cannot be combined.", {
|
|
1651
|
+
hint: "`--activity` prints comments AND history; `--comments` prints just the comments. Pick one."
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
if (opts.activity) return "all";
|
|
1655
|
+
if (opts.comments) return "comments";
|
|
1656
|
+
return null;
|
|
1657
|
+
}
|
|
1197
1658
|
async function showCommand(key, opts) {
|
|
1198
1659
|
const identifier = parseItemKey(key, "show");
|
|
1199
|
-
const
|
|
1660
|
+
const view = activityView(opts);
|
|
1661
|
+
const { detail, activity } = await withProjectSession(async ({ client }) => {
|
|
1662
|
+
const detail2 = await client.getWorkItem(identifier);
|
|
1663
|
+
const activity2 = view === null ? null : await client.getWorkItemActivity({ key: identifier, view });
|
|
1664
|
+
return { detail: detail2, activity: activity2 };
|
|
1665
|
+
});
|
|
1200
1666
|
if (opts.json) {
|
|
1201
|
-
json(detailWithWaves(detail));
|
|
1667
|
+
json(view === null ? detailWithWaves(detail) : { ...detailWithWaves(detail), activity });
|
|
1202
1668
|
return;
|
|
1203
1669
|
}
|
|
1204
1670
|
out(renderWorkItemDetail(detail));
|
|
1671
|
+
if (view !== null && activity !== null) {
|
|
1672
|
+
out();
|
|
1673
|
+
out(renderActivityStream(view, activity, identifier, /* @__PURE__ */ new Date()));
|
|
1674
|
+
}
|
|
1205
1675
|
}
|
|
1206
1676
|
async function openCommand(key, opts) {
|
|
1207
1677
|
const trimmed = key.trim();
|
|
@@ -1215,9 +1685,9 @@ async function openCommand(key, opts) {
|
|
|
1215
1685
|
}
|
|
1216
1686
|
|
|
1217
1687
|
// src/commands/doctor.ts
|
|
1218
|
-
import { homedir as
|
|
1688
|
+
import { homedir as homedir3 } from "os";
|
|
1219
1689
|
import { accessSync, constants, existsSync as existsSync3, statSync } from "fs";
|
|
1220
|
-
import { delimiter, isAbsolute as isAbsolute2, join as join4, resolve as
|
|
1690
|
+
import { delimiter, isAbsolute as isAbsolute2, join as join4, resolve as resolve3 } from "path";
|
|
1221
1691
|
import { execFile } from "child_process";
|
|
1222
1692
|
|
|
1223
1693
|
// src/agentProfiles.ts
|
|
@@ -1700,7 +2170,7 @@ function countByStatus(checks) {
|
|
|
1700
2170
|
for (const check of checks) counts[check.status] += 1;
|
|
1701
2171
|
return counts;
|
|
1702
2172
|
}
|
|
1703
|
-
function
|
|
2173
|
+
function plural2(n, one, many) {
|
|
1704
2174
|
return `${n} ${n === 1 ? one : many}`;
|
|
1705
2175
|
}
|
|
1706
2176
|
function renderDoctorReport(report) {
|
|
@@ -1714,7 +2184,7 @@ function renderDoctorReport(report) {
|
|
|
1714
2184
|
}
|
|
1715
2185
|
const counts = countByStatus(report.checks);
|
|
1716
2186
|
const parts = STATUS_ORDER.filter((s) => counts[s] > 0).map(
|
|
1717
|
-
(s) => s === "fail" ?
|
|
2187
|
+
(s) => s === "fail" ? plural2(counts.fail, "failed", "failed") : s === "warn" ? plural2(counts.warn, "warning", "warnings") : plural2(counts.pass, "passed", "passed")
|
|
1718
2188
|
);
|
|
1719
2189
|
lines.push(
|
|
1720
2190
|
"",
|
|
@@ -1777,7 +2247,7 @@ function resolveOnPath(binary, env = process.env, platform = process.platform) {
|
|
|
1777
2247
|
return [base, ...exts.map((ext) => base + ext)];
|
|
1778
2248
|
};
|
|
1779
2249
|
if (binary.includes("/") || binary.includes("\\")) {
|
|
1780
|
-
const direct = isAbsolute2(binary) ? binary :
|
|
2250
|
+
const direct = isAbsolute2(binary) ? binary : resolve3(binary);
|
|
1781
2251
|
return withExtensions(direct).find(isExecutableFile) ?? null;
|
|
1782
2252
|
}
|
|
1783
2253
|
for (const dir of (env["PATH"] ?? "").split(delimiter).filter(Boolean)) {
|
|
@@ -1840,9 +2310,9 @@ function defaultDoctorProbe() {
|
|
|
1840
2310
|
const value = process.env[name];
|
|
1841
2311
|
return typeof value === "string" && value.length > 0;
|
|
1842
2312
|
},
|
|
1843
|
-
home: () =>
|
|
1844
|
-
xdgConfigHome: () => process.env["XDG_CONFIG_HOME"] || join4(
|
|
1845
|
-
xdgDataHome: () => process.env["XDG_DATA_HOME"] || join4(
|
|
2313
|
+
home: () => homedir3(),
|
|
2314
|
+
xdgConfigHome: () => process.env["XDG_CONFIG_HOME"] || join4(homedir3(), ".config"),
|
|
2315
|
+
xdgDataHome: () => process.env["XDG_DATA_HOME"] || join4(homedir3(), ".local", "share")
|
|
1846
2316
|
};
|
|
1847
2317
|
}
|
|
1848
2318
|
async function doctorCommand(opts, probe = defaultDoctorProbe()) {
|
|
@@ -1867,7 +2337,7 @@ async function runAgent(opts) {
|
|
|
1867
2337
|
const promptFile = join5(dir, "prompt.md");
|
|
1868
2338
|
writeFileSync3(promptFile, opts.prompt, { mode: PROMPT_FILE_MODE });
|
|
1869
2339
|
try {
|
|
1870
|
-
return await new Promise((
|
|
2340
|
+
return await new Promise((resolve4, reject) => {
|
|
1871
2341
|
const child = spawnFn(opts.command.binary, opts.command.args, {
|
|
1872
2342
|
cwd: opts.cwd,
|
|
1873
2343
|
// stdin piped (we write the prompt); stdout/stderr inherited so the
|
|
@@ -1889,7 +2359,7 @@ async function runAgent(opts) {
|
|
|
1889
2359
|
stdin.end(opts.prompt);
|
|
1890
2360
|
}
|
|
1891
2361
|
child.on("close", (code, signal) => {
|
|
1892
|
-
|
|
2362
|
+
resolve4({ exitCode: signal ? 1 : code ?? 1, signal });
|
|
1893
2363
|
});
|
|
1894
2364
|
});
|
|
1895
2365
|
} finally {
|
|
@@ -2037,6 +2507,17 @@ function cwdReasonLabel(target) {
|
|
|
2037
2507
|
function workflowLabel(mode, sessionBranch) {
|
|
2038
2508
|
return mode === "session_lineage" ? `session lineage on ${sessionBranch ?? "(unnamed branch)"}` : "one pull request of its own";
|
|
2039
2509
|
}
|
|
2510
|
+
function renderDispatchAdvisories(dispatch) {
|
|
2511
|
+
const advisories = dispatch.advisories ?? [];
|
|
2512
|
+
if (advisories.length === 0) return null;
|
|
2513
|
+
return [
|
|
2514
|
+
`Advisory: ${dispatch.key}'s acceptance criteria name work items it has no blocked_by edge to.`,
|
|
2515
|
+
...advisories.map((a) => ` - ${a.referenced} (${a.referencedStatus})`),
|
|
2516
|
+
" This is NOT a blocker \u2014 the dispatch proceeds. Before branching,",
|
|
2517
|
+
" check each one is already on origin/main; if it lives only on an open",
|
|
2518
|
+
" PR, add the blocked_by edge and stop instead of rebuilding its half."
|
|
2519
|
+
].join("\n");
|
|
2520
|
+
}
|
|
2040
2521
|
function renderDispatchSummary(input) {
|
|
2041
2522
|
const { key, title, dispatch, target, agent } = input;
|
|
2042
2523
|
const lines = [
|
|
@@ -2117,13 +2598,16 @@ async function deliver(input) {
|
|
|
2117
2598
|
target,
|
|
2118
2599
|
agent: agent ? { command: agent.parsed.command, source: agent.source } : null
|
|
2119
2600
|
});
|
|
2601
|
+
const advisory = renderDispatchAdvisories(dispatch);
|
|
2120
2602
|
if (!agent) {
|
|
2121
2603
|
info(summary);
|
|
2604
|
+
if (advisory) info(advisory);
|
|
2122
2605
|
info("");
|
|
2123
2606
|
outVerbatim(dispatch.prompt);
|
|
2124
2607
|
return;
|
|
2125
2608
|
}
|
|
2126
2609
|
info(summary);
|
|
2610
|
+
if (advisory) info(advisory);
|
|
2127
2611
|
info("");
|
|
2128
2612
|
const result = await runAgent({
|
|
2129
2613
|
command: agent.parsed,
|
|
@@ -3334,9 +3818,9 @@ function nextStepHint(reviewUrl, anchored) {
|
|
|
3334
3818
|
}
|
|
3335
3819
|
|
|
3336
3820
|
// src/commands/plan.ts
|
|
3337
|
-
function
|
|
3338
|
-
return new Promise((
|
|
3339
|
-
setTimeout(
|
|
3821
|
+
function delay2(ms) {
|
|
3822
|
+
return new Promise((resolve4) => {
|
|
3823
|
+
setTimeout(resolve4, ms);
|
|
3340
3824
|
});
|
|
3341
3825
|
}
|
|
3342
3826
|
async function planCommand(args = [], opts = {}, deps = {}) {
|
|
@@ -3428,7 +3912,7 @@ function reportDetached(submitted, reviewUrl, targetKeys) {
|
|
|
3428
3912
|
}
|
|
3429
3913
|
async function watchAndShow(client, planId, reviewUrl, targetKeys, deps) {
|
|
3430
3914
|
const now = deps.now ?? Date.now;
|
|
3431
|
-
const sleep = deps.sleep ??
|
|
3915
|
+
const sleep = deps.sleep ?? delay2;
|
|
3432
3916
|
const started = now();
|
|
3433
3917
|
info(
|
|
3434
3918
|
`Waiting for the planner (up to ${Math.round(WATCH_TIMEOUT_MS / 6e4)} minutes; --detach skips the wait)\u2026`
|
|
@@ -3588,13 +4072,17 @@ function findHelpTopic(name) {
|
|
|
3588
4072
|
}
|
|
3589
4073
|
var EXAMPLES = [
|
|
3590
4074
|
"EXAMPLES:",
|
|
3591
|
-
" $ motir
|
|
4075
|
+
" $ motir login # connect this terminal",
|
|
4076
|
+
" $ motir login --no-browser # \u2026over SSH: print the code",
|
|
4077
|
+
" $ motir auth login --server https://app.motir.co # paste a token you hold",
|
|
3592
4078
|
" $ motir link --project MOTIR # bind this folder",
|
|
3593
4079
|
" $ motir doctor # preflight the setup",
|
|
3594
4080
|
" $ motir ready --kinds subtask --assignee me # what can I pick up?",
|
|
3595
4081
|
" $ motir status --json # the project pulse",
|
|
3596
4082
|
" $ motir sprint # what\u2019s in the active sprint",
|
|
3597
4083
|
" $ motir show MOTIR-7 # the item, its edges, its body",
|
|
4084
|
+
" $ motir show MOTIR-7 --activity # \u2026plus comments + history",
|
|
4085
|
+
" $ motir show MOTIR-7 --comments # \u2026just what people said",
|
|
3598
4086
|
" $ motir open MOTIR-7 --print # the item\u2019s URL",
|
|
3599
4087
|
" $ motir auto --agent claude --max 5 # drain 5 items unattended",
|
|
3600
4088
|
" $ motir batch --agent claude # snapshot now, one PR each",
|
|
@@ -3704,11 +4192,36 @@ function buildProgram() {
|
|
|
3704
4192
|
"Motir CLI \u2014 terminal dispatch of the work loop (an MCP client of the Motir server)."
|
|
3705
4193
|
).version(CLI_VERSION, "-v, --version", "Print the CLI version.");
|
|
3706
4194
|
applyHelpConfiguration(program);
|
|
4195
|
+
program.command("login").description("Connect this terminal: shows a code, opens Motir, waits for your approval.").helpGroup(HELP_GROUP.setup).option("--server <url>", "Server base URL, e.g. https://app.motir.co").option(
|
|
4196
|
+
"--no-browser",
|
|
4197
|
+
"Do not launch a browser \u2014 just print the code and the URL to open anywhere."
|
|
4198
|
+
).addHelpText(
|
|
4199
|
+
"after",
|
|
4200
|
+
[
|
|
4201
|
+
"",
|
|
4202
|
+
"No browser on this machine? The code and URL are printed either way, so an",
|
|
4203
|
+
"SSH session or a container uses this same command \u2014 open the URL on any",
|
|
4204
|
+
"device, sign in, and enter the code. For an unattended agent set MOTIR_TOKEN",
|
|
4205
|
+
"instead (`motir help environment`)."
|
|
4206
|
+
].join("\n")
|
|
4207
|
+
).action((opts) => loginCommand(opts));
|
|
4208
|
+
program.command("logout").description("Disconnect this terminal: remove the stored token for a server.").helpGroup(HELP_GROUP.setup).option("--server <url>", "Server to log out of (defaults to the linked / single server).").addHelpText(
|
|
4209
|
+
"after",
|
|
4210
|
+
[
|
|
4211
|
+
"",
|
|
4212
|
+
"This removes the credential from THIS machine only. The server-side kill",
|
|
4213
|
+
"switch is revoking the token in Settings \u2192 Account \u2192 API tokens \u2014 a terminal",
|
|
4214
|
+
"connected by `motir login` appears there as `CLI \xB7 <hostname>`."
|
|
4215
|
+
].join("\n")
|
|
4216
|
+
).action(authLogout);
|
|
3707
4217
|
const auth = program.command("auth").description("Authenticate to a Motir server with a PAT.").helpGroup(HELP_GROUP.setup);
|
|
3708
4218
|
auth.command("login").description("Validate and store a personal access token for a server.").option("--server <url>", "Server base URL, e.g. https://app.motir.co").option("--token <pat>", "Personal access token (or set MOTIR_TOKEN; prompted if omitted).").action(authLogin);
|
|
3709
4219
|
auth.command("status").description("Show the resolved server, token prefix, and owning user.").option("--server <url>", "Server to report (defaults to the linked / single server).").action(authStatus);
|
|
3710
4220
|
auth.command("logout").description("Remove the stored token for a server.").option("--server <url>", "Server to log out of (defaults to the linked / single server).").action(authLogout);
|
|
3711
|
-
const link = program.command("link").description("Bind this workspace-root folder to a server + workspace + project.").helpGroup(HELP_GROUP.setup).option("--server <url>", "Server base URL (defaults to the existing link / single server).").option("--workspace <slug>", "Workspace slug (defaults to the token\u2019s active workspace).").option(
|
|
4221
|
+
const link = program.command("link").description("Bind this workspace-root folder to a server + workspace + project.").helpGroup(HELP_GROUP.setup).option("--server <url>", "Server base URL (defaults to the existing link / single server).").option("--workspace <slug>", "Workspace slug (defaults to the token\u2019s active workspace).").option(
|
|
4222
|
+
"--project <key>",
|
|
4223
|
+
"Project key, e.g. PROD. Omit it and the workspace\u2019s only project is used."
|
|
4224
|
+
).option(
|
|
3712
4225
|
"--repo <name>",
|
|
3713
4226
|
'Mark THIS directory as a single repo\u2019s checkout (writes a "." override).'
|
|
3714
4227
|
).action(linkCommand);
|
|
@@ -3718,7 +4231,14 @@ function buildProgram() {
|
|
|
3718
4231
|
program.command("status").description("Show the project pulse: ready / in-flight counts + the active sprint.").helpGroup(HELP_GROUP.read).option("--json", "Emit the pulse as JSON.").action(statusCommand);
|
|
3719
4232
|
program.command("sprints").description("List the project\u2019s sprints: state, item count, points, window.").helpGroup(HELP_GROUP.read).option("--state <state>", "Only sprints in this state: planned, active, or complete.").option("--json", "Emit the sprint rows as JSON.").action(sprintsCommand);
|
|
3720
4233
|
program.command("sprint [ref]").description("List ONE sprint\u2019s work items (defaults to the active sprint).").helpGroup(HELP_GROUP.read).option("--kinds <list>", "Comma-separated kinds: epic,story,task,bug,subtask.").option("--json", "Emit the sprint and its items as JSON.").action((ref, opts) => sprintCommand(ref, opts));
|
|
3721
|
-
program.command("show <key>").description("Read one work item (e.g. PROD-7): fields, readiness, children, edges, body.").helpGroup(HELP_GROUP.read).option("--json", "Emit the get_work_item payload as JSON.").
|
|
4234
|
+
program.command("show <key>").description("Read one work item (e.g. PROD-7): fields, readiness, children, edges, body.").helpGroup(HELP_GROUP.read).option("--json", "Emit the get_work_item payload as JSON.").option("--activity", "Also print the activity stream: comments and history, one page.").option("--comments", "Also print the comment threads only, one page.").addHelpText(
|
|
4235
|
+
"after",
|
|
4236
|
+
[
|
|
4237
|
+
"",
|
|
4238
|
+
"ONE page of the stream is printed, never a drain loop: when more remains,",
|
|
4239
|
+
"the output says how much and points at `motir open <key>` for the rest."
|
|
4240
|
+
].join("\n")
|
|
4241
|
+
).action(showCommand);
|
|
3722
4242
|
program.command("doctor").description(
|
|
3723
4243
|
"Preflight your BYOK setup: auth, project link, agent binary, credential presence."
|
|
3724
4244
|
).helpGroup(HELP_GROUP.setup).option("--agent <cmd>", "Check THIS agent command instead of the configured one.").option("--json", "Emit the check results as JSON.").action((opts) => doctorCommand(opts));
|