flowcollab 0.3.14 → 0.3.16
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 +10 -2
- package/bin/create.mjs +9 -3
- package/bin/decisions.mjs +20 -4
- package/bin/edit.mjs +7 -1
- package/bin/pull.mjs +17 -12
- package/bin/scan.mjs +28 -8
- package/bin/standup.mjs +1 -0
- package/bin/sync.mjs +1 -0
- package/package.json +3 -2
package/bin/_client.mjs
CHANGED
|
@@ -44,14 +44,21 @@ process.on('exit', () => {
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
if (!_uc.checkedAt || (Date.now() - _uc.checkedAt) > 86_400_000) {
|
|
47
|
-
|
|
47
|
+
// B21 (audit): AbortSignal.timeout keeps a 4s timer alive that pins the event loop open AFTER the
|
|
48
|
+
// command is done (up to 4s of dead time on exit). Use a manual controller with an UNREF'd timer so
|
|
49
|
+
// the process can exit as soon as the real work finishes — the check is fire-and-forget either way.
|
|
50
|
+
const _uctrl = new AbortController();
|
|
51
|
+
const _utimer = setTimeout(() => _uctrl.abort(), 4000);
|
|
52
|
+
_utimer.unref?.();
|
|
53
|
+
fetch('https://registry.npmjs.org/flowcollab/latest', { signal: _uctrl.signal })
|
|
48
54
|
.then(r => r.json())
|
|
49
55
|
.then(({ version: latest }) => {
|
|
50
56
|
mkdirSync(join(homedir(), '.flow'), { recursive: true });
|
|
51
57
|
writeFileSync(_updateCache, JSON.stringify({ checkedAt: Date.now(), latest }));
|
|
52
58
|
_uc = { checkedAt: Date.now(), latest };
|
|
53
59
|
})
|
|
54
|
-
.catch(() => {})
|
|
60
|
+
.catch(() => {})
|
|
61
|
+
.finally(() => clearTimeout(_utimer));
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
function loadGlobalConfig() {
|
|
@@ -155,6 +162,7 @@ export function sanitizeText(s, maxLen = 120) {
|
|
|
155
162
|
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
|
|
156
163
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
157
164
|
.replace(/@to:\S+/g, '[mention-stripped]')
|
|
165
|
+
.replace(/comment_from_untrusted_user/gi, '[marker]') // S4: neutralize the untrusted-text fence marker
|
|
158
166
|
.replace(/\r?\n|\r/g, ' ')
|
|
159
167
|
.trim()
|
|
160
168
|
.slice(0, maxLen);
|
package/bin/create.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import 'dotenv/config';
|
|
24
|
-
import { flowFetch, arg, die } from './_client.mjs';
|
|
24
|
+
import { flowFetch, resolveTaskId, arg, die } from './_client.mjs';
|
|
25
25
|
import { githubHeaders, fetchProjectItem, labelsToType } from './_github.mjs';
|
|
26
26
|
|
|
27
27
|
async function fetchGithubIssue(issueNum) {
|
|
@@ -84,6 +84,12 @@ async function main() {
|
|
|
84
84
|
const title = arg('title') || prefill.title;
|
|
85
85
|
if (!title) die('Required: --title="..." (or use --from-issue=<num> / --template=<id>)\n\nUsage: flow-create --title="..." [--type=bug|feature|chore|docs] [--area=<area>] [--priority=P0-now|P1-soon|P2-later] [--status=backlog|todo] [--assignee=<actor_id>] [--from-issue=<num>] [--template=<id>]');
|
|
86
86
|
|
|
87
|
+
// B9 (audit): --blocked-by accepts a comma list + 6-char id prefixes (like the positional id
|
|
88
|
+
// everywhere else) — resolve each to a full UUID before sending; the server expects array(uuid).
|
|
89
|
+
const blockedBy = arg('blocked-by')
|
|
90
|
+
? await Promise.all(arg('blocked-by').split(',').map(s => s.trim()).filter(Boolean).map(x => resolveTaskId(x)))
|
|
91
|
+
: null;
|
|
92
|
+
|
|
87
93
|
const body = {
|
|
88
94
|
...prefill,
|
|
89
95
|
title,
|
|
@@ -93,8 +99,8 @@ async function main() {
|
|
|
93
99
|
...(arg('priority') ? { priority: arg('priority') } : {}),
|
|
94
100
|
...(arg('status') ? { status: arg('status') } : {}),
|
|
95
101
|
...(arg('assignee') ? { assignee_id: arg('assignee') } : {}),
|
|
96
|
-
...(arg('due') ? (() => { const
|
|
97
|
-
...(
|
|
102
|
+
...(arg('due') ? (() => { const v = arg('due'); if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return { due_at: v + 'T12:00:00Z' }; const d = new Date(v); if (isNaN(d.getTime())) die(`Invalid --due date: "${v}". Use YYYY-MM-DD format.`); return { due_at: d.toISOString() }; })() : {}), // B15: noon-UTC for a date-only value (new Date('YYYY-MM-DD') is midnight UTC → shows the previous day for negative-UTC users)
|
|
103
|
+
...(blockedBy ? { blocked_by: blockedBy } : {}),
|
|
98
104
|
...(arg('milestone') ? { milestone: arg('milestone') } : {}),
|
|
99
105
|
};
|
|
100
106
|
|
package/bin/decisions.mjs
CHANGED
|
@@ -87,16 +87,32 @@ async function watch(rawId) {
|
|
|
87
87
|
const short = `#${id.slice(0, 6)}`;
|
|
88
88
|
process.stdout.write(`Watching decision ${short} — polling every ${interval}s${timeout ? `, timeout ${timeout}s` : ' (no timeout)'}.\n`);
|
|
89
89
|
|
|
90
|
+
let consecutiveErrors = 0; // B4: transient blips must not abandon the watch
|
|
91
|
+
const MAX_CONSECUTIVE_ERRORS = 5;
|
|
90
92
|
for (;;) {
|
|
91
93
|
let decision;
|
|
92
94
|
try {
|
|
93
95
|
const r = await flowFetch(`/api/flow/decisions/${id}`);
|
|
94
96
|
decision = r.decision;
|
|
97
|
+
consecutiveErrors = 0; // clean poll — reset the budget
|
|
95
98
|
} catch (e) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
const msg = e?.message || String(e);
|
|
100
|
+
if (e?.status === 404 || /\b404\b/.test(msg)) {
|
|
101
|
+
// Definitively gone (deleted / org mismatch) — fatal.
|
|
102
|
+
process.stderr.write(`flow: decision ${short} not found: ${msg}\n`);
|
|
103
|
+
process.exitCode = 4;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// B4 (audit): a transient network/5xx blip must NOT abandon a task a human may have just
|
|
107
|
+
// approved — warn and keep polling, giving up only after several consecutive failures.
|
|
108
|
+
if (++consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
109
|
+
process.stderr.write(`flow: watch ${short} gave up after ${consecutiveErrors} consecutive errors (last: ${msg}).\n`);
|
|
110
|
+
process.exitCode = 4;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
process.stderr.write(`flow: watch ${short} transient error ${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS} (${msg}) — retrying in ${interval}s.\n`);
|
|
114
|
+
await sleep(interval * 1000);
|
|
115
|
+
continue;
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
const status = decision?.status;
|
package/bin/edit.mjs
CHANGED
|
@@ -22,7 +22,7 @@ const FIELD_MAP = {
|
|
|
22
22
|
area: v => ({ area: v }),
|
|
23
23
|
due: v => ({ due_at: /^\d{4}-\d{2}-\d{2}$/.test(v) ? v + 'T12:00:00Z' : v }),
|
|
24
24
|
milestone: v => ({ milestone: v }),
|
|
25
|
-
'blocked-by': v => ({ blocked_by: v === 'null' ? [] :
|
|
25
|
+
'blocked-by': v => ({ blocked_by: v === 'null' ? [] : v.split(',').map(s => s.trim()).filter(Boolean) }), // B9: split the comma list; prefixes are resolved after the loop
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
async function main() {
|
|
@@ -35,6 +35,12 @@ async function main() {
|
|
|
35
35
|
if (val !== undefined && val !== '') Object.assign(changes, toField(val));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// B9 (audit): --blocked-by accepts a comma list + 6-char id prefixes (like the positional id
|
|
39
|
+
// everywhere else) — resolve each to a full UUID before sending; the server expects array(uuid).
|
|
40
|
+
if (Array.isArray(changes.blocked_by) && changes.blocked_by.length) {
|
|
41
|
+
changes.blocked_by = await Promise.all(changes.blocked_by.map(x => resolveTaskId(x)));
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
if (Object.keys(changes).length === 0) die('No fields to update. Pass at least one flag like --title="..." or --priority=P1-soon');
|
|
39
45
|
|
|
40
46
|
const id = await resolveTaskId(raw);
|
package/bin/pull.mjs
CHANGED
|
@@ -33,6 +33,7 @@ function sanitizeText(s, maxLen = 400) {
|
|
|
33
33
|
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') // strip ANSI escape sequences
|
|
34
34
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '') // strip control chars (keep \t \n)
|
|
35
35
|
.replace(/@to:\S+/g, '[mention-stripped]') // prevent nested @to: injection
|
|
36
|
+
.replace(/comment_from_untrusted_user/gi, '[marker]') // S4: neutralize the wrapper marker so a comment can't inject </comment_from_untrusted_user> and break out of the untrusted-text fence
|
|
36
37
|
.replace(/\r?\n|\r/g, ' ↵ ') // collapse newlines to visible marker
|
|
37
38
|
.trim()
|
|
38
39
|
.slice(0, maxLen);
|
|
@@ -79,7 +80,7 @@ function fmtTimeline(ev, taskTitle) {
|
|
|
79
80
|
const ts = _d && !isNaN(_d) ? _d.toISOString().replace('T', ' ').slice(0, 16) : '?';
|
|
80
81
|
const who = ev.slot_label || ev.actor_id || '?';
|
|
81
82
|
const actingAs = ev.acting_as_id && ev.acting_as_id !== ev.actor_id ? `(as ${ev.acting_as_id})` : '';
|
|
82
|
-
const body = extractBody(ev);
|
|
83
|
+
const body = sanitizeText(extractBody(ev)); // S4: sanitize BEFORE wrapping — the marker neutralization can't fence text that was interpolated raw
|
|
83
84
|
// Wrap user-generated comments in XML markers so Claude distinguishes them
|
|
84
85
|
// from structured pull output and cannot be injected via timeline text.
|
|
85
86
|
const wrapped = ev.kind === 'comment'
|
|
@@ -165,8 +166,8 @@ async function main() {
|
|
|
165
166
|
const taskRef = t ? `#${t.issue_num ?? t.id.slice(0, 6)}` : `#${ev.task_id.slice(0, 6)}`;
|
|
166
167
|
const _md = ev.ts ? new Date(ev.ts) : null;
|
|
167
168
|
const ts = _md && !isNaN(_md) ? _md.toISOString().replace('T', ' ').slice(0, 16) : '?';
|
|
168
|
-
out.push(` ${taskRef} ${title} (${ts}, from ${ev.actor_id || '?'})`);
|
|
169
|
-
out.push(` └─ <comment_from_untrusted_user>${extractBody(ev)}</comment_from_untrusted_user>`);
|
|
169
|
+
out.push(` ${taskRef} ${sanitizeText(title, 80)} (${ts}, from ${ev.actor_id || '?'})`);
|
|
170
|
+
out.push(` └─ <comment_from_untrusted_user>${sanitizeText(extractBody(ev))}</comment_from_untrusted_user>`); // S4: sanitize before wrapping
|
|
170
171
|
});
|
|
171
172
|
if (mentions.length > 10) out.push(` … and ${mentions.length - 10} older mention${mentions.length - 10 === 1 ? '' : 's'} not shown`);
|
|
172
173
|
out.push('');
|
|
@@ -419,15 +420,19 @@ async function main() {
|
|
|
419
420
|
process.stderr.write(`flow-pull: --sync-md failed: ${e.message}\n`);
|
|
420
421
|
}
|
|
421
422
|
|
|
422
|
-
// Write project_id to global config (atomic, 0o600-enforced — see saveGlobalConfig)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
423
|
+
// Write project_id to global config (atomic, 0o600-enforced — see saveGlobalConfig).
|
|
424
|
+
// CLI-1 (audit): only SEED it when none is resolved yet — don't clobber a project the user already
|
|
425
|
+
// selected (the old code overwrote it with 'main'/the oldest project on every --sync-md).
|
|
426
|
+
if (!RESOLVED_PROJECT) {
|
|
427
|
+
try {
|
|
428
|
+
const { projects } = await flowFetch('/api/flow/projects');
|
|
429
|
+
const defaultProject = projects?.find(p => p.slug === 'main') || projects?.[0];
|
|
430
|
+
if (defaultProject?.id) {
|
|
431
|
+
saveGlobalConfig({ projectId: defaultProject.id });
|
|
432
|
+
process.stdout.write(` project_id → ~/.flow/config.json (project: ${defaultProject.slug})\n`);
|
|
433
|
+
}
|
|
434
|
+
} catch { /* non-fatal */ }
|
|
435
|
+
}
|
|
431
436
|
}
|
|
432
437
|
}
|
|
433
438
|
|
package/bin/scan.mjs
CHANGED
|
@@ -96,13 +96,28 @@ async function fetchGitHubIssues(repo) {
|
|
|
96
96
|
return all;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// B14 (audit): the coverage set must span ALL tasks, not just the first 500 — else an issue/PR
|
|
100
|
+
// tracked by a task beyond page 1 reads as "untracked" and prompts a duplicate tracking task.
|
|
101
|
+
async function fetchAllTasksForCoverage() {
|
|
102
|
+
const all = [];
|
|
103
|
+
let cursor = null;
|
|
104
|
+
for (let i = 0; i < 50; i++) { // hard cap (50 * 500 = 25k tasks)
|
|
105
|
+
const qs = cursor ? `?limit=500&cursor=${encodeURIComponent(cursor)}` : '?limit=500';
|
|
106
|
+
const resp = await flowFetch('/api/flow/tasks' + qs);
|
|
107
|
+
all.push(...(resp.tasks || []));
|
|
108
|
+
if (!resp.has_more || !resp.next_cursor) break;
|
|
109
|
+
cursor = resp.next_cursor;
|
|
110
|
+
}
|
|
111
|
+
return all;
|
|
112
|
+
}
|
|
113
|
+
|
|
99
114
|
async function scanIssues(repo) {
|
|
100
115
|
if (!repo) return null;
|
|
101
|
-
const [ghIssues,
|
|
116
|
+
const [ghIssues, allTasks] = await Promise.all([
|
|
102
117
|
fetchGitHubIssues(repo),
|
|
103
|
-
|
|
118
|
+
fetchAllTasksForCoverage(),
|
|
104
119
|
]);
|
|
105
|
-
const tracked = new Set(
|
|
120
|
+
const tracked = new Set(allTasks.filter(t => t.github_issue_num).map(t => t.github_issue_num));
|
|
106
121
|
const untracked = ghIssues.filter(i => !tracked.has(i.number));
|
|
107
122
|
return untracked.slice(0, 15).map(i => {
|
|
108
123
|
const labels = (i.labels || []).map(l => l.name.toLowerCase());
|
|
@@ -128,11 +143,11 @@ async function fetchGitHubPRs(repo) {
|
|
|
128
143
|
|
|
129
144
|
async function scanPRs(repo) {
|
|
130
145
|
if (!repo) return null;
|
|
131
|
-
const [ghPRs,
|
|
146
|
+
const [ghPRs, allTasks] = await Promise.all([
|
|
132
147
|
fetchGitHubPRs(repo),
|
|
133
|
-
|
|
148
|
+
fetchAllTasksForCoverage(),
|
|
134
149
|
]);
|
|
135
|
-
const trackedPRs = new Set(
|
|
150
|
+
const trackedPRs = new Set(allTasks.filter(t => t.pr_num).map(t => t.pr_num));
|
|
136
151
|
const unlinked = ghPRs.filter(pr => !trackedPRs.has(pr.number));
|
|
137
152
|
return unlinked.slice(0, 10).map(pr => {
|
|
138
153
|
const title = pr.title.replace(/"/g, '\\"').slice(0, 55);
|
|
@@ -222,22 +237,27 @@ async function main() {
|
|
|
222
237
|
process.stdout.write(' [issues] fetching GitHub Issues vs Flow tasks...\n');
|
|
223
238
|
let s, issueErr = false;
|
|
224
239
|
try { s = await scanIssues(repo); } catch (e) { process.stdout.write(` [issues] Error: ${e.message}\n`); issueErr = true; s = []; }
|
|
225
|
-
|
|
240
|
+
// B14 (audit): handle the no-repo / error cases BEFORE the explicit-flag return, so `--issues` with
|
|
241
|
+
// no FLOW_GITHUB_REPO reports "Skipped" instead of a misleading "Untracked Issues (0) None found".
|
|
226
242
|
if (s === null) process.stdout.write('\n[issues] Skipped — FLOW_GITHUB_REPO not set.\n');
|
|
227
243
|
else if (issueErr) process.stdout.write('\n[issues] Scan failed — check FLOW_GITHUB_REPO and token.\n');
|
|
244
|
+
else if (doIssues) { printSection('Untracked GitHub Issues', s); return; }
|
|
228
245
|
else if (all && s.length) printSection('Untracked GitHub Issues', s);
|
|
229
246
|
else if (all) process.stdout.write('\n[issues] All open Issues are tracked in Flow.\n');
|
|
247
|
+
if (doIssues) return; // explicit --issues exits after its own report (Skipped/error already printed)
|
|
230
248
|
}
|
|
231
249
|
|
|
232
250
|
if (all || doPRs) {
|
|
233
251
|
process.stdout.write(' [prs] fetching open PRs vs Flow tasks...\n');
|
|
234
252
|
let s, prErr = false;
|
|
235
253
|
try { s = await scanPRs(repo); } catch (e) { process.stdout.write(` [prs] Error: ${e.message}\n`); prErr = true; s = []; }
|
|
236
|
-
|
|
254
|
+
// B14 (audit): null/error before the explicit-flag return (see the issues block above).
|
|
237
255
|
if (s === null) process.stdout.write('\n[prs] Skipped — FLOW_GITHUB_REPO not set.\n');
|
|
238
256
|
else if (prErr) process.stdout.write('\n[prs] Scan failed — check FLOW_GITHUB_REPO and token.\n');
|
|
257
|
+
else if (doPRs) { printSection('Unlinked open PRs', s); return; }
|
|
239
258
|
else if (all && s.length) printSection('Unlinked open PRs', s);
|
|
240
259
|
else if (all) process.stdout.write('\n[prs] All open PRs are linked to Flow tasks.\n');
|
|
260
|
+
if (doPRs) return; // explicit --prs exits after its own report
|
|
241
261
|
}
|
|
242
262
|
|
|
243
263
|
if (all || doSecurity) {
|
package/bin/standup.mjs
CHANGED
|
@@ -17,6 +17,7 @@ function sanitizeText(s, maxLen = 200) {
|
|
|
17
17
|
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
|
|
18
18
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
19
19
|
.replace(/@to:\S+/g, '[mention-stripped]')
|
|
20
|
+
.replace(/comment_from_untrusted_user/gi, '[marker]') // S4: neutralize the untrusted-text fence marker
|
|
20
21
|
.replace(/\r?\n|\r/g, ' ')
|
|
21
22
|
.trim()
|
|
22
23
|
.slice(0, maxLen);
|
package/bin/sync.mjs
CHANGED
|
@@ -18,6 +18,7 @@ function sanitizeText(s, maxLen = 400) {
|
|
|
18
18
|
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
|
|
19
19
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
20
20
|
.replace(/@to:\S+/g, '[mention-stripped]')
|
|
21
|
+
.replace(/comment_from_untrusted_user/gi, '[marker]') // S4: neutralize the untrusted-text fence marker
|
|
21
22
|
.replace(/\r?\n|\r/g, ' ↵ ')
|
|
22
23
|
.trim()
|
|
23
24
|
.slice(0, maxLen);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowcollab",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.16",
|
|
4
4
|
"description": "Multi-Claude coordination layer — shared task board + CLI for teams running Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"test:ui": "node --test test/*.browser.test.mjs"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"dotenv": "^16.4.7"
|
|
51
|
+
"dotenv": "^16.4.7",
|
|
52
|
+
"web-push": "^3.6.7"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@supabase/supabase-js": "^2.49.4",
|