gipity 1.0.401 → 1.0.403

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/api.js CHANGED
@@ -17,6 +17,28 @@ export class ApiError extends Error {
17
17
  this.name = 'ApiError';
18
18
  }
19
19
  }
20
+ // Bound every network call so a wedged connection becomes a clear error instead
21
+ // of an unbounded hang (the "sync gets stuck forever" bug). JSON API calls must
22
+ // finish well within REQUEST_TIMEOUT_MS; the S3 PUT path keeps its own
23
+ // size-based timeout. Streaming downloads can't use a single total cap (a large
24
+ // but healthy tree would be cut off mid-stream), so downloadStream bounds only
25
+ // the time-to-first-byte here and an idle watchdog in sync.ts guards the body.
26
+ const REQUEST_TIMEOUT_MS = 60_000;
27
+ const DOWNLOAD_HEADER_TIMEOUT_MS = 30_000;
28
+ /** fetch() that rejects with a clean 408 ApiError if the whole exchange (headers
29
+ * + body) doesn't complete within timeoutMs, instead of hanging forever. For
30
+ * request/response JSON calls only - never wrap a long streaming body in this. */
31
+ async function fetchWithTimeout(url, init, timeoutMs, label) {
32
+ try {
33
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
34
+ }
35
+ catch (e) {
36
+ if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
37
+ throw new ApiError(408, 'REQUEST_TIMEOUT', `${label} timed out after ${Math.round(timeoutMs / 1000)}s`);
38
+ }
39
+ throw e;
40
+ }
41
+ }
20
42
  /** Resolve the Bearer token value. A GIPITY_TOKEN env var (a long-lived agent
21
43
  * API token) takes precedence over the saved session — the persistent,
22
44
  * login-free path for headless agents and CI. Falls back to the refreshed
@@ -55,11 +77,11 @@ export async function getAuthHeader() {
55
77
  async function request(method, path, body) {
56
78
  const headers = await getHeaders();
57
79
  const url = `${baseUrl()}${path}`;
58
- const res = await fetch(url, {
80
+ const res = await fetchWithTimeout(url, {
59
81
  method,
60
82
  headers,
61
83
  body: body ? JSON.stringify(body) : undefined,
62
- });
84
+ }, REQUEST_TIMEOUT_MS, `${method} ${path}`);
63
85
  if (!res.ok) {
64
86
  const json = await res.json().catch(() => ({ error: { code: 'UNKNOWN', message: res.statusText } }));
65
87
  const err = json.error || { code: 'UNKNOWN', message: res.statusText };
@@ -150,14 +172,45 @@ export async function download(path) {
150
172
  export async function downloadStream(path) {
151
173
  const { Readable } = await import('stream');
152
174
  const url = `${baseUrl()}${path}`;
153
- const res = await fetch(url, {
154
- headers: { ...clientHeaders(), 'Authorization': `Bearer ${await bearerToken()}` },
155
- });
175
+ // Bound time-to-first-byte only: abort if no RESPONSE within the header window,
176
+ // then clear the timer so the (possibly large) body streams without a total cap.
177
+ // The body is guarded by an idle watchdog at the call site (sync.ts), which
178
+ // destroys this stream - cancelling the fetch - if bytes stop flowing.
179
+ const ac = new AbortController();
180
+ const headerTimer = setTimeout(() => ac.abort(), DOWNLOAD_HEADER_TIMEOUT_MS);
181
+ let res;
182
+ try {
183
+ res = await fetch(url, {
184
+ headers: { ...clientHeaders(), 'Authorization': `Bearer ${await bearerToken()}` },
185
+ signal: ac.signal,
186
+ });
187
+ }
188
+ catch (e) {
189
+ if (ac.signal.aborted) {
190
+ throw new ApiError(408, 'DOWNLOAD_TIMEOUT', `Download did not respond within ${DOWNLOAD_HEADER_TIMEOUT_MS / 1000}s`);
191
+ }
192
+ throw e;
193
+ }
194
+ finally {
195
+ clearTimeout(headerTimer);
196
+ }
156
197
  if (!res.ok) {
157
198
  throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
158
199
  }
159
200
  return Readable.fromWeb(res.body);
160
201
  }
202
+ // A presigned PUT has no built-in deadline: `fetch` will wait forever if S3
203
+ // accepts the connection then stalls mid-body. That can't surface as a retry
204
+ // (a hang never throws), so one stalled PUT wedges an entire deploy/sync while
205
+ // it holds the project lock. Bound every PUT with a throughput-scaled deadline:
206
+ // a generous floor for tiny files plus time for the body at a conservative
207
+ // assumed-minimum rate, so a genuinely-progressing large upload survives but a
208
+ // true stall aborts and lets withRetry() retry it.
209
+ const PUT_TIMEOUT_FLOOR_MS = 120_000; // 2 min minimum, regardless of size
210
+ const PUT_MIN_BYTES_PER_SEC = 256 * 1024; // assume the link sustains ≥256 KB/s
211
+ export function putTimeoutMs(contentLength) {
212
+ return Math.max(PUT_TIMEOUT_FLOOR_MS, Math.ceil(contentLength / PUT_MIN_BYTES_PER_SEC) * 1000);
213
+ }
161
214
  /**
162
215
  * PUT raw bytes to a presigned URL (no auth header - the URL is signed).
163
216
  * Supports a Buffer or a Readable stream body. Returns the response ETag header
@@ -174,12 +227,25 @@ export async function putToPresignedUrl(url, body, contentLength, contentType) {
174
227
  const fetchBody = isStream
175
228
  ? Readable.toWeb(body)
176
229
  : new Uint8Array(body);
177
- const res = await fetch(url, {
178
- method: 'PUT',
179
- headers,
180
- body: fetchBody,
181
- ...(isStream ? { duplex: 'half' } : {}),
182
- });
230
+ const timeoutMs = putTimeoutMs(contentLength);
231
+ let res;
232
+ try {
233
+ res = await fetch(url, {
234
+ method: 'PUT',
235
+ headers,
236
+ body: fetchBody,
237
+ signal: AbortSignal.timeout(timeoutMs),
238
+ ...(isStream ? { duplex: 'half' } : {}),
239
+ });
240
+ }
241
+ catch (err) {
242
+ // A timeout aborts with a TimeoutError/AbortError. Surface it as a 408 so
243
+ // withRetry() treats it as transient and retries the PUT.
244
+ if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
245
+ throw new ApiError(408, 'S3_UPLOAD_TIMEOUT', `S3 PUT stalled (no completion in ${Math.round(timeoutMs / 1000)}s)`);
246
+ }
247
+ throw err;
248
+ }
183
249
  if (!res.ok) {
184
250
  const text = await res.text().catch(() => res.statusText);
185
251
  throw new ApiError(res.status, 'S3_UPLOAD', `S3 PUT failed: ${res.status} ${text.slice(0, 200)}`);
package/dist/auth.js CHANGED
@@ -70,6 +70,24 @@ export function sessionExpired() {
70
70
  return false; // undecodable - let the refresh path decide
71
71
  return Date.now() > exp * 1000;
72
72
  }
73
+ /** True when the access token is currently past its expiry. Unlike
74
+ * sessionExpired() (which only inspects the refresh token's lifetime), this
75
+ * reflects whether the NEXT authenticated request can actually succeed right
76
+ * now. Meaningful only when read AFTER refreshTokenIfNeeded(): a normal
77
+ * expired access token gets silently renewed, so a token that is STILL expired
78
+ * after a refresh attempt means the renewal failed — the refresh token was
79
+ * rejected (genuinely lapsed, or rotated away by a sibling process sharing this
80
+ * auth.json) — and re-login is required. Used to keep the up-front "Logged in"
81
+ * message from contradicting a 401 on the very next call. */
82
+ export function accessTokenExpired() {
83
+ const auth = getAuth();
84
+ if (!auth)
85
+ return true;
86
+ const t = new Date(auth.expiresAt).getTime();
87
+ if (isNaN(t))
88
+ return false; // unparseable - let the live 401 path decide
89
+ return Date.now() >= t;
90
+ }
73
91
  const delay = (ms) => new Promise(r => setTimeout(r, ms));
