flowcollab 0.3.5 → 0.3.7

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
@@ -144,12 +144,18 @@ export async function resolveTaskId(input) {
144
144
  if (!input) return input;
145
145
  const stripped = input.replace(/^#/, '');
146
146
  if (UUID_RE.test(stripped)) return stripped;
147
- const [tasksRes, decRes] = await Promise.all([
148
- flowFetch('/api/flow/tasks?limit=500'),
149
- flowFetch('/api/flow/decisions'),
150
- ]);
151
- const list = Array.isArray(tasksRes) ? tasksRes : (tasksRes.tasks ?? []);
152
- const taskMatches = list.filter(t => t.id.startsWith(stripped));
147
+ // Paginate through all tasks (boards > 500 tasks would silently miss with a single page)
148
+ let allTasks = [];
149
+ let cursor = null;
150
+ do {
151
+ const url = '/api/flow/tasks?limit=200' + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '');
152
+ const page = await flowFetch(url);
153
+ const items = Array.isArray(page) ? page : (page.tasks ?? []);
154
+ allTasks = allTasks.concat(items);
155
+ cursor = page.has_more ? page.next_cursor : null;
156
+ } while (cursor);
157
+ const decRes = await flowFetch('/api/flow/decisions');
158
+ const taskMatches = allTasks.filter(t => typeof t.id === 'string' && t.id.startsWith(stripped));
153
159
  if (taskMatches.length === 1) return taskMatches[0].id;
154
160
  if (taskMatches.length > 1) throw new Error(`Ambiguous prefix "${stripped}" matches ${taskMatches.length} tasks:\n${taskMatches.map(t => ` #${t.id.slice(0, 6)} ${t.title}`).join('\n')}`);
155
161
  const decisions = decRes.decisions || [];
package/bin/decisions.mjs CHANGED
@@ -1,11 +1,29 @@
1
1
  #!/usr/bin/env node
2
- /* flow:decisions — list pending decisions.
3
- Usage: flow-decisions
2
+ /* flow:decisions — list pending decisions, or watch one until it resolves.
3
+
4
+ Usage:
5
+ flow-decisions list pending decisions
6
+ flow-decisions --watch <id> block until decision <id> resolves
7
+
8
+ --watch exit codes (so the Claude Code harness can branch on resume):
9
+ 0 approved — proceed with the proposed work
10
+ 1 rejected — do not proceed; read the rejection reason
11
+ 2 timed out — --timeout elapsed before a human decided
12
+ 3 expired — the decision auto-expired before a human decided
13
+ 4 error — auth / network / decision not found
14
+ 130 interrupted (Ctrl-C)
15
+
16
+ Flags:
17
+ --watch <id> decision id (6-char prefix or full UUID)
18
+ --interval=<sec> poll interval, default 5
19
+ --timeout=<sec> give up after N seconds, default 1800 (30 min); 0 = forever
4
20
  */
5
21
 
6
- import { flowFetch, die } from './_client.mjs';
22
+ import { flowFetch, resolveTaskId, arg, positional, die } from './_client.mjs';
7
23
 
8
- async function main() {
24
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
25
+
26
+ async function list() {
9
27
  const [decRes, tasksRes] = await Promise.all([
10
28
  flowFetch('/api/flow/decisions'),
11
29
  flowFetch('/api/flow/tasks?limit=500'),
@@ -30,4 +48,82 @@ async function main() {
30
48
  }
31
49
  }
32
50
 
33
- main().catch(e => die(e.message || e));
51
+ async function watch(rawId) {
52
+ const interval = Math.max(1, Number(arg('interval')) || 5);
53
+ const timeout = arg('timeout') !== undefined ? Math.max(0, Number(arg('timeout'))) : 1800;
54
+
55
+ // Resolve the prefix to a full UUID once, up front, while the decision is
56
+ // still pending (resolveTaskId can only resolve prefixes via the pending
57
+ // list). After this we poll by full UUID, which survives resolution.
58
+ let id;
59
+ try {
60
+ id = await resolveTaskId(rawId);
61
+ } catch (e) {
62
+ die(e.message || String(e), 4);
63
+ }
64
+
65
+ // No custom SIGINT handler: registering one and then calling process.exit()
66
+ // trips a libuv async-handle assertion on Windows (async.c). Default Ctrl-C
67
+ // behaviour already exits 130, which is the contract we want.
68
+ //
69
+ // We never call process.exit() from inside the loop either — we set
70
+ // process.exitCode and `return`, letting the event loop drain cleanly. An
71
+ // abrupt process.exit() mid-teardown is what triggers the same Windows crash.
72
+ const startedAt = Date.now();
73
+ const short = `#${id.slice(0, 6)}`;
74
+ process.stdout.write(`Watching decision ${short} — polling every ${interval}s${timeout ? `, timeout ${timeout}s` : ' (no timeout)'}.\n`);
75
+
76
+ for (;;) {
77
+ let decision;
78
+ try {
79
+ const r = await flowFetch(`/api/flow/decisions/${id}`);
80
+ decision = r.decision;
81
+ } catch (e) {
82
+ // 404 means the decision is gone (deleted/org mismatch) — treat as error.
83
+ process.stderr.write(`flow: watch failed for ${short}: ${e.message || e}\n`);
84
+ process.exitCode = 4;
85
+ return;
86
+ }
87
+
88
+ const status = decision?.status;
89
+ if (status === 'approved') {
90
+ process.stdout.write(`✓ ${short} approved — proceed.\n`);
91
+ process.exitCode = 0;
92
+ return;
93
+ }
94
+ if (status === 'rejected') {
95
+ const reason = decision.rejected_reason ? ` Reason: ${decision.rejected_reason}` : '';
96
+ process.stdout.write(`✗ ${short} rejected — do not proceed.${reason}\n`);
97
+ process.exitCode = 1;
98
+ return;
99
+ }
100
+ if (status === 'expired') {
101
+ process.stdout.write(`⌛ ${short} expired before a human decided — do not proceed.\n`);
102
+ process.exitCode = 3;
103
+ return;
104
+ }
105
+
106
+ if (timeout && (Date.now() - startedAt) / 1000 >= timeout) {
107
+ process.stdout.write(`⌛ ${short} still pending after ${timeout}s — giving up (timed out).\n`);
108
+ process.exitCode = 2;
109
+ return;
110
+ }
111
+
112
+ await sleep(interval * 1000);
113
+ }
114
+ }
115
+
116
+ async function main() {
117
+ const isWatch = process.argv.slice(2).some(a => a === '--watch' || a.startsWith('--watch='));
118
+ if (isWatch) {
119
+ // arg('watch') handles --watch=<id> and --watch <id>; positional(0) is a
120
+ // fallback for when the value after a bare --watch is itself a flag.
121
+ const watchId = arg('watch') || positional(0);
122
+ if (!watchId) die('Usage: flow-decisions --watch <id>', 4);
123
+ await watch(watchId);
124
+ return;
125
+ }
126
+ await list();
127
+ }
128
+
129
+ main().catch(e => die(e.message || e, 4));
package/bin/flow.mjs CHANGED
@@ -35,7 +35,7 @@ const CMDS = [
35
35
  ['approve', 'approve.mjs', 'Approve a pending proposal'],
36
36
  ['reject', 'reject.mjs', 'Reject a pending proposal with a reason'],
37
37
  ['assign', 'assign.mjs', 'Assign a task to another actor'],
38
- ['decisions', 'decisions.mjs', 'List pending proposals awaiting approval'],
38
+ ['decisions', 'decisions.mjs', 'List pending proposals (--watch <id> blocks until resolved)'],
39
39
  ['handoff', 'handoff.mjs', 'Hand off a task to another agent'],
40
40
  ['review', 'review.mjs', 'Request code review; moves task to in-review'],
41
41
  ['search', 'search.mjs', 'Search tasks by text, status, area, or assignee'],
@@ -105,4 +105,9 @@ if (!target) {
105
105
  // read [--focus] — identical to what flow-pull sees when called directly.
106
106
  process.argv.splice(2, 1);
107
107
 
108
- await import(new URL(target, import.meta.url));
108
+ try {
109
+ await import(new URL(target, import.meta.url));
110
+ } catch (e) {
111
+ process.stderr.write(`flow ${sub}: ${e.message || e}\n`);
112
+ process.exit(1);
113
+ }
package/bin/scan.mjs CHANGED
@@ -68,14 +68,21 @@ function scanTodos(files, dir) {
68
68
  }
69
69
 
70
70
  // ── GitHub Issues scan ─────────────────────────────────────────────────────────
71
+ async function fetchGitHub(url) {
72
+ const res = await fetch(url, { headers: githubHeaders() });
73
+ if (res.status === 429 || res.status === 403) {
74
+ const reset = res.headers.get('x-ratelimit-reset');
75
+ const wait = reset ? new Date(Number(reset) * 1000).toLocaleTimeString() : 'soon';
76
+ throw new Error(`GitHub rate limit exceeded — resets at ${wait}`);
77
+ }
78
+ if (!res.ok) throw new Error(`GitHub API ${res.status}: ${res.statusText}`);
79
+ return res.json();
80
+ }
81
+
71
82
  async function fetchGitHubIssues(repo) {
72
83
  const all = [];
73
84
  for (let page = 1; page <= 5; page++) {
74
- const res = await fetch(`https://api.github.com/repos/${repo}/issues?state=open&per_page=100&page=${page}`, {
75
- headers: githubHeaders(),
76
- });
77
- if (!res.ok) throw new Error(`GitHub API ${res.status}`);
78
- const batch = await res.json();
85
+ const batch = await fetchGitHub(`https://api.github.com/repos/${repo}/issues?state=open&per_page=100&page=${page}`);
79
86
  all.push(...batch.filter(i => !i.pull_request));
80
87
  if (batch.length < 100) break;
81
88
  }
@@ -105,11 +112,7 @@ async function scanIssues(repo) {
105
112
  async function fetchGitHubPRs(repo) {
106
113
  const all = [];
107
114
  for (let page = 1; page <= 3; page++) {
108
- const res = await fetch(`https://api.github.com/repos/${repo}/pulls?state=open&per_page=100&page=${page}`, {
109
- headers: githubHeaders(),
110
- });
111
- if (!res.ok) throw new Error(`GitHub API ${res.status}`);
112
- const batch = await res.json();
115
+ const batch = await fetchGitHub(`https://api.github.com/repos/${repo}/pulls?state=open&per_page=100&page=${page}`);
113
116
  all.push(...batch);
114
117
  if (batch.length < 100) break;
115
118
  }
@@ -136,14 +139,15 @@ async function scanPRs(repo) {
136
139
 
137
140
  // ── Security scan ──────────────────────────────────────────────────────────────
138
141
  const SEC_PATTERNS = [
139
- { re: /(['"`])(sk_live_|AKIA[0-9A-Z]{16}|ghp_[0-9A-Za-z]{36})[0-9A-Za-z]+\1/i, label: 'Hardcoded secret (live key pattern)' },
142
+ { re: /(['"`])(sk_live_|AKIA[0-9A-Z]{16}|ghp_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z]{36}|glpat-[0-9A-Za-z]{20}|xox[bpas]-[0-9A-Za-z-]{10,})[0-9A-Za-z-]*\1/i, label: 'Hardcoded secret (live key pattern)' },
140
143
  { re: /\beval\s*\(/, label: 'eval() usage (code injection risk)' },
141
144
  { re: /dangerouslySetInnerHTML/, label: 'dangerouslySetInnerHTML (XSS risk)' },
142
- { re: /child_process.*\.exec\s*\(/, label: 'exec() call (command injection risk)' },
145
+ { re: /(?:child_process.*\.(?:exec|execSync|spawn|spawnSync)\s*\(|execSync\s*\(|require\(['"]child_process['"]\))/, label: 'exec/spawn call (command injection risk)' },
143
146
  { re: /\.query\s*\(`[^`]*\$\{/, label: 'Template literal in SQL query (injection risk)' },
147
+ { re: /['"`]\s*\+\s*(?:req\.|request\.|body\.|params\.|query\.|input)/, label: 'String concatenation in query (injection risk)' },
144
148
  { re: /new\s+Function\s*\(/, label: 'new Function() (eval-equivalent)' },
145
149
  { re: /require\s*\(\s*(?:req\.|request\.|body\.|params\.|query\.)/, label: 'Dynamic require() from request input' },
146
- { re: /(?:password|secret|apikey)\s*(?:=|:)\s*['"`][^'"`]{6,}/i, label: 'Possible hardcoded credential' },
150
+ { re: /(?:password|secret|apikey|api_key|access_token)\s*(?:=|:)\s*['"`][^'"`]{6,}/i, label: 'Possible hardcoded credential' },
147
151
  ];
148
152
 
149
153
  function scanSecurity(files, dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowcollab",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Multi-Claude coordination layer — shared task board + CLI for teams running Claude Code",
5
5
  "type": "module",
6
6
  "files": [