flowcollab 0.3.10 → 0.3.11

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
@@ -7,7 +7,7 @@
7
7
  import { spawnSync } from 'child_process';
8
8
  import 'dotenv/config';
9
9
  import { homedir } from 'os';
10
- import { readFileSync, writeFileSync, mkdirSync } from 'fs';
10
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from 'fs';
11
11
  import { join } from 'path';
12
12
 
13
13
  // On Windows, Node's bundled CA store doesn't include certs added by corporate/AV
@@ -15,10 +15,16 @@ import { join } from 'path';
15
15
  // --use-system-ca so Node trusts the Windows certificate store instead.
16
16
  // Requires Node 22+; on older Node the child exits with "bad option" which tells
17
17
  // the user to upgrade.
18
- if (process.platform === 'win32' && !process.execArgv.includes('--use-system-ca')) {
18
+ // FLOW_TLS_RESPAWNED guards against an infinite re-spawn loop: if a Node build
19
+ // ever drops --use-system-ca from execArgv, the execArgv check alone would
20
+ // re-spawn forever. The env flag survives across the spawn, so we only ever
21
+ // re-spawn once.
22
+ if (process.platform === 'win32'
23
+ && !process.execArgv.includes('--use-system-ca')
24
+ && process.env.FLOW_TLS_RESPAWNED !== '1') {
19
25
  const r = spawnSync(process.execPath, ['--use-system-ca', ...process.argv.slice(1)], {
20
26
  stdio: 'inherit',
21
- env: process.env,
27
+ env: { ...process.env, FLOW_TLS_RESPAWNED: '1' },
22
28
  });
23
29
  process.exit(r.status ?? 1);
24
30
  }
@@ -54,6 +60,24 @@ function loadGlobalConfig() {
54
60
  }
55
61
  const _gc = loadGlobalConfig();
56
62
 
63
+ // Merge `patch` into ~/.flow/config.json and persist atomically. writeFileSync's
64
+ // `mode` is only honoured when the file is *created*, so an existing config keeps
65
+ // its old (possibly world-readable) perms — we chmod 0o600 explicitly. The temp +
66
+ // rename keeps the file from being truncated to garbage if the process dies mid-write.
67
+ export function saveGlobalConfig(patch) {
68
+ const dir = join(homedir(), '.flow');
69
+ const target = join(dir, 'config.json');
70
+ mkdirSync(dir, { recursive: true });
71
+ let existing = {};
72
+ try { existing = JSON.parse(readFileSync(target, 'utf8')); } catch {}
73
+ const merged = { ...existing, ...patch };
74
+ const tmp = join(dir, `config.json.${process.pid}.tmp`);
75
+ writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
76
+ try { chmodSync(tmp, 0o600); } catch {}
77
+ renameSync(tmp, target);
78
+ return merged;
79
+ }
80
+
57
81
  // flow-login writes apiBase + token to ~/.flow/config.json — both take precedence over
58
82
  // env vars so a stale FLOW_API_BASE / FLOW_API_TOKEN_* doesn't silently override the
59
83
  // user's authenticated session. Env vars are still honoured when config.json has no value.
@@ -16,7 +16,7 @@ const COMMANDS = [
16
16
  ];
17
17
 
18
18
  const FLAGS = {
19
- pull: ['--focus', '--milestone=', '--sync-md'],
19
+ pull: ['--focus', '--milestone=', '--sync-md', '--force'],
20
20
  claim: ['--files=', '--watch'],
21
21
  create: ['--title=', '--type=', '--area=', '--priority=', '--due=', '--blocked-by=', '--milestone=', '--from-issue=', '--from-project-item=', '--template='],
22
22
  handoff: ['--task=', '--to=', '--context=', '--branch=', '--questions=', '--next-step='],
package/bin/flow.mjs CHANGED
@@ -9,12 +9,17 @@ import { readFileSync } from 'fs';
9
9
  import { fileURLToPath } from 'url';
10
10
  import { dirname } from 'path';
11
11
 
12
- // Mirror the _client.mjs TLS re-spawn so the child sees --use-system-ca
13
- // and doesn't re-spawn a third time (checks process.execArgv).
14
- if (process.platform === 'win32' && !process.execArgv.includes('--use-system-ca')) {
12
+ // Mirror the _client.mjs TLS re-spawn so the child sees --use-system-ca.
13
+ // FLOW_TLS_RESPAWNED is a belt-and-suspenders guard against an infinite loop
14
+ // if a Node build ever drops --use-system-ca from execArgv (the env flag
15
+ // survives the spawn, so we re-spawn at most once). The router child also
16
+ // hands this flag to the subcommand, so _client.mjs won't re-spawn a third time.
17
+ if (process.platform === 'win32'
18
+ && !process.execArgv.includes('--use-system-ca')
19
+ && process.env.FLOW_TLS_RESPAWNED !== '1') {
15
20
  const r = spawnSync(process.execPath, ['--use-system-ca', ...process.argv.slice(1)], {
16
21
  stdio: 'inherit',
17
- env: process.env,
22
+ env: { ...process.env, FLOW_TLS_RESPAWNED: '1' },
18
23
  });
19
24
  process.exit(r.status ?? 1);
20
25
  }
package/bin/login.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  flow-login [--server=https://flow-production-84b7.up.railway.app]
5
5
  */
6
6
  import { homedir } from 'os';
7
- import { mkdirSync, readFileSync, writeFileSync } from 'fs';
7
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync, renameSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { exec } from 'child_process';
10
10
 
@@ -74,11 +74,17 @@ async function main() {
74
74
  const configPath = join(dir, 'config.json');
75
75
  let existing = {};
76
76
  try { existing = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
77
+ // Atomic write + explicit chmod: writeFileSync's `mode` is ignored when the
78
+ // file already exists, so an old config would keep looser perms; the temp +
79
+ // rename avoids a truncated config if the process dies mid-write.
80
+ const tmp = join(dir, `config.json.${process.pid}.tmp`);
77
81
  writeFileSync(
78
- configPath,
82
+ tmp,
79
83
  JSON.stringify({ ...existing, apiBase: api_base || base, token, actorId: actor_id, orgId: org_id }, null, 2) + '\n',
80
84
  { encoding: 'utf8', mode: 0o600 },
81
85
  );
86
+ try { chmodSync(tmp, 0o600); } catch {}
87
+ renameSync(tmp, configPath);
82
88
  process.stdout.write('\n\n');
83
89
  process.stdout.write(`✓ Logged in as ${actor_id}\n`);
84
90
  process.stdout.write(` Config saved to ~/.flow/config.json\n`);
package/bin/pull.mjs CHANGED
@@ -12,9 +12,8 @@
12
12
  3. Recent timeline activity on YOUR tasks (last 5 per task)
13
13
  */
14
14
 
15
- import { flowFetch, arg, RESOLVED_ACTOR, RESOLVED_PROJECT } from './_client.mjs';
15
+ import { flowFetch, arg, saveGlobalConfig, RESOLVED_ACTOR, RESOLVED_PROJECT } from './_client.mjs';
16
16
  import { existsSync, readFileSync, writeFileSync } from 'fs';
17
- import { homedir } from 'os';
18
17
  import { join } from 'path';
19
18
 
20
19
  // Per-status age threshold (days) before pull flags a task as aging.
@@ -399,22 +398,33 @@ async function main() {
399
398
  const mdText = mdRes.raw || (typeof mdRes === 'string' ? mdRes : null);
400
399
  if (!mdText) throw new Error('empty response');
401
400
  const dest = join(process.cwd(), 'CLAUDE.md');
402
- writeFileSync(dest, mdText, 'utf8');
403
- process.stdout.write(`\n✓ CLAUDE.md updated (${dest})\n`);
401
+ const force = arg('force') !== undefined;
402
+ // Don't blindly clobber a CLAUDE.md that has local edits — back it up first
403
+ // (unless --force) so a sync can never silently destroy hand-written context.
404
+ if (existsSync(dest) && !force) {
405
+ const current = readFileSync(dest, 'utf8');
406
+ if (current === mdText) {
407
+ process.stdout.write(`\n✓ CLAUDE.md already up to date (${dest})\n`);
408
+ } else {
409
+ const backup = dest + '.bak';
410
+ writeFileSync(backup, current, 'utf8');
411
+ writeFileSync(dest, mdText, 'utf8');
412
+ process.stdout.write(`\n✓ CLAUDE.md updated (${dest})\n Previous version backed up → ${backup} (pass --force to skip the backup)\n`);
413
+ }
414
+ } else {
415
+ writeFileSync(dest, mdText, 'utf8');
416
+ process.stdout.write(`\n✓ CLAUDE.md ${force && existsSync(dest) ? 'overwritten (--force)' : 'written'} (${dest})\n`);
417
+ }
404
418
  } catch (e) {
405
419
  process.stderr.write(`flow-pull: --sync-md failed: ${e.message}\n`);
406
420
  }
407
421
 
408
- // Write project_id to global config
422
+ // Write project_id to global config (atomic, 0o600-enforced — see saveGlobalConfig)
409
423
  try {
410
424
  const { projects } = await flowFetch('/api/flow/projects');
411
425
  const defaultProject = projects?.find(p => p.slug === 'main') || projects?.[0];
412
426
  if (defaultProject?.id) {
413
- const configPath = join(homedir(), '.flow', 'config.json');
414
- let gc = {};
415
- try { gc = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
416
- gc.projectId = defaultProject.id;
417
- writeFileSync(configPath, JSON.stringify(gc, null, 2), { encoding: 'utf8', mode: 0o600 });
427
+ saveGlobalConfig({ projectId: defaultProject.id });
418
428
  process.stdout.write(` project_id → ~/.flow/config.json (project: ${defaultProject.slug})\n`);
419
429
  }
420
430
  } catch { /* non-fatal */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowcollab",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Multi-Claude coordination layer — shared task board + CLI for teams running Claude Code",
5
5
  "type": "module",
6
6
  "files": [