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 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
- fetch('https://registry.npmjs.org/flowcollab/latest', { signal: AbortSignal.timeout(4000) })
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 d = new Date(arg('due')); if (isNaN(d.getTime())) die(`Invalid --due date: "${arg('due')}". Use YYYY-MM-DD format.`); return { due_at: d.toISOString() }; })() : {}),
97
- ...(arg('blocked-by') ? { blocked_by: arg('blocked-by').split(',').map(s => s.trim()).filter(Boolean) } : {}),
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
- // 404 means the decision is gone (deleted/org mismatch) — treat as error.
97
- process.stderr.write(`flow: watch failed for ${short}: ${e.message || e}\n`);
98
- process.exitCode = 4;
99
- return;
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' ? [] : [v] }),
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
- try {
424
- const { projects } = await flowFetch('/api/flow/projects');
425
- const defaultProject = projects?.find(p => p.slug === 'main') || projects?.[0];
426
- if (defaultProject?.id) {
427
- saveGlobalConfig({ projectId: defaultProject.id });
428
- process.stdout.write(` project_id ~/.flow/config.json (project: ${defaultProject.slug})\n`);
429
- }
430
- } catch { /* non-fatal */ }
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, flowResp] = await Promise.all([
116
+ const [ghIssues, allTasks] = await Promise.all([
102
117
  fetchGitHubIssues(repo),
103
- flowFetch('/api/flow/tasks?limit=500'),
118
+ fetchAllTasksForCoverage(),
104
119
  ]);
105
- const tracked = new Set((flowResp.tasks || []).filter(t => t.github_issue_num).map(t => t.github_issue_num));
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, flowResp] = await Promise.all([
146
+ const [ghPRs, allTasks] = await Promise.all([
132
147
  fetchGitHubPRs(repo),
133
- flowFetch('/api/flow/tasks?limit=500'),
148
+ fetchAllTasksForCoverage(),
134
149
  ]);
135
- const trackedPRs = new Set((flowResp.tasks || []).filter(t => t.pr_num).map(t => t.pr_num));
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
- if (doIssues) { printSection('Untracked GitHub Issues', s || []); return; }
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
- if (doPRs) { printSection('Unlinked open PRs', s || []); return; }
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.14",
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",