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.
- package/dist/claude-setup.js +3 -2
- package/dist/commands/add.js +11 -2
- package/dist/commands/approval.js +1 -1
- package/dist/commands/bug.js +69 -0
- package/dist/commands/credits.js +242 -15
- package/dist/commands/email.js +2 -1
- package/dist/commands/gmail.js +2 -1
- package/dist/commands/init.js +2 -1
- package/dist/commands/job.js +5 -4
- package/dist/commands/login.js +1 -0
- package/dist/commands/notify.js +2 -1
- package/dist/commands/page.js +1 -1
- package/dist/commands/payments.js +2 -1
- package/dist/commands/plan.js +1 -1
- package/dist/commands/push.js +7 -3
- package/dist/commands/relay.js +26 -2
- package/dist/commands/remove.js +10 -4
- package/dist/commands/secrets.js +2 -1
- package/dist/commands/service.js +2 -1
- package/dist/commands/storage.js +64 -0
- package/dist/commands/sync.js +2 -1
- package/dist/commands/text.js +2 -1
- package/dist/commands/token.js +2 -1
- package/dist/commands/uninstall.js +2 -2
- package/dist/commands/upload.js +1 -2
- package/dist/config.js +4 -4
- package/dist/helpers/output.js +42 -13
- 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/bootstrap.js +1 -1
- package/dist/updater/shim.js +4 -4
- package/dist/upload.js +1 -1
- package/package.json +6 -3
- package/dist/coding-guidelines.js +0 -52
- package/dist/commands/browser.js +0 -84
- package/dist/commands/checkpoint.js +0 -68
- package/dist/commands/hook-capture.js +0 -215
- package/dist/commands/scaffold.js +0 -58
- package/dist/commands/skills.js +0 -45
- package/dist/commands/start-cc.js +0 -313
- package/dist/gip3dw-guide.js +0 -18
- package/dist/platform-overview.js +0 -52
- package/dist/relay/transcripts.js +0 -119
package/dist/relay/onboarding.js
CHANGED
|
@@ -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
|
|
60
|
-
|
|
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.
|
package/dist/relay/redact.js
CHANGED
|
@@ -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
|
-
/**
|
|
42
|
-
*
|
|
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
|
-
|
|
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. */
|
package/dist/relay/setup.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
139
|
+
const r = spawnSyncCommand(argv[0], argv.slice(1), { stdio });
|
|
140
140
|
if (r.status !== 0)
|
|
141
141
|
ok = false;
|
|
142
142
|
}
|
package/dist/relay/state.js
CHANGED
|
@@ -47,9 +47,12 @@ export function loadState() {
|
|
|
47
47
|
}
|
|
48
48
|
export function saveState(state) {
|
|
49
49
|
mkdirSync(RELAY_DIR, { recursive: true });
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
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
|