gipity 1.0.409 → 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.
- package/dist/claude-setup.js +3 -2
- package/dist/commands/add.js +10 -1
- package/dist/commands/bug.js +69 -0
- package/dist/commands/credits.js +242 -15
- package/dist/commands/job.js +3 -3
- package/dist/commands/push.js +7 -3
- package/dist/commands/relay.js +26 -2
- package/dist/commands/remove.js +9 -3
- package/dist/commands/storage.js +64 -0
- package/dist/commands/uninstall.js +2 -2
- package/dist/commands/upload.js +1 -2
- package/dist/index.js +5 -4
- package/dist/knowledge.js +22 -0
- package/dist/platform.js +9 -5
- package/dist/relay/daemon.js +112 -7
- package/dist/relay/installers.js +10 -2
- package/dist/relay/onboarding.js +5 -2
- package/dist/relay/redact.js +26 -3
- package/dist/relay/setup.js +4 -4
- package/dist/relay/state.js +6 -3
- package/dist/setup.js +2 -3
- package/dist/sync.js +227 -106
- package/dist/updater/shim.js +4 -4
- package/dist/upload.js +1 -1
- package/package.json +6 -3
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
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
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
|
-
|
|
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
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
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
|
|
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
|
|
512
|
-
|
|
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
|
|
524
|
-
//
|
|
525
|
-
//
|
|
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
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
//
|
|
738
|
-
const
|
|
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(
|
|
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
|
package/dist/updater/shim.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Thin launcher. Resolves the user-local install at ~/.gipity/local/, exec's
|
|
3
3
|
// it, and kicks off a detached background updater. Modeled on Claude Code.
|
|
4
|
-
import {
|
|
4
|
+
import { spawnCommand, spawnSyncCommand } from '../platform.js';
|
|
5
5
|
import { existsSync, readFileSync } from 'fs';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { dirname, resolve, join } from 'path';
|
|
@@ -22,7 +22,7 @@ const isDevLink = existsSync(join(pkgRoot, 'src'));
|
|
|
22
22
|
function startBackgroundUpdater() {
|
|
23
23
|
const checkScript = join(__dirname, 'check.js');
|
|
24
24
|
try {
|
|
25
|
-
const child =
|
|
25
|
+
const child = spawnCommand(process.execPath, [checkScript], {
|
|
26
26
|
detached: true,
|
|
27
27
|
stdio: 'ignore',
|
|
28
28
|
env: process.env,
|
|
@@ -36,7 +36,7 @@ function startBackgroundUpdater() {
|
|
|
36
36
|
}
|
|
37
37
|
function execLocal() {
|
|
38
38
|
const args = process.argv.slice(2);
|
|
39
|
-
const res =
|
|
39
|
+
const res = spawnSyncCommand(process.execPath, [LOCAL_ENTRY, ...args], {
|
|
40
40
|
stdio: 'inherit',
|
|
41
41
|
env: process.env,
|
|
42
42
|
});
|
|
@@ -48,7 +48,7 @@ function execSelf() {
|
|
|
48
48
|
// so the user is never blocked (they can retry via `gipity update --force`).
|
|
49
49
|
const ownEntry = resolve(__dirname, '..', 'index.js');
|
|
50
50
|
const args = process.argv.slice(2);
|
|
51
|
-
const res =
|
|
51
|
+
const res = spawnSyncCommand(process.execPath, [ownEntry, ...args], {
|
|
52
52
|
stdio: 'inherit',
|
|
53
53
|
env: process.env,
|
|
54
54
|
});
|
package/dist/upload.js
CHANGED
|
@@ -109,7 +109,7 @@ export class UploadConflictError extends Error {
|
|
|
109
109
|
/**
|
|
110
110
|
* Upload one local file to a project's virtual path via the presigned-S3 flow.
|
|
111
111
|
* Handles single-part PUT and multipart fan-out, server-driven resume, and
|
|
112
|
-
* skip-if-identical (
|
|
112
|
+
* skip-if-identical (a byte-identical file at the same path is a no-op).
|
|
113
113
|
*/
|
|
114
114
|
export async function uploadOneFile(projectGuid, localPath, virtualPath, opts = {}) {
|
|
115
115
|
const { sha256, size } = await hashFile(localPath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gipity",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.410",
|
|
4
4
|
"description": "The full-stack platform tuned for AI agents. Database, storage, auth, functions, deploy, and drop-in kits - all agent-tuned. Pair with Claude Code or use standalone.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"gipity": "dist/updater/shim.js",
|
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
"postbuild": "node scripts/gen-build-info.mjs",
|
|
14
14
|
"dev": "tsc --watch",
|
|
15
15
|
"test": "npm run test:smoke",
|
|
16
|
-
"test:smoke": "tsc && node --test dist/__tests__/utils.test.js dist/__tests__/platform.test.js dist/__tests__/colors.test.js dist/__tests__/config.test.js dist/__tests__/sync.test.js dist/__tests__/sync-apply.test.js dist/__tests__/sync-lock.test.js dist/__tests__/auth-lock.test.js dist/__tests__/api-401-retry.test.js dist/__tests__/push-cas.test.js dist/__tests__/upload.test.js dist/__tests__/progress.test.js dist/__tests__/updater.test.js dist/__tests__/cli-smoke.test.js dist/__tests__/claude-noninteractive.test.js dist/__tests__/claude-trust.test.js dist/__tests__/relay-state.test.js dist/__tests__/relay-daemon.test.js dist/__tests__/relay-installers.test.js dist/__tests__/relay-bridge-abort.test.js dist/__tests__/relay-redact.test.js dist/__tests__/relay-machine-id.test.js dist/__tests__/stream-json.test.js dist/__tests__/relay-ingest-contract.test.js dist/__tests__/prompts.test.js dist/__tests__/capture-transcript.test.js dist/__tests__/flag-aliases.test.js dist/__tests__/client-context.test.js dist/__tests__/adopt-cwd.test.js dist/__tests__/cli-cmd-agent.test.js dist/__tests__/cli-cmd-approval.test.js dist/__tests__/cli-cmd-audit.test.js dist/__tests__/cli-cmd-chat.test.js dist/__tests__/cli-cmd-credits.test.js dist/__tests__/cli-cmd-db.test.js dist/__tests__/cli-cmd-deploy.test.js dist/__tests__/cli-cmd-domain.test.js dist/__tests__/cli-cmd-email.test.js dist/__tests__/cli-cmd-file.test.js dist/__tests__/cli-cmd-fn.test.js dist/__tests__/cli-cmd-service.test.js dist/__tests__/cli-cmd-job.test.js dist/__tests__/cli-cmd-generate.test.js dist/__tests__/cli-cmd-gmail.test.js dist/__tests__/cli-cmd-info.test.js dist/__tests__/cli-cmd-init.test.js dist/__tests__/cli-cmd-location.test.js dist/__tests__/cli-cmd-text.test.js dist/__tests__/cli-cmd-login.test.js dist/__tests__/cli-cmd-logout.test.js dist/__tests__/cli-cmd-token.test.js dist/__tests__/cli-cmd-logs.test.js dist/__tests__/cli-cmd-memory.test.js dist/__tests__/cli-cmd-page.test.js dist/__tests__/cli-cmd-plan.test.js dist/__tests__/cli-cmd-project.test.js dist/__tests__/cli-cmd-rbac.test.js dist/__tests__/cli-cmd-realtime.test.js dist/__tests__/cli-cmd-records.test.js dist/__tests__/cli-cmd-relay.test.js dist/__tests__/cli-cmd-doctor.test.js dist/__tests__/claude-setup.test.js dist/__tests__/cli-cmd-sandbox.test.js dist/__tests__/cli-cmd-add.test.js dist/__tests__/cli-cmd-remove.test.js dist/__tests__/cli-cmd-skill.test.js dist/__tests__/cli-cmd-test.test.js dist/__tests__/cli-cmd-workflow.test.js dist/__tests__/setup-skills-block.test.js dist/__tests__/setup-hooks.test.js",
|
|
17
|
-
"test:
|
|
16
|
+
"test:smoke": "tsc && node --test dist/__tests__/utils.test.js dist/__tests__/platform.test.js dist/__tests__/colors.test.js dist/__tests__/config.test.js dist/__tests__/sync.test.js dist/__tests__/sync-apply.test.js dist/__tests__/sync-lock.test.js dist/__tests__/auth-lock.test.js dist/__tests__/api-401-retry.test.js dist/__tests__/push-cas.test.js dist/__tests__/upload.test.js dist/__tests__/progress.test.js dist/__tests__/updater.test.js dist/__tests__/cli-smoke.test.js dist/__tests__/claude-noninteractive.test.js dist/__tests__/claude-trust.test.js dist/__tests__/relay-state.test.js dist/__tests__/relay-daemon.test.js dist/__tests__/relay-installers.test.js dist/__tests__/relay-bridge-abort.test.js dist/__tests__/relay-redact.test.js dist/__tests__/relay-machine-id.test.js dist/__tests__/stream-json.test.js dist/__tests__/relay-ingest-contract.test.js dist/__tests__/prompts.test.js dist/__tests__/capture-transcript.test.js dist/__tests__/flag-aliases.test.js dist/__tests__/client-context.test.js dist/__tests__/adopt-cwd.test.js dist/__tests__/cli-cmd-agent.test.js dist/__tests__/cli-cmd-approval.test.js dist/__tests__/cli-cmd-audit.test.js dist/__tests__/cli-cmd-chat.test.js dist/__tests__/cli-cmd-credits.test.js dist/__tests__/cli-cmd-db.test.js dist/__tests__/cli-cmd-deploy.test.js dist/__tests__/cli-cmd-domain.test.js dist/__tests__/cli-cmd-email.test.js dist/__tests__/cli-cmd-file.test.js dist/__tests__/cli-cmd-storage.test.js dist/__tests__/cli-cmd-fn.test.js dist/__tests__/cli-cmd-service.test.js dist/__tests__/cli-cmd-job.test.js dist/__tests__/cli-cmd-generate.test.js dist/__tests__/cli-cmd-gmail.test.js dist/__tests__/cli-cmd-info.test.js dist/__tests__/cli-cmd-init.test.js dist/__tests__/cli-cmd-location.test.js dist/__tests__/cli-cmd-text.test.js dist/__tests__/cli-cmd-login.test.js dist/__tests__/cli-cmd-logout.test.js dist/__tests__/cli-cmd-token.test.js dist/__tests__/cli-cmd-logs.test.js dist/__tests__/cli-cmd-memory.test.js dist/__tests__/cli-cmd-page.test.js dist/__tests__/cli-cmd-plan.test.js dist/__tests__/cli-cmd-project.test.js dist/__tests__/cli-cmd-rbac.test.js dist/__tests__/cli-cmd-realtime.test.js dist/__tests__/cli-cmd-records.test.js dist/__tests__/cli-cmd-relay.test.js dist/__tests__/cli-cmd-doctor.test.js dist/__tests__/claude-setup.test.js dist/__tests__/cli-cmd-sandbox.test.js dist/__tests__/cli-cmd-add.test.js dist/__tests__/cli-cmd-remove.test.js dist/__tests__/cli-cmd-skill.test.js dist/__tests__/cli-cmd-test.test.js dist/__tests__/cli-cmd-workflow.test.js dist/__tests__/setup-skills-block.test.js dist/__tests__/setup-hooks.test.js",
|
|
17
|
+
"test:smoke:quick": "node scripts/smoke-quick.mjs",
|
|
18
|
+
"test:e2e": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-live.test.js dist/__tests__/cli-e2e-sync-live.test.js dist/__tests__/cli-e2e-sync-stress-live.test.js dist/__tests__/cli-e2e-rollback-live.test.js dist/__tests__/cli-e2e-services-media-live.test.js dist/__tests__/cli-e2e-workflow-live.test.js dist/__tests__/cli-e2e-sandbox-live.test.js dist/__tests__/cli-e2e-page-fetch-live.test.js dist/__tests__/cli-e2e-page-test-live.test.js",
|
|
18
19
|
"test:e2e:sync": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sync-live.test.js",
|
|
20
|
+
"test:e2e:sync-stress": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sync-stress-live.test.js",
|
|
21
|
+
"test:e2e:rollback": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-rollback-live.test.js",
|
|
19
22
|
"test:e2e:sandbox": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sandbox-live.test.js"
|
|
20
23
|
},
|
|
21
24
|
"dependencies": {
|