gipity 1.0.401 → 1.0.402

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
@@ -158,6 +158,18 @@ export async function downloadStream(path) {
158
158
  }
159
159
  return Readable.fromWeb(res.body);
160
160
  }
161
+ // A presigned PUT has no built-in deadline: `fetch` will wait forever if S3
162
+ // accepts the connection then stalls mid-body. That can't surface as a retry
163
+ // (a hang never throws), so one stalled PUT wedges an entire deploy/sync while
164
+ // it holds the project lock. Bound every PUT with a throughput-scaled deadline:
165
+ // a generous floor for tiny files plus time for the body at a conservative
166
+ // assumed-minimum rate, so a genuinely-progressing large upload survives but a
167
+ // true stall aborts and lets withRetry() retry it.
168
+ const PUT_TIMEOUT_FLOOR_MS = 120_000; // 2 min minimum, regardless of size
169
+ const PUT_MIN_BYTES_PER_SEC = 256 * 1024; // assume the link sustains ≥256 KB/s
170
+ export function putTimeoutMs(contentLength) {
171
+ return Math.max(PUT_TIMEOUT_FLOOR_MS, Math.ceil(contentLength / PUT_MIN_BYTES_PER_SEC) * 1000);
172
+ }
161
173
  /**
162
174
  * PUT raw bytes to a presigned URL (no auth header - the URL is signed).
163
175
  * Supports a Buffer or a Readable stream body. Returns the response ETag header
@@ -174,12 +186,25 @@ export async function putToPresignedUrl(url, body, contentLength, contentType) {
174
186
  const fetchBody = isStream
175
187
  ? Readable.toWeb(body)
176
188
  : new Uint8Array(body);
177
- const res = await fetch(url, {
178
- method: 'PUT',
179
- headers,
180
- body: fetchBody,
181
- ...(isStream ? { duplex: 'half' } : {}),
182
- });
189
+ const timeoutMs = putTimeoutMs(contentLength);
190
+ let res;
191
+ try {
192
+ res = await fetch(url, {
193
+ method: 'PUT',
194
+ headers,
195
+ body: fetchBody,
196
+ signal: AbortSignal.timeout(timeoutMs),
197
+ ...(isStream ? { duplex: 'half' } : {}),
198
+ });
199
+ }
200
+ catch (err) {
201
+ // A timeout aborts with a TimeoutError/AbortError. Surface it as a 408 so
202
+ // withRetry() treats it as transient and retries the PUT.
203
+ if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
204
+ throw new ApiError(408, 'S3_UPLOAD_TIMEOUT', `S3 PUT stalled (no completion in ${Math.round(timeoutMs / 1000)}s)`);
205
+ }
206
+ throw err;
207
+ }
183
208
  if (!res.ok) {
184
209
  const text = await res.text().catch(() => res.statusText);
185
210
  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
  }
@@ -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',
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.
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/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';
@@ -54,6 +54,41 @@ function projectDir() {
54
54
  // ─── Advisory lock ─────────────────────────────────────────────
55
55
  const LOCK_WAIT_MS = 30_000;
56
56
  const LOCK_POLL_MS = 500;
57
+ // While a holder works it refreshes the lock's mtime on this cadence; a lock
58
+ // whose mtime is older than the stale window is treated as abandoned and
59
+ // reclaimed even if a process with its PID still exists. This catches the two
60
+ // cases a dead-PID check misses: a CPU-wedged holder that stopped heartbeating,
61
+ // and PID reuse (some unrelated process now owns the old holder's PID). The
62
+ // stale window must stay comfortably larger than the heartbeat so a briefly
63
+ // busy holder isn't robbed mid-run.
64
+ const LOCK_HEARTBEAT_MS = 15_000;
65
+ export const LOCK_STALE_MS = 90_000;
66
+ /** Decide whether an existing lock file is reclaimable. Exported for tests.
67
+ * Reclaim when: the file is empty/garbage (holder crashed between creating the
68
+ * lock and writing its PID), the holder PID is dead, or the lock's heartbeat
69
+ * went silent past {@link LOCK_STALE_MS}. A live, freshly-heartbeating holder
70
+ * is never reclaimed. */
71
+ export function isLockReclaimable(path, now = Date.now()) {
72
+ let raw;
73
+ let mtimeMs;
74
+ try {
75
+ raw = readFileSync(path, 'utf-8').trim();
76
+ mtimeMs = statSync(path).mtimeMs;
77
+ }
78
+ catch {
79
+ return false; // can't read it (likely already gone / racing) - retry, don't steal
80
+ }
81
+ const pid = parseInt(raw, 10);
82
+ if (!raw || !pid || isNaN(pid))
83
+ return true; // empty/garbage = crashed mid-create
84
+ try {
85
+ process.kill(pid, 0);
86
+ }
87
+ catch {
88
+ return true;
89
+ } // holder PID is dead
90
+ return now - mtimeMs > LOCK_STALE_MS; // alive but heartbeat went silent
91
+ }
57
92
  /** Acquire the per-project sync lock. Returns a release function. Exported for tests. */
58
93
  export async function acquireLock() {
59
94
  const path = lockPath();
@@ -64,30 +99,30 @@ export async function acquireLock() {
64
99
  const fd = openSync(path, 'wx');
65
100
  writeFileSync(fd, String(process.pid));
66
101
  closeSync(fd);
67
- return () => { try {
102
+ // Heartbeat: keep the lock's mtime fresh so peers can distinguish a live
103
+ // holder from an abandoned one. unref() so it never holds the process open.
104
+ const beat = setInterval(() => {
105
+ try {
106
+ utimesSync(path, new Date(), new Date());
107
+ }
108
+ catch { /* lock gone */ }
109
+ }, LOCK_HEARTBEAT_MS);
110
+ beat.unref?.();
111
+ return () => { clearInterval(beat); try {
68
112
  unlinkSync(path);
69
113
  }
70
114
  catch { /* already gone */ } };
71
115
  }
72
116
  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
- }
117
+ // Lock exists (or the race gave a transient error). Reclaim it if the
118
+ // holder is dead/abandoned; otherwise wait and retry.
119
+ if (isLockReclaimable(path)) {
120
+ try {
121
+ unlinkSync(path);
88
122
  }
123
+ catch { /* race - someone else got it */ }
124
+ continue;
89
125
  }
90
- catch { /* couldn't read - retry */ }
91
126
  if (Date.now() - start > LOCK_WAIT_MS) {
92
127
  throw new Error(`Another sync is in progress (${path}). Waited ${LOCK_WAIT_MS / 1000}s. ` +
93
128
  `Remove the file manually if you're sure no sync is running.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.401",
3
+ "version": "1.0.402",
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",