flowcollab 0.3.18 → 0.3.20
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/bin/_commands.mjs +75 -0
- package/bin/approve.mjs +2 -2
- package/bin/archive.mjs +2 -2
- package/bin/assign.mjs +2 -2
- package/bin/claim.mjs +3 -3
- package/bin/close.mjs +5 -5
- package/bin/create.mjs +2 -2
- package/bin/edit.mjs +2 -2
- package/bin/flow.mjs +7 -42
- package/bin/log.mjs +6 -14
- package/bin/project.mjs +3 -3
- package/bin/propose.mjs +2 -2
- package/bin/pull.mjs +2 -2
- package/bin/reject.mjs +3 -3
- package/bin/scan.mjs +4 -4
- package/bin/search.mjs +2 -2
- package/bin/status.mjs +4 -4
- package/bin/unblock.mjs +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/* Single source of truth for the Flow CLI command catalog.
|
|
2
|
+
*
|
|
3
|
+
* Consumed by:
|
|
4
|
+
* - bin/flow.mjs → the `flow <cmd>` router + `flow --help` list
|
|
5
|
+
* - routes/flow.js → the "All CLI commands" block in GET /claudemd
|
|
6
|
+
* - test/commands.test.js → drift guard (every command here has a bin file,
|
|
7
|
+
* a package.json bin entry, and a ui/docs.html card)
|
|
8
|
+
*
|
|
9
|
+
* Add a command in ONE place (here) and every surface stays in sync.
|
|
10
|
+
* Fields:
|
|
11
|
+
* name — the subcommand (`flow <name>` / `flow-<name>`)
|
|
12
|
+
* file — the bin module it dispatches to
|
|
13
|
+
* summary — one line for `flow --help`
|
|
14
|
+
* usage — full signature + flags for the docs / CLAUDE.md reference
|
|
15
|
+
*/
|
|
16
|
+
export const CLI_COMMANDS = [
|
|
17
|
+
{ name: 'login', file: 'login.mjs', summary: 'Authorize the CLI via browser (device flow)',
|
|
18
|
+
usage: 'flow-login [--server=<url>] — browser device-flow auth; writes ~/.flow/config.json' },
|
|
19
|
+
{ name: 'pull', file: 'pull.mjs', summary: 'Fetch board snapshot + @mentions (start of day)',
|
|
20
|
+
usage: 'flow-pull [--focus] [--milestone=<name>] [--sync-md] — board snapshot + mentions + handoffs; --sync-md rewrites CLAUDE.md' },
|
|
21
|
+
{ name: 'claim', file: 'claim.mjs', summary: 'Claim a task and mark it in-progress',
|
|
22
|
+
usage: 'flow-claim <id> [--files=a,b] [--watch] — self-assign; warns on file conflicts; --watch retries on 409' },
|
|
23
|
+
{ name: 'comment', file: 'comment.mjs', summary: 'Post a comment (--commit[=<sha>] links a clickable diff)',
|
|
24
|
+
usage: 'flow-comment <id> "..." [--commit[=<sha>]] — add a comment; --commit links a commit (or HEAD) → clickable diff' },
|
|
25
|
+
{ name: 'close', file: 'close.mjs', summary: 'Mark a task done with a summary',
|
|
26
|
+
usage: 'flow-close <id> "summary" — mark task done' },
|
|
27
|
+
{ name: 'create', file: 'create.mjs', summary: 'Create a new task',
|
|
28
|
+
usage: 'flow-create --title="..." [--type=] [--area=] [--priority=] [--due=] [--blocked-by=<uuid>] [--milestone=] [--from-issue=<num>] [--template=<id>]' },
|
|
29
|
+
{ name: 'edit', file: 'edit.mjs', summary: 'Update task fields (title, priority, area, due…)',
|
|
30
|
+
usage: 'flow-edit <id> [--title=] [--priority=] [--area=] [--due=] [--milestone=] [--blocked-by=<uuid>|null]' },
|
|
31
|
+
{ name: 'unblock', file: 'unblock.mjs', summary: "Clear a task's blocked-by dependency",
|
|
32
|
+
usage: 'flow-unblock <id> — clear blocked_by' },
|
|
33
|
+
{ name: 'propose', file: 'propose.mjs', summary: 'Propose a sub-task for human approval',
|
|
34
|
+
usage: 'flow-propose --parent=<id> --title="..." --md="..." — propose work for approval' },
|
|
35
|
+
{ name: 'approve', file: 'approve.mjs', summary: 'Approve a pending proposal',
|
|
36
|
+
usage: 'flow-approve <id> — approve a proposal (owner only)' },
|
|
37
|
+
{ name: 'reject', file: 'reject.mjs', summary: 'Reject a pending proposal with a reason',
|
|
38
|
+
usage: 'flow-reject <id> "reason" — reject a proposal (owner only)' },
|
|
39
|
+
{ name: 'assign', file: 'assign.mjs', summary: 'Assign a task to another actor',
|
|
40
|
+
usage: 'flow-assign <id> <actor> — reassign (owner only)' },
|
|
41
|
+
{ name: 'decisions', file: 'decisions.mjs', summary: 'List pending proposals (--watch <id> blocks until resolved)',
|
|
42
|
+
usage: 'flow-decisions [--watch <id>] — list pending; --watch blocks until <id> resolves (exit 0/1/2/3/4)' },
|
|
43
|
+
{ name: 'handoff', file: 'handoff.mjs', summary: 'Hand off a task to another agent',
|
|
44
|
+
usage: 'flow-handoff --task=<id> --to=<actor> --context="..." [--branch=] [--questions="q1|q2"] [--next-step="..."]' },
|
|
45
|
+
{ name: 'review', file: 'review.mjs', summary: 'Request code review; moves task to in-review',
|
|
46
|
+
usage: 'flow-review <id> [--pr=<num>] [--reviewer=<actor>] [--context="..."]' },
|
|
47
|
+
{ name: 'search', file: 'search.mjs', summary: 'Search tasks by text, status, area, or assignee',
|
|
48
|
+
usage: 'flow-search "query" [--status=] [--area=] [--assignee=]' },
|
|
49
|
+
{ name: 'status', file: 'status.mjs', summary: 'Quick board summary',
|
|
50
|
+
usage: 'flow-status — board summary' },
|
|
51
|
+
{ name: 'standup', file: 'standup.mjs', summary: 'Done/in-progress digest; --velocity for trend chart',
|
|
52
|
+
usage: 'flow-standup [--since=<hours>] [--velocity] — activity digest; --velocity adds a week-over-week chart' },
|
|
53
|
+
{ name: 'sync', file: 'sync.mjs', summary: 'Delta sync since N minutes (default 60)',
|
|
54
|
+
usage: 'flow-sync [--since=<minutes>] — delta update' },
|
|
55
|
+
{ name: 'log', file: 'log.mjs', summary: 'Tail recent timeline events',
|
|
56
|
+
usage: 'flow-log [--task=<id>] [--limit=20] — tail timeline events (board-wide without --task)' },
|
|
57
|
+
{ name: 'heartbeat', file: 'heartbeat.mjs', summary: 'Send a presence ping (~60s while active)',
|
|
58
|
+
usage: 'flow-heartbeat [--task=<id>] — presence ping' },
|
|
59
|
+
{ name: 'ping', file: 'ping.mjs', summary: 'Health check or send a direct agent message',
|
|
60
|
+
usage: 'flow-ping [<actor> "message"] — 0 args: health check; 2 args: message an actor' },
|
|
61
|
+
{ name: 'pr', file: 'pr.mjs', summary: 'Record a PR link on a task timeline',
|
|
62
|
+
usage: 'flow-pr <id> <pr_url> — record a PR link on a task' },
|
|
63
|
+
{ name: 'project', file: 'project.mjs', summary: 'List GitHub Projects v2 items',
|
|
64
|
+
usage: 'flow-project [--project=<node_id>] — list GitHub Projects v2 items' },
|
|
65
|
+
{ name: 'archive', file: 'archive.mjs', summary: 'Archive or unarchive a task (--undo to restore)',
|
|
66
|
+
usage: 'flow-archive <id> [--undo] — archive/unarchive a task (owner only)' },
|
|
67
|
+
{ name: 'scan', file: 'scan.mjs', summary: 'Audit for TODOs, untracked Issues, unlinked PRs, security patterns',
|
|
68
|
+
usage: 'flow-scan [--todos] [--issues] [--prs] [--security] — audit codebase for untracked work' },
|
|
69
|
+
{ name: 'close-sprint', file: 'close-sprint.mjs', summary: 'Close a sprint milestone (owner only)',
|
|
70
|
+
usage: 'flow-close-sprint --milestone=<name> [--archive-done] — owner only' },
|
|
71
|
+
{ name: 'whoami', file: 'whoami.mjs', summary: 'Verify token and show current identity',
|
|
72
|
+
usage: 'flow-whoami — verify token + show identity' },
|
|
73
|
+
{ name: 'completion', file: 'completion.mjs', summary: 'Print shell completion script (bash/zsh/fish)',
|
|
74
|
+
usage: 'flow-completion <bash|zsh|fish> — print shell completion script' },
|
|
75
|
+
];
|
package/bin/approve.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
flow-approve <decision_id> --title="override" --assignee=...
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
|
|
8
|
+
import { flowFetch, resolveTaskId, positional, arg, die, sanitizeText } from './_client.mjs';
|
|
9
9
|
|
|
10
10
|
async function main() {
|
|
11
11
|
const raw = positional(0);
|
|
@@ -21,7 +21,7 @@ async function main() {
|
|
|
21
21
|
const r = await flowFetch(`/api/flow/decisions/${id}/approve`, {
|
|
22
22
|
method: 'POST', body: overrides,
|
|
23
23
|
});
|
|
24
|
-
process.stdout.write(`Promoted to task #${r.task.id.slice(0, 6)} — "${r.task.title}"\n`);
|
|
24
|
+
process.stdout.write(`Promoted to task #${r.task.id.slice(0, 6)} — "${sanitizeText(r.task.title, 80)}"\n`);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
main().catch(e => die(e.message || e));
|
package/bin/archive.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
flow-archive <task-id> --undo # unarchive
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { flowFetch, RESOLVED_ACTOR } from './_client.mjs';
|
|
11
|
+
import { flowFetch, RESOLVED_ACTOR, sanitizeText } from './_client.mjs';
|
|
12
12
|
|
|
13
13
|
const [,, taskId, ...rest] = process.argv;
|
|
14
14
|
const undo = rest.includes('--undo');
|
|
@@ -30,4 +30,4 @@ const res = await flowFetch(endpoint, { method: 'POST' }).catch(e => {
|
|
|
30
30
|
const t = res.task;
|
|
31
31
|
const ref = `#${t.issue_num ?? taskId.slice(0, 6)}`;
|
|
32
32
|
const verb = undo ? 'Unarchived' : 'Archived';
|
|
33
|
-
process.stdout.write(`${verb} ${ref}: ${t.title}\n`);
|
|
33
|
+
process.stdout.write(`${verb} ${ref}: ${sanitizeText(t.title, 80)}\n`);
|
package/bin/assign.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
Server-side: human-only (claude alone cannot push work onto humans).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { flowFetch, resolveTaskId, positional, die } from './_client.mjs';
|
|
11
|
+
import { flowFetch, resolveTaskId, positional, die, sanitizeText } from './_client.mjs';
|
|
12
12
|
|
|
13
13
|
async function main() {
|
|
14
14
|
const raw = positional(0);
|
|
@@ -23,7 +23,7 @@ async function main() {
|
|
|
23
23
|
method: 'POST',
|
|
24
24
|
body: { assignee_id },
|
|
25
25
|
});
|
|
26
|
-
process.stdout.write(`Assigned: ${r.task.title}\n`);
|
|
26
|
+
process.stdout.write(`Assigned: ${sanitizeText(r.task.title, 80)}\n`);
|
|
27
27
|
process.stdout.write(` Now assignee: ${r.task.assignee_id || '(unassigned)'}\n`);
|
|
28
28
|
} catch (e) {
|
|
29
29
|
if (e.status === 403) die('Assignment requires a human actor (FLOW_ACTING_VIA=self or no claude header).');
|
package/bin/claim.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Usage: flow-claim <task_id>
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
|
|
6
|
+
import { flowFetch, resolveTaskId, positional, arg, die, sanitizeText } from './_client.mjs';
|
|
7
7
|
|
|
8
8
|
async function main() {
|
|
9
9
|
const raw = positional(0);
|
|
@@ -36,13 +36,13 @@ async function main() {
|
|
|
36
36
|
r = await flowFetch('/api/flow/claim', { method: 'POST', body });
|
|
37
37
|
}
|
|
38
38
|
if (!r?.task?.id) throw new Error('Server returned no task. Check your connection and try again.');
|
|
39
|
-
process.stdout.write(`Claimed #${r.task.issue_num ?? r.task.id.slice(0, 6)} — "${r.task.title}"\n`);
|
|
39
|
+
process.stdout.write(`Claimed #${r.task.issue_num ?? r.task.id.slice(0, 6)} — "${sanitizeText(r.task.title, 80)}"\n`);
|
|
40
40
|
|
|
41
41
|
// Warn on file conflicts
|
|
42
42
|
if (r.conflict_warnings?.length) {
|
|
43
43
|
process.stdout.write(`\n⚠ File conflict${r.conflict_warnings.length === 1 ? '' : 's'} detected:\n`);
|
|
44
44
|
for (const w of r.conflict_warnings) {
|
|
45
|
-
process.stdout.write(` #${w.task_ref} "${w.title}" (${w.claimed_by || 'unassigned'})\n`);
|
|
45
|
+
process.stdout.write(` #${w.task_ref} "${sanitizeText(w.title, 60)}" (${w.claimed_by || 'unassigned'})\n`);
|
|
46
46
|
process.stdout.write(` overlapping files: ${w.overlapping_files.join(', ')}\n`);
|
|
47
47
|
}
|
|
48
48
|
process.stdout.write('Consider coordinating before editing these files.\n\n');
|
package/bin/close.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/* flow:close — mark a task done.
|
|
3
|
-
Usage: flow-close <task_id>
|
|
3
|
+
Usage: flow-close <task_id> "<summary>" (--summary="..." also accepted)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
|
|
6
|
+
import { flowFetch, resolveTaskId, positional, arg, die, sanitizeText } from './_client.mjs';
|
|
7
7
|
|
|
8
8
|
async function main() {
|
|
9
9
|
const raw = positional(0);
|
|
10
|
-
if (!raw) die('Usage: flow-close <task_id>
|
|
10
|
+
if (!raw) die('Usage: flow-close <task_id> "<summary>"');
|
|
11
11
|
const id = await resolveTaskId(raw);
|
|
12
|
-
const summary = arg('summary');
|
|
12
|
+
const summary = positional(1) || arg('summary');
|
|
13
13
|
|
|
14
14
|
const r = await flowFetch('/api/flow/close', {
|
|
15
15
|
method: 'POST',
|
|
@@ -17,7 +17,7 @@ async function main() {
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
const ref = r.task.issue_num != null ? r.task.issue_num : r.task.id.slice(0, 6);
|
|
20
|
-
process.stdout.write(`Closed #${ref} — "${r.task.title}"\n`);
|
|
20
|
+
process.stdout.write(`Closed #${ref} — "${sanitizeText(r.task.title, 80)}"\n`);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
main().catch(e => die(e.message || e));
|
package/bin/create.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import 'dotenv/config';
|
|
24
|
-
import { flowFetch, resolveTaskId, arg, die } from './_client.mjs';
|
|
24
|
+
import { flowFetch, resolveTaskId, arg, die, sanitizeText } from './_client.mjs';
|
|
25
25
|
import { githubHeaders, fetchProjectItem, labelsToType } from './_github.mjs';
|
|
26
26
|
|
|
27
27
|
async function fetchGithubIssue(issueNum) {
|
|
@@ -113,7 +113,7 @@ async function main() {
|
|
|
113
113
|
const r = await flowFetch('/api/flow/tasks', { method: 'POST', body });
|
|
114
114
|
const t = r.task || r;
|
|
115
115
|
if (!t?.id) throw new Error('Server returned no task. Check your connection and try again.');
|
|
116
|
-
process.stdout.write(`Created #${t.id.slice(0, 6)} — "${t.title}"\n`);
|
|
116
|
+
process.stdout.write(`Created #${t.id.slice(0, 6)} — "${sanitizeText(t.title, 80)}"\n`);
|
|
117
117
|
process.stdout.write(` id: ${t.id}\n`);
|
|
118
118
|
process.stdout.write(` status: ${t.status}\n`);
|
|
119
119
|
process.stdout.write(` priority: ${t.priority} type: ${t.type} area: ${t.area}\n`);
|
package/bin/edit.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
flow-edit <id> --blocked-by=<uuid>
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
|
|
11
|
+
import { flowFetch, resolveTaskId, positional, arg, die, sanitizeText } from './_client.mjs';
|
|
12
12
|
|
|
13
13
|
// B16: the server enum is P0-now / P1-soon / P2-later. Accept p0/p1/p2 (and any case of the
|
|
14
14
|
// canonical form) so the documented shorthand isn't rejected; pass anything else through for
|
|
@@ -47,7 +47,7 @@ async function main() {
|
|
|
47
47
|
const res = await flowFetch(`/api/flow/tasks/${id}`, { method: 'PATCH', body: changes });
|
|
48
48
|
|
|
49
49
|
const updated = res.task || res;
|
|
50
|
-
const label = updated.title || id.slice(0, 8);
|
|
50
|
+
const label = sanitizeText(updated.title, 80) || id.slice(0, 8);
|
|
51
51
|
process.stdout.write(`Updated: ${label}\n`);
|
|
52
52
|
for (const [k, v] of Object.entries(changes)) {
|
|
53
53
|
const display = v === null || (Array.isArray(v) && v.length === 0) ? '(cleared)' : v;
|
package/bin/flow.mjs
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { spawnSync } from 'child_process';
|
|
8
8
|
import { readFileSync } from 'fs';
|
|
9
|
-
import {
|
|
10
|
-
import { dirname } from 'path';
|
|
9
|
+
import { CLI_COMMANDS } from './_commands.mjs';
|
|
11
10
|
|
|
12
11
|
// Mirror the _client.mjs TLS re-spawn so the child sees --use-system-ca.
|
|
13
12
|
// FLOW_TLS_RESPAWNED is a belt-and-suspenders guard against an infinite loop
|
|
@@ -24,42 +23,8 @@ if (process.platform === 'win32'
|
|
|
24
23
|
process.exit(r.status ?? 1);
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// ── Command registry ────────────────────────────────────────────────────────
|
|
30
|
-
const CMDS = [
|
|
31
|
-
['login', 'login.mjs', 'Authorize the CLI via browser (device flow)'],
|
|
32
|
-
['pull', 'pull.mjs', 'Fetch board snapshot + @mentions (start of day)'],
|
|
33
|
-
['claim', 'claim.mjs', 'Claim a task and mark it in-progress'],
|
|
34
|
-
['comment', 'comment.mjs', 'Post a comment (--commit[=<sha>] links a clickable diff)'],
|
|
35
|
-
['close', 'close.mjs', 'Mark a task done with a summary'],
|
|
36
|
-
['create', 'create.mjs', 'Create a new task'],
|
|
37
|
-
['edit', 'edit.mjs', 'Update task fields (title, priority, area, due…)'],
|
|
38
|
-
['unblock', 'unblock.mjs', 'Clear a task\'s blocked-by dependency'],
|
|
39
|
-
['propose', 'propose.mjs', 'Propose a sub-task for human approval'],
|
|
40
|
-
['approve', 'approve.mjs', 'Approve a pending proposal'],
|
|
41
|
-
['reject', 'reject.mjs', 'Reject a pending proposal with a reason'],
|
|
42
|
-
['assign', 'assign.mjs', 'Assign a task to another actor'],
|
|
43
|
-
['decisions', 'decisions.mjs', 'List pending proposals (--watch <id> blocks until resolved)'],
|
|
44
|
-
['handoff', 'handoff.mjs', 'Hand off a task to another agent'],
|
|
45
|
-
['review', 'review.mjs', 'Request code review; moves task to in-review'],
|
|
46
|
-
['search', 'search.mjs', 'Search tasks by text, status, area, or assignee'],
|
|
47
|
-
['status', 'status.mjs', 'Quick board summary'],
|
|
48
|
-
['standup', 'standup.mjs', 'Done/in-progress digest; --velocity for trend chart'],
|
|
49
|
-
['sync', 'sync.mjs', 'Delta sync since N minutes (default 60)'],
|
|
50
|
-
['log', 'log.mjs', 'Tail recent timeline events'],
|
|
51
|
-
['heartbeat', 'heartbeat.mjs', 'Send a presence ping (~60s while active)'],
|
|
52
|
-
['ping', 'ping.mjs', 'Health check or send a direct agent message'],
|
|
53
|
-
['pr', 'pr.mjs', 'Record a PR link on a task timeline'],
|
|
54
|
-
['project', 'project.mjs', 'List GitHub Projects v2 items'],
|
|
55
|
-
['archive', 'archive.mjs', 'Archive or unarchive a task (--undo to restore)'],
|
|
56
|
-
['scan', 'scan.mjs', 'Audit for TODOs, untracked Issues, unlinked PRs, security patterns'],
|
|
57
|
-
['close-sprint', 'close-sprint.mjs', 'Close a sprint milestone (owner only)'],
|
|
58
|
-
['whoami', 'whoami.mjs', 'Verify token and show current identity'],
|
|
59
|
-
['completion', 'completion.mjs', 'Print shell completion script (bash/zsh/fish)'],
|
|
60
|
-
];
|
|
61
|
-
|
|
62
|
-
const CMD_MAP = new Map(CMDS.map(([name, file]) => [name, file]));
|
|
26
|
+
// ── Command registry (single source of truth: bin/_commands.mjs) ─────────────
|
|
27
|
+
const CMD_MAP = new Map(CLI_COMMANDS.map((c) => [c.name, c.file]));
|
|
63
28
|
|
|
64
29
|
// ── Help ────────────────────────────────────────────────────────────────────
|
|
65
30
|
function printHelp() {
|
|
@@ -72,16 +37,16 @@ function printHelp() {
|
|
|
72
37
|
process.stdout.write(`flow${version} — FlowCollab CLI\n\n`);
|
|
73
38
|
process.stdout.write(`Usage: flow <command> [options]\n\n`);
|
|
74
39
|
process.stdout.write(`Commands:\n`);
|
|
75
|
-
const pad = Math.max(...
|
|
76
|
-
for (const
|
|
77
|
-
process.stdout.write(` ${name.padEnd(pad)}${
|
|
40
|
+
const pad = Math.max(...CLI_COMMANDS.map((c) => c.name.length)) + 2;
|
|
41
|
+
for (const { name, summary } of CLI_COMMANDS) {
|
|
42
|
+
process.stdout.write(` ${name.padEnd(pad)}${summary}\n`);
|
|
78
43
|
}
|
|
79
44
|
process.stdout.write(`\nRun 'flow <command> --help' for command-specific flags.\n`);
|
|
80
45
|
process.stdout.write(`Hyphenated aliases (flow-login, flow-pull, …) remain supported.\n`);
|
|
81
46
|
}
|
|
82
47
|
|
|
83
48
|
// ── Dispatch ────────────────────────────────────────────────────────────────
|
|
84
|
-
const [, , sub
|
|
49
|
+
const [, , sub] = process.argv;
|
|
85
50
|
|
|
86
51
|
if (!sub || sub === '--help' || sub === '-h') {
|
|
87
52
|
printHelp();
|
package/bin/log.mjs
CHANGED
|
@@ -6,17 +6,7 @@
|
|
|
6
6
|
flow-log --limit=50 # more events
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { flowFetch, resolveTaskId, arg, die } from './_client.mjs';
|
|
10
|
-
|
|
11
|
-
function sanitize(s) {
|
|
12
|
-
if (!s || typeof s !== 'string') return '';
|
|
13
|
-
return s
|
|
14
|
-
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
|
|
15
|
-
.replace(/@to:\S+/g, '[mention]')
|
|
16
|
-
.replace(/\r?\n|\r/g, ' ')
|
|
17
|
-
.trim()
|
|
18
|
-
.slice(0, 120);
|
|
19
|
-
}
|
|
9
|
+
import { flowFetch, resolveTaskId, arg, die, sanitizeText } from './_client.mjs';
|
|
20
10
|
|
|
21
11
|
function fmt(ts) {
|
|
22
12
|
if (!ts) return ' ';
|
|
@@ -29,8 +19,10 @@ function pad(s, n) { return String(s ?? '').padEnd(n).slice(0, n); }
|
|
|
29
19
|
|
|
30
20
|
function fmtEvent(e) {
|
|
31
21
|
const p = e.payload || {};
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
// Wrap untrusted comment bodies in the fence marker (sanitize BEFORE wrapping so a
|
|
23
|
+
// crafted comment can't inject a closing tag) — matches pull.mjs.
|
|
24
|
+
if (p.comment) return `<comment_from_untrusted_user>${sanitizeText(p.comment)}</comment_from_untrusted_user>`;
|
|
25
|
+
if (p.act) return `[${p.act}${p.summary ? ': ' + sanitizeText(p.summary, 80) : ''}]`;
|
|
34
26
|
return `[${e.kind || 'event'}]`;
|
|
35
27
|
}
|
|
36
28
|
|
|
@@ -53,7 +45,7 @@ async function main() {
|
|
|
53
45
|
|
|
54
46
|
if (events.length === 0) { process.stdout.write('No timeline events.\n'); return; }
|
|
55
47
|
|
|
56
|
-
process.stdout.write(`Timeline for #${task.issue_num ?? id.slice(0, 6)}: ${task.title}\n\n`);
|
|
48
|
+
process.stdout.write(`Timeline for #${task.issue_num ?? id.slice(0, 6)}: ${sanitizeText(task.title, 120)}\n\n`);
|
|
57
49
|
for (const e of events) {
|
|
58
50
|
const who = e.acting_as_id ? `${e.acting_as_id}(${e.actor_id})` : (e.actor_id || '?');
|
|
59
51
|
process.stdout.write(` ${fmt(e.ts)} ${pad(who, 18)} ${fmtEvent(e)}\n`);
|
package/bin/project.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import 'dotenv/config';
|
|
13
|
-
import { die } from './_client.mjs';
|
|
13
|
+
import { die, sanitizeText } from './_client.mjs';
|
|
14
14
|
import { fetchProjectItems, contentKind } from './_github.mjs';
|
|
15
15
|
|
|
16
16
|
async function main() {
|
|
@@ -30,7 +30,7 @@ async function main() {
|
|
|
30
30
|
|
|
31
31
|
const { title, items } = await fetchProjectItems(projectId);
|
|
32
32
|
|
|
33
|
-
process.stdout.write(`Project: ${title}\n`);
|
|
33
|
+
process.stdout.write(`Project: ${sanitizeText(title, 100)}\n`);
|
|
34
34
|
process.stdout.write(` ${items.length} item${items.length !== 1 ? 's' : ''}\n\n`);
|
|
35
35
|
|
|
36
36
|
if (!items.length) {
|
|
@@ -41,7 +41,7 @@ async function main() {
|
|
|
41
41
|
for (const { id, content } of items) {
|
|
42
42
|
const kind = contentKind(content);
|
|
43
43
|
const num = content.number ? ` #${content.number}` : '';
|
|
44
|
-
process.stdout.write(` [${id}] ${content.title}${num ? ` (${kind}${num})` : ` (${kind})`}\n`);
|
|
44
|
+
process.stdout.write(` [${id}] ${sanitizeText(content.title, 80)}${num ? ` (${kind}${num})` : ` (${kind})`}\n`);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
process.stdout.write(`\nTo import an item:\n flow-create --from-project-item=<id above>\n`);
|
package/bin/propose.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
- Dedup on title (HTTP 409)
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { flowFetch, resolveTaskId, arg, die } from './_client.mjs';
|
|
22
|
+
import { flowFetch, resolveTaskId, arg, die, sanitizeText } from './_client.mjs';
|
|
23
23
|
|
|
24
24
|
async function main() {
|
|
25
25
|
const parent = arg('parent') ? await resolveTaskId(arg('parent')) : null;
|
|
@@ -42,7 +42,7 @@ async function main() {
|
|
|
42
42
|
try {
|
|
43
43
|
const r = await flowFetch('/api/flow/propose', { method: 'POST', body });
|
|
44
44
|
process.stdout.write(`Proposal accepted: #${r.decision.id.slice(0, 6)}\n`);
|
|
45
|
-
process.stdout.write(` Title: ${r.decision.proposal_title}\n`);
|
|
45
|
+
process.stdout.write(` Title: ${sanitizeText(r.decision.proposal_title, 100)}\n`);
|
|
46
46
|
process.stdout.write(` Suggested assignee: ${r.decision.suggested_assignee_id || '-'}\n`);
|
|
47
47
|
process.stdout.write(` Source refs: ${(r.decision.source_refs || []).join(', ') || '-'}\n`);
|
|
48
48
|
process.stdout.write('\nThe user will see this in the "Awaiting Direction" column.\n');
|
package/bin/pull.mjs
CHANGED
|
@@ -45,14 +45,14 @@ const PRIO_RANK = { 'P0-now': 0, 'P1-soon': 1, 'P2-later': 2 };
|
|
|
45
45
|
const STATUS_RANK = { in_progress: 0, in_review: 1, todo: 2, backlog: 3, awaiting_direction: 4 };
|
|
46
46
|
|
|
47
47
|
function fmtTask(t) {
|
|
48
|
-
const miTag = t.milestone ? ` [${t.milestone}]` : '';
|
|
48
|
+
const miTag = t.milestone ? ` [${sanitizeText(t.milestone, 40)}]` : '';
|
|
49
49
|
const prTag = t.pr_num ? ` [PR #${t.pr_num}: ${t.pr_state || 'open'}]` : '';
|
|
50
50
|
const parts = [
|
|
51
51
|
pad(`#${t.issue_num ?? t.id.slice(0, 6)}`, 8),
|
|
52
52
|
pad(t.status, 12),
|
|
53
53
|
pad(t.priority || '-', 9),
|
|
54
54
|
pad(t.assignee_id || '(unassigned)', 14),
|
|
55
|
-
t.title + miTag + prTag,
|
|
55
|
+
sanitizeText(t.title, 100) + miTag + prTag,
|
|
56
56
|
];
|
|
57
57
|
return parts.join(' ');
|
|
58
58
|
}
|
package/bin/reject.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/* flow:reject — reject a pending decision.
|
|
3
|
-
Usage: flow-reject <decision_id>
|
|
3
|
+
Usage: flow-reject <decision_id> "<reason>" (--reason="..." also accepted)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
|
|
7
7
|
|
|
8
8
|
async function main() {
|
|
9
9
|
const raw = positional(0);
|
|
10
|
-
if (!raw) die('Usage: flow-reject <decision_id>
|
|
10
|
+
if (!raw) die('Usage: flow-reject <decision_id> "<reason>"');
|
|
11
11
|
const id = await resolveTaskId(raw);
|
|
12
|
-
const reason = arg('reason');
|
|
12
|
+
const reason = positional(1) || arg('reason');
|
|
13
13
|
await flowFetch(`/api/flow/decisions/${id}/reject`, {
|
|
14
14
|
method: 'POST', body: reason ? { reason } : {},
|
|
15
15
|
});
|
package/bin/scan.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import 'dotenv/config';
|
|
13
|
-
import { flowFetch, arg, die } from './_client.mjs';
|
|
13
|
+
import { flowFetch, arg, die, sanitizeText } from './_client.mjs';
|
|
14
14
|
import { githubHeaders } from './_github.mjs';
|
|
15
15
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
16
16
|
import { join, extname, relative } from 'node:path';
|
|
@@ -123,7 +123,7 @@ async function scanIssues(repo) {
|
|
|
123
123
|
const labels = (i.labels || []).map(l => l.name.toLowerCase());
|
|
124
124
|
const type = labels.includes('bug') ? 'bug' : labels.includes('documentation') ? 'docs' : 'feature';
|
|
125
125
|
return {
|
|
126
|
-
label: `GitHub Issue #${i.number}: ${i.title}`,
|
|
126
|
+
label: `GitHub Issue #${i.number}: ${sanitizeText(i.title, 80)}`,
|
|
127
127
|
detail: ` ${i.html_url}`,
|
|
128
128
|
cmd: `flow-create --from-issue=${i.number} --type=${type}`,
|
|
129
129
|
};
|
|
@@ -150,9 +150,9 @@ async function scanPRs(repo) {
|
|
|
150
150
|
const trackedPRs = new Set(allTasks.filter(t => t.pr_num).map(t => t.pr_num));
|
|
151
151
|
const unlinked = ghPRs.filter(pr => !trackedPRs.has(pr.number));
|
|
152
152
|
return unlinked.slice(0, 10).map(pr => {
|
|
153
|
-
const title = pr.title.replace(/"/g, '\\"')
|
|
153
|
+
const title = sanitizeText(pr.title, 55).replace(/"/g, '\\"');
|
|
154
154
|
return {
|
|
155
|
-
label: `Unlinked PR #${pr.number}: ${pr.title}`,
|
|
155
|
+
label: `Unlinked PR #${pr.number}: ${sanitizeText(pr.title, 80)}`,
|
|
156
156
|
detail: ` ${pr.html_url} (${pr.draft ? 'draft' : 'open'})`,
|
|
157
157
|
cmd: `flow-create --title="Track PR #${pr.number}: ${title}" --type=chore --area=backend`,
|
|
158
158
|
};
|
package/bin/search.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
flow-search "ui" --area=frontend --assignee=nate
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { flowFetch, arg, positional } from './_client.mjs';
|
|
9
|
+
import { flowFetch, arg, positional, sanitizeText } from './_client.mjs';
|
|
10
10
|
|
|
11
11
|
function pad(s, n) { return String(s).padEnd(n).slice(0, n); }
|
|
12
12
|
|
|
@@ -40,7 +40,7 @@ async function main() {
|
|
|
40
40
|
const ref = `#${t.issue_num ?? t.id.slice(0, 6)}`;
|
|
41
41
|
const due = t.due_at ? ` due:${t.due_at.slice(0, 10)}` : '';
|
|
42
42
|
process.stdout.write(
|
|
43
|
-
`${pad(ref, 8)} ${pad(t.status, 12)} ${pad(t.priority || '-', 9)} ${pad(t.assignee_id || '-', 14)} ${t.title}${due}\n`
|
|
43
|
+
`${pad(ref, 8)} ${pad(t.status, 12)} ${pad(t.priority || '-', 9)} ${pad(t.assignee_id || '-', 14)} ${sanitizeText(t.title, 60)}${due}\n`
|
|
44
44
|
);
|
|
45
45
|
}
|
|
46
46
|
}
|
package/bin/status.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Usage: flow-status
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { flowFetch } from './_client.mjs';
|
|
6
|
+
import { flowFetch, sanitizeText } from './_client.mjs';
|
|
7
7
|
|
|
8
8
|
function pad(s, n) { return String(s).padEnd(n).slice(0, n); }
|
|
9
9
|
|
|
@@ -53,7 +53,7 @@ async function main() {
|
|
|
53
53
|
for (const t of arr) {
|
|
54
54
|
const stale = t.updated_at && (now - new Date(t.updated_at).getTime()) > STALE_HOURS * 3600000;
|
|
55
55
|
const staleMark = stale ? ' ⚠ STALE' : '';
|
|
56
|
-
out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${pad(t.assignee_id || 'unassigned', 14)} ${t.title
|
|
56
|
+
out.push(` #${t.issue_num ?? t.id.slice(0, 6)} ${pad(t.assignee_id || 'unassigned', 14)} ${sanitizeText(t.title, 50)}${staleMark}`);
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -74,7 +74,7 @@ async function main() {
|
|
|
74
74
|
const heartbeatTask = a.task_id ? taskById.get(a.task_id) : null;
|
|
75
75
|
const displayTask = heartbeatTask || myTasks[0];
|
|
76
76
|
const taskPart = displayTask
|
|
77
|
-
? `#${displayTask.issue_num ?? displayTask.id.slice(0, 6)} "${displayTask.title
|
|
77
|
+
? `#${displayTask.issue_num ?? displayTask.id.slice(0, 6)} "${sanitizeText(displayTask.title, 38)}"`
|
|
78
78
|
: 'idle';
|
|
79
79
|
const extraCount = myTasks.length > 1 ? ` +${myTasks.length - 1} more` : '';
|
|
80
80
|
out.push(` ${pad(a.actor_id, 16)} ${taskPart}${extraCount} (${relAgo(a.last_seen_at)})`);
|
|
@@ -91,7 +91,7 @@ async function main() {
|
|
|
91
91
|
out.push(`[decisions] ${decisions.length} pending`);
|
|
92
92
|
if (decisions.length) {
|
|
93
93
|
for (const d of decisions.slice(0, 5)) {
|
|
94
|
-
out.push(` #${d.id.slice(0, 6)} [${d.confidence}] ${d.proposal_title
|
|
94
|
+
out.push(` #${d.id.slice(0, 6)} [${d.confidence}] ${sanitizeText(d.proposal_title, 65)}`);
|
|
95
95
|
}
|
|
96
96
|
if (decisions.length > 5) out.push(` … and ${decisions.length - 5} more`);
|
|
97
97
|
}
|
package/bin/unblock.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Usage: flow-unblock <task_id>
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { flowFetch, resolveTaskId, positional, die } from './_client.mjs';
|
|
6
|
+
import { flowFetch, resolveTaskId, positional, die, sanitizeText } from './_client.mjs';
|
|
7
7
|
|
|
8
8
|
async function main() {
|
|
9
9
|
const raw = positional(0);
|
|
@@ -12,7 +12,7 @@ async function main() {
|
|
|
12
12
|
const id = await resolveTaskId(raw);
|
|
13
13
|
const res = await flowFetch(`/api/flow/tasks/${id}`, { method: 'PATCH', body: { blocked_by: [] } });
|
|
14
14
|
|
|
15
|
-
const title = res.task?.title || id.slice(0, 8);
|
|
15
|
+
const title = sanitizeText(res.task?.title, 80) || id.slice(0, 8);
|
|
16
16
|
process.stdout.write(`Unblocked: ${title}\n`);
|
|
17
17
|
}
|
|
18
18
|
|