gipity 1.0.408 → 1.0.410

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.
Files changed (52) hide show
  1. package/dist/claude-setup.js +3 -2
  2. package/dist/commands/add.js +11 -2
  3. package/dist/commands/approval.js +1 -1
  4. package/dist/commands/bug.js +69 -0
  5. package/dist/commands/credits.js +242 -15
  6. package/dist/commands/email.js +2 -1
  7. package/dist/commands/gmail.js +2 -1
  8. package/dist/commands/init.js +2 -1
  9. package/dist/commands/job.js +5 -4
  10. package/dist/commands/login.js +1 -0
  11. package/dist/commands/notify.js +2 -1
  12. package/dist/commands/page.js +1 -1
  13. package/dist/commands/payments.js +2 -1
  14. package/dist/commands/plan.js +1 -1
  15. package/dist/commands/push.js +7 -3
  16. package/dist/commands/relay.js +26 -2
  17. package/dist/commands/remove.js +10 -4
  18. package/dist/commands/secrets.js +2 -1
  19. package/dist/commands/service.js +2 -1
  20. package/dist/commands/storage.js +64 -0
  21. package/dist/commands/sync.js +2 -1
  22. package/dist/commands/text.js +2 -1
  23. package/dist/commands/token.js +2 -1
  24. package/dist/commands/uninstall.js +2 -2
  25. package/dist/commands/upload.js +1 -2
  26. package/dist/config.js +4 -4
  27. package/dist/helpers/output.js +42 -13
  28. package/dist/index.js +5 -4
  29. package/dist/knowledge.js +22 -0
  30. package/dist/platform.js +9 -5
  31. package/dist/relay/daemon.js +112 -7
  32. package/dist/relay/installers.js +10 -2
  33. package/dist/relay/onboarding.js +5 -2
  34. package/dist/relay/redact.js +26 -3
  35. package/dist/relay/setup.js +4 -4
  36. package/dist/relay/state.js +6 -3
  37. package/dist/setup.js +2 -3
  38. package/dist/sync.js +227 -106
  39. package/dist/updater/bootstrap.js +1 -1
  40. package/dist/updater/shim.js +4 -4
  41. package/dist/upload.js +1 -1
  42. package/package.json +6 -3
  43. package/dist/coding-guidelines.js +0 -52
  44. package/dist/commands/browser.js +0 -84
  45. package/dist/commands/checkpoint.js +0 -68
  46. package/dist/commands/hook-capture.js +0 -215
  47. package/dist/commands/scaffold.js +0 -58
  48. package/dist/commands/skills.js +0 -45
  49. package/dist/commands/start-cc.js +0 -313
  50. package/dist/gip3dw-guide.js +0 -18
  51. package/dist/platform-overview.js +0 -52
  52. package/dist/relay/transcripts.js +0 -119
@@ -56,8 +56,11 @@ export async function maybeOfferRelayOn() {
56
56
  }
57
57
  catch (err) {
58
58
  console.error(`\n ${clrError(`Could not create device: ${err?.message || err}`)}`);
59
- console.error(` ${dim('Skipping relay setup. Try later with `gipity relay install`.')}`);
60
- state.setRelayEnabled(false);
59
+ console.error(` ${dim('Skipping for now - we\'ll offer again next time. Or turn it on with `gipity relay install`.')}`);
60
+ // Deliberately DON'T persist relay_enabled here: the user SAID YES, and
61
+ // this is a transient failure (network blip, server down). Leaving the
62
+ // tri-state unset means onboarding re-offers on the next `gipity claude`
63
+ // run instead of silently opting the user out forever.
61
64
  return;
62
65
  }
63
66
  // Start the daemon for this session.
@@ -38,15 +38,38 @@ const JWT_RE = /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
38
38
  * literal-secret list. An `sk-ant-` token in a relay transcript is always a
39
39
  * credential, so over-redaction risk is negligible. */
40
40
  const ANTHROPIC_KEY_RE = /sk-ant-[A-Za-z0-9_-]{20,}/g;
