flowcollab 0.3.19 → 0.3.21
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/_client.mjs +9 -1
- 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/comment.mjs +8 -7
- package/bin/completion.mjs +4 -6
- package/bin/create.mjs +2 -2
- package/bin/edit.mjs +2 -2
- package/bin/log.mjs +6 -14
- package/bin/login.mjs +25 -5
- package/bin/project.mjs +3 -3
- package/bin/propose.mjs +2 -2
- package/bin/pull.mjs +3 -3
- package/bin/reject.mjs +3 -3
- package/bin/scan.mjs +21 -6
- package/bin/search.mjs +2 -2
- package/bin/status.mjs +4 -4
- package/bin/unblock.mjs +2 -2
- package/package.json +1 -1
package/bin/_client.mjs
CHANGED
|
@@ -215,9 +215,17 @@ export async function resolveTaskId(input) {
|
|
|
215
215
|
cursor = page.has_more ? page.next_cursor : null;
|
|
216
216
|
} while (cursor);
|
|
217
217
|
const decRes = await flowFetch('/api/flow/decisions');
|
|
218
|
+
// B5: a bare integer is the human-friendly per-project task number (#42), not a UUID prefix.
|
|
219
|
+
// Match it exactly first so `flow claim 42` works; if no num matches we still fall through to
|
|
220
|
+
// UUID-prefix matching below (a purely-numeric UUID prefix is vanishingly unlikely).
|
|
221
|
+
if (/^\d+$/.test(stripped)) {
|
|
222
|
+
const numMatches = allTasks.filter(t => t.num != null && String(t.num) === stripped);
|
|
223
|
+
if (numMatches.length === 1) return numMatches[0].id;
|
|
224
|
+
if (numMatches.length > 1) throw new Error(`Ambiguous number #${stripped} matches ${numMatches.length} tasks (across projects):\n${numMatches.map(t => ` ${t.id.slice(0, 6)} ${sanitizeText(t.title)}`).join('\n')}\nPass the id prefix instead.`);
|
|
225
|
+
}
|
|
218
226
|
const taskMatches = allTasks.filter(t => typeof t.id === 'string' && t.id.startsWith(stripped));
|
|
219
227
|
if (taskMatches.length === 1) return taskMatches[0].id;
|
|
220
|
-
if (taskMatches.length > 1) throw new Error(`Ambiguous prefix "${stripped}" matches ${taskMatches.length} tasks:\n${taskMatches.map(t => `
|
|
228
|
+
if (taskMatches.length > 1) throw new Error(`Ambiguous prefix "${stripped}" matches ${taskMatches.length} tasks:\n${taskMatches.map(t => ` ${t.num != null ? '#' + t.num : t.id.slice(0, 6)} ${sanitizeText(t.title)}`).join('\n')}`);
|
|
221
229
|
const decisions = decRes.decisions || [];
|
|
222
230
|
const decMatches = decisions.filter(d => d.id.startsWith(stripped));
|
|
223
231
|
if (decMatches.length === 0) throw new Error(`No task or decision found with ID prefix "${stripped}". Run \`flow-pull\` to see your current tasks.`);
|
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/comment.mjs
CHANGED
|
@@ -30,13 +30,14 @@ async function main() {
|
|
|
30
30
|
let commit;
|
|
31
31
|
const hasEq = process.argv.some(a => a.startsWith('--commit='));
|
|
32
32
|
if (process.argv.includes('--commit') || hasEq) {
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
// arg('commit') resolves BOTH --commit=<sha> and the space form --commit <sha>; '' means bare
|
|
34
|
+
// --commit with nothing after it → link the current HEAD. B14: the space form previously fell
|
|
35
|
+
// into the bare branch and silently linked HEAD instead of the SHA passed. --commit= with an
|
|
36
|
+
// empty value is a typo, not "use HEAD".
|
|
37
|
+
const val = arg('commit');
|
|
38
|
+
if (val) commit = val;
|
|
39
|
+
else if (hasEq) die('--commit= was given with no value. Use --commit (HEAD) or --commit=<sha>.');
|
|
40
|
+
else commit = headSha();
|
|
40
41
|
if (!/^[0-9a-f]{7,64}$/i.test(commit)) die(`Invalid commit SHA: ${commit}`);
|
|
41
42
|
}
|
|
42
43
|
|
package/bin/completion.mjs
CHANGED
|
@@ -7,13 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { positional, die } from './_client.mjs';
|
|
10
|
+
import { CLI_COMMANDS } from './_commands.mjs';
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
'search', 'status', 'standup', 'sync', 'log', 'heartbeat', 'ping', 'pr',
|
|
15
|
-
'project', 'archive', 'close-sprint', 'whoami', 'completion',
|
|
16
|
-
];
|
|
12
|
+
// B14: derive the command list from the single source of truth so completion can't drift.
|
|
13
|
+
// (It previously hardcoded the list and had already lost `scan`, so `flow scan` had no completion.)
|
|
14
|
+
const COMMANDS = CLI_COMMANDS.map((c) => c.name);
|
|
17
15
|
|
|
18
16
|
const FLAGS = {
|
|
19
17
|
pull: ['--focus', '--milestone=', '--sync-md', '--force'],
|
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/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/login.mjs
CHANGED
|
@@ -6,17 +6,37 @@
|
|
|
6
6
|
import { homedir } from 'os';
|
|
7
7
|
import { mkdirSync, readFileSync, writeFileSync, chmodSync, renameSync } from 'fs';
|
|
8
8
|
import { join } from 'path';
|
|
9
|
-
import {
|
|
9
|
+
import { execFile, spawnSync } from 'child_process';
|
|
10
|
+
|
|
11
|
+
// BUG-6: mirror the _client.mjs Windows TLS re-spawn. flow-login does NOT import _client.mjs
|
|
12
|
+
// (it has its own fetch loop), so run directly as `flow-login` it never got --use-system-ca and
|
|
13
|
+
// would throw "fetch failed" behind corporate/AV TLS inspection. Invoked via `flow login` the
|
|
14
|
+
// router already re-spawned, so FLOW_TLS_RESPAWNED=1 makes this a no-op.
|
|
15
|
+
if (process.platform === 'win32'
|
|
16
|
+
&& !process.execArgv.includes('--use-system-ca')
|
|
17
|
+
&& process.env.FLOW_TLS_RESPAWNED !== '1') {
|
|
18
|
+
const r = spawnSync(process.execPath, ['--use-system-ca', ...process.argv.slice(1)], {
|
|
19
|
+
stdio: 'inherit',
|
|
20
|
+
env: { ...process.env, FLOW_TLS_RESPAWNED: '1' },
|
|
21
|
+
});
|
|
22
|
+
process.exit(r.status ?? 1);
|
|
23
|
+
}
|
|
10
24
|
|
|
11
25
|
const serverArg = process.argv.find(a => a.startsWith('--server='))?.slice(9);
|
|
12
26
|
const base = (serverArg || process.env.FLOW_API_BASE || 'https://flowcollab.dev').replace(/\/+$/, '');
|
|
13
27
|
|
|
14
28
|
function openBrowser(url) {
|
|
29
|
+
// SEC-9: `url` comes from the server's device-code response. Open it WITHOUT a shell —
|
|
30
|
+
// execFile passes the URL as a plain argument to the opener, so shell metacharacters in a
|
|
31
|
+
// tampered response (MITM / rogue --server) can't be interpreted. Only http(s) is opened, and
|
|
32
|
+
// the URL is also printed for manual use, so a rejected one never blocks login.
|
|
15
33
|
try {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
34
|
+
const u = new URL(url);
|
|
35
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
|
|
36
|
+
const [file, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
37
|
+
: process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
38
|
+
: ['xdg-open', [url]];
|
|
39
|
+
execFile(file, args);
|
|
20
40
|
} catch { /* best-effort */ }
|
|
21
41
|
}
|
|
22
42
|
|
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
|
}
|
|
@@ -361,7 +361,7 @@ async function main() {
|
|
|
361
361
|
// 4. Recent timeline on YOUR tasks (last 5 per task, newest first)
|
|
362
362
|
const activeTaskIds = new Set([...myTaskIds].filter(id => {
|
|
363
363
|
const t = taskById.get(id);
|
|
364
|
-
return t && t.status !== 'closed'
|
|
364
|
+
return t && t.status !== 'done'; // B14: was !== 'closed', a status that never exists → dead no-op
|
|
365
365
|
}));
|
|
366
366
|
if (activeTaskIds.size && allTimeline.length) {
|
|
367
367
|
const recentByTask = new Map();
|
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';
|
|
@@ -77,11 +77,16 @@ function scanTodos(files, dir) {
|
|
|
77
77
|
// ── GitHub Issues scan ─────────────────────────────────────────────────────────
|
|
78
78
|
async function fetchGitHub(url) {
|
|
79
79
|
const res = await fetch(url, { headers: githubHeaders() });
|
|
80
|
-
|
|
80
|
+
// B14: GitHub returns 403 for BOTH rate limiting (x-ratelimit-remaining: 0) AND auth/permission
|
|
81
|
+
// failures. Only call it a rate limit when the remaining count is actually 0 — otherwise a bad or
|
|
82
|
+
// unscoped token was mislabeled as "rate limit exceeded", sending users down the wrong path.
|
|
83
|
+
const rateLimited = res.status === 429 || (res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0');
|
|
84
|
+
if (rateLimited) {
|
|
81
85
|
const reset = res.headers.get('x-ratelimit-reset');
|
|
82
86
|
const wait = reset ? new Date(Number(reset) * 1000).toLocaleTimeString() : 'soon';
|
|
83
87
|
throw new Error(`GitHub rate limit exceeded — resets at ${wait}`);
|
|
84
88
|
}
|
|
89
|
+
if (res.status === 403) throw new Error('GitHub API 403 — token lacks access to this repo (check FLOW_GITHUB_TOKEN scope / GitHub App install).');
|
|
85
90
|
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${res.statusText}`);
|
|
86
91
|
return res.json();
|
|
87
92
|
}
|
|
@@ -123,7 +128,7 @@ async function scanIssues(repo) {
|
|
|
123
128
|
const labels = (i.labels || []).map(l => l.name.toLowerCase());
|
|
124
129
|
const type = labels.includes('bug') ? 'bug' : labels.includes('documentation') ? 'docs' : 'feature';
|
|
125
130
|
return {
|
|
126
|
-
label: `GitHub Issue #${i.number}: ${i.title}`,
|
|
131
|
+
label: `GitHub Issue #${i.number}: ${sanitizeText(i.title, 80)}`,
|
|
127
132
|
detail: ` ${i.html_url}`,
|
|
128
133
|
cmd: `flow-create --from-issue=${i.number} --type=${type}`,
|
|
129
134
|
};
|
|
@@ -150,9 +155,9 @@ async function scanPRs(repo) {
|
|
|
150
155
|
const trackedPRs = new Set(allTasks.filter(t => t.pr_num).map(t => t.pr_num));
|
|
151
156
|
const unlinked = ghPRs.filter(pr => !trackedPRs.has(pr.number));
|
|
152
157
|
return unlinked.slice(0, 10).map(pr => {
|
|
153
|
-
const title = pr.title.replace(/"/g, '\\"')
|
|
158
|
+
const title = sanitizeText(pr.title, 55).replace(/"/g, '\\"');
|
|
154
159
|
return {
|
|
155
|
-
label: `Unlinked PR #${pr.number}: ${pr.title}`,
|
|
160
|
+
label: `Unlinked PR #${pr.number}: ${sanitizeText(pr.title, 80)}`,
|
|
156
161
|
detail: ` ${pr.html_url} (${pr.draft ? 'draft' : 'open'})`,
|
|
157
162
|
cmd: `flow-create --title="Track PR #${pr.number}: ${title}" --type=chore --area=backend`,
|
|
158
163
|
};
|
|
@@ -176,6 +181,16 @@ const SEC_PATTERNS = [
|
|
|
176
181
|
{ re: /(?:password|secret|apikey|api_key|access_token)\s*(?:=|:)\s*['"`][^'"`]{6,}/i, label: 'Possible hardcoded credential' },
|
|
177
182
|
];
|
|
178
183
|
|
|
184
|
+
// SEC-3: `flow scan --security` prints a snippet of each matched line. For the credential
|
|
185
|
+
// pattern the match IS the secret, so mask any secret-ish assigned value before it reaches
|
|
186
|
+
// stdout / a created task / a shell history. Enough of the line stays to locate the finding.
|
|
187
|
+
function redactSecrets(s) {
|
|
188
|
+
return s.replace(
|
|
189
|
+
/((?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|token|bearer)['"`]?\s*[:=]\s*['"`]?)([^\s'"`]{4,})/gi,
|
|
190
|
+
(_m, pre) => `${pre}***REDACTED***`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
179
194
|
function scanSecurity(dir) {
|
|
180
195
|
const files = walkFiles(dir, secMatch); // S13: include config/.env/secret files
|
|
181
196
|
const out = [];
|
|
@@ -183,7 +198,7 @@ function scanSecurity(dir) {
|
|
|
183
198
|
const hits = grepLines(files, re);
|
|
184
199
|
for (const h of hits.slice(0, 3)) {
|
|
185
200
|
const rel = relative(dir, h.file);
|
|
186
|
-
const snippet = h.text.slice(0, 70);
|
|
201
|
+
const snippet = redactSecrets(h.text.slice(0, 70));
|
|
187
202
|
out.push({
|
|
188
203
|
label: `${label} — ${rel}:${h.lineNo}`,
|
|
189
204
|
detail: ` ${rel}:${h.lineNo}: ${snippet}`,
|
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
|
|