74
92
  // ─── Cross-process refresh lock ────────────────────────────────
75
93
  // Serializes token refreshes across every `gipity` process that shares this
@@ -5,7 +5,7 @@ import { spawn } from 'child_process';
5
5
  import { homedir } from 'os';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { resolveCommand } from '../platform.js';
8
- import { getAuth, saveAuth, sessionExpired } from '../auth.js';
8
+ import { getAuth, saveAuth, sessionExpired, accessTokenExpired, refreshTokenIfNeeded } from '../auth.js';
9
9
  import { get, post, publicPost, ApiError, getAccountSlug } from '../api.js';
10
10
  import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
11
11
  import { sync } from '../sync.js';
@@ -307,11 +307,35 @@ export const claudeCommand = new Command('claude')
307
307
  if (!nonInteractive) {
308
308
  printBanner({ version: __clPkg.version, email: auth?.email, cwd: process.cwd() });
309
309
  }
310
- if (auth && sessionExpired()) {
311
- // The cached auth.json exists but its refresh token has lapsed, so the
312
- // first API call below would 401 anyway. Re-login up front instead of
313
- // printing "Logged in" and immediately contradicting it with "session
314
- // expired" once the projects fetch fails.
310
+ // A GIPITY_TOKEN env token authenticates every request on its own, so the
311
+ // saved session's expiry is irrelevant when one is set never re-login or
312
+ // warn about expiry in that case.
313
+ const hasEnvToken = Boolean(process.env.GIPITY_TOKEN?.trim());
314
+ // For an interactive saved-session run, renew the access token UP FRONT so
315
+ // the login line we're about to print is actually true. A saved session
316
+ // can look valid locally (refresh token not past its JWT expiry, so
317
+ // sessionExpired() is false) yet be dead on the server — most often because
318
+ // a sibling process sharing this auth.json already consumed the single-use
319
+ // refresh token (GipRunner runs many gipity processes against one auth dir).
320
+ // refreshTokenIfNeeded() attempts the rotation here; if it fails, the
321
+ // access token stays expired and accessTokenExpired() catches it below.
322
+ if (auth && !hasEnvToken && !nonInteractive && !sessionExpired()) {
323
+ await refreshTokenIfNeeded();
324
+ auth = getAuth();
325
+ }
326
+ // Re-login is genuinely required when the refresh token has lapsed, OR the
327
+ // proactive renewal above could not produce a still-valid access token
328
+ // (refresh token rejected/rotated away). The access-token check is gated to
329
+ // interactive runs: headless runs can't prompt, and their downstream API
330
+ // call renews the token itself. Never triggered while a GIPITY_TOKEN env
331
+ // token is supplying auth.
332
+ const reloginRequired = auth != null && !hasEnvToken &&
333
+ (sessionExpired() || (!nonInteractive && accessTokenExpired()));
334
+ if (auth && reloginRequired) {
335
+ // The saved session is dead (refresh token lapsed, or rotated away by a
336
+ // sibling process), so the first API call below would 401. Re-login up
337
+ // front instead of printing "Logged in" and immediately contradicting it
338
+ // with "session expired" once the projects fetch fails.
315
339
  console.log(` ${muted('Your session expired. Let\'s sign you back in.')}\n`);
316
340
  auth = await interactiveLogin();
317
341
  }
@@ -148,6 +148,7 @@ export const pageEvalCommand = new Command('eval')
148
148
  .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)', '500')
149
149
  .option('--wait-for <selector>', 'Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)')
150
150
  .option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