41
- /** Replace every occurrence of each known secret - and any JWT- or
42
- * Anthropic-key-shaped substring - in `text` with the marker. */
41
+ /** Well-known third-party credential shapes. A `bypassPermissions` session can
42
+ * read the host's environment and files, so a `cat .env` / `env` could echo
43
+ * ANY of these into the transcript - not just the Gipity/Anthropic tokens on
44
+ * the literal-secret list. Each pattern is a high-entropy, provider-specific
45
+ * prefix that is effectively never a false positive in a relay transcript, so
46
+ * the over-redaction risk is the same negligible trade-off already accepted
47
+ * for the JWT and `sk-ant-` passes. Extend this list as new providers appear;
48
+ * it is defense-in-depth, NOT a guarantee (a base64/chunked secret still
49
+ * slips through - the real backstop remains an instantly-revocable
50
+ * credential). */
51
+ const THIRD_PARTY_KEY_RES = [
52
+ /sk-[A-Za-z0-9_-]{20,}/g, // OpenAI (sk-, sk-proj-) + any sk- key
53
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g, // GitHub tokens
54
+ /\bgithub_pat_[A-Za-z0-9_]{60,}\b/g, // GitHub fine-grained PAT
55
+ /\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
56
+ /\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b/g, // Stripe live secret/restricted key
57
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, // Slack tokens
58
+ /\bAIza[A-Za-z0-9_-]{35}\b/g, // Google API key
59
+ /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g, // PEM private keys
60
+ ];
61
+ /** Replace every occurrence of each known secret - plus any JWT-, Anthropic-,
62
+ * or well-known-third-party-key-shaped substring - in `text` with the marker. */
43
63
  function redactString(text, secrets) {
44
64
  let out = text;
45
65
  for (const secret of secrets) {
46
66
  if (out.includes(secret))
47
67
  out = out.split(secret).join(REDACTION_MARKER);
48
68
  }
49
- return out.replace(JWT_RE, REDACTION_MARKER).replace(ANTHROPIC_KEY_RE, REDACTION_MARKER);
69
+ out = out.replace(JWT_RE, REDACTION_MARKER).replace(ANTHROPIC_KEY_RE, REDACTION_MARKER);
70
+ for (const re of THIRD_PARTY_KEY_RES)
71
+ out = out.replace(re, REDACTION_MARKER);
72
+ return out;
50
73
  }
51
74
  /** Deep-walk any JSON-ish value, redacting every string. Returns a new
52
75
  * value; objects/arrays are cloned, primitives passed through. */
@@ -10,7 +10,7 @@
10
10
  * the interactive prompts that live in `onboarding.ts`. No `console` output
11
11
  * and no `process.exit` in this module: callers own all UX.
12
12
  */
13
- import { spawn, spawnSync } from 'child_process';
13
+ import { spawnCommand, spawnSyncCommand } from '../platform.js';
14
14
  import { hostname, platform as osPlatform } from 'os';
15
15
  import { resolve, dirname } from 'path';
16
16
  import { mkdirSync, writeFileSync } from 'fs';
