gipity 1.0.397 → 1.0.399

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.
@@ -6,6 +6,7 @@ import { resolveProjectContext, getConfigPath } from '../config.js';
6
6
  import { sync } from '../sync.js';
7
7
  import { error as clrError, dim } from '../colors.js';
8
8
  import { run } from '../helpers/index.js';
9
+ import { createProgressReporter, withSpinner } from '../progress.js';
9
10
  const LANG_MAP = {
10
11
  js: 'javascript',
11
12
  javascript: 'javascript',
@@ -159,15 +160,20 @@ GCC/Rust).
159
160
  // Bidirectional + CAS, so it's a cheap manifest check when nothing changed.
160
161
  // Symmetric with the post-run pull below. Skip in one-off mode (no project).
161
162
  if (getConfigPath()) {
162
- await sync({ interactive: false });
163
+ await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
163
164
  }
164
- const res = await post(`/projects/${config.projectGuid}/sandbox/execute`, {
165
+ // The run blocks until the sandbox finishes (up to the timeout); animate the
166
+ // wait, then clear the spinner so stdout/stderr is the result. JSON skips it.
167
+ const doRun = () => post(`/projects/${config.projectGuid}/sandbox/execute`, {
165
168
  code: source,
166
169
  language,
167
170
  timeout: isNaN(timeout) ? 30 : timeout,
168
171
  input_files: opts.input,
169
172
  cwd,
170
173
  });
174
+ const res = opts.json
175
+ ? await doRun()
176
+ : await withSpinner('Running in sandbox…', doRun, { done: null });
171
177
  // Pull sandbox-written outputs down to the local cwd automatically. The
172
178
  // server has already mirrored them into the project (VFS) and handed back
173
179
  // the exact list, so honoring it here means files land locally without a
@@ -175,7 +181,7 @@ GCC/Rust).
175
181
  // `filesChanged` flag. Skip in one-off mode (no local project to sync into).
176
182
  const pulledLocal = !!(res.data.outputFiles?.length && getConfigPath());
177
183
  if (pulledLocal) {
178
- await sync({ interactive: false });
184
+ await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
179
185
  }
180
186
  if (opts.json) {
181
187
  console.log(JSON.stringify({ ...res.data, filesSynced: pulledLocal }));
@@ -12,7 +12,11 @@ export const syncCommand = new Command('sync')
12
12
  try {
13
13
  // JSON mode stays machine-clean; otherwise show live progress on a TTY.
14
14
  const progress = opts.json ? undefined : createProgressReporter();
15
- const result = await sync({ plan: opts.plan, force: opts.force, prune: opts.prune, progress });
15
+ // confirmMerge arms the uncertain-merge guard: a first-time sync into a
16
+ // folder that has its own files prompts on a TTY, proceeds with --yes, and
17
+ // fail-safe ABORTS when non-interactive without --yes - so a script never
18
+ // merges an unexpected folder into the project silently.
19
+ const result = await sync({ plan: opts.plan, force: opts.force, prune: opts.prune, confirmMerge: true, progress });
16
20
  if (opts.json) {
17
21
  console.log(JSON.stringify(result));
18
22
  }
@@ -33,7 +37,9 @@ export const syncCommand = new Command('sync')
33
37
  for (const e of result.errors)
34
38
  console.error(clrError(e));
35
39
  }
36
- if (result.errors.length > 0)
40
+ // A refused merge or any sync error is a non-zero exit so scripts/CI can
41
+ // detect that nothing was applied.
42
+ if (result.aborted || result.errors.length > 0)
37
43
  process.exit(1);
38
44
  }
39
45
  catch (err) {
@@ -10,9 +10,24 @@ import { error as clrError } from '../colors.js';
10
10
  * Usage:
11
11
  * .action((name, opts) => run('Create', async () => { ... }))
12
12
  */
13
+ /**
14
+ * Print a command error. Out-of-credits is a recoverable, user-actionable
15
+ * state, so flag it plainly (and keep the server's message, which carries the
16
+ * buy link) - that way an agent reading this output, e.g. Claude Code, can tell
17
+ * the user they're out of Gipity credits and where to top up rather than
18
+ * treating it as a hard failure. Does not exit; the caller decides.
19
+ */
20
+ export function printCommandError(label, err) {
21
+ if (err?.code === 'INSUFFICIENT_CREDITS') {
22
+ console.error(clrError(`${label} failed - out of Gipity credits.`));
23
+ console.error(err.message);
24
+ return;
25
+ }
26
+ console.error(clrError(`${label} failed: ${err.message}`));
27
+ }
13
28
  export function run(label, action) {
14
29
  action().catch((err) => {
15
- console.error(clrError(`${label} failed: ${err.message}`));
30
+ printCommandError(label, err);
16
31
  process.exit(1);
17
32
  });
18
33
  }
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { sync } from '../sync.js';
5
5
  import { muted, error as clrError } from '../colors.js';
6
+ import { createProgressReporter } from '../progress.js';
6
7
  /**
7
8
  * Sync local files with the server before an action (deploy, test, scaffold).
8
9
  * Respects --no-sync and --json flags. Non-interactive: bulk-deletion guard
@@ -14,7 +15,14 @@ import { muted, error as clrError } from '../colors.js';
14
15
  export async function syncBeforeAction(opts) {
15
16
  if (opts.sync === false)
16
17
  return;
17
- const result = await sync({ interactive: false, force: opts.force });
18
+ // Pass a progress reporter so a large pre-action upload shows the transfer
19
+ // bar instead of a silent pause (the reporter is a no-op on non-TTY / when
20
+ // piped, so JSON and headless output stay clean).
21
+ const result = await sync({
22
+ interactive: false,
23
+ force: opts.force,
24
+ progress: opts.json ? undefined : createProgressReporter(),
25
+ });
18
26
  if (result.applied > 0 && !opts.json) {
19
27
  console.log(muted(`Synced ${result.applied} change${result.applied > 1 ? 's' : ''}`));
20
28
  }
package/dist/index.js CHANGED
@@ -57,6 +57,24 @@ import { bold, dim, brand, muted, success } from './colors.js';
57
57
  import { normalizeAliases } from './flag-aliases.js';
58
58
  const __dirname = dirname(fileURLToPath(import.meta.url));
59
59
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
60
+ // Local builds stamp dist/build-info.json (git SHA + dirty flag) via the npm
61
+ // `postbuild` hook, so `-v` can show whether the linked binary is your latest
62
+ // code. It is intentionally absent from published installs: the npm `files`
63
+ // allowlist ships only dist/**/*.js, so a released CLI prints a clean
64
+ // `v1.0.398` with no dev marker. package.json `version` stays the source of
65
+ // truth for the published release; this marker never touches it.
66
+ function versionLabel() {
67
+ const base = `v${pkg.version}`;
68
+ try {
69
+ const info = JSON.parse(readFileSync(resolve(__dirname, 'build-info.json'), 'utf-8'));
70
+ if (info?.sha)
71
+ return `${base} (dev ${info.sha}${info.dirty ? ', modified' : ''})`;
72
+ }
73
+ catch {
74
+ // No build-info.json (published install or pre-build) → clean version.
75
+ }
76
+ return base;
77
+ }
60
78
  // Custom -v/--version output: include auth status so agents know whether
61
79
  // the next CLI call will succeed. Intercepted before Commander parses,
62
80
  // because Commander's built-in `.version()` only prints a string and exits.
@@ -79,7 +97,7 @@ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-
79
97
  authLine = `${success('Logged in')} as ${auth.email}`;
80
98
  }
81
99
  console.log('');
82
- console.log(`${brand(bold('Gipity'))} ${muted(`v${pkg.version}`)}`);
100
+ console.log(`${brand(bold('Gipity'))} ${muted(versionLabel())}`);
83
101
  console.log(authLine);
84
102
  console.log('');
85
103
  process.exit(0);
@@ -150,7 +168,7 @@ program.configureHelp({
150
168
  const padOpt = (s) => s.padEnd(optColWidth);
151
169
  const lines = [];
152
170
  lines.push('');
153
- lines.push(`${brand(bold('Gipity CLI'))} ${muted(`v${pkg.version}`)}`);
171
+ lines.push(`${brand(bold('Gipity CLI'))} ${muted(versionLabel())}`);
154
172
  lines.push(dim(GIPITY_TAGLINE));
155
173
  lines.push(dim('Hosting, databases, deploys, workflows - one place. Pair with Claude Code or use standalone.'));
156
174
  lines.push(dim('Works with Claude Code, Codex, Aider, or any AI coding tool - no MCP server needed.'));
@@ -0,0 +1,26 @@
1
+ import { execSync } from 'child_process';
2
+ /**
3
+ * Resolve a command to something Node's spawn/spawnSync can launch directly.
4
+ *
5
+ * On Windows, npm-installed CLIs (npm, npx, gipity, claude) are `.cmd` batch
6
+ * shims, not `.exe`s. Node's spawn without `shell: true` cannot launch a bare
7
+ * `npm` - it fails with ENOENT and a null exit status. Resolving the real path
8
+ * (preferring `.exe`, falling back to `.cmd`) lets us spawn without `shell: true`
9
+ * (which would otherwise require escaping arguments).
10
+ */
11
+ export function resolveCommand(cmd) {
12
+ if (process.platform !== 'win32')
13
+ return cmd;
14
+ try {
15
+ const lines = execSync(`where ${cmd}`, { encoding: 'utf-8' })
16
+ .split(/\r?\n/)
17
+ .map(l => l.trim())
18
+ .filter(Boolean);
19
+ // Prefer .exe (native) over .cmd (npm shim)
20
+ return lines.find(l => l.endsWith('.exe')) || lines.find(l => l.endsWith('.cmd')) || cmd;
21
+ }
22
+ catch {
23
+ return `${cmd}.cmd`;
24
+ }
25
+ }
26
+ //# sourceMappingURL=platform.js.map
package/dist/progress.js CHANGED
@@ -1,33 +1,57 @@
1
1
  // ── Gipity CLI Progress Reporter ────────────────────────────────────────
2
2
  // One central place for long-running terminal feedback, so commands don't
3
- // each reinvent status lines. Two channels:
3
+ // each reinvent status lines. Three channels, one shared visual vocabulary:
4
4
  //
5
5
  // phase(msg) - a discrete step with no measurable size
6
6
  // (scanning, hashing). Prints one committed line.
7
- // transfer(label, n, tot) - a determinate byte transfer. Renders a single
8
- // in-place bar that updates as bytes move.
7
+ // transfer(label, n, tot) - a DETERMINATE byte transfer. Renders a single
8
+ // in-place bar that fills as bytes move.
9
+ // spinner(label) - an INDETERMINATE wait (a server call where we
10
+ // can't measure bytes: deploy, generate, chat).
11
+ // Renders the SAME-width track as transfer(), but
12
+ // with a short block that bounces left↔right plus
13
+ // an elapsed timer, so a long wait never reads as
14
+ // frozen. Settles to a committed ✓/✗ line.
9
15
  //
10
16
  // On a non-TTY (piped output, hook-driven sync, headless -p) the reporter is a
11
17
  // silent no-op - no `\r` spam in logs. Colors come from ./colors so the bar
12
18
  // matches the rest of the CLI (orange fill + percentage).
13
- import { brand, brandBold, muted, dim } from './colors.js';
19
+ import { brand, brandBold, muted, dim, success, error as clrError } from './colors.js';
14
20
  import { formatSize } from './utils.js';
15
21
  const CLEAR_TO_EOL = '\x1b[K';
16
22
  const BAR_WIDTH = 18;
17
23
  const RENDER_THROTTLE_MS = 60;
24
+ // Indeterminate bounce: a short block sliding across the same BAR_WIDTH track
25
+ // the determinate bar fills. Frame cadence is slow enough to read, fast enough
26
+ // to feel alive.
27
+ const SPIN_BLOCK = 5;
28
+ const SPIN_FRAME_MS = 90;
29
+ /** Compact elapsed: "8s", "1m 04s". Keeps the timer narrow and glanceable. */
30
+ function formatElapsed(ms) {
31
+ const s = Math.floor(ms / 1000);
32
+ if (s < 60)
33
+ return `${s}s`;
34
+ const m = Math.floor(s / 60);
35
+ return `${m}m ${String(s % 60).padStart(2, '0')}s`;
36
+ }
18
37
  class TerminalProgress {
19
- /** True while an in-place transfer line is on screen and not yet committed. */
38
+ /** True while an in-place transfer/spinner line is on screen and not committed. */
20
39
  liveOpen = false;
21
40
  lastRenderAt = 0;
22
41
  /** The label of the current transfer session; a change starts a fresh one. */
23
42
  barLabel = null;
24
43
  /** True once the current session hit 100% - late/overshoot ticks are dropped. */
25
44
  barSettled = false;
45
+ /** Active indeterminate spinner timer, if any. */
46
+ spinTimer = null;
26
47
  phase(message) {
48
+ this.stopSpinTimer();
27
49
  this.commitLive();
28
50
  process.stdout.write(` ${muted(message)}\n`);
29
51
  }
30
52
  transfer(label, doneBytes, totalBytes) {
53
+ // A determinate transfer takes over the live line from any spinner.
54
+ this.stopSpinTimer();
31
55
  // A new label begins a fresh transfer session (e.g. downloads → uploads on
32
56
  // the same reporter). Within a session, once we've drawn the 100% frame we
33
57
  // drop any further ticks - download byte totals are estimated, so the wire
@@ -46,32 +70,115 @@ class TerminalProgress {
46
70
  return;
47
71
  this.lastRenderAt = now;
48
72
  this.liveOpen = true;
49
- process.stdout.write('\r' + this.frame(label, doneBytes, totalBytes) + CLEAR_TO_EOL);
73
+ process.stdout.write('\r' + this.barFrame(label, doneBytes, totalBytes) + CLEAR_TO_EOL);
50
74
  if (finished) {
51
75
  this.commitLive();
52
76
  this.barSettled = true;
53
77
  }
54
78
  }
79
+ spinner(label) {
80
+ this.stopSpinTimer();
81
+ this.commitLive();
82
+ const startedAt = Date.now();
83
+ let tick = 0;
84
+ const draw = () => {
85
+ this.liveOpen = true;
86
+ process.stdout.write('\r' + this.spinFrame(label, tick++, Date.now() - startedAt) + CLEAR_TO_EOL);
87
+ };
88
+ draw();
89
+ this.spinTimer = setInterval(draw, SPIN_FRAME_MS);
90
+ // Don't let the animation keep the event loop (and process) alive on its own.
91
+ this.spinTimer.unref?.();
92
+ const settle = (icon, message) => {
93
+ this.stopSpinTimer();
94
+ if (this.liveOpen)
95
+ process.stdout.write('\r');
96
+ if (icon) {
97
+ // Replace the spinner line in place with a committed ✓/✗ result line.
98
+ const elapsed = muted(formatElapsed(Date.now() - startedAt));
99
+ process.stdout.write(` ${icon} ${muted(message ?? label)} ${elapsed}${CLEAR_TO_EOL}\n`);
100
+ }
101
+ else if (this.liveOpen) {
102
+ // Silent stop: clear the spinner row but DON'T advance - leave the
103
+ // cursor at column 0 so the command's own result overwrites the row
104
+ // instead of leaving a blank line behind it.
105
+ process.stdout.write(CLEAR_TO_EOL);
106
+ }
107
+ this.liveOpen = false;
108
+ };
109
+ return {
110
+ succeed: (m) => settle(success('✓'), m),
111
+ fail: (m) => settle(clrError('✗'), m),
112
+ stop: () => settle(null),
113
+ };
114
+ }
55
115
  finish() {
116
+ this.stopSpinTimer();
56
117
  this.commitLive();
57
118
  }
119
+ stopSpinTimer() {
120
+ if (this.spinTimer) {
121
+ clearInterval(this.spinTimer);
122
+ this.spinTimer = null;
123
+ }
124
+ }
58
125
  commitLive() {
59
126
  if (!this.liveOpen)
60
127
  return;
61
128
  process.stdout.write('\n');
62
129
  this.liveOpen = false;
63
130
  }
64
- frame(label, done, total) {
131
+ barFrame(label, done, total) {
65
132
  const pct = total > 0 ? Math.min(100, Math.floor((done / total) * 100)) : 100;
66
133
  const filled = Math.round((pct / 100) * BAR_WIDTH);
67
134
  const bar = brand('█'.repeat(filled)) + dim('░'.repeat(BAR_WIDTH - filled));
68
135
  const sizes = muted(`${formatSize(done)} / ${formatSize(total)}`);
69
136
  return ` ${muted(label)} ${bar} ${brandBold(`${pct}%`)} ${sizes}`;
70
137
  }
138
+ spinFrame(label, tick, elapsedMs) {
139
+ // Ping-pong the block's left edge between 0 and (BAR_WIDTH - SPIN_BLOCK).
140
+ const span = BAR_WIDTH - SPIN_BLOCK;
141
+ const cycle = span * 2;
142
+ const phase = tick % cycle;
143
+ const pos = phase <= span ? phase : cycle - phase;
144
+ const bar = dim('░'.repeat(pos)) +
145
+ brand('█'.repeat(SPIN_BLOCK)) +
146
+ dim('░'.repeat(BAR_WIDTH - pos - SPIN_BLOCK));
147
+ return ` ${muted(label)} ${bar} ${muted(formatElapsed(elapsedMs))}`;
148
+ }
71
149
  }
72
- const NOOP = { phase() { }, transfer() { }, finish() { } };
150
+ const NOOP_SPINNER = { succeed() { }, fail() { }, stop() { } };
151
+ const NOOP = {
152
+ phase() { }, transfer() { }, finish() { },
153
+ spinner: () => NOOP_SPINNER,
154
+ };
73
155
  /** A reporter that draws on a TTY and stays silent otherwise. */
74
156
  export function createProgressReporter() {
75
157
  return process.stdout.isTTY ? new TerminalProgress() : NOOP;
76
158
  }
159
+ /**
160
+ * Run an indeterminate async operation behind the standard bouncing-block
161
+ * spinner: animate `label` while it's in flight, then settle. On success the
162
+ * line becomes a ✓ (`done`, or `label` if omitted) — or, when `done` is null,
163
+ * the spinner just clears silently (use this when the command prints its own
164
+ * result, e.g. a chat reply). On throw it becomes a ✗ and re-throws so the
165
+ * caller's own error handling still runs. On a non-TTY this is a silent
166
+ * pass-through. The single wrapper every command uses for a server call whose
167
+ * size/duration we can't measure (deploy, generate, chat, sandbox, …).
168
+ */
169
+ export async function withSpinner(label, fn, opts = {}) {
170
+ const sp = (opts.reporter ?? createProgressReporter()).spinner(label);
171
+ try {
172
+ const result = await fn();
173
+ if (opts.done === null)
174
+ sp.stop();
175
+ else
176
+ sp.succeed(opts.done);
177
+ return result;
178
+ }
179
+ catch (e) {
180
+ sp.fail();
181
+ throw e;
182
+ }
183
+ }
77
184
  //# sourceMappingURL=progress.js.map
@@ -21,6 +21,7 @@
21
21
  * See docs/feature-backlog/gipity-relay-phases.md (Phase A Step 7).
22
22
  */
23
23
  import { spawn } from 'child_process';
24
+ import { resolveCommand } from '../platform.js';
24
25
  import { appendFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, closeSync, openSync, unlinkSync } from 'fs';
25
26
  import { stat, readFile } from 'fs/promises';
26
27
  import { createInterface } from 'readline';
@@ -631,6 +632,12 @@ async function handleDispatch(d) {
631
632
  // prompt is correct (same authority as running `claude -p` in a local
632
633
  // terminal yourself).
633
634
  const args = ['claude', '-p', d.message, '--permission-mode', 'bypassPermissions'];
635
+ // Per-chat model: the user picked it with `/model` in the web CLI. `gipity
636
+ // claude` forwards --model straight through to the `claude` binary, which
637
+ // honors it on both a fresh session and a --resume. null => agent default.
638
+ if (d.model) {
639
+ args.push('--model', d.model);
640
+ }
634
641
  if (d.kind === 'resume' && d.remote_session_id) {
635
642
  args.push('--resume', d.remote_session_id);
636
643
  }
@@ -855,7 +862,10 @@ export async function killRunningForConv(convGuid) {
855
862
  * resolution (the daemon itself doesn't chdir into projects).
856
863
  * Non-blocking on failure - caller catches and logs. */
857
864
  async function spawnSync(cwd, timeoutMs) {
858
- const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || 'gipity';
865
+ // resolveCommand: on Windows the bare `gipity` is a .cmd shim that spawn
866
+ // can't launch without an explicit path. An explicit env override is used
867
+ // verbatim (it may be a full path); only the default name is resolved.
868
+ const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || resolveCommand('gipity');
859
869
  return new Promise((resolve, reject) => {
860
870
  const child = spawn(cmd, ['sync', '--json'], {
861
871
  cwd,
@@ -901,7 +911,10 @@ async function spawnSync(cwd, timeoutMs) {
901
911
  });
902
912
  }
903
913
  export async function spawnGipityClaude(args, cwd, d) {
904
- const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || 'gipity';
914
+ // resolveCommand: on Windows the bare `gipity` is a .cmd shim that spawn
915
+ // can't launch without an explicit path. An explicit env override is used
916
+ // verbatim (it may be a full path); only the default name is resolved.
917
+ const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || resolveCommand('gipity');
905
918
  // Inject stream-json flags here rather than at the call site so every
906
919
  // relay spawn path gets the same protocol. `--verbose` is required by
907
920
  // Claude Code when combining `-p` with `--output-format stream-json`.
@@ -30,10 +30,13 @@ export function ensureDaemonRunning() {
30
30
  if (state.isDaemonRunning())
31
31
  return;
32
32
  try {
33
- const child = spawn(resolveCliPath(), ['relay', 'run'], {
33
+ // Launch via the current Node binary + the CLI entry script. The previous
34
+ // `shell: true` on Windows ran the .js path through the shell, where the
35
+ // file association (Windows Script Host, not Node) would mis-handle it.
36
+ // process.execPath is cross-platform and needs no shell.
37
+ const child = spawn(process.execPath, [resolveCliPath(), 'relay', 'run'], {
34
38
  detached: true,
35
39
  stdio: 'ignore',
36
- shell: process.platform === 'win32',
37
40
  });
38
41
  child.unref();
39
42
  }
package/dist/setup.js CHANGED
@@ -5,6 +5,7 @@ import { resolve, join, dirname } from 'path';
5
5
  import { homedir } from 'os';
6
6
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
7
7
  import { spawnSync } from 'child_process';
8
+ import { resolveCommand } from './platform.js';
8
9
  import { SKILLS_CONTENT, BUILD_VS_NON_BUILD_RULE, DEFINITION_OF_DONE } from './knowledge.js';
9
10
  export { SKILLS_CONTENT };
10
11
  /** Canonical list of workstation artifacts that are NOT part of the project.
@@ -268,11 +269,15 @@ export function ensureGipityPluginInstalled() {
268
269
  // Refresh the marketplace clone so `install` resolves the current version,
269
270
  // then (re)install at user scope - idempotent, and upgrades an older or
270
271
  // project-scoped install to the current one at user scope.
271
- spawnSync('claude', ['plugin', 'marketplace', 'update', GIPITY_MARKETPLACE_NAME], {
272
+ // resolveCommand: on Windows `claude` is a .cmd shim that spawn can't launch
273
+ // without an explicit path, so resolve it (otherwise the install silently
274
+ // ENOENTs and the plugin's hooks never land at user scope).
275
+ const claudeCmd = resolveCommand('claude');
276
+ spawnSync(claudeCmd, ['plugin', 'marketplace', 'update', GIPITY_MARKETPLACE_NAME], {
272
277
  stdio: 'ignore',
273
278
  timeout: 120_000,
274
279
  });
275
- spawnSync('claude', ['plugin', 'install', GIPITY_PLUGIN_ID, '--scope', 'user'], {
280
+ spawnSync(claudeCmd, ['plugin', 'install', GIPITY_PLUGIN_ID, '--scope', 'user'], {
276
281
  stdio: 'ignore',
277
282
  timeout: 120_000,
278
283
  });
@@ -478,7 +483,10 @@ export function setupGitignore() {
478
483
  const entries = ['.gipity/', '.gipity.json'];
479
484
  if (existsSync(gitignorePath)) {
480
485
  let content = readFileSync(gitignorePath, 'utf-8');
481
- const lines = content.split('\n');
486
+ // Split on \r?\n so a CRLF .gitignore (the Windows default) doesn't leave a
487
+ // trailing \r on each entry - otherwise `lines.includes('.gipity/')` never
488
+ // matches '.gipity/\r' and every run re-appends the entries as duplicates.
489
+ const lines = content.split(/\r?\n/);
482
490
  const toAdd = entries.filter(e => !lines.includes(e));
483
491
  if (toAdd.length > 0) {
484
492
  content = content.trimEnd() + '\n' + toAdd.join('\n') + '\n';
package/dist/sync.js CHANGED
@@ -235,6 +235,11 @@ async function downloadAll(projectGuid, onBytes) {
235
235
  });
236
236
  extract.on('finish', () => resolve(files));
237
237
  extract.on('error', reject);
238
+ // pipe() does NOT forward source-stream errors to the destination, so a
239
+ // truncated/aborted HTTP body would otherwise surface as a clean tar
240
+ // 'finish' with a partial file set. Reject explicitly so a short download is
241
+ // an error the caller can recover from, never a silent partial.
242
+ stream.on('error', reject);
238
243
  stream.pipe(extract);
239
244
  });
240
245
  }
@@ -579,6 +584,60 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
579
584
  deferredDeletes: 0,
580
585
  };
581
586
  }
587
+ // Uncertain-merge guard (armed via confirmMerge): syncing INTO a populated
588
+ // directory we've never synced for this project (empty baseline + local files
589
+ // that would upload or collide). Local-only files get pushed UP into the
590
+ // project and same-path differences fork into conflict copies - a two-way
591
+ // merge that may be unintended. Nothing here can delete.
592
+ //
593
+ // Three ways to resolve it, so both outcomes are scriptable:
594
+ // --yes / autoConfirm → proceed (merge confirmed)
595
+ // interactive TTY → prompt the user
596
+ // non-interactive → fail safe: ABORT rather than merge blindly
597
+ if (opts.confirmMerge &&
598
+ remote.size > 0 && // project already has files - a real merge target
599
+ Object.keys(baseline.files).length === 0 && // we've never synced this folder for it
600
+ (planned.uploads > 0 || planned.conflicts > 0) // and local content would push up or collide
601
+ ) {
602
+ const f = (n) => `${n} file${n === 1 ? '' : 's'}`;
603
+ const shape = [` Server: ${f(remote.size)} · Local: ${f(local.size)}`];
604
+ if (planned.downloads > 0)
605
+ shape.push(` ↓ download ${f(planned.downloads)} from the project into this folder`);
606
+ if (planned.uploads > 0)
607
+ shape.push(` ↑ upload ${f(planned.uploads)} from this folder INTO the project (they become part of it)`);
608
+ if (planned.conflicts > 0)
609
+ shape.push(` ! ${f(planned.conflicts)} differ on both sides — both kept (your copy is renamed)`);
610
+ const abort = () => ({
611
+ plan: planned, applied: 0, skipped: planned.actions.length, errors: [],
612
+ summary: [
613
+ `This folder has files that haven't been synced with this project yet — merge not confirmed.`,
614
+ ...shape,
615
+ `Re-run with --yes to merge, or sync into an empty folder.`,
616
+ ].join('\n'),
617
+ deferredDeletes: 0, aborted: true,
618
+ });
619
+ if (getAutoConfirm()) {
620
+ // --yes: proceed with the merge; the caller reports the applied counts.
621
+ }
622
+ else if (interactive) {
623
+ const answer = await prompt([
624
+ '',
625
+ ` This folder has files that haven't been synced with this project yet.`,
626
+ ` Syncing here MERGES the two — nothing is deleted:`,
627
+ '',
628
+ ...shape,
629
+ '',
630
+ ` Continue? [y/N]: `,
631
+ ].join('\n'));
632
+ if (!/^(y|yes|continue)$/i.test(answer.trim()))
633
+ return abort();
634
+ }
635
+ else {
636
+ // Non-interactive and not confirmed: don't silently merge a folder we
637
+ // weren't told to. `--yes` opts in.
638
+ return abort();
639
+ }
640
+ }
582
641
  // Bulk-delete guard over the *planned* deletes.
583
642
  const knownFiles = local.size + remote.size;
584
643
  const deletesOk = await bulkDeleteGuard(planned, knownFiles, { ...opts, interactive });
@@ -590,8 +649,14 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
590
649
  // ── Pre-fetch remote bytes once for all downloads (conflict-originating
591
650
  // remote versions are fetched on demand after 409). ──
592
651
  const downloadedBytes = new Map();
593
- const needsBulkDownload = plannedToApply.some(a => a.kind === 'download' || a.kind === 'conflict');
594
- if (needsBulkDownload) {
652
+ // Set when the download phase could not retrieve every byte the plan needs.
653
+ // A delete is only safe against a complete, authoritative view, so an
654
+ // incomplete download disarms the deletes pass below - this is what breaks
655
+ // the "truncated pull → files missing locally → next sync deletes them"
656
+ // amplification loop.
657
+ let downloadIncomplete = false;
658
+ const wantedDownloads = plannedToApply.filter(a => a.kind === 'download' || a.kind === 'conflict');
659
+ if (wantedDownloads.length) {
595
660
  // The tree endpoint streams the *whole* remote tree as one tar (the caller
596
661
  // then picks out only the paths it planned to apply), so the bytes that
597
662
  // actually move = the sum of every remote file's size. That's the honest
@@ -609,22 +674,46 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
609
674
  : undefined;
610
675
  try {
611
676
  const all = await downloadAll(config.projectGuid, onBytes);
612
- for (const a of plannedToApply) {
613
- if (a.kind === 'download' || a.kind === 'conflict') {
614
- const buf = all.get(a.path);
615
- if (buf)
616
- downloadedBytes.set(a.path, buf);
617
- }
677
+ for (const a of wantedDownloads) {
678
+ const buf = all.get(a.path);
679
+ if (buf)
680
+ downloadedBytes.set(a.path, buf);
618
681
  }
619
682
  }
620
683
  catch (err) {
621
- errors.push(`Download batch failed: ${err.message}`);
684
+ // The bulk tar can truncate mid-stream on a large project (transport or
685
+ // proxy timeout) and either reject here or "finish" with a partial set.
686
+ // Either way we fall through to the single-file recovery below rather than
687
+ // proceeding on a half-empty tree - a partial download must never be
688
+ // mistaken for a complete one.
689
+ errors.push(`Bulk download incomplete (${err.message}); recovering files individually…`);
622
690
  }
623
691
  finally {
624
692
  // Settle the bar even if the extracted-byte tally fell short of the
625
693
  // estimate (the live line stays open until something hits 100% or finish()).
626
694
  p?.finish();
627
695
  }
696
+ // Recover whatever the bulk tar dropped over the reliable single-file
697
+ // endpoint. This is what lets a project whose tar keeps truncating still
698
+ // sync to completion - and what recovers a checkout left half-downloaded by
699
+ // an earlier truncated pull.
700
+ const missing = wantedDownloads.filter(a => !downloadedBytes.has(a.path));
701
+ if (missing.length) {
702
+ p?.phase(`Recovering ${missing.length} file${missing.length === 1 ? '' : 's'} the bulk download dropped…`);
703
+ for (const a of missing) {
704
+ let buf = null;
705
+ for (let attempt = 0; attempt < 3 && !buf; attempt++) {
706
+ buf = await fetchOne(config.projectGuid, a.path);
707
+ }
708
+ if (buf)
709
+ downloadedBytes.set(a.path, buf);
710
+ }
711
+ }
712
+ // Anything still missing is a hard failure: the plan needs bytes we could
713
+ // not retrieve. Mark the download incomplete so the deletes pass is skipped;
714
+ // the per-path "Download missing" errors below carry the detail.
715
+ if (wantedDownloads.some(a => !downloadedBytes.has(a.path)))
716
+ downloadIncomplete = true;
628
717
  }
629
718
  // ── Writes pass: uploads, downloads, conflicts (rename + download + upload copy) ──
630
719
  // We serialize conflicts; uploads run with bounded concurrency.
@@ -911,7 +1000,17 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
911
1000
  }
912
1001
  }
913
1002
  // ── Deletes pass ──
1003
+ // A delete is only safe against a complete, authoritative view. If the
1004
+ // download phase couldn't retrieve everything it planned, the local tree is
1005
+ // not a trustworthy deletion signal - this is exactly how a truncated pull
1006
+ // turns "files we failed to fetch" into "delete those files." Skip ALL deletes
1007
+ // this run and let a clean sync replan them once the pull succeeds.
1008
+ let deletesSkippedIncomplete = 0;
914
1009
  for (const a of plannedToApply) {
1010
+ if (downloadIncomplete && (a.kind === 'delete-local' || a.kind === 'delete-remote')) {
1011
+ deletesSkippedIncomplete++;
1012
+ continue;
1013
+ }
915
1014
  if (a.kind === 'delete-local') {
916
1015
  try {
917
1016
  unlinkSync(resolveInRoot(root, a.path));
@@ -977,6 +1076,10 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
977
1076
  }
978
1077
  }
979
1078
  }
1079
+ if (deletesSkippedIncomplete > 0) {
1080
+ errors.push(`Skipped ${deletesSkippedIncomplete} deletion${deletesSkippedIncomplete === 1 ? '' : 's'} because the download was incomplete - ` +
1081
+ `nothing was deleted. Re-run \`gipity sync\` once the pull finishes to apply any real deletions.`);
1082
+ }
980
1083
  // Clean up empty local directories after delete-local actions.
981
1084
  cleanupEmptyDirs(root, config.ignore);
982
1085
  baseline.lastFullSync = new Date().toISOString();