flowcollab 0.2.3 → 0.2.5
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 +3 -0
- package/bin/flow.mjs +1 -0
- package/bin/pull.mjs +33 -4
- package/bin/scan.mjs +238 -0
- package/package.json +3 -2
package/bin/_client.mjs
CHANGED
|
@@ -76,6 +76,8 @@ export async function flowFetch(path, { method = 'GET', body } = {}) {
|
|
|
76
76
|
'content-type': 'application/json',
|
|
77
77
|
};
|
|
78
78
|
if (actingViaClaude()) headers['x-flow-acting-via'] = 'claude';
|
|
79
|
+
const proj = process.env.FLOW_PROJECT_ID || _gc.projectId || '';
|
|
80
|
+
if (proj) headers['x-flow-project'] = proj;
|
|
79
81
|
|
|
80
82
|
const res = await fetch(url, {
|
|
81
83
|
method,
|
|
@@ -96,6 +98,7 @@ export async function flowFetch(path, { method = 'GET', body } = {}) {
|
|
|
96
98
|
|
|
97
99
|
export const RESOLVED_BASE = DEFAULT_BASE;
|
|
98
100
|
export const RESOLVED_ACTOR = process.env.FLOW_DEFAULT_ASSIGNEE || _gc.actorId || '';
|
|
101
|
+
export const RESOLVED_PROJECT = process.env.FLOW_PROJECT_ID || _gc.projectId || '';
|
|
99
102
|
export function resolvedTokenDisplay() {
|
|
100
103
|
const raw = envToken();
|
|
101
104
|
if (!raw) return '(none — run `flow-login`)';
|
package/bin/flow.mjs
CHANGED
|
@@ -48,6 +48,7 @@ const CMDS = [
|
|
|
48
48
|
['pr', 'pr.mjs', 'Record a PR link on a task timeline'],
|
|
49
49
|
['project', 'project.mjs', 'List GitHub Projects v2 items'],
|
|
50
50
|
['archive', 'archive.mjs', 'Archive or unarchive a task (--undo to restore)'],
|
|
51
|
+
['scan', 'scan.mjs', 'Audit for TODOs, untracked Issues, unlinked PRs, security patterns'],
|
|
51
52
|
['close-sprint', 'close-sprint.mjs', 'Close a sprint milestone (owner only)'],
|
|
52
53
|
['whoami', 'whoami.mjs', 'Verify token and show current identity'],
|
|
53
54
|
['completion', 'completion.mjs', 'Print shell completion script (bash/zsh/fish)'],
|
package/bin/pull.mjs
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
3. Recent timeline activity on YOUR tasks (last 5 per task)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { flowFetch, arg, RESOLVED_ACTOR } from './_client.mjs';
|
|
16
|
-
import { existsSync, writeFileSync } from 'fs';
|
|
15
|
+
import { flowFetch, arg, RESOLVED_ACTOR, RESOLVED_PROJECT } from './_client.mjs';
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
17
|
+
import { homedir } from 'os';
|
|
17
18
|
import { join } from 'path';
|
|
18
19
|
|
|
19
20
|
// Per-status age threshold (days) before pull flags a task as aging.
|
|
@@ -92,16 +93,29 @@ function mentionRegex(assignee) {
|
|
|
92
93
|
return new RegExp(`@to:(?:${a}-claude|${a}|claude)(?=[\\s.,;:!?)\\]]|$)`, 'i');
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
async function getActiveProjectName() {
|
|
97
|
+
try {
|
|
98
|
+
const { projects } = await flowFetch('/api/flow/projects');
|
|
99
|
+
if (!projects?.length) return null;
|
|
100
|
+
if (RESOLVED_PROJECT) {
|
|
101
|
+
const match = projects.find(p => p.id === RESOLVED_PROJECT);
|
|
102
|
+
if (match) return match.name;
|
|
103
|
+
}
|
|
104
|
+
return projects[0]?.name || null;
|
|
105
|
+
} catch { return null; }
|
|
106
|
+
}
|
|
107
|
+
|
|
95
108
|
async function main() {
|
|
96
109
|
const explicit = arg('assignee');
|
|
97
110
|
const filterAssignee = explicit === 'all' ? '' : (explicit || RESOLVED_ACTOR);
|
|
98
111
|
const focusMode = arg('focus') !== undefined;
|
|
99
112
|
const filterMilestone = arg('milestone') || '';
|
|
100
113
|
|
|
101
|
-
const [tasksRes, decRes, presenceRes] = await Promise.all([
|
|
114
|
+
const [tasksRes, decRes, presenceRes, projectName] = await Promise.all([
|
|
102
115
|
flowFetch('/api/flow/tasks?limit=200'),
|
|
103
116
|
flowFetch('/api/flow/decisions'),
|
|
104
117
|
flowFetch('/api/flow/presence').catch(() => null),
|
|
118
|
+
getActiveProjectName(),
|
|
105
119
|
]);
|
|
106
120
|
|
|
107
121
|
const allTasks = tasksRes.tasks || [];
|
|
@@ -300,7 +314,8 @@ async function main() {
|
|
|
300
314
|
focusMode ? 'focus' : null,
|
|
301
315
|
].filter(Boolean).join(', ') || 'unfiltered';
|
|
302
316
|
|
|
303
|
-
|
|
317
|
+
const headerTitle = projectName ? `Flow Board — ${projectName}` : 'Flow Board';
|
|
318
|
+
out.push(`=== ${headerTitle} snapshot (${snapshotLabel}) ===`);
|
|
304
319
|
|
|
305
320
|
if (focusMode) {
|
|
306
321
|
const open = tasks.filter(t => t.status !== 'done' && t.status !== 'awaiting_direction');
|
|
@@ -385,6 +400,20 @@ async function main() {
|
|
|
385
400
|
} catch (e) {
|
|
386
401
|
process.stderr.write(`flow-pull: --sync-md failed: ${e.message}\n`);
|
|
387
402
|
}
|
|
403
|
+
|
|
404
|
+
// Write project_id to global config
|
|
405
|
+
try {
|
|
406
|
+
const { projects } = await flowFetch('/api/flow/projects');
|
|
407
|
+
const defaultProject = projects?.find(p => p.slug === 'main') || projects?.[0];
|
|
408
|
+
if (defaultProject?.id) {
|
|
409
|
+
const configPath = join(homedir(), '.flow', 'config.json');
|
|
410
|
+
let gc = {};
|
|
411
|
+
try { gc = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
|
|
412
|
+
gc.projectId = defaultProject.id;
|
|
413
|
+
writeFileSync(configPath, JSON.stringify(gc, null, 2));
|
|
414
|
+
process.stdout.write(` project_id → ~/.flow/config.json (project: ${defaultProject.slug})\n`);
|
|
415
|
+
}
|
|
416
|
+
} catch { /* non-fatal */ }
|
|
388
417
|
}
|
|
389
418
|
}
|
|
390
419
|
|
package/bin/scan.mjs
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow-scan — audit for TODOs, untracked GitHub Issues, unlinked PRs, and security patterns.
|
|
3
|
+
Outputs numbered suggestions with ready-to-run flow-create commands.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
flow-scan [--todos] [--issues] [--prs] [--security]
|
|
7
|
+
[--dir=<path>] default: cwd
|
|
8
|
+
[--repo=<owner/repo>] default: FLOW_GITHUB_REPO env var
|
|
9
|
+
(no flags = run all four scans)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import 'dotenv/config';
|
|
13
|
+
import { flowFetch, arg, die } from './_client.mjs';
|
|
14
|
+
import { githubHeaders } from './_github.mjs';
|
|
15
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join, extname, relative } from 'node:path';
|
|
17
|
+
|
|
18
|
+
// ── File walker ────────────────────────────────────────────────────────────────
|
|
19
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '__pycache__', 'vendor', '.cache', 'out']);
|
|
20
|
+
const CODE_EXTS = new Set(['.js', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rb', '.java', '.sh', '.rs', '.c', '.cpp', '.h', '.cs', '.php', '.vue', '.svelte']);
|
|
21
|
+
|
|
22
|
+
function walkFiles(dir, base) {
|
|
23
|
+
base = base || dir;
|
|
24
|
+
const out = [];
|
|
25
|
+
let entries;
|
|
26
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
27
|
+
for (const e of entries) {
|
|
28
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
29
|
+
const full = join(dir, e.name);
|
|
30
|
+
if (e.isDirectory()) { out.push(...walkFiles(full, base)); continue; }
|
|
31
|
+
if (CODE_EXTS.has(extname(e.name))) out.push(full);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function grepLines(files, re) {
|
|
37
|
+
const hits = [];
|
|
38
|
+
for (const f of files) {
|
|
39
|
+
let lines;
|
|
40
|
+
try { lines = readFileSync(f, 'utf8').split('\n'); } catch { continue; }
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
if (re.test(lines[i])) hits.push({ file: f, lineNo: i + 1, text: lines[i].trim() });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return hits;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── TODO scan ──────────────────────────────────────────────────────────────────
|
|
49
|
+
const TODO_RE = /\b(TODO|FIXME|HACK|XXX)\b[:\s]*(.*)/i;
|
|
50
|
+
|
|
51
|
+
function scanTodos(files, dir) {
|
|
52
|
+
const hits = grepLines(files, TODO_RE);
|
|
53
|
+
if (!hits.length) return [];
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const h of hits.slice(0, 25)) {
|
|
56
|
+
const m = h.text.match(TODO_RE);
|
|
57
|
+
const kind = (m?.[1] || 'TODO').toUpperCase();
|
|
58
|
+
const desc = ((m?.[2] || '').trim() || h.text).slice(0, 80);
|
|
59
|
+
const rel = relative(dir, h.file);
|
|
60
|
+
const title = (desc.length > 58 ? desc.slice(0, 55) + '...' : desc).replace(/"/g, '\\"');
|
|
61
|
+
out.push({
|
|
62
|
+
label: `${kind} in ${rel}:${h.lineNo}`,
|
|
63
|
+
detail: ` ${rel}:${h.lineNo} — ${desc}`,
|
|
64
|
+
cmd: `flow-create --title="${title}" --type=chore --area=infra`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── GitHub Issues scan ─────────────────────────────────────────────────────────
|
|
71
|
+
async function fetchGitHubIssues(repo) {
|
|
72
|
+
const all = [];
|
|
73
|
+
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();
|
|
79
|
+
all.push(...batch.filter(i => !i.pull_request));
|
|
80
|
+
if (batch.length < 100) break;
|
|
81
|
+
}
|
|
82
|
+
return all;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function scanIssues(repo) {
|
|
86
|
+
if (!repo) return null;
|
|
87
|
+
const [ghIssues, flowResp] = await Promise.all([
|
|
88
|
+
fetchGitHubIssues(repo),
|
|
89
|
+
flowFetch('/api/flow/tasks?limit=500'),
|
|
90
|
+
]);
|
|
91
|
+
const tracked = new Set((flowResp.tasks || []).filter(t => t.github_issue_num).map(t => t.github_issue_num));
|
|
92
|
+
const untracked = ghIssues.filter(i => !tracked.has(i.number));
|
|
93
|
+
return untracked.slice(0, 15).map(i => {
|
|
94
|
+
const labels = (i.labels || []).map(l => l.name.toLowerCase());
|
|
95
|
+
const type = labels.includes('bug') ? 'bug' : labels.includes('documentation') ? 'docs' : 'feature';
|
|
96
|
+
return {
|
|
97
|
+
label: `GitHub Issue #${i.number}: ${i.title}`,
|
|
98
|
+
detail: ` ${i.html_url}`,
|
|
99
|
+
cmd: `flow-create --from-issue=${i.number} --type=${type}`,
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── GitHub PRs scan ────────────────────────────────────────────────────────────
|
|
105
|
+
async function fetchGitHubPRs(repo) {
|
|
106
|
+
const all = [];
|
|
107
|
+
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();
|
|
113
|
+
all.push(...batch);
|
|
114
|
+
if (batch.length < 100) break;
|
|
115
|
+
}
|
|
116
|
+
return all;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function scanPRs(repo) {
|
|
120
|
+
if (!repo) return null;
|
|
121
|
+
const [ghPRs, flowResp] = await Promise.all([
|
|
122
|
+
fetchGitHubPRs(repo),
|
|
123
|
+
flowFetch('/api/flow/tasks?limit=500'),
|
|
124
|
+
]);
|
|
125
|
+
const trackedPRs = new Set((flowResp.tasks || []).filter(t => t.pr_num).map(t => t.pr_num));
|
|
126
|
+
const unlinked = ghPRs.filter(pr => !trackedPRs.has(pr.number));
|
|
127
|
+
return unlinked.slice(0, 10).map(pr => {
|
|
128
|
+
const title = pr.title.replace(/"/g, '\\"').slice(0, 55);
|
|
129
|
+
return {
|
|
130
|
+
label: `Unlinked PR #${pr.number}: ${pr.title}`,
|
|
131
|
+
detail: ` ${pr.html_url} (${pr.draft ? 'draft' : 'open'})`,
|
|
132
|
+
cmd: `flow-create --title="Track PR #${pr.number}: ${title}" --type=chore --area=backend`,
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Security scan ──────────────────────────────────────────────────────────────
|
|
138
|
+
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)' },
|
|
140
|
+
{ re: /\beval\s*\(/, label: 'eval() usage (code injection risk)' },
|
|
141
|
+
{ re: /dangerouslySetInnerHTML/, label: 'dangerouslySetInnerHTML (XSS risk)' },
|
|
142
|
+
{ re: /child_process.*\.exec\s*\(/, label: 'exec() call (command injection risk)' },
|
|
143
|
+
{ re: /\.query\s*\(`[^`]*\$\{/, label: 'Template literal in SQL query (injection risk)' },
|
|
144
|
+
{ re: /new\s+Function\s*\(/, label: 'new Function() (eval-equivalent)' },
|
|
145
|
+
{ 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' },
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
function scanSecurity(files, dir) {
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const { re, label } of SEC_PATTERNS) {
|
|
152
|
+
const hits = grepLines(files, re);
|
|
153
|
+
for (const h of hits.slice(0, 3)) {
|
|
154
|
+
const rel = relative(dir, h.file);
|
|
155
|
+
const snippet = h.text.slice(0, 70);
|
|
156
|
+
out.push({
|
|
157
|
+
label: `${label} — ${rel}:${h.lineNo}`,
|
|
158
|
+
detail: ` ${rel}:${h.lineNo}: ${snippet}`,
|
|
159
|
+
cmd: `flow-create --title="Security: ${label.split(' (')[0].replace(/"/g, '\\"')}" --type=chore --area=infra --priority=P0-now`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Output ─────────────────────────────────────────────────────────────────────
|
|
167
|
+
function printSection(heading, suggestions) {
|
|
168
|
+
process.stdout.write(`\n${heading} (${suggestions.length})\n`);
|
|
169
|
+
process.stdout.write('─'.repeat(52) + '\n');
|
|
170
|
+
if (!suggestions.length) { process.stdout.write(' None found.\n'); return; }
|
|
171
|
+
for (let i = 0; i < suggestions.length; i++) {
|
|
172
|
+
const s = suggestions[i];
|
|
173
|
+
process.stdout.write(`${i + 1}. ${s.label}\n${s.detail}\n $ ${s.cmd}\n\n`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Main ───────────────────────────────────────────────────────────────────────
|
|
178
|
+
async function main() {
|
|
179
|
+
const doTodos = !!arg('todos');
|
|
180
|
+
const doIssues = !!arg('issues');
|
|
181
|
+
const doPRs = !!arg('prs');
|
|
182
|
+
const doSecurity = !!arg('security');
|
|
183
|
+
const all = !doTodos && !doIssues && !doPRs && !doSecurity;
|
|
184
|
+
|
|
185
|
+
const dir = arg('dir') || process.cwd();
|
|
186
|
+
const repo = arg('repo') || process.env.FLOW_GITHUB_REPO;
|
|
187
|
+
|
|
188
|
+
process.stdout.write(`flow-scan\n dir: ${dir}\n`);
|
|
189
|
+
if (repo) process.stdout.write(` repo: ${repo}\n`);
|
|
190
|
+
process.stdout.write('\n');
|
|
191
|
+
|
|
192
|
+
const needFiles = all || doTodos || doSecurity;
|
|
193
|
+
const files = needFiles ? walkFiles(dir) : [];
|
|
194
|
+
if (needFiles) process.stdout.write(` scanning ${files.length} source files...\n`);
|
|
195
|
+
|
|
196
|
+
if (all || doTodos) {
|
|
197
|
+
process.stdout.write(' [todos] scanning...\n');
|
|
198
|
+
const s = scanTodos(files, dir);
|
|
199
|
+
if (doTodos) { printSection('TODO / FIXME items', s); return; }
|
|
200
|
+
if (all && s.length) printSection('TODO / FIXME items', s);
|
|
201
|
+
else if (all) process.stdout.write('\n[todos] None found.\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (all || doIssues) {
|
|
205
|
+
process.stdout.write(' [issues] fetching GitHub Issues vs Flow tasks...\n');
|
|
206
|
+
let s;
|
|
207
|
+
try { s = await scanIssues(repo); } catch (e) { process.stdout.write(` [issues] Error: ${e.message}\n`); s = []; }
|
|
208
|
+
if (doIssues) { printSection('Untracked GitHub Issues', s || []); return; }
|
|
209
|
+
if (s === null) process.stdout.write('\n[issues] Skipped — FLOW_GITHUB_REPO not set.\n');
|
|
210
|
+
else if (all && s.length) printSection('Untracked GitHub Issues', s);
|
|
211
|
+
else if (all) process.stdout.write('\n[issues] All open Issues are tracked in Flow.\n');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (all || doPRs) {
|
|
215
|
+
process.stdout.write(' [prs] fetching open PRs vs Flow tasks...\n');
|
|
216
|
+
let s;
|
|
217
|
+
try { s = await scanPRs(repo); } catch (e) { process.stdout.write(` [prs] Error: ${e.message}\n`); s = []; }
|
|
218
|
+
if (doPRs) { printSection('Unlinked open PRs', s || []); return; }
|
|
219
|
+
if (s === null) process.stdout.write('\n[prs] Skipped — FLOW_GITHUB_REPO not set.\n');
|
|
220
|
+
else if (all && s.length) printSection('Unlinked open PRs', s);
|
|
221
|
+
else if (all) process.stdout.write('\n[prs] All open PRs are linked to Flow tasks.\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (all || doSecurity) {
|
|
225
|
+
process.stdout.write(' [security] scanning patterns...\n');
|
|
226
|
+
const s = scanSecurity(files, dir);
|
|
227
|
+
if (doSecurity) { printSection('Security concerns', s); return; }
|
|
228
|
+
if (all && s.length) printSection('Security concerns', s);
|
|
229
|
+
else if (all) process.stdout.write('\n[security] No common patterns found.\n');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (all) {
|
|
233
|
+
process.stdout.write('\nRun "flow-scan --todos / --issues / --prs / --security" for focused output.\n');
|
|
234
|
+
process.stdout.write('Use any "$ flow-create ..." command above to create a tracking task.\n');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
main().catch(e => { process.stderr.write('flow-scan: ' + (e.message || e) + '\n'); process.exit(1); });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowcollab",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Multi-Claude coordination layer — shared task board + CLI for teams running Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"flow-unblock": "bin/unblock.mjs",
|
|
36
36
|
"flow-log": "bin/log.mjs",
|
|
37
37
|
"flow-completion": "bin/completion.mjs",
|
|
38
|
-
"flow-archive": "bin/archive.mjs"
|
|
38
|
+
"flow-archive": "bin/archive.mjs",
|
|
39
|
+
"flow-scan": "bin/scan.mjs"
|
|
39
40
|
},
|
|
40
41
|
"scripts": {
|
|
41
42
|
"build": "node scripts/build.mjs",
|