@@ -87,7 +87,7 @@ export function startDaemon() {
87
87
  if (state.isDaemonRunning())
88
88
  return;
89
89
  try {
90
- const child = spawn(process.execPath, [resolveCliPath(), 'relay', 'run'], {
90
+ const child = spawnCommand(process.execPath, [resolveCliPath(), 'relay', 'run'], {
91
91
  detached: true,
92
92
  stdio: 'ignore',
93
93
  });
@@ -118,7 +118,7 @@ export function installAutostart(opts = {}) {
118
118
  writeFileSync(plan.path, plan.content);
119
119
  let ok = true;
120
120
  for (const argv of plan.enableCmds) {
121
- const r = spawnSync(argv[0], argv.slice(1), { stdio });
121
+ const r = spawnSyncCommand(argv[0], argv.slice(1), { stdio });
122
122
  if (r.status !== 0) {
123
123
  ok = false;
124
124
  break;
@@ -136,7 +136,7 @@ export function removeAutostart(opts = {}) {
136
136
  const plan = planFor({ cliPath: resolveCliPath() });
137
137
  let ok = true;
138
138
  for (const argv of plan.disableCmds) {
139
- const r = spawnSync(argv[0], argv.slice(1), { stdio });
139
+ const r = spawnSyncCommand(argv[0], argv.slice(1), { stdio });
140
140
  if (r.status !== 0)
141
141
  ok = false;
142
142
  }
@@ -47,9 +47,12 @@ export function loadState() {
47
47
  }
48
48
  export function saveState(state) {
49
49
  mkdirSync(RELAY_DIR, { recursive: true });
50
- writeFileSync(RELAY_FILE, JSON.stringify(state, null, 2) + '\n');
51
- // Token is inside - enforce owner-only even if the file existed with
52
- // looser permissions before.
50
+ // `mode` on write means a NEWLY-created file is owner-only from the first
51
+ // byte - no window where it exists at the umask default (typically 0644)
52
+ // before a follow-up chmod tightens it. The chmodSync still runs to fix a
53
+ // file that already existed with looser permissions (mode is ignored for
54
+ // an existing file).
55
+ writeFileSync(RELAY_FILE, JSON.stringify(state, null, 2) + '\n', { mode: FILE_MODE });
53
56
  try {
54
57
  chmodSync(RELAY_FILE, FILE_MODE);
55
58
  }
package/dist/setup.js CHANGED
@@ -4,7 +4,6 @@
4
4
  import { resolve, join, dirname } from 'path';
5
5
  import { homedir } from 'os';
6
6
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
7
- import { spawnSync } from 'child_process';
8
7
  import { resolveCommand, spawnSyncCommand } from './platform.js';
9
8
  import { SKILLS_CONTENT, BUILD_VS_NON_BUILD_RULE, DEFINITION_OF_DONE } from './knowledge.js';
10
9
  export { SKILLS_CONTENT };
@@ -257,10 +256,10 @@ export function userScopePluginCurrent() {
257
256
  }
258
257
  }
259
258
  function claudeOnPath() {
260
- const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['claude'], {
259
+ const probe = spawnSyncCommand(process.platform === 'win32' ? 'where' : 'which', ['claude'], {
261
260
  encoding: 'utf-8',
262
261
  });
263
- return probe.status === 0 && !!probe.stdout?.trim();
262
+ return probe.status === 0 && !!probe.stdout?.toString().trim();
264
263
  }
265
264
  /** Materialize the Gipity plugin at USER scope via Claude Code's own plugin
266
265
  * CLI, so its hooks (session capture + file sync) load in EVERY directory.
package/dist/sync.js CHANGED
@@ -21,9 +21,10 @@
21
21
  * `name (conflict from <host> YYYY-MM-DD-HHMMSS).ext` and then uploaded on
22
22
  * the next pass so every client sees it. No content merging, ever.
23
23
  */
24
- import { writeFileSync, mkdirSync, existsSync, statSync, unlinkSync, readdirSync, rmdirSync, readFileSync, renameSync, openSync, closeSync, utimesSync } from 'fs';
24
+ import { writeFileSync, mkdirSync, existsSync, statSync, lstatSync, unlinkSync, readdirSync, rmdirSync, readFileSync, renameSync, openSync, closeSync, utimesSync, realpathSync } from 'fs';
25
25
  import { join, relative, dirname, extname, resolve, sep } from 'path';
26
26
  import { hostname } from 'os';
27
+ import { createHash } from 'crypto';
27
28
  import { get, del, downloadStream, ApiError } from './api.js';
28
29
  import { requireConfig, shouldIgnore, getConfigPath } from './config.js';
29
30
  import { formatSize, prompt, getAutoConfirm } from './utils.js';
@@ -263,9 +264,33 @@ function normalizeTreePath(p) {
263
264
  export function resolveInRoot(root, relPath) {
264
265
  const rootResolved = resolve(root);
265
266
  const full = resolve(rootResolved, relPath);
267
+ // Fast lexical pre-filter.
266
268
  if (full !== rootResolved && !full.startsWith(rootResolved + sep)) {
267
269
  throw new Error(`Refusing path outside project root: ${relPath}`);
268
270
  }
271
+ // Symlink-aware containment (FIX M6a): a lexical check can't see that a
272
+ // symlinked directory INSIDE the project points its real location outside the
273
+ // root, so a remote-supplied path could still write/rename/delete outside the
274
+ // project - and the relay daemon runs sync unattended. Resolve the nearest
275
+ // existing ancestor (the leaf may not exist yet) with realpath and assert its
276
+ // real path is still inside the project's real root. Realpath failures (root or
277
+ // ancestors that don't exist yet) fall through to the lexical result - there's
278
+ // no symlink to escape through when nothing exists.
279
+ try {
280
+ const rootReal = realpathSync(rootResolved);
281
+ let ancestor = full;
282
+ while (!existsSync(ancestor) && dirname(ancestor) !== ancestor)
283
+ ancestor = dirname(ancestor);
284
+ const ancestorReal = realpathSync(ancestor);
285
+ if (ancestorReal !== rootReal && !ancestorReal.startsWith(rootReal + sep)) {
286
+ throw new Error(`Refusing path outside project root (symlink escape): ${relPath}`);
287
+ }
288
+ }
289
+ catch (e) {
290
+ if (e instanceof Error && /outside project root/.test(e.message))
291
+ throw e;
292
+ // else: realpath couldn't resolve (nonexistent root/ancestor) - keep lexical result.
293
+ }
269
294
  return full;
270
295
  }
271
296
  async function fetchRemote(projectGuid) {
@@ -346,46 +371,36 @@ async function downloadAll(projectGuid, onBytes) {
346
371
  const stream = await downloadStream(`/projects/${projectGuid}/files/tree?content=tar`);
347
372
  return extractTarToMap(stream, DOWNLOAD_IDLE_MS, onBytes);
348
373
  }
349
- async function fetchOne(projectGuid, path) {
350
- // Exact single-file read first. `/files/read` is the exact-path endpoint
351
- // (what `gipity file cat` uses); it returns text content, reliable for the
352
- // config/code files that actually hit a restore. Binary falls through to the
353
- // tar path below, which now resolves an exact file path server-side (the
354
- // /files/tree endpoint stats a non-directory `path` and packs that one file
355
- // under its full name). Before that fix the tar endpoint treated `path` as a
356
- // DIRECTORY prefix, so a single file came back empty and a dropped binary
357
- // (e.g. an agent-generated image) could never be recovered.
358
- try {
359
- const res = await get(`/projects/${projectGuid}/files/read?path=${encodeURIComponent(path)}`);
360
- const content = res?.data?.content;
361
- if (typeof content === 'string' && isTextMime(res?.data?.mime, path)) {
362
- return Buffer.from(content, 'utf-8');
363
- }
364
- }
365
- catch {
366
- /* fall through to the tar path */
367
- }
374
+ async function fetchOne(projectGuid, path, expectedSha) {
375
+ // Byte-exact single-file recovery over the tar path only (FIX M3). We used to
376
+ // prefer `/files/read` for text-extension files, but that returns the content as
377
+ // a JSON *string* which we re-encoded as UTF-8 - a non-UTF-8 file (latin-1
378
+ // .csv/.txt) came back replacement-mangled, and the downloads pass then recorded
379
+ // the REMOTE sha against those wrong bytes, so the next sync uploaded the
380
+ // corruption over the good remote copy. The tar path packs the raw stored blob
381
+ // (the /files/tree endpoint stats a non-directory `path` and packs that one file
382
+ // under its full name), so the buffer is identical regardless of encoding. It's
383
+ // idle-guarded like the bulk path so a stalled single file can't wedge the sync.
368
384
  try {
369
385
  const stream = await downloadStream(`/projects/${projectGuid}/files/tree?content=tar&path=${encodeURIComponent(path)}`);
370
- // Same idle-guarded extraction as the bulk path; keep only the one entry we
371
- // asked for. The recovery path must not hang either, or a single stalled file
372
- // wedges the whole sync.
373
386
  const want = normalizeTreePath(path);
374
387
  const files = await extractTarToMap(stream, DOWNLOAD_IDLE_MS, undefined, (p) => p === want);
375
- return files.get(want) ?? null;
388
+ const buf = files.get(want) ?? null;
389
+ // Verify the bytes against the expected content hash when the caller knows it
390
+ // (the recovery path records this sha into the baseline). A mismatch means a
391
+ // truncated/partial entry - treat it as a failed fetch and leave the file for
392
+ // the tar/next run rather than writing bytes that don't match what we'll record.
393
+ if (buf && expectedSha) {
394
+ const sha = createHash('sha256').update(buf).digest('hex');
395
+ if (sha !== expectedSha)
396
+ return null;
397
+ }
398
+ return buf;
376
399
  }
377
400
  catch {
378
401
  return null;
379
402
  }
380
403
  }
381
- // Treat a file as text (safe to round-trip through `/files/read`'s string body)
382
- // from its mime or, failing that, a code/config extension. Binary needs the
383
- // byte-exact tar path.
384
- function isTextMime(mime, path) {
385
- if (mime && (mime.startsWith('text/') || /(json|javascript|xml|yaml|x-sh|sql)/.test(mime)))
386
- return true;
387
- return /\.(js|mjs|cjs|ts|tsx|jsx|json|yaml|yml|sql|md|txt|html|css|svg|csv|env|sh|toml|ini)$/i.test(path);
388
- }
389
404
  // ─── Classification ────────────────────────────────────────────
390
405
  function classifyLocal(info, base) {
391
406
  if (!info && !base)
@@ -426,6 +441,22 @@ export function conflictedCopyName(path) {
426
441
  // ─── Plan ─────────────────────────────────────────────────────
427
442
  export function plan(local, remote, baseline) {
428
443
  const actions = [];
444
+ const baselineDrops = [];
445
+ const baselineAdopts = [];
446
+ // Record the agreed (content-match) state into the baseline when it's missing or
447
+ // stale, so a locally-and-remotely-identical file is a known synced pair going
448
+ // forward. Size/mtime mirror what the scanner records (they key its hash cache);
449
+ // serverVersion + sha come from the agreed remote/local state. (FIX M1)
450
+ const adoptIfNeeded = (p, L, R) => {
451
+ const entry = {
452
+ size: L.size, mtime: L.mtime, sha256: L.sha256, serverVersion: R.serverVersion,
453
+ };
454
+ const b = baseline[p];
455
+ if (!b || b.sha256 !== entry.sha256 || b.serverVersion !== entry.serverVersion
456
+ || b.size !== entry.size || b.mtime !== entry.mtime) {
457
+ baselineAdopts.push({ path: p, entry });
458
+ }
459
+ };
429
460
  const allPaths = new Set([...local.keys(), ...remote.keys(), ...Object.keys(baseline)]);
430
461
  for (const path of allPaths) {
431
462
  const L = local.get(path);
@@ -451,15 +482,16 @@ export function plan(local, remote, baseline) {
451
482
  // unchanged × unchanged → noop
452
483
  if (lSide === 'unchanged' && rSide === 'unchanged')
453
484
  continue;
454
- // unchanged × modified → download, but ONLY if the remote genuinely advanced.
455
- // Guards a read-after-write race: right after a local write+push, the push can
456
- // advance the local baseline to the new version while the remote tree API still
457
- // serves the OLD bytes (stale read). That makes remote look "modified" vs an
458
- // already-updated baseline, and a blind download would silently clobber the
459
- // just-written local file with a stale older version. A real remote change
460
- // always carries a strictly newer serverVersion; an equal/older one is stale.
485
+ // unchanged × modified → download. server_version is per-NODE, not per-path:
486
+ // a server-side delete+recreate mints a fresh node whose counter restarts at 1,
487
+ // so the old `R.serverVersion <= B.serverVersion` guard wrongly treated a
488
+ // genuine recreate (e.g. remote v1 vs baseline v5) as a stale read and skipped
489
+ // it forever. Skip ONLY a true no-op re-read - the same node version AND the
490
+ // same content. Since rSide 'modified' already means the sha differs from
491
+ // baseline (or the remote sha is unknown), a regressed/equal counter with
492
+ // different bytes falls through to a real download. (FIX H7a)
461
493
  if (lSide === 'unchanged' && rSide === 'modified') {
462
- if (B && R.serverVersion <= B.serverVersion)
494
+ if (B && R.serverVersion === B.serverVersion && R.sha256 === B.sha256)
463
495
  continue;
464
496
  actions.push({ path, kind: 'download', remoteSize: R.size });
465
497
  continue;
@@ -476,8 +508,10 @@ export function plan(local, remote, baseline) {
476
508
  }
477
509
  // modified × modified → conflict (or noop if content happens to match)
478
510
  if (lSide === 'modified' && rSide === 'modified') {
479
- if (shasMatch)
511
+ if (shasMatch) {
512
+ adoptIfNeeded(path, L, R);
480
513
  continue;
514
+ }
481
515
  actions.push({
482
516
  path, kind: 'conflict',
483
517
  localSize: L.size, remoteSize: R.size,
@@ -497,8 +531,10 @@ export function plan(local, remote, baseline) {
497
531
  }
498
532
  // added × added → noop if shas match, else conflict
499
533
  if (lSide === 'added' && rSide === 'added') {
500
- if (shasMatch)
534
+ if (shasMatch) {
535
+ adoptIfNeeded(path, L, R);
501
536
  continue;
537
+ }
502
538
  actions.push({
503
539
  path, kind: 'conflict',
504
540
  localSize: L.size, remoteSize: R.size,
@@ -508,9 +544,12 @@ export function plan(local, remote, baseline) {
508
544
  });
509
545
  continue;
510
546
  }
511
- // deleted × absent → baseline is stale, drop it silently (no action)
512
- if (lSide === 'deleted' && rSide === 'absent')
547
+ // deleted × absent → baseline is stale, drop it (no action). Actually pruning
548
+ // the entry (FIX H7b) stops it accumulating and masking a future recreate.
549
+ if (lSide === 'deleted' && rSide === 'absent') {
550
+ baselineDrops.push(path);
513
551
  continue;
552
+ }
514
553
  // deleted × unchanged → delete remote. Use the remote's CURRENT version for
515
554
  // the optimistic-delete check, not the baseline's: the content can be equal
516
555
  // (rSide 'unchanged' is sha-based) while the server version moved ahead - the
@@ -520,11 +559,12 @@ export function plan(local, remote, baseline) {
520
559
  actions.push({ path, kind: 'delete-remote', remoteSize: R.size, expectedServerVersion: R.serverVersion });
521
560
  continue;
522
561
  }
523
- // deleted × modified → remote wins, restore locally but only if the remote
524
- // actually advanced past the baseline. A stale (older/equal) remote read must
525
- // not resurrect a file the user intentionally deleted.
562
+ // deleted × modified → remote wins, restore locally. Skip ONLY a true no-op
563
+ // re-read (same node version AND same content); a regressed counter is a
564
+ // delete+recreate (fresh node, restarted counter), which is a genuine remote
565
+ // change to restore, not a stale read to ignore. (FIX H7a)
526
566
  if (lSide === 'deleted' && rSide === 'modified') {
527
- if (B && R.serverVersion <= B.serverVersion)
567
+ if (B && R.serverVersion === B.serverVersion && R.sha256 === B.sha256)
528
568
  continue;
529
569
  actions.push({
530
570
  path, kind: 'download', remoteSize: R.size,
@@ -532,9 +572,11 @@ export function plan(local, remote, baseline) {
532
572
  });
533
573
  continue;
534
574
  }
535
- // deleted × deleted → noop, drop baseline
536
- if (lSide === 'deleted' && rSide === 'deleted')
575
+ // deleted × deleted → noop, drop baseline (FIX H7b: actually prune it)
576
+ if (lSide === 'deleted' && rSide === 'deleted') {
577
+ baselineDrops.push(path);
537
578
  continue;
579
+ }
538
580
  // Remaining combinations are impossible given baseline semantics.
539
581
  }
540
582
  const uploads = actions.filter(a => a.kind === 'upload').length;
@@ -542,7 +584,7 @@ export function plan(local, remote, baseline) {
542
584
  const deletesLocal = actions.filter(a => a.kind === 'delete-local').length;
543
585
  const deletesRemote = actions.filter(a => a.kind === 'delete-remote').length;
544
586
  const conflicts = actions.filter(a => a.kind === 'conflict').length;
545
- return { actions, uploads, downloads, deletesLocal, deletesRemote, conflicts };
587
+ return { actions, uploads, downloads, deletesLocal, deletesRemote, conflicts, baselineDrops, baselineAdopts };
546
588
  }
547
589
  // ─── Apply ─────────────────────────────────────────────────────
548
590
  function formatAction(a) {
@@ -731,11 +773,21 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
731
773
  return abort();
732
774
  }
733
775
  }
734
- // Bulk-delete guard over the *planned* deletes.
735
- const knownFiles = local.size + remote.size;
736
- const deletesOk = await bulkDeleteGuard(planned, knownFiles, { ...opts, interactive });
737
- // Filter actions based on guard
738
- const plannedToApply = deletesOk ? planned.actions : planned.actions.filter(a => a.kind !== 'delete-local' && a.kind !== 'delete-remote');
776
+ // Bulk-delete guard over the *planned* deletes. Count DISTINCT paths across
777
+ // local remote (FIX M7): summing the two sizes double-counts every synced
778
+ // file, roughly doubling the denominator so the "delete ≥25% of the tree"
779
+ // trip-wire needed ~50% before it fired. A set union reflects the real tree size.
780
+ const knownFiles = new Set([...local.keys(), ...remote.keys()]).size;
781
+ // Pre-authorized deletions (FIX M5): a caller that owns a specific set of paths
782
+ // (e.g. `gipity remove` deleting a kit's files) whitelists them so they bypass
783
+ // the guard, while any OTHER pending deletion is still counted and guarded.
784
+ const whitelist = new Set((opts.deleteWhitelist ?? []).map(normalizeTreePath));
785
+ const isDelete = (a) => a.kind === 'delete-local' || a.kind === 'delete-remote';
786
+ const guardDeletesLocal = planned.actions.filter(a => a.kind === 'delete-local' && !whitelist.has(a.path)).length;
787
+ const guardDeletesRemote = planned.actions.filter(a => a.kind === 'delete-remote' && !whitelist.has(a.path)).length;
788
+ const deletesOk = await bulkDeleteGuard({ ...planned, deletesLocal: guardDeletesLocal, deletesRemote: guardDeletesRemote }, knownFiles, { ...opts, interactive });
789
+ // Filter actions based on guard - but always keep the caller's whitelisted deletes.
790
+ const plannedToApply = deletesOk ? planned.actions : planned.actions.filter(a => !isDelete(a) || whitelist.has(a.path));
739
791
  const skippedByGuard = planned.actions.length - plannedToApply.length;
740
792
  const errors = [];
741
793
  let applied = 0;
@@ -794,9 +846,13 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
794
846
  if (missing.length) {
795
847
  p?.phase(`Recovering ${missing.length} file${missing.length === 1 ? '' : 's'} the bulk download dropped…`);
796
848
  for (const a of missing) {
849
+ // Verify against the remote's content hash: the downloads pass records
850
+ // this sha into the baseline, so bytes that don't match it must be
851
+ // rejected (FIX M3) rather than written and then re-uploaded as corruption.
852
+ const expectedSha = remote.get(a.path)?.sha256 ?? undefined;
797
853
  let buf = null;
798
854
  for (let attempt = 0; attempt < 3 && !buf; attempt++) {
799
- buf = await fetchOne(config.projectGuid, a.path);
855
+ buf = await fetchOne(config.projectGuid, a.path, expectedSha);
800
856
  }
801
857
  if (buf)
802
858
  downloadedBytes.set(a.path, buf);
@@ -813,6 +869,52 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
813
869
  const uploadQueue = plannedToApply.filter(a => a.kind === 'upload');
814
870
  const downloadQueue = plannedToApply.filter(a => a.kind === 'download');
815
871
  const conflictQueue = plannedToApply.filter(a => a.kind === 'conflict');
872
+ // Conflict downgrade shared by the download TOCTOU guard and init-/complete-time
873
+ // CAS rejections: remote wins the canonical path - rename local out of the way,
874
+ // restore the server copy, re-upload the rename as a brand-new path so the local
875
+ // edit survives as a conflicted copy on every client.
876
+ const downgradeToConflict = async (a, full, currentServerVersion) => {
877
+ const currentBytes = await fetchOne(config.projectGuid, a.path);
878
+ const renamedRel = conflictedCopyName(a.path);
879
+ let renamedFull;
880
+ try {
881
+ renamedFull = resolveInRoot(root, renamedRel);
882
+ }
883
+ catch (e) {
884
+ errors.push(e.message);
885
+ return;
886
+ }
887
+ try {
888
+ renameSync(full, renamedFull);
889
+ }
890
+ catch (e) {
891
+ errors.push(`Rename failed for ${a.path}: ${e.message}`);
892
+ return;
893
+ }
894
+ if (currentBytes) {
895
+ mkdirSync(dirname(full), { recursive: true });
896
+ writeFileSync(full, currentBytes);
897
+ const stat = statSync(full);
898
+ baseline.files[a.path] = {
899
+ size: stat.size, mtime: stat.mtime.toISOString(),
900
+ sha256: '', // will re-hash on next sync
901
+ serverVersion: currentServerVersion ?? 0,
902
+ };
903
+ }
904
+ try {
905
+ const result = await uploadOneFile(config.projectGuid, renamedFull, renamedRel, { expectedServerVersion: null });
906
+ const stat = statSync(renamedFull);
907
+ const { sha256 } = await hashFile(renamedFull);
908
+ baseline.files[renamedRel] = {
909
+ size: stat.size, mtime: stat.mtime.toISOString(),
910
+ sha256, serverVersion: result.serverVersion,
911
+ };
912
+ }
913
+ catch (e) {
914
+ errors.push(`Conflict-copy upload failed for ${renamedRel}: ${e.message}`);
915
+ }
916
+ applied++;
917
+ };
816
918
  // Handle downloads first (no network writes) - fills local fs with remote changes.
817
919
  for (const a of downloadQueue) {
818
920
  const buf = downloadedBytes.get(a.path);
@@ -828,6 +930,29 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
828
930
  errors.push(e.message);
829
931
  continue;
830
932
  }
933
+ // Pull TOCTOU guard (FIX H8): the whole-tree tar download between scan and
934
+ // this write can take a long time, and a local edit landing in that window
935
+ // would be silently clobbered by the remote bytes. Re-hash the on-disk file
936
+ // now and compare to what the plan saw at scan time (the local scan sha, or
937
+ // the baseline sha it was derived from). If it moved, the newer local edit
938
+ // must not be lost - downgrade to the conflict path so the remote lands as
939
+ // the canonical copy and the local edit is preserved as a conflicted copy.
940
+ if (existsSync(full)) {
941
+ const scanSha = local.get(a.path)?.sha256 ?? baseline.files[a.path]?.sha256;
942
+ const remoteSha = remote.get(a.path)?.sha256 ?? undefined;
943
+ let currentSha;
944
+ try {
945
+ currentSha = (await hashFile(full)).sha256;
946
+ }
947
+ catch { /* unreadable - fall through to overwrite */ }
948
+ // Clobber only if the on-disk bytes moved since the scan (or a file appeared
949
+ // in a path we scanned as absent) AND they don't already equal the remote
950
+ // bytes we're about to write (no point conflicting on an identical file).
951
+ if (currentSha !== undefined && currentSha !== scanSha && currentSha !== remoteSha) {
952
+ await downgradeToConflict(a, full, remote.get(a.path).serverVersion);
953
+ continue;
954
+ }
955
+ }
831
956
  mkdirSync(dirname(full), { recursive: true });
832
957
  writeFileSync(full, buf);
833
958
  const stat = statSync(full);
@@ -908,51 +1033,6 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
908
1033
  const onBytes = p
909
1034
  ? (delta) => { sentBytes += delta; p.transfer(uploadLabel, sentBytes, totalUploadBytes); }
910
1035
  : undefined;
911
- // Conflict downgrade shared by init-time and complete-time CAS rejections:
912
- // remote moved under us, so remote wins the canonical path - rename local,
913
- // restore the server copy, re-upload the rename as a brand-new path.
914
- const downgradeToConflict = async (a, full, currentServerVersion) => {
915
- const currentBytes = await fetchOne(config.projectGuid, a.path);
916
- const renamedRel = conflictedCopyName(a.path);
917
- let renamedFull;
918
- try {
919
- renamedFull = resolveInRoot(root, renamedRel);
920
- }
921
- catch (e) {
922
- errors.push(e.message);
923
- return;
924
- }
925
- try {
926
- renameSync(full, renamedFull);
927
- }
928
- catch (e) {
929
- errors.push(`Rename failed for ${a.path}: ${e.message}`);
930
- return;
931
- }
932
- if (currentBytes) {
933
- mkdirSync(dirname(full), { recursive: true });
934
- writeFileSync(full, currentBytes);
935
- const stat = statSync(full);
936
- baseline.files[a.path] = {
937
- size: stat.size, mtime: stat.mtime.toISOString(),
938
- sha256: '', // will re-hash on next sync
939
- serverVersion: currentServerVersion ?? 0,
940
- };
941
- }
942
- try {
943
- const result = await uploadOneFile(config.projectGuid, renamedFull, renamedRel, { expectedServerVersion: null });
944
- const stat = statSync(renamedFull);
945
- const { sha256 } = await hashFile(renamedFull);
946
- baseline.files[renamedRel] = {
947
- size: stat.size, mtime: stat.mtime.toISOString(),
948
- sha256, serverVersion: result.serverVersion,
949
- };
950
- }
951
- catch (e) {
952
- errors.push(`Conflict-copy upload failed for ${renamedRel}: ${e.message}`);
953
- }
954
- applied++;
955
- };
956
1036
  for (let chunkStart = 0; chunkStart < uploadQueue.length; chunkStart += UPLOAD_INIT_BATCH_SIZE) {
957
1037
  const chunk = uploadQueue.slice(chunkStart, chunkStart + UPLOAD_INIT_BATCH_SIZE);
958
1038
  // Stat + hash once per file; the same numbers feed init, the baseline,
@@ -1105,12 +1185,32 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
1105
1185
  continue;
1106
1186
  }
1107
1187
  if (a.kind === 'delete-local') {
1188
+ // Only drop the baseline entry when the file is actually gone. A locked file
1189
+ // (Windows EBUSY/EPERM) that survives the unlink must KEEP its baseline: with
1190
+ // no baseline the next sync sees added×absent and re-uploads it, reverting the
1191
+ // remote deletion. ENOENT counts as success (already gone). (FIX M4)
1192
+ let full;
1193
+ try {
1194
+ full = resolveInRoot(root, a.path);
1195
+ }
1196
+ catch (e) {
1197
+ errors.push(e.message);
1198
+ continue;
1199
+ }
1108
1200
  try {
1109
- unlinkSync(resolveInRoot(root, a.path));
1201
+ unlinkSync(full);
1202
+ delete baseline.files[a.path];
1203
+ applied++;
1204
+ }
1205
+ catch (err) {
1206
+ if (err.code === 'ENOENT') {
1207
+ delete baseline.files[a.path];
1208
+ applied++;
1209
+ }
1210
+ else {
1211
+ errors.push(`Could not delete local ${a.path}: ${err.message} - left in place, will retry next sync`);
1212
+ }
1110
1213
  }
1111
- catch { /* already gone or outside root */ }
1112
- delete baseline.files[a.path];
1113
- applied++;
1114
1214
  }
1115
1215
  else if (a.kind === 'delete-remote') {
1116
1216
  try {
@@ -1175,6 +1275,15 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
1175
1275
  }
1176
1276
  // Clean up empty local directories after delete-local actions.
1177
1277
  cleanupEmptyDirs(root, config.ignore);
1278
+ // Adopt agreed content-match state into the baseline, and prune stale entries.
1279
+ // `plan()` is pure, so it emits these as lists and the mutation happens here.
1280
+ // Adopting (FIX M1) makes sync convergent - a locally+remotely-identical file
1281
+ // becomes a known synced pair, so a later delete propagates instead of
1282
+ // resurrecting. Pruning (FIX H7b) stops stale entries masking a later recreate.
1283
+ for (const { path, entry } of planned.baselineAdopts)
1284
+ baseline.files[path] = entry;
1285
+ for (const path of planned.baselineDrops)
1286
+ delete baseline.files[path];
1178
1287
  baseline.lastFullSync = new Date().toISOString();
1179
1288
  writeBaseline(baseline);
1180
1289
  p?.finish();
@@ -1232,6 +1341,18 @@ export async function pushFile(filePath) {
1232
1341
  const rel = relative(root, filePath).replace(/\\/g, '/');
1233
1342
  if (shouldIgnore(rel, effectiveIgnore(root, config.ignore)))
1234
1343
  return;
1344
+ // Skip symlinks (FIX M6b): `walkLocal` (the full-sync scanner) ignores symlink
1345
+ // dirents, so if the push path followed a link and uploaded it, the next full
1346
+ // sync would see a remote path with no local counterpart and plan delete-remote
1347
+ // - churn, and eventual remote loss. Both sides must agree symlinks are out of
1348
+ // scope. lstat (not stat) so we inspect the link itself, not its target.
1349
+ try {
1350
+ if (lstatSync(filePath).isSymbolicLink())
1351
+ return;
1352
+ }
1353
+ catch {
1354
+ return; /* gone/unreadable - nothing to push */
1355
+ }
1235
1356
  // Serialize against `gipity sync` and other concurrent pushes by holding the
1236
1357
  // same per-project lock `sync()` uses. Both paths read-modify-write the shared
1237
1358
  // baseline; without a common lock, a burst of PostToolUse pushes (each a
@@ -52,7 +52,7 @@ export function bootstrap(version, quiet = false) {
52
52
  state.installedVersion = version;
53
53
  writeState(state);
54
54
  if (!quiet)
55
- process.stderr.write(`Done.\n\n`);
55
+ process.stderr.write(`Done.\n`);
56
56
  return true;
57
57
  }
58
58
  export { LOCAL_PKG_DIR };