151
+ .option('--auth', 'Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
151
152
  .option('--json', 'Output as JSON')
152
153
  .action((url, exprArg, opts) => run('Page eval', async () => {
153
154
  // A JS-intent flag guess (captured as a hidden decoy below): redirect to the
@@ -205,6 +206,7 @@ export const pageEvalCommand = new Command('eval')
205
206
  url, expr: sentExpr, waitMs,
206
207
  waitForSelector: opts.waitFor || undefined,
207
208
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
209
+ auth: opts.auth || undefined,
208
210
  });
209
211
  const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
210
212
  const { result, noValue } = normalizeEvalResult(d.result);
@@ -39,6 +39,7 @@ export const pageInspectCommand = new Command('inspect')
39
39
  .option('--no-truncate', 'Show full URLs instead of truncating long ones with middle-ellipsis')
40
40
  .option('--all', 'Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail')
41
41
  .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps run headlessly (audio is a built-in tone, not real speech)')
42
+ .option('--auth', 'Load the page signed in as you (your Gipity account), so pages behind a Sign-in-with-Gipity login are reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
42
43
  // Hidden redirect: agents reach for `page inspect --screenshot`. We don't take
43
44
  // an image here (`page screenshot` is the single path for that) — just point there.
44
45
  .addOption(new Option('--screenshot [path]', 'Capture a screenshot').hideHelp())
@@ -59,6 +60,7 @@ export const pageInspectCommand = new Command('inspect')
59
60
  waitForSelector: opts.waitFor || undefined,
60
61
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
61
62
  fakeMedia: opts.fakeMedia || undefined,
63
+ auth: opts.auth || undefined,
62
64
  };
63
65
  const res = await post(`/tools/browser/inspect`, inspectBody);
64
66
  const b = res.data;
@@ -108,6 +108,7 @@ export const pageScreenshotCommand = new Command('screenshot')
108
108
  .option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)', appendOption, [])
109
109
  .option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
110
110
  .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly (audio is a built-in tone, not real speech)')
111
+ .option('--auth', 'Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
111
112
  .option('--json', 'Output JSON metadata instead of a friendly summary')
112
113
  .addOption(new Option('--wait <ms>', 'Alias for --post-load-delay').hideHelp())
113
114
  // `--full-page` is the Puppeteer/Playwright name for this (their `fullPage`),
@@ -142,6 +143,7 @@ export const pageScreenshotCommand = new Command('screenshot')
142
143
  reloadBetween: opts.reloadBetween !== false,
143
144
  ...(userSpecifiedViewports ? { viewports: customViewports } : {}),
144
145
  ...(opts.fakeMedia ? { fakeMedia: true } : {}),
146
+ ...(opts.auth ? { auth: true } : {}),
145
147
  ...(opts.action ? { action: opts.action } : {}),
146
148
  };
147
149
  // Load + render across viewports runs server-side and can take many
@@ -231,6 +231,47 @@ testCommand
231
231
  }
232
232
  }
233
233
  }));
234
+ // ── List subcommand (show test files without running) ─────────────────
235
+ testCommand
236
+ .command('list')
237
+ .description('List the test files that would run — no run, optional path filter')
238
+ .argument('[path]', 'Test path filter (e.g. "api", "e2e/portal")')
239
+ .option('--json', 'Output as JSON')
240
+ // optsWithGlobals: see the status subcommand — `--json` lands on the parent.
241
+ .action((pathFilter, _o, command) => run('List', async () => {
242
+ const opts = command.optsWithGlobals();
243
+ const config = requireConfig();
244
+ const qs = pathFilter ? `?filterPath=${encodeURIComponent(pathFilter)}` : '';
245
+ const res = await get(`/projects/${config.projectGuid}/test/list${qs}`);
246
+ const { files, total } = res.data;
247
+ if (opts.json) {
248
+ console.log(JSON.stringify(res.data, null, 2));
249
+ return;
250
+ }
251
+ if (total === 0) {
252
+ console.log(muted(pathFilter
253
+ ? `No test files matched filter: ${pathFilter}`
254
+ : 'No test files found. Add *.test.js files under tests/.'));
255
+ return;
256
+ }
257
+ console.log(bold(`Test files${pathFilter ? ` (filter: ${pathFilter})` : ''}: ${total}`));
258
+ console.log('');
259
+ // Group by directory so the layout mirrors `gipity test` run output.
260
+ const groups = new Map();
261
+ for (const f of files) {
262
+ const key = f.path || '(root)';
263
+ if (!groups.has(key))
264
+ groups.set(key, []);
265
+ groups.get(key).push(f.name);
266
+ }
267
+ for (const [dir, names] of groups) {
268
+ console.log(` ${dim(dir)}`);
269
+ for (const name of names)
270
+ console.log(` ${name}`);
271
+ }
272
+ console.log('');
273
+ console.log(muted(`Run them: gipity test${pathFilter ? ` ${pathFilter}` : ''}`));
274
+ }));
234
275
  // ── History subcommand ─────────────────────────────────────────────────
235
276
  testCommand
236
277
  .command('history')
@@ -181,6 +181,6 @@ export const uninstallCommand = new Command('uninstall')
181
181
  }
182
182
  console.log('');
183
183
  console.log(`${success('Uninstall complete.')} ${dim('Run')} ${brand('npm uninstall -g gipity')} ${dim('to remove the binary too.')}`);
184
- console.log(`${dim('Then run')} ${brand('hash -r')} ${dim('(or open a new shell) so bash forgets the old binary path.')}`);
184
+ console.log(`${dim('Then run')} ${brand('hash -r')} ${dim('(or open a new shell) - your shell caches the old binary path, and a reinstall may place it elsewhere.')}`);
185
185
  });
186
186
  //# sourceMappingURL=uninstall.js.map
@@ -24,6 +24,7 @@ export const FLAG_ALIASES = {
24
24
  '--ratio': '--aspect-ratio',
25
25
  '--res': '--resolution',
26
26
  '--desc': '--description',
27
+ '--body': '--data',
27
28
  '--src': '--source-dir',
28
29
  '--srcdir': '--source-dir',
29
30
  '--parallel': '--concurrency',
@@ -23,7 +23,7 @@
23
23
  * - Anything unexpected (parse error, network error, etc.). We must
24
24
  * not break the user's interactive session.
25
25
  */
26
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, createReadStream, } from 'fs';
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, statSync, utimesSync, createReadStream, } from 'fs';
27
27
  import { homedir } from 'os';
28
28
  import { join } from 'path';
29
29
  import { getDevice } from '../relay/state.js';
@@ -74,33 +74,90 @@ function deleteState(convGuid) {
74
74
  }
75
75
  catch { /* already gone */ }
76
76
  }
