flowcollab 0.3.20 → 0.3.22

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
@@ -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 => ` #${t.id.slice(0, 6)} ${sanitizeText(t.title)}`).join('\n')}`);
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/_commands.mjs CHANGED
@@ -44,6 +44,8 @@ export const CLI_COMMANDS = [
44
44
  usage: 'flow-handoff --task=<id> --to=<actor> --context="..." [--branch=] [--questions="q1|q2"] [--next-step="..."]' },
45
45
  { name: 'review', file: 'review.mjs', summary: 'Request code review; moves task to in-review',
46
46
  usage: 'flow-review <id> [--pr=<num>] [--reviewer=<actor>] [--context="..."]' },
47
+ { name: 'verify', file: 'verify.mjs', summary: 'Run the project test command + record a pass/fail on the task',
48
+ usage: 'flow-verify <id> [--cmd="npm test"] [--close] — run tests, post a verification event; --close closes on pass' },
47
49
  { name: 'search', file: 'search.mjs', summary: 'Search tasks by text, status, area, or assignee',
48
50
  usage: 'flow-search "query" [--status=] [--area=] [--assignee=]' },
49
51
  { name: 'status', file: 'status.mjs', summary: 'Quick board summary',
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
- // B14: --commit= with an empty value is a typo, not "use HEAD". Bare --commit means HEAD.
34
- if (hasEq) {
35
- commit = arg('commit');
36
- if (!commit) die('--commit= was given with no value. Use --commit (HEAD) or --commit=<sha>.');
37
- } else {
38
- commit = headSha();
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
 
@@ -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
- const COMMANDS = [
12
- 'login', 'pull', 'claim', 'comment', 'close', 'create', 'edit', 'unblock',
13
- 'propose', 'approve', 'reject', 'assign', 'decisions', 'handoff', 'review',
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/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 { exec } from 'child_process';
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 cmd = process.platform === 'darwin' ? `open "${url}"`
17
- : process.platform === 'win32' ? `start "" "${url}"`
18
- : `xdg-open "${url}"`;
19
- exec(cmd);
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/pull.mjs CHANGED
@@ -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/scan.mjs CHANGED
@@ -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
- if (res.status === 429 || res.status === 403) {
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
  }
@@ -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/verify.mjs ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ /* flow:verify — run the project's test command, record a pass/fail verification on the task
3
+ timeline, and exit with the command's status so an agent's harness can branch on the result.
4
+ Usage: flow-verify <task_id> [--cmd="npm test"] [--close]
5
+ command resolution: --cmd > ./flow.config.json "verify_command" > "npm test"
6
+ --close closes the task (flow-close) on a PASSING run.
7
+ */
8
+
9
+ import { spawnSync } from 'node:child_process';
10
+ import { readFileSync } from 'node:fs';
11
+ import { flowFetch, resolveTaskId, positional, arg, die, sanitizeText } from './_client.mjs';
12
+
13
+ function resolveCommand() {
14
+ const c = arg('cmd');
15
+ if (c) return c;
16
+ try {
17
+ const cfg = JSON.parse(readFileSync('flow.config.json', 'utf8'));
18
+ if (typeof cfg.verify_command === 'string' && cfg.verify_command.trim()) return cfg.verify_command;
19
+ } catch { /* no local config — fall through to the default */ }
20
+ return 'npm test';
21
+ }
22
+
23
+ async function main() {
24
+ const rawId = positional(0);
25
+ if (!rawId) die('Usage: flow-verify <task_id> [--cmd="npm test"] [--close]');
26
+ const id = await resolveTaskId(rawId);
27
+ const command = resolveCommand();
28
+ const doClose = process.argv.slice(2).includes('--close');
29
+ const shortRef = '#' + rawId.replace(/^#/, '').slice(0, 6);
30
+
31
+ process.stdout.write(`Running: ${command}\n`);
32
+ // spawnSync is blocking, so output is buffered then printed; the snippet below is what we record.
33
+ const r = spawnSync(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
34
+ const out = (r.stdout || '') + (r.stderr || '');
35
+ if (out) process.stdout.write(out.endsWith('\n') ? out : out + '\n');
36
+ const passed = r.status === 0;
37
+
38
+ // record the trailing ~20 non-empty lines as the audit snippet
39
+ const snippet = out.split('\n').filter(l => l.trim()).slice(-20).join('\n').slice(0, 4000);
40
+ await flowFetch('/api/flow/verify', { method: 'POST', body: { task_id: id, passed, command, summary: snippet } });
41
+ process.stdout.write(`${passed ? '✓ PASS' : '✗ FAIL'} — recorded on ${shortRef}\n`);
42
+
43
+ if (passed && doClose) {
44
+ await flowFetch('/api/flow/close', { method: 'POST', body: { task_id: id, summary: `Verified: ${sanitizeText(command, 100)}` } });
45
+ process.stdout.write(`Closed ${shortRef}.\n`);
46
+ }
47
+ process.exitCode = r.status ?? 1;
48
+ }
49
+
50
+ main().catch(e => die(e.message || e));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowcollab",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "description": "Multi-Claude coordination layer — shared task board + CLI for teams running Claude Code",
5
5
  "type": "module",
6
6
  "files": [
@@ -30,6 +30,7 @@
30
30
  "flow-project": "bin/project.mjs",
31
31
  "flow-close-sprint": "bin/close-sprint.mjs",
32
32
  "flow-review": "bin/review.mjs",
33
+ "flow-verify": "bin/verify.mjs",
33
34
  "flow-login": "bin/login.mjs",
34
35
  "flow-edit": "bin/edit.mjs",
35
36
  "flow-unblock": "bin/unblock.mjs",