77
- /** Simple exclusive file-lock via `wx` open. Stop and SubagentStop can
78
- * fire concurrently on the same conv (e.g. a Task subagent finishing
79
- * while the parent is also wrapping up), and a crashed hook retry would
80
- * race the next one. Holding a lock for the duration serializes them.
81
- * Returns a releaser (or null if the lock is already held - in which
82
- * case we skip this run; the holder will catch our data on its next
83
- * transcript scan). */
84
- function acquireLock(convGuid) {
85
- mkdirSync(CAPTURE_DIR, { recursive: true });
86
- const path = lockPath(convGuid);
87
- let fd;
77
+ // Crash-safe reclaim, mirroring the advisory lock in src/sync.ts: a holder
78
+ // writes its PID and heartbeats the lock's mtime; a peer reclaims it when the
79
+ // holder crashed (dead PID), died before writing a PID (empty/garbage file), or
80
+ // went silent past the stale window (wedged, or its PID was reused by an
81
+ // unrelated process). Without this a SIGKILL'd hook would strand the lock and
82
+ // silently disable capture for that conversation until SessionEnd.
83
+ const LOCK_HEARTBEAT_MS = 15_000;
84
+ export const LOCK_STALE_MS = 90_000;
85
+ /** Decide whether an existing capture lock is reclaimable. Exported for tests.
86
+ * Kept in sync with sync.ts's namesake. */
87
+ export function isLockReclaimable(path, now = Date.now()) {
88
+ let raw;
89
+ let mtimeMs;
88
90
  try {
89
- fd = openSync(path, 'wx');
91
+ raw = readFileSync(path, 'utf-8').trim();
92
+ mtimeMs = statSync(path).mtimeMs;
90
93
  }
91
94
  catch {
92
- return null;
95
+ return false; // unreadable / already gone - don't steal, just skip
96
+ }
97
+ const pid = parseInt(raw, 10);
98
+ if (!raw || !pid || isNaN(pid))
99
+ return true; // empty/garbage = crashed mid-create
100
+ try {
101
+ process.kill(pid, 0);
93
102
  }
94
- return () => {
103
+ catch {
104
+ return true;
105
+ } // holder PID is dead
106
+ return now - mtimeMs > LOCK_STALE_MS; // alive but heartbeat went silent
107
+ }
108
+ /** Exclusive file-lock via `wx` open, with crash-safe reclaim. Stop and
109
+ * SubagentStop can fire concurrently on the same conv (e.g. a Task subagent
110
+ * finishing while the parent is also wrapping up), and a crashed hook retry
111
+ * would race the next one. Holding a lock for the duration serializes them.
112
+ * Returns a releaser, or null when a *live* holder is already flushing - in
113
+ * which case we skip this run; the holder will catch our data on its next
114
+ * transcript scan. A dead/abandoned holder's lock is reclaimed once and
115
+ * retried rather than waited on (capture is fire-and-forget; we never block
116
+ * the user's session). Exported for tests. */
117
+ export function acquireLock(convGuid) {
118
+ mkdirSync(CAPTURE_DIR, { recursive: true });
119
+ const path = lockPath(convGuid);
120
+ // At most one reclaim+retry: a genuinely live holder is flushing the same
121
+ // transcript, so we just skip; only a crashed/stale holder is stolen.
122
+ for (let attempt = 0; attempt < 2; attempt++) {
123
+ let fd;
95
124
  try {
96
- closeSync(fd);
125
+ fd = openSync(path, 'wx'); // fails if the file exists
126
+ }
127
+ catch {
128
+ if (attempt === 0 && isLockReclaimable(path)) {
129
+ try {
130
+ unlinkSync(path);
131
+ }
132
+ catch { /* race - someone else got it */ }
133
+ continue;
134
+ }
135
+ return null; // live holder (or lost the reclaim race) - let it cover us
97
136
  }
98
- catch { /* ignore */ }
99
137
  try {
100
- unlinkSync(path);
138
+ writeFileSync(fd, String(process.pid));
101
139
  }
102
- catch { /* ignore */ }
103
- };
140
+ finally {
141
+ closeSync(fd);
142
+ }
143
+ // Heartbeat the mtime so a long flush isn't mistaken for abandoned. unref()
144
+ // so the timer never keeps the hook process alive on its own.
145
+ const beat = setInterval(() => {
146
+ try {
147
+ utimesSync(path, new Date(), new Date());
148
+ }
149
+ catch { /* lock gone */ }
150
+ }, LOCK_HEARTBEAT_MS);
151
+ beat.unref?.();
152
+ return () => {
153
+ clearInterval(beat);
154
+ try {
155
+ unlinkSync(path);
156
+ }
157
+ catch { /* already gone */ }
158
+ };
159
+ }
160
+ return null;
104
161
  }
105
162
  async function readStdin() {
106
163
  if (process.stdin.isTTY)
package/dist/index.js CHANGED
@@ -223,16 +223,47 @@ function fullCommandName(cmd) {
223
223
  parts.unshift(c.name());
224
224
  return parts.join(' ');
225
225
  }
226
+ // Resolve the deepest (sub)command the user actually targeted by walking the
227
+ // command tree against the leading positional tokens of argv (skipping flags,
228
+ // and a root value-option's value). Used at error time to render the RIGHT
229
+ // command's help — see enableHelpAfterError below for why we can't just capture
230
+ // the command in a closure.
231
+ function resolveTargetCommand(argv) {
232
+ const args = argv.slice(2);
233
+ const rootValueFlags = new Set(program.options.filter(o => o.long && o.required).map(o => o.long));
234
+ let cmd = program;
235
+ for (let i = 0; i < args.length; i++) {
236
+ const tok = args[i];
237
+ if (tok.startsWith('-')) {
238
+ if (!tok.includes('=') && rootValueFlags.has(tok))
239
+ i++; // consume its value
240
+ continue;
241
+ }
242
+ const next = cmd.commands.find(c => c.name() === tok || c.aliases().includes(tok));
243
+ if (!next)
244
+ break; // first non-subcommand operand ends the command chain
245
+ cmd = next;
246
+ }
247
+ return cmd;
248
+ }
226
249
  function enableHelpAfterError(cmd) {
227
250
  cmd.configureOutput({
228
- // Commander calls this on the offending (sub)command. We render that exact
229
- // command's full help (via outputHelp, so addHelpText blocks are included)
230
- // FIRST, then write the one-line error LAST. Both go to the same writeErr
231
- // stream synchronously, so the order holds. We do NOT call
251
+ // Render the offending command's full help (via outputHelp, so addHelpText
252
+ // blocks are included) FIRST, then the one-line error LAST. Both go to the
253
+ // same writeErr stream synchronously, so the order holds. We do NOT call
232
254
  // showHelpAfterError - that would render help a second time, before the error.
255
+ //
256
+ // We must resolve the target command from argv rather than capturing `cmd`
257
+ // here: commander shares ONE _outputConfiguration object across a command
258
+ // and all its subcommands (copyInheritedSettings copies it by reference, and
259
+ // configureOutput mutates it in place). So every subcommand's closure would
260
+ // clobber its siblings', leaving only the last-registered subcommand's — and
261
+ // an unknown option on `fn call` would print `fn delete`'s help. Installing
262
+ // one identical, self-resolving handler everywhere sidesteps the clobber.
233
263
  outputError: (str, write) => {
234
- write(`Showing \`${fullCommandName(cmd)} --help\`:\n\n`);
235
- cmd.outputHelp({ error: true });
264
+ const target = resolveTargetCommand(process.argv);
265
+ write(`Showing \`${fullCommandName(target)} --help\`:\n\n`);
266
+ target.outputHelp({ error: true });
236
267
  write(`\n${str.replace(/\n+$/, '')}\n`);
237
268
  },
238
269
  });
package/dist/knowledge.js CHANGED
@@ -121,6 +121,19 @@ Write files locally - the Gipity Claude Code plugin's hooks auto-push every save
121
121
 
122
122
  To keep local-only material (research clones, scratch data, vendored references) in the project directory without syncing or deploying it, list it in a \`.gipityignore\` at the project root - gitignore-style, one pattern per line, \`#\` comments. Ignored paths are invisible to sync in both directions; anything that already synced before being ignored stays on the server until you delete it.
123
123
 
124
+ ### Where files go: deploy only ships \`src/\`
125
+
126
+ Deploy is opt-in, not opt-out: the \`files\` phase uploads **only** what's under \`src/\` (plus \`functions/\` and \`migrations/\` as backend, not CDN files). Anything else at the project root is kept but never deployed. Put each kind of file in the right bucket so scratch and reference material can't bloat a deploy:
127
+
128
+ - **\`src/\`** - the app itself. Synced **and** deployed to the CDN. Only app code, assets, and pages belong here.
129
+ - **\`tmp/\`** - ephemeral scratch: file conversions, intermediate outputs, design staging. **Already ignored** (never synced, never deployed) - the one place to do throwaway work. Use this single root. (\`*_tmp/\` dirs and \`.gipityscratch/\` are auto-ignored too, as a safety net, so legacy scattered scratch like \`_vsd_tmp/\` can't leak - but write new scratch to \`tmp/\`, not scattered dirs.)
130
+ - **\`docs/\`** - reference material you want to keep: UI/architecture diagrams, design decks, notes, ADRs. Synced and versioned on the server (backed up, rollback-able) but **never deployed**, because it's outside \`src/\`. This is the home for "keep forever, don't ship" artifacts.
131
+ - **\`tests/\`** - \`*.test.js\` suites. Synced, run by \`gipity test\`, never deployed. \`gipity test list [path]\` lists the test files (and what a filter selects) without running them.
132
+
133
+ Rule of thumb: shipping to users → \`src/\`; keep as reference → \`docs/\`; throwaway → \`tmp/\`.
134
+
135
+ Watch for **bulky output dirs dropped loose at the root** (e.g. \`out/\`, \`vsd_out/\`, \`renders/\`). Unlike scratch, those are NOT ignored - they sync on every push and re-hash on every deploy, which is the classic cause of a slow, bloated deploy. Move them into \`docs/\` if you want to keep them or \`tmp/\` if they're disposable.
136
+
124
137
  ## Skills (detailed documentation)
125
138
 
126
139
  Run \`gipity skill list\` to see every skill. Run \`gipity skill read <name>\` to read one. Load the relevant skill before starting a task - they have the correct API patterns, code examples, and common mistakes.
package/dist/progress.js CHANGED
@@ -22,10 +22,13 @@ const CLEAR_TO_EOL = '\x1b[K';
22
22
  const BAR_WIDTH = 18;
23
23
  const RENDER_THROTTLE_MS = 60;
24
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.
25
+ // the determinate bar fills. Redraw cadence steps down once the wait gets long:
26
+ // a snappy 10×/s under 10s, then once a second so a long wait reads as a calm
27
+ // clock rather than a frantic blur.
27
28
  const SPIN_BLOCK = 5;
28
- const SPIN_FRAME_MS = 90;
29
+ const SPIN_FRAME_FAST_MS = 100;
30
+ const SPIN_FRAME_SLOW_MS = 1000;
31
+ const SPIN_SLOWDOWN_AFTER_MS = 10_000;
29
32
  /** Compact elapsed: "8s", "1m 04s". Keeps the timer narrow and glanceable. */
30
33
  function formatElapsed(ms) {
31
34
  const s = Math.floor(ms / 1000);
@@ -34,12 +37,25 @@ function formatElapsed(ms) {
34
37
  const m = Math.floor(s / 60);
35
38
  return `${m}m ${String(s % 60).padStart(2, '0')}s`;
36
39
  }
40
+ /**
41
+ * Spinner clock. Under 10s shows tenths ("2.2s") so a fresh wait visibly moves;
42
+ * at/after 10s switches to m:ss ("0:11", "1:02") - whole seconds are plenty once
43
+ * the redraw has slowed to once a second.
44
+ */
45
+ function formatSpinClock(ms) {
46
+ if (ms < SPIN_SLOWDOWN_AFTER_MS)
47
+ return `${(ms / 1000).toFixed(1)}s`;
48
+ const total = Math.floor(ms / 1000);
49
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
50
+ }
37
51
  class TerminalProgress {
38
52
  /** True while an in-place transfer/spinner line is on screen and not committed. */
39
53
  liveOpen = false;
40
54
  lastRenderAt = 0;
41
55
  /** The label of the current transfer session; a change starts a fresh one. */
42
56
  barLabel = null;
57
+ /** Wall-clock start of the current transfer session, for the elapsed timer. */
58
+ barStartedAt = 0;
43
59
  /** True once the current session hit 100% - late/overshoot ticks are dropped. */
44
60
  barSettled = false;
45
61
  /** Active indeterminate spinner timer, if any. */
@@ -60,6 +76,7 @@ class TerminalProgress {
60
76
  if (label !== this.barLabel) {
61
77
  this.barLabel = label;
62
78
  this.barSettled = false;
79
+ this.barStartedAt = Date.now();
63
80
  }
64
81
  if (this.barSettled)
65
82
  return;
@@ -82,13 +99,16 @@ class TerminalProgress {
82
99
  const startedAt = Date.now();
83
100
  let tick = 0;
84
101
  const draw = () => {
102
+ const elapsed = Date.now() - startedAt;
85
103
  this.liveOpen = true;
86
- process.stdout.write('\r' + this.spinFrame(label, tick++, Date.now() - startedAt) + CLEAR_TO_EOL);
104
+ process.stdout.write('\r' + this.spinFrame(tick++, elapsed) + CLEAR_TO_EOL);
105
+ // Reschedule each frame so the cadence can step down as the wait lengthens.
106
+ const next = elapsed < SPIN_SLOWDOWN_AFTER_MS ? SPIN_FRAME_FAST_MS : SPIN_FRAME_SLOW_MS;
107
+ this.spinTimer = setTimeout(draw, next);
108
+ // Don't let the animation keep the event loop (and process) alive on its own.
109
+ this.spinTimer.unref?.();
87
110
  };
88
111
  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
112
  const settle = (icon, message) => {
93
113
  this.stopSpinTimer();
94
114
  if (this.liveOpen)
@@ -118,7 +138,7 @@ class TerminalProgress {
118
138
  }
119
139
  stopSpinTimer() {
120
140
  if (this.spinTimer) {
121
- clearInterval(this.spinTimer);
141
+ clearTimeout(this.spinTimer);
122
142
  this.spinTimer = null;
123
143
  }
124
144
  }
@@ -133,9 +153,14 @@ class TerminalProgress {
133
153
  const filled = Math.round((pct / 100) * BAR_WIDTH);
134
154
  const bar = brand('█'.repeat(filled)) + dim('░'.repeat(BAR_WIDTH - filled));
135
155
  const sizes = muted(`${formatSize(done)} / ${formatSize(total)}`);
136
- return ` ${muted(label)} ${bar} ${brandBold(`${pct}%`)} ${sizes}`;
156
+ // Same built-in elapsed timer the spinner carries, so a determinate transfer
157
+ // that stalls mid-flight (slow upload, wedged S3 PUT) still visibly ticks
158
+ // instead of freezing at "80%" - and the in-place line and the committed
159
+ // 100% frame both show how long the transfer took.
160
+ const elapsed = muted(formatElapsed(Date.now() - this.barStartedAt));
161
+ return ` ${muted(label)} ${bar} ${brandBold(`${pct}%`)} ${sizes} ${elapsed}`;
137
162
  }
138
- spinFrame(label, tick, elapsedMs) {
163
+ spinFrame(tick, elapsedMs) {
139
164
  // Ping-pong the block's left edge between 0 and (BAR_WIDTH - SPIN_BLOCK).
140
165
  const span = BAR_WIDTH - SPIN_BLOCK;
141
166
  const cycle = span * 2;
@@ -144,7 +169,8 @@ class TerminalProgress {
144
169
  const bar = dim('░'.repeat(pos)) +
145
170
  brand('█'.repeat(SPIN_BLOCK)) +
146
171
  dim('░'.repeat(BAR_WIDTH - pos - SPIN_BLOCK));
147
- return ` ${muted(label)} ${bar} ${muted(formatElapsed(elapsedMs))}`;
172
+ // No label: a bare track plus a clock, framed by three spaces on each side.
173
+ return ` ${bar} ${muted(formatSpinClock(elapsedMs))}`;
148
174
  }
149
175
  }
150
176
  const NOOP_SPINNER = { succeed() { }, fail() { }, stop() { } };
package/dist/setup.js CHANGED
@@ -39,8 +39,23 @@ export const PRIMER_FILES = {
39
39
  /** Aider's config file. We write/merge a `read:` entry into it so aider loads
40
40
  * AGENTS.md - a per-workstation artifact like the primers, never synced. */
41
41
  export const AIDER_CONF_FILE = '.aider.conf.yml';
42
+ /** Project-local scratch namespaces: file conversions, intermediate outputs,
43
+ * design staging - work the agent wants on disk but should never sync or
44
+ * deploy. These MUST mirror the sandbox's `isEphemeralSandboxPath` denylist
45
+ * (`platform/server/src/services/sandbox/no-persist.ts`) so the same dirs are
46
+ * treated as throwaway everywhere: a sandbox run refuses to persist them, and
47
+ * `gipity sync`/deploy refuses to upload them. Keeping the two in lockstep is
48
+ * what makes scratch coherent across the platform - update both together.
49
+ * `tmp/` is the one we teach agents to use (see knowledge.ts "Files and sync");
50
+ * `*_tmp/` and `.gipityscratch/` are caught defensively so legacy/scattered
51
+ * scratch (the `_vsd_tmp/`/`_convert_tmp/` dirs that bloated past deploys)
52
+ * can't leak in either. Reference material to KEEP (diagrams, decks, ADRs) goes
53
+ * in `docs/` instead - synced and versioned, but outside `src/` so it's never
54
+ * deployed. Gitignore-glob form, matched by the `ignore` package in config.ts. */
55
+ export const SCRATCH_IGNORE = ['tmp/', '.tmp/', '*_tmp/', '.gipityscratch/'];
42
56
  export const DEFAULT_SYNC_IGNORE = [
43
57
  'node_modules', '.git', '.gipity.json', '.gipity/', '.claude/', '.gitignore', AIDER_CONF_FILE,
58
+ ...SCRATCH_IGNORE,
44
59
  ...new Set(Object.values(PRIMER_FILES)),
45
60
  ];
46
61
  /** True if `name` (a top-level dir entry) is a workstation artifact that
@@ -480,7 +495,9 @@ export const SUPPORTED_TOOLS = [
480
495
  export const DEFAULT_TOOLS = SUPPORTED_TOOLS.filter(t => !t.optIn);
481
496
  export function setupGitignore() {
482
497
  const gitignorePath = resolve(process.cwd(), '.gitignore');
483
- const entries = ['.gipity/', '.gipity.json'];
498
+ // Sync already skips the scratch namespaces (DEFAULT_SYNC_IGNORE); ignore them
499
+ // in git too so ephemeral conversion/staging work never gets committed.
500
+ const entries = ['.gipity/', '.gipity.json', ...SCRATCH_IGNORE];
484
501
  if (existsSync(gitignorePath)) {
485
502
  let content = readFileSync(gitignorePath, 'utf-8');
486
503
  // Split on \r?\n so a CRLF .gitignore (the Windows default) doesn't leave a
package/dist/sync.js CHANGED
@@ -21,7 +21,7 @@
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 } from 'fs';
24
+ import { writeFileSync, mkdirSync, existsSync, statSync, unlinkSync, readdirSync, rmdirSync, readFileSync, renameSync, openSync, closeSync, utimesSync } from 'fs';
25
25
  import { join, relative, dirname, extname, resolve, sep } from 'path';
26
26
  import { hostname } from 'os';
27
27
  import { get, del, downloadStream, ApiError } from './api.js';
@@ -38,6 +38,10 @@ import * as tar from 'tar-stream';
38
38
  * project probably are intentional. */
39
39
  const BULK_DELETE_COUNT = 10;
40
40
  const BULK_DELETE_FRACTION = 0.25;
41
+ /** A tar download that stops producing bytes for this long - without ever
42
+ * ending the stream - is treated as a stall and aborted, so a wedged
43
+ * connection becomes a recoverable error instead of an unbounded hang. */
44
+ const DOWNLOAD_IDLE_MS = 30_000;
41
45
  // ─── Paths ─────────────────────────────────────────────────────
42
46
  function syncStatePath() {
43
47
  const configPath = getConfigPath();
@@ -54,44 +58,103 @@ function projectDir() {
54
58
  // ─── Advisory lock ─────────────────────────────────────────────
55
59
  const LOCK_WAIT_MS = 30_000;
56
60
  const LOCK_POLL_MS = 500;
57
- /** Acquire the per-project sync lock. Returns a release function. Exported for tests. */
58
- export async function acquireLock() {
61
+ // While a holder works it refreshes the lock's mtime on this cadence; a lock
62
+ // whose mtime is older than the stale window is treated as abandoned and
63
+ // reclaimed even if a process with its PID still exists. This catches the two
64
+ // cases a dead-PID check misses: a CPU-wedged holder that stopped heartbeating,
65
+ // and PID reuse (some unrelated process now owns the old holder's PID). The
66
+ // stale window must stay comfortably larger than the heartbeat so a briefly
67
+ // busy holder isn't robbed mid-run.
68
+ const LOCK_HEARTBEAT_MS = 15_000;
69
+ export const LOCK_STALE_MS = 90_000;
70
+ /** Decide whether an existing lock file is reclaimable. Exported for tests.
71
+ * Reclaim when: the file is empty/garbage (holder crashed between creating the
72
+ * lock and writing its PID), the holder PID is dead, or the lock's heartbeat
73
+ * went silent past {@link LOCK_STALE_MS}. A live, freshly-heartbeating holder
74
+ * is never reclaimed. */
75
+ export function isLockReclaimable(path, now = Date.now()) {
76
+ let raw;
77
+ let mtimeMs;
78
+ try {
79
+ raw = readFileSync(path, 'utf-8').trim();
80
+ mtimeMs = statSync(path).mtimeMs;
81
+ }
82
+ catch {
83
+ return false; // can't read it (likely already gone / racing) - retry, don't steal
84
+ }
85
+ const pid = parseInt(raw, 10);
86
+ if (!raw || !pid || isNaN(pid))
87
+ return true; // empty/garbage = crashed mid-create
88
+ try {
89
+ process.kill(pid, 0);
90
+ }
91
+ catch {
92
+ return true;
93
+ } // holder PID is dead
94
+ return now - mtimeMs > LOCK_STALE_MS; // alive but heartbeat went silent
95
+ }
96
+ /** Acquire the per-project sync lock. Returns a release function. Exported for tests.
97
+ * Pass `progress` so a *contended* wait (another sync/push holds the lock) shows a
98
+ * live "Waiting for another sync…" spinner instead of a silent stall - the lock is
99
+ * taken before any sync phase prints, so without this an agent or user staring at a
100
+ * frozen terminal can't tell a 30s lock wait from a genuine hang. The spinner is
101
+ * created lazily, only when we actually have to wait, so the common instant-acquire
102
+ * path stays output-free (and on a non-TTY the reporter is a no-op regardless). */
103
+ export async function acquireLock(progress) {
59
104
  const path = lockPath();
60
105
  mkdirSync(dirname(path), { recursive: true });
61
106
  const start = Date.now();
107
+ // Lazily-opened spinner for the contended case; settled before we return.
108
+ let waitSpinner = null;
109
+ const settleWait = (ok) => {
110
+ if (!waitSpinner)
111
+ return;
112
+ if (ok)
113
+ waitSpinner.stop();
114
+ else
115
+ waitSpinner.fail('Gave up waiting for the sync lock');
116
+ waitSpinner = null;
117
+ };
62
118
  while (true) {
63
119
  try {
64
120
  const fd = openSync(path, 'wx');
65
121
  writeFileSync(fd, String(process.pid));
66
122
  closeSync(fd);
67
- return () => { try {
123
+ // Heartbeat: keep the lock's mtime fresh so peers can distinguish a live
124
+ // holder from an abandoned one. unref() so it never holds the process open.
125
+ const beat = setInterval(() => {
126
+ try {
127
+ utimesSync(path, new Date(), new Date());
128
+ }
129
+ catch { /* lock gone */ }
130
+ }, LOCK_HEARTBEAT_MS);
131
+ beat.unref?.();
132
+ settleWait(true);
133
+ return () => { clearInterval(beat); try {
68
134
  unlinkSync(path);
69
135
  }
70
136
  catch { /* already gone */ } };
71
137
  }
72
138
  catch {
73
- // Either lock exists, or the race gave us a transient error. Check for
74
- // staleness (holder PID is dead treat as unlocked).
75
- try {
76
- const pid = parseInt(readFileSync(path, 'utf-8').trim(), 10);
77
- if (pid && !isNaN(pid)) {
78
- try {
79
- process.kill(pid, 0);
80
- }
81
- catch {
82
- try {
83
- unlinkSync(path);
84
- }
85
- catch { /* race */ }
86
- continue;
87
- }
139
+ // Lock exists (or the race gave a transient error). Reclaim it if the
140
+ // holder is dead/abandoned; otherwise wait and retry.
141
+ if (isLockReclaimable(path)) {
142
+ try {
143
+ unlinkSync(path);
88
144
  }
145
+ catch { /* race - someone else got it */ }
146
+ continue;
89
147
  }
90
- catch { /* couldn't read - retry */ }
91
148
  if (Date.now() - start > LOCK_WAIT_MS) {
149
+ settleWait(false);
92
150
  throw new Error(`Another sync is in progress (${path}). Waited ${LOCK_WAIT_MS / 1000}s. ` +
93
151
  `Remove the file manually if you're sure no sync is running.`);
94
152
  }
153
+ // First time we're forced to wait: open the spinner so the wait is visible
154
+ // (with an elapsed timer) rather than reading as a frozen process.
155
+ if (!waitSpinner) {
156
+ waitSpinner = progress?.spinner('Waiting for another sync to finish…') ?? null;
157
+ }
95
158
  await new Promise(r => setTimeout(r, LOCK_POLL_MS));
96
159
  }
97
160
  }
@@ -222,27 +285,67 @@ async function fetchRemote(projectGuid) {
222
285
  }
223
286
  return out;
224
287
  }
225
- async function downloadAll(projectGuid, onBytes) {
226
- const stream = await downloadStream(`/projects/${projectGuid}/files/tree?content=tar`);
288
+ /**
289
+ * Extract a tar stream into a path→bytes map, guarded by an idle watchdog.
290
+ *
291
+ * The sync hang was here: the server delivered every byte (progress bar hit
292
+ * 100%) but never cleanly ended the stream, so tar's 'finish' never fired and
293
+ * the awaiting Promise never settled - an unbounded hang. The watchdog turns
294
+ * that into a recoverable error: if no bytes arrive for idleMs without the
295
+ * stream ending, destroy it (closing the socket) and reject. pipe() also doesn't
296
+ * forward source errors to the destination, so we reject on a source 'error' too
297
+ * - a truncated body must never look like a clean 'finish' with partial files.
298
+ *
299
+ * `keep` filters which entries to buffer (default: all); every entry is still
300
+ * drained so the stream can progress. Exported for tests.
301
+ */
302
+ export function extractTarToMap(stream, idleMs, onBytes, keep) {
227
303
  const extract = tar.extract();
228
304
  const files = new Map();
229
305
  return new Promise((resolve, reject) => {
306
+ let settled = false;
307
+ let idle;
308
+ const arm = () => {
309
+ clearTimeout(idle);
310
+ idle = setTimeout(() => {
311
+ if (settled)
312
+ return;
313
+ settled = true;
314
+ const e = new Error(`download stalled: no data for ${idleMs / 1000}s`);
315
+ stream.destroy(e);
316
+ reject(e);
317
+ }, idleMs);
318
+ idle.unref?.();
319
+ };
320
+ const done = (fn, arg) => {
321
+ if (settled)
322
+ return;
323
+ settled = true;
324
+ clearTimeout(idle);
325
+ fn(arg);
326
+ };
230
327
  extract.on('entry', (header, entryStream, next) => {
328
+ arm();
329
+ const name = normalizeTreePath(header.name);
330
+ const wanted = keep ? keep(name) : true;
231
331
  const chunks = [];
232
- entryStream.on('data', (c) => { chunks.push(c); onBytes?.(c.length); });
233
- entryStream.on('end', () => { files.set(normalizeTreePath(header.name), Buffer.concat(chunks)); next(); });
332
+ entryStream.on('data', (c) => { if (wanted)
333
+ chunks.push(c); onBytes?.(c.length); arm(); });
334
+ entryStream.on('end', () => { if (wanted)
335
+ files.set(name, Buffer.concat(chunks)); next(); });
234
336
  entryStream.resume();
235
337
  });
236
- extract.on('finish', () => resolve(files));
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);
338
+ extract.on('finish', () => done(resolve, files));
339
+ extract.on('error', (e) => done(reject, e));
340
+ stream.on('error', (e) => done(reject, e));
341
+ arm();
243
342
  stream.pipe(extract);
244
343
  });
245
344
  }
345
+ async function downloadAll(projectGuid, onBytes) {
346
+ const stream = await downloadStream(`/projects/${projectGuid}/files/tree?content=tar`);
347
+ return extractTarToMap(stream, DOWNLOAD_IDLE_MS, onBytes);
348
+ }
246
349
  async function fetchOne(projectGuid, path) {
247
350
  // Exact single-file read first. The tree-tar endpoint below treats its `path`
248
351
  // as a DIRECTORY prefix, so a single root file (e.g. `gipity.yaml`) comes back
@@ -262,24 +365,12 @@ async function fetchOne(projectGuid, path) {
262
365
  }
263
366
  try {
264
367
  const stream = await downloadStream(`/projects/${projectGuid}/files/tree?content=tar&path=${encodeURIComponent(path)}`);
265
- const extract = tar.extract();
266
- return await new Promise((resolve, reject) => {
267
- let found = null;
268
- extract.on('entry', (header, entryStream, next) => {
269
- if (normalizeTreePath(header.name) === normalizeTreePath(path)) {
270
- const chunks = [];
271
- entryStream.on('data', (c) => chunks.push(c));
272
- entryStream.on('end', () => { found = Buffer.concat(chunks); next(); });
273
- }
274
- else {
275
- entryStream.on('end', () => next());
276
- }
277
- entryStream.resume();
278
- });
279
- extract.on('finish', () => resolve(found));
280
- extract.on('error', reject);
281
- stream.pipe(extract);
282
- });
368
+ // Same idle-guarded extraction as the bulk path; keep only the one entry we
369
+ // asked for. The recovery path must not hang either, or a single stalled file
370
+ // wedges the whole sync.
371
+ const want = normalizeTreePath(path);
372
+ const files = await extractTarToMap(stream, DOWNLOAD_IDLE_MS, undefined, (p) => p === want);
373
+ return files.get(want) ?? null;
283
374
  }
284
375
  catch {
285
376
  return null;
@@ -534,7 +625,7 @@ export async function sync(opts = {}) {
534
625
  const root = projectDir();
535
626
  const interactive = opts.interactive ?? process.stdout.isTTY ?? false;
536
627
  const ignore = effectiveIgnore(root, config.ignore);
537
- const releaseLock = await acquireLock();
628
+ const releaseLock = await acquireLock(opts.progress);
538
629
  try {
539
630
  return await syncInner(config.projectGuid, root, ignore, opts, interactive);
540
631
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.401",
3
+ "version": "1.0.403",
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",