gipity 1.0.387 → 1.0.389

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/README.md CHANGED
@@ -8,27 +8,33 @@ This CLI connects [Claude Code](https://claude.ai/claude-code) to Gipity's cloud
8
8
 
9
9
  ## Getting Started
10
10
 
11
- You need **Node.js 18+** (which includes npm) and **Claude Code**.
11
+ One line installs everything. It sets up Node 18+ (if you don't already have it) and the Gipity CLI, with no sudo required:
12
12
 
13
13
  ```bash
14
- # 1. Install Node.js (if you don't have it)
14
+ # macOS / Linux / WSL
15
+ curl -fsSL https://gipity.ai/install.sh | bash
15
16
 
16
- # macOS
17
- brew install node
17
+ # Windows (PowerShell)
18
+ irm https://gipity.ai/install.ps1 | iex
19
+ ```
20
+
21
+ Then launch your coding agent wired into Gipity:
22
+
23
+ ```bash
24
+ gipity claude
25
+ ```
18
26
 
19
- # Windows - download the installer from https://nodejs.org
27
+ `gipity claude` walks you through login, project setup, and launches Claude Code. Using Codex, Gemini, or Cursor instead? Run `gipity init`.
20
28
 
21
- # Linux (Ubuntu/Debian)
22
- curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install -y nodejs
29
+ ### Prefer npm
23
30
 
24
- # 2. Install Gipity CLI and Claude Code
25
- npm install -g gipity @anthropic-ai/claude-code
31
+ If you already have **Node.js 18+** you can install directly:
26
32
 
27
- # 3. Go
28
- gipity claude
33
+ ```bash
34
+ npm install -g gipity
29
35
  ```
30
36
 
31
- That's it. `claude` walks you through login, project setup, and launches Claude Code.
37
+ If that fails with `EACCES`, your npm global prefix is root-owned. Don't reach for `sudo`: point npm at a user-owned prefix instead (`npm config set prefix ~/.npm-global` and add `~/.npm-global/bin` to your `PATH`), or just use the one-line installer above, which does this for you. See https://docs.npmjs.com/resolving-eacces-permissions-errors.
32
38
 
33
39
  ## Updates
34
40
 
package/dist/banner.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // ── Gipity CLI Startup Banner ──────────────────────────────────────────
2
2
  // Two-panel box showing all AI models, platform tools, and sandbox capabilities.
3
- import { brand, bold, faint, muted } from './colors.js';
3
+ import { brand, bold, faint, muted, fg } from './colors.js';
4
4
  // ── Static content ─────────────────────────────────────────────────────
5
5
  // ── Feature groups ────────────────────────────────────────────────────
6
6
  const AI_MODELS = [
@@ -38,11 +38,13 @@ const INFRASTRUCTURE = [
38
38
  'Deploy', 'Uploads', 'Rollback',
39
39
  ];
40
40
  // ── Egg color palette (shared) - gradient built around #FEA60E ───────
41
- const _hi = (s) => `\x1b[38;2;255;215;80m${s}\x1b[39m`; // golden highlight
42
- const _lt = (s) => `\x1b[38;2;254;190;45m${s}\x1b[39m`; // light
43
- const _br = (s) => `\x1b[38;2;254;166;14m${s}\x1b[39m`; // base - #FEA60E
44
- const _md = (s) => `\x1b[38;2;218;112;8m${s}\x1b[39m`; // medium-dark
45
- const _sh = (s) => `\x1b[38;2;170;68;4m${s}\x1b[39m`; // shadow
41
+ // Colors route through fg() so they downgrade (256 / 16 / none) on terminals
42
+ // without truecolor support instead of rendering as misparsed garbage.
43
+ const _hi = fg(255, 215, 80); // golden highlight
44
+ const _lt = fg(254, 190, 45); // light
45
+ const _br = fg(254, 166, 14); // base - #FEA60E
46
+ const _md = fg(218, 112, 8); // medium-dark
47
+ const _sh = fg(170, 68, 4); // shadow
46
48
  // Wobbly egg - asymmetric left/right, organic feel (8 rows)
47
49
  // Widths: 6 → 8 → 10 → 12 → 14 → 12 → 10 → 8 | Widest at row 5/8 (62%)
48
50
  function eggWobbly() {
@@ -76,8 +78,8 @@ function eggSym() {
76
78
  // Edge pairs: ▗/▖ cap → ▟/▙ expand → ▐/▌ straight → ▜/▛ contract → ▝/▘ cap
77
79
  function eggTall() {
78
80
  // Extra intermediate tones for a smoother gradient
79
- const _m1 = (s) => `\x1b[38;2;238;138;10m${s}\x1b[39m`; // base medium
80
- const _dk = (s) => `\x1b[38;2;195;88;6m${s}\x1b[39m`; // medium shadow
81
+ const _m1 = fg(238, 138, 10); // base to medium
82
+ const _dk = fg(195, 88, 6); // medium to shadow
81
83
  return [
82
84
  _lt('▗▄') + _br('██') + _md('▄▖'), // 6 - top cap
83
85
  _lt('▟') + _hi('█') + _br('████') + _m1('█▙'), // 8 - expanding, highlight
@@ -161,7 +163,7 @@ function buildLeftPanel(opts, panelW) {
161
163
  const nameDisplay = opts.email
162
164
  ? opts.email.split('@')[0].replace(/^./, c => c.toUpperCase())
163
165
  : null;
164
- const white = (s) => `\x1b[38;2;255;255;255m${s}\x1b[39m`;
166
+ const white = fg(255, 255, 255);
165
167
  const welcome = nameDisplay
166
168
  ? white(bold(`Welcome back ${nameDisplay}!`))
167
169
  : white(bold('Welcome to Gipity'));
package/dist/colors.js CHANGED
@@ -1,27 +1,127 @@
1
1
  // ── Gipity CLI Color System ─────────────────────────────────────────────
2
2
  // Centralized color definitions matching the Gipity platform palette.
3
3
  // All command files should import from here - no inline ANSI codes.
4
+ //
5
+ // Color depth is detected once at load. RGB colors automatically downgrade:
6
+ // level 3 → 24-bit truecolor \x1b[38;2;R;G;Bm
7
+ // level 2 → 256-color \x1b[38;5;Nm
8
+ // level 1 → 16-color \x1b[3Xm / \x1b[9Xm
9
+ // level 0 → no color (plain text)
10
+ // This avoids the failure mode where a terminal that does not understand the
11
+ // 24-bit escape misparses it and renders garbage (e.g. the orange egg coming
12
+ // out purple on consoles without truecolor support).
4
13
  const ESC = '\x1b';
5
- // Detect whether colors should be suppressed
6
- const noColor = !!process.env['NO_COLOR'] || !process.stdout.isTTY;
14
+ // ── Color-depth detection ───────────────────────────────────────────────
15
+ // 0 = none, 1 = 16-color, 2 = 256-color, 3 = truecolor. Mirrors the common
16
+ // supports-color / chalk heuristics, biased toward NOT emitting truecolor
17
+ // unless the terminal advertises it, so unknown terminals get a safe 256 or
18
+ // 16-color approximation instead of a misparsed 24-bit sequence.
19
+ function detectColorLevel() {
20
+ if (process.env['NO_COLOR'])
21
+ return 0;
22
+ // FORCE_COLOR overrides detection (matches Node / chalk semantics).
23
+ const force = process.env['FORCE_COLOR'];
24
+ if (force !== undefined) {
25
+ if (force === '0' || force === 'false')
26
+ return 0;
27
+ if (force === '3')
28
+ return 3;
29
+ if (force === '2')
30
+ return 2;
31
+ // '1', 'true', '' → at least basic color
32
+ if (force === '1' || force === 'true' || force === '')
33
+ return 1;
34
+ }
35
+ if (!process.stdout.isTTY)
36
+ return 0;
37
+ const term = (process.env['TERM'] || '').toLowerCase();
38
+ if (term === 'dumb')
39
+ return 0;
40
+ const colorterm = (process.env['COLORTERM'] || '').toLowerCase();
41
+ if (colorterm === 'truecolor' || colorterm === '24bit')
42
+ return 3;
43
+ const termProgram = process.env['TERM_PROGRAM'] || '';
44
+ if (termProgram === 'iTerm.app' || termProgram === 'vscode')
45
+ return 3;
46
+ if (termProgram === 'Apple_Terminal')
47
+ return 2;
48
+ if (/-256(color)?$/.test(term) || term.includes('256'))
49
+ return 2;
50
+ // Modern Windows consoles set COLORTERM (caught above). Older cmd.exe /
51
+ // conhost only do 16-color reliably.
52
+ if (process.platform === 'win32')
53
+ return 1;
54
+ if (term)
55
+ return 1;
56
+ return 0;
57
+ }
58
+ const COLOR_LEVEL = detectColorLevel();
7
59
  // Identity function for when colors are disabled
8
60
  const identity = (s) => s;
61
+ // ── RGB downgrade helpers ───────────────────────────────────────────────
62
+ // RGB → nearest xterm-256 palette index (6x6x6 cube + grayscale ramp).
63
+ function rgbTo256(r, g, b) {
64
+ if (r === g && g === b) {
65
+ if (r < 8)
66
+ return 16;
67
+ if (r > 248)
68
+ return 231;
69
+ return Math.round(((r - 8) / 247) * 24) + 232;
70
+ }
71
+ return (16 +
72
+ 36 * Math.round((r / 255) * 5) +
73
+ 6 * Math.round((g / 255) * 5) +
74
+ Math.round((b / 255) * 5));
75
+ }
76
+ // RGB → nearest 16-color SGR foreground code (30-37 / 90-97).
77
+ function rgbTo16(r, g, b) {
78
+ const value = Math.round((Math.max(r, g, b) / 255) * 3);
79
+ if (value === 0)
80
+ return 30;
81
+ let code = 30 +
82
+ ((Math.round(b / 255) << 2) |
83
+ (Math.round(g / 255) << 1) |
84
+ Math.round(r / 255));
85
+ if (value === 3)
86
+ code += 60; // bright variant
87
+ return code;
88
+ }
9
89
  // ── Low-level builders ──────────────────────────────────────────────────
10
- function makeFg(r, g, b) {
11
- if (noColor)
90
+ export function makeFg(r, g, b) {
91
+ if (COLOR_LEVEL === 0)
12
92
  return identity;
13
- return (s) => `${ESC}[38;2;${r};${g};${b}m${s}${ESC}[39m`;
93
+ if (COLOR_LEVEL === 3) {
94
+ return (s) => `${ESC}[38;2;${r};${g};${b}m${s}${ESC}[39m`;
95
+ }
96
+ if (COLOR_LEVEL === 2) {
97
+ const n = rgbTo256(r, g, b);
98
+ return (s) => `${ESC}[38;5;${n}m${s}${ESC}[39m`;
99
+ }
100
+ const code = rgbTo16(r, g, b);
101
+ return (s) => `${ESC}[${code}m${s}${ESC}[39m`;
14
102
  }
15
- function makeBg(r, g, b) {
16
- if (noColor)
103
+ export function makeBg(r, g, b) {
104
+ if (COLOR_LEVEL === 0)
17
105
  return identity;
18
- return (s) => `${ESC}[48;2;${r};${g};${b}m${s}${ESC}[49m`;
106
+ if (COLOR_LEVEL === 3) {
107
+ return (s) => `${ESC}[48;2;${r};${g};${b}m${s}${ESC}[49m`;
108
+ }
109
+ if (COLOR_LEVEL === 2) {
110
+ const n = rgbTo256(r, g, b);
111
+ return (s) => `${ESC}[48;5;${n}m${s}${ESC}[49m`;
112
+ }
113
+ const code = rgbTo16(r, g, b) + 10; // fg 30-97 → bg 40-107
114
+ return (s) => `${ESC}[${code}m${s}${ESC}[49m`;
19
115
  }
20
116
  function makeStyle(open, close) {
21
- if (noColor)
117
+ if (COLOR_LEVEL === 0)
22
118
  return identity;
23
119
  return (s) => `${ESC}[${open}m${s}${ESC}[${close}m`;
24
120
  }
121
+ // Convenience alias for callers that just want an RGB foreground (e.g. banner).
122
+ export const fg = makeFg;
123
+ // The detected level, exported for callers that want to branch on it.
124
+ export const colorLevel = COLOR_LEVEL;
25
125
  // ── Text style helpers ──────────────────────────────────────────────────
26
126
  export const bold = makeStyle(1, 22);
27
127
  export const dim = makeStyle(2, 22);
@@ -58,11 +58,21 @@ approvalCommand
58
58
  approvalCommand
59
59
  .command('answer <guid> [selection...]')
60
60
  .description('Answer an approval: a = approve, b = deny, c = ignore, or free text for text-type')
61
+ .option('--deny', 'Resolve as denied; any trailing text is sent as feedback (e.g. requested edits)')
61
62
  .option('--json', 'Output as JSON')
62
63
  .action((guid, selectionParts, opts) => run('Answer', async () => {
63
64
  if (!guid.startsWith('ap_')) {
64
65
  throw new Error('Expected approval guid like ap_xxxxxxxx');
65
66
  }
67
+ // Deny with optional free-text feedback works for every response type -
68
+ // it's how a workflow gate's revision loop gets the user's edits
69
+ // ({{gate.action}} == rejected + {{gate.human_response}}).
70
+ if (opts.deny) {
71
+ const feedback = selectionParts.join(' ').trim();
72
+ await post(`/approvals/${guid}/resolve`, { status: 'denied', response: feedback || undefined });
73
+ printResult(`Denied${feedback ? `: ${feedback}` : '.'}`, opts, { guid, status: 'denied' });
74
+ return;
75
+ }
66
76
  const detailRes = await get(`/approvals/${guid}`);
67
77
  const detail = detailRes.data;
68
78
  const first = selectionParts[0];
@@ -219,6 +219,10 @@ export const claudeCommand = new Command('claude')
219
219
  .option('--project <slug>', 'Open an existing project by slug or id')
220
220
  .option('--here', 'Use the current directory instead of ~/GipityProjects/<slug>/')
221
221
  .option('--quiet', "Suppress Claude's live progress output (headless --new-project/--project runs)")
222
+ // Forwarded to `claude` via the unknown-arg passthrough below (NOT in the
223
+ // gipity strip lists). Declared here only so it shows up in --help — without
224
+ // this, callers can't discover that the session model is selectable.
225
+ .option('--model <model>', 'Model for the Claude session, forwarded to claude (e.g. sonnet, opus, or a full id like claude-sonnet-4-6)')
222
226
  .allowUnknownOption(true)
223
227
  .allowExcessArguments(true)
224
228
  .action(async (opts) => {
@@ -1,8 +1,9 @@
1
1
  import { Command } from 'commander';
2
- import { get, post } from '../api.js';
2
+ import { get, post, del } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { error as clrError, bold, muted, success } from '../colors.js';
5
5
  import { run, printList } from '../helpers/index.js';
6
+ import { confirm } from '../utils.js';
6
7
  export const fnCommand = new Command('fn')
7
8
  .description('Manage functions');
8
9
  fnCommand
@@ -45,4 +46,24 @@ fnCommand
45
46
  const res = await post(`/api/${config.projectGuid}/fn/${encodeURIComponent(name)}`, body);
46
47
  console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
47
48
  }));
49
+ fnCommand
50
+ .command('delete <name>')
51
+ .alias('rm')
52
+ .description('Delete a function')
53
+ .option('--yes', 'Skip confirmation')
54
+ .option('--json', 'Output as JSON')
55
+ .action((name, opts) => run('Delete', async () => {
56
+ const config = requireConfig();
57
+ if (!await confirm(`Delete function '${name}'? This cannot be undone.`, { skip: opts.yes })) {
58
+ console.log('Cancelled.');
59
+ return;
60
+ }
61
+ await del(`/projects/${config.projectGuid}/functions/${encodeURIComponent(name)}`);
62
+ if (opts.json) {
63
+ console.log(JSON.stringify({ name, deleted: true }));
64
+ }
65
+ else {
66
+ console.log(success(`Deleted function '${name}'.`));
67
+ }
68
+ }));
48
69
  //# sourceMappingURL=fn.js.map
@@ -1,8 +1,21 @@
1
1
  import { Command } from 'commander';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
2
4
  import { get } from '../api.js';
3
- import { resolveProjectContext } from '../config.js';
5
+ import { resolveProjectContext, getProjectRoot } from '../config.js';
4
6
  import { error as clrError, bold, muted } from '../colors.js';
5
7
  import { run, printList } from '../helpers/index.js';
8
+ /** Many kits ship a README but no skill doc. When `skill read <name>` misses
9
+ * the server catalog, fall back to an installed kit's README so the canonical
10
+ * lookup doesn't dead-end. Returns the README text, or null if no such kit. */
11
+ function readInstalledKitReadme(name) {
12
+ const root = getProjectRoot() ?? process.cwd();
13
+ const kitDir = join(root, 'src', 'packages', name);
14
+ if (!existsSync(join(kitDir, 'package.json')))
15
+ return null;
16
+ const readme = join(kitDir, 'README.md');
17
+ return existsSync(readme) ? readFileSync(readme, 'utf-8') : null;
18
+ }
6
19
  export const skillCommand = new Command('skill')
7
20
  .description('Read platform docs');
8
21
  skillCommand
@@ -32,6 +45,19 @@ skillCommand
32
45
  const listRes = await get(`/skills?agent=${config.agentGuid}`);
33
46
  const match = listRes.data.find(s => s.name.toLowerCase() === name.toLowerCase());
34
47
  if (!match) {
48
+ // No catalog skill — but if a kit by this name is installed, its README is
49
+ // the guidance the agent is after. Surface it instead of dead-ending.
50
+ const readme = readInstalledKitReadme(name);
51
+ if (readme) {
52
+ if (opts.json) {
53
+ console.log(JSON.stringify({ name, source: 'kit-readme', content: readme }, null, 2));
54
+ }
55
+ else {
56
+ console.log(muted(`No skill doc for "${name}"; showing the installed kit's README (src/packages/${name}/README.md):\n`));
57
+ console.log(readme);
58
+ }
59
+ return;
60
+ }
35
61
  console.error(clrError(`Skill "${name}" not found. Run: gipity skill list`));
36
62
  process.exit(1);
37
63
  }
@@ -3,6 +3,9 @@ import { get, post } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { success, error as clrError, warning, muted, bold, dim } from '../colors.js';
5
5
  import { run, syncBeforeAction } from '../helpers/index.js';
6
+ // Absolute poll ceiling - the server reaps stalled runs (~65 min) well before
7
+ // this, so hitting it means even the reaper is unreachable.
8
+ const POLL_HARD_CAP_MS = 75 * 60_000;
6
9
  function statusIcon(status) {
7
10
  if (status === 'passed')
8
11
  return success('✓');
@@ -42,6 +45,9 @@ async function pollTestStatus(projectGuid, runGuid, opts) {
42
45
  let lastHeartbeat = 0; // 0 => emit the first heartbeat immediately (non-TTY)
43
46
  let longRunHintShown = false;
44
47
  while (true) {
48
+ if (Date.now() - startTime > POLL_HARD_CAP_MS) {
49
+ throw new Error(`Test run ${runGuid} still not finished after ${Math.round(POLL_HARD_CAP_MS / 60000)} minutes - giving up on the poll. Check it later with \`gipity test status ${runGuid}\` or re-run.`);
50
+ }
45
51
  const res = await get(`/projects/${projectGuid}/test/status/${runGuid}`);
46
52
  const data = res.data;
47
53
  // Show progress for new results (non-JSON mode)
@@ -162,6 +168,10 @@ export const testCommand = new Command('test')
162
168
  console.log(clrError(`No tests matched filter: ${filterPath}`));
163
169
  process.exit(1);
164
170
  }
171
+ if (data.status === 'failed' && data.errorMessage) {
172
+ console.log(clrError(`Run failed: ${data.errorMessage}`));
173
+ console.log('');
174
+ }
165
175
  // Summary
166
176
  const parts = [];
167
177
  if (data.passed > 0)
@@ -29,6 +29,35 @@ function formatRunLine(r) {
29
29
  const statusColor = r.status === 'completed' ? success : r.status === 'failed' ? clrError : muted;
30
30
  return `${muted(r.short_guid)} ${statusColor(r.status)} ${dur} ${runTokens(r)} tokens ${muted(fmtTime(r.started_at))}`;
31
31
  }
32
+ const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled']);
33
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
34
+ /**
35
+ * Poll a workflow's runs until the run triggered after `prevGuid` reaches a
36
+ * terminal state, returning it. Throws on timeout so the `run()` wrapper reports
37
+ * it. Two phases: wait for the new run row to appear, then poll it to terminal.
38
+ */
39
+ async function waitForRun(wfGuid, prevGuid, timeoutSec) {
40
+ const deadline = Date.now() + timeoutSec * 1000;
41
+ let runGuid;
42
+ while (!runGuid) {
43
+ if (Date.now() > deadline)
44
+ throw new Error(`Timed out after ${timeoutSec}s waiting for the run to start.`);
45
+ const latest = await get(`/workflows/${wfGuid}/runs?limit=1`);
46
+ const g = latest.data[0]?.short_guid;
47
+ if (g && g !== prevGuid)
48
+ runGuid = g;
49
+ else
50
+ await sleep(1500);
51
+ }
52
+ while (true) {
53
+ const res = await get(`/workflows/${wfGuid}/runs/${runGuid}`);
54
+ if (TERMINAL_RUN_STATUSES.has(res.data.status))
55
+ return res.data;
56
+ if (Date.now() > deadline)
57
+ throw new Error(`Timed out after ${timeoutSec}s; run ${runGuid} is still ${res.data.status}. Check: gipity workflow runs ${wfGuid} ${runGuid}`);
58
+ await sleep(2000);
59
+ }
60
+ }
32
61
  async function listWorkflows(opts) {
33
62
  const res = await get('/workflows');
34
63
  if (opts.json) {
@@ -73,6 +102,8 @@ workflowCommand
73
102
  console.log(`GUID: ${w.short_guid}`);
74
103
  console.log(`Active: ${w.is_active ? 'yes' : 'no'}`);
75
104
  console.log(`Trigger: ${w.trigger_type}${w.cron_expression ? ` (${w.cron_expression})` : ''}${w.trigger_table ? ` (table: ${w.trigger_table})` : ''}`);
105
+ if (w.webhook_url)
106
+ console.log(`Webhook: ${w.webhook_url}`);
76
107
  if (w.description)
77
108
  console.log(`Desc: ${w.description}`);
78
109
  if (w.steps && w.steps.length > 0) {
@@ -85,13 +116,36 @@ workflowCommand
85
116
  }));
86
117
  workflowCommand
87
118
  .command('run <name>')
88
- .description('Trigger a workflow')
119
+ .description('Trigger a workflow (add --wait to block until it finishes)')
89
120
  .option('--json', 'Output as JSON')
121
+ .option('--wait', 'Block until the triggered run reaches a terminal state, then print it')
122
+ .option('--timeout <s>', 'Max seconds to wait with --wait', '120')
90
123
  .action((name, _opts, cmd) => run('Run', async () => {
91
124
  const opts = mergedOpts(cmd);
92
125
  const wf = await resolveWorkflow(name);
93
- const res = await post(`/workflows/${wf.short_guid}/run`, {});
94
- printResult(`Triggered "${wf.name}".`, opts, res.data);
126
+ if (!opts.wait) {
127
+ const res = await post(`/workflows/${wf.short_guid}/run`, {});
128
+ printResult(`Triggered "${wf.name}".`, opts, res.data);
129
+ return;
130
+ }
131
+ // The trigger endpoint is fire-and-forget — it returns the workflow guid,
132
+ // not a run guid (the run row is created asynchronously inside the executor).
133
+ // So capture the latest run guid BEFORE triggering, then wait for a newer one
134
+ // to appear and poll it to a terminal state. Avoids matching a concurrent run.
135
+ const before = await get(`/workflows/${wf.short_guid}/runs?limit=1`);
136
+ const prevGuid = before.data[0]?.short_guid;
137
+ await post(`/workflows/${wf.short_guid}/run`, {});
138
+ const r = await waitForRun(wf.short_guid, prevGuid, Number(opts.timeout) || 120);
139
+ if (opts.json) {
140
+ console.log(JSON.stringify(r));
141
+ }
142
+ else {
143
+ console.log(formatRunLine(r));
144
+ if (r.error_message)
145
+ console.log(` ${clrError(r.error_message)}`);
146
+ }
147
+ if (r.status !== 'completed')
148
+ process.exit(1);
95
149
  }));
96
150
  workflowCommand
97
151
  .command('runs <name> [runGuid]')
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from 'fs';
2
2
  import { dirname, resolve } from 'path';
3
+ import ignore from 'ignore';
3
4
  const CONFIG_FILE = '.gipity.json';
4
5
  export const DEFAULT_API_BASE = 'https://a.gipity.ai';
5
6
  let cached = null;
@@ -211,23 +212,31 @@ export function saveConfigAt(dir, data) {
211
212
  cached = data;
212
213
  cachedPath = path;
213
214
  }
215
+ /** Compiled matchers cached by their pattern set so the per-file `shouldIgnore`
216
+ * call in the sync loop doesn't rebuild the matcher every time. */
217
+ const ignoreMatcherCache = new Map();
218
+ /**
219
+ * True if filePath (a POSIX-relative path under the project root) is excluded
220
+ * by the given .gipityignore / config ignore patterns. Uses real gitignore
221
+ * semantics via the "ignore" package, so all documented forms work: bare names
222
+ * match in any directory (node_modules), a trailing slash means directory
223
+ * (.gipity/), star-dot matches any depth (*.log), and the previously
224
+ * unsupported forms (data/*.csv, anchored /build, double-star, and negation
225
+ * with a leading bang) now behave as users expect.
226
+ */
214
227
  export function shouldIgnore(filePath, ignorePatterns) {
215
- for (const pattern of ignorePatterns) {
216
- // Simple glob matching: exact match, prefix match, or extension match
217
- if (filePath === pattern)
218
- return true;
219
- if (filePath.startsWith(pattern + '/'))
220
- return true;
221
- if (pattern.startsWith('*.') && filePath.endsWith(pattern.slice(1)))
222
- return true;
223
- if (pattern.endsWith('/') && filePath.startsWith(pattern))
224
- return true;
225
- // Directory name match anywhere in path
226
- if (!pattern.includes('*') && !pattern.includes('/')) {
227
- if (filePath.split('/').includes(pattern))
228
- return true;
229
- }
228
+ if (ignorePatterns.length === 0)
229
+ return false;
230
+ const key = ignorePatterns.join('\n');
231
+ let matcher = ignoreMatcherCache.get(key);
232
+ if (!matcher) {
233
+ matcher = ignore().add(ignorePatterns);
234
+ ignoreMatcherCache.set(key, matcher);
230
235
  }
231
- return false;
236
+ // `ignore` wants a clean relative path; it rejects absolute paths and '.'.
237
+ const rel = filePath.replace(/^\.\//, '').replace(/\/+$/, '');
238
+ if (!rel || rel === '.')
239
+ return false;
240
+ return matcher.ignores(rel);
232
241
  }
233
242
  //# sourceMappingURL=config.js.map
package/dist/index.js CHANGED
@@ -216,10 +216,17 @@ function enableHelpAfterError(cmd) {
216
216
  enableHelpAfterError(sub);
217
217
  }
218
218
  enableHelpAfterError(program);
219
- // Auto-fetch related skill docs when --help is run on mapped commands
219
+ // Auto-fetch related skill docs when --help is run on a doc-bearing TOP-LEVEL
220
+ // command (e.g. `gipity fn --help`, `gipity db --help`). It must NOT fire for a
221
+ // subcommand's help: `gipity db query --help` should render commander's own
222
+ // usage for `db query`, not the parent's help plus a skill doc. So only trigger
223
+ // when the first token is a mapped command and nothing after it is a subcommand
224
+ // (every remaining token is a flag).
220
225
  const argv = process.argv.slice(2);
221
226
  const hasHelp = argv.includes('--help') || argv.includes('-h');
222
- const mappedCmd = hasHelp ? argv.find(a => a in HELP_SKILL_MAP) : undefined;
227
+ const topCmd = argv[0];
228
+ const targetsTopCmdOnly = argv.slice(1).every(a => a.startsWith('-'));
229
+ const mappedCmd = hasHelp && targetsTopCmdOnly && topCmd in HELP_SKILL_MAP ? topCmd : undefined;
223
230
  if (mappedCmd) {
224
231
  const cmdObj = program.commands.find(c => c.name() === mappedCmd);
225
232
  if (cmdObj) {
package/dist/knowledge.js CHANGED
@@ -92,6 +92,8 @@ Run \`gipity --help\` for the full list. Use \`--help\` on any command for detai
92
92
 
93
93
  Function return shape: \`gipity fn call\`, the in-test \`ctx.fn.call\`/\`callAs\`, and the client \`Gipity.fn\` all return your function's value **unwrapped** — read/assert \`result.field\`. Only raw HTTP/\`curl\` wraps it as \`{ data: ... }\`; never write \`result.data.field\` in a test.
94
94
 
95
+ Tests write to your real DB: \`gipity test\` runs the test code sandboxed, but \`ctx.fn.call\`/\`callAs\` hit your actual deployed functions, which write to the same project database the app reads from — rows a test creates persist and surface on the live page. Register \`ctx.cleanup(fn)\` in any write-test to delete what it made; the harness runs every cleanup after the suite (even on failure).
96
+
95
97
  ## Tool output is complete and synchronous
96
98
 
97
99
  Every tool call returns its full output with that call. There is no output buffer to flush. Never run no-op commands (echo, date, sleep, repeated reads) to "retrieve" or "flush" lagged output - if a result looks empty or delayed, treat it as the actual result and move on, or re-run the real command once.
@@ -130,6 +132,7 @@ App development skills:
130
132
 
131
133
  Kit skills (reusable building blocks - \`gipity add <kit>\`):
132
134
  - \`audio-align\` - the audio-align kit: forced alignment of audio + lyrics into word-level timing JSON
135
+ - \`chatbot\` - the chatbot kit: persona + scope guardrails + static knowledge, bubble widget or headless engine
133
136
 
134
137
  Other key skills:
135
138
  - \`sandbox-tools\` - cloud sandbox capabilities and pre-installed tools
package/dist/sync.js CHANGED
@@ -27,7 +27,7 @@ import { hostname } from 'os';
27
27
  import { get, del, downloadStream, ApiError } from './api.js';
28
28
  import { requireConfig, shouldIgnore, getConfigPath } from './config.js';
29
29
  import { formatSize, prompt, getAutoConfirm } from './utils.js';
30
- import { uploadOneFile, hashFile, UploadConflictError } from './upload.js';
30
+ import { uploadOneFile, hashFile, guessMime, transferToS3, uploadInitBatch, uploadCompleteBatch, UploadConflictError, UPLOAD_CONCURRENCY, UPLOAD_INIT_BATCH_SIZE, UPLOAD_MAX_BYTES, UPLOAD_MAX_PATH_CHARS, } from './upload.js';
31
31
  import { DEFAULT_SYNC_IGNORE } from './setup.js';
32
32
  const CONFIG_FILE = '.gipity.json';
33
33
  import * as tar from 'tar-stream';
@@ -38,7 +38,6 @@ 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
- const UPLOAD_CONCURRENCY = 4;
42
41
  // ─── Paths ─────────────────────────────────────────────────────
43
42
  function syncStatePath() {
44
43
  const configPath = getConfigPath();
@@ -497,20 +496,6 @@ async function bulkDeleteGuard(p, knownFiles, opts) {
497
496
  const answer = await prompt(`\nPlan deletes ${totalDeletes} files (${Math.round(fraction * 100)}% of the tree). Type "delete" to confirm: `);
498
497
  return answer.trim().toLowerCase() === 'delete';
499
498
  }
500
- async function applyUpload(projectGuid, root, a, onConflict) {
501
- try {
502
- const result = await uploadOneFile(projectGuid, join(root, a.path), a.path, {
503
- expectedServerVersion: a.expectedServerVersion,
504
- });
505
- return { ...a, reason: `uploaded serverVersion=${result.serverVersion}` };
506
- }
507
- catch (err) {
508
- if (err instanceof UploadConflictError) {
509
- return onConflict(a.path, err.currentServerVersion);
510
- }
511
- throw err;
512
- }
513
- }
514
499
  /** Name of the optional per-project ignore file (gitignore-style: one pattern
515
500
  * per line, blank lines and `#` comments skipped). Patterns use the same
516
501
  * matcher as the config `ignore` list (see shouldIgnore) and let research
@@ -720,9 +705,14 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
720
705
  }
721
706
  applied++;
722
707
  }
723
- // Uploads: bounded concurrency. On 409, rewrite as a conflict inline.
724
- // A single byte bar tracks the whole batch (workers share the counter; JS is
725
- // single-threaded so the += is race-free).
708
+ // Uploads: batched. Each chunk of UPLOAD_INIT_BATCH_SIZE files costs one
709
+ // upload-init-batch call (the server answers per file: already-have-it /
710
+ // conflict / presigned URL), then the S3 PUTs run UPLOAD_CONCURRENCY wide,
711
+ // then one upload-complete-batch call registers everything that landed.
712
+ // A single byte bar tracks the whole run (workers share the counter; JS is
713
+ // single-threaded so the += is race-free). Files the server already has -
714
+ // identical content at the same path, or dedup-linked from another path -
715
+ // transfer nothing but still count their bytes, so the bar reaches 100%.
726
716
  const uploadLabel = `Uploading ${uploadQueue.length} file${uploadQueue.length === 1 ? '' : 's'}`;
727
717
  const totalUploadBytes = uploadQueue.reduce((sum, a) => sum + (a.localSize ?? 0), 0);
728
718
  let sentBytes = 0;
@@ -731,90 +721,190 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
731
721
  const onBytes = p
732
722
  ? (delta) => { sentBytes += delta; p.transfer(uploadLabel, sentBytes, totalUploadBytes); }
733
723
  : undefined;
734
- let cursor = 0;
735
- const workers = [];
736
- for (let w = 0; w < Math.min(UPLOAD_CONCURRENCY, uploadQueue.length); w++) {
737
- workers.push((async () => {
738
- while (true) {
739
- const idx = cursor++;
740
- if (idx >= uploadQueue.length)
741
- return;
742
- const a = uploadQueue[idx];
743
- let full;
744
- try {
745
- full = resolveInRoot(root, a.path);
746
- }
747
- catch (e) {
748
- errors.push(e.message);
724
+ // Conflict downgrade shared by init-time and complete-time CAS rejections:
725
+ // remote moved under us, so remote wins the canonical path - rename local,
726
+ // restore the server copy, re-upload the rename as a brand-new path.
727
+ const downgradeToConflict = async (a, full, currentServerVersion) => {
728
+ const currentBytes = await fetchOne(config.projectGuid, a.path);
729
+ const renamedRel = conflictedCopyName(a.path);
730
+ let renamedFull;
731
+ try {
732
+ renamedFull = resolveInRoot(root, renamedRel);
733
+ }
734
+ catch (e) {
735
+ errors.push(e.message);
736
+ return;
737
+ }
738
+ try {
739
+ renameSync(full, renamedFull);
740
+ }
741
+ catch (e) {
742
+ errors.push(`Rename failed for ${a.path}: ${e.message}`);
743
+ return;
744
+ }
745
+ if (currentBytes) {
746
+ mkdirSync(dirname(full), { recursive: true });
747
+ writeFileSync(full, currentBytes);
748
+ const stat = statSync(full);
749
+ baseline.files[a.path] = {
750
+ size: stat.size, mtime: stat.mtime.toISOString(),
751
+ sha256: '', // will re-hash on next sync
752
+ serverVersion: currentServerVersion ?? 0,
753
+ };
754
+ }
755
+ try {
756
+ const result = await uploadOneFile(config.projectGuid, renamedFull, renamedRel, { expectedServerVersion: null });
757
+ const stat = statSync(renamedFull);
758
+ const { sha256 } = await hashFile(renamedFull);
759
+ baseline.files[renamedRel] = {
760
+ size: stat.size, mtime: stat.mtime.toISOString(),
761
+ sha256, serverVersion: result.serverVersion,
762
+ };
763
+ }
764
+ catch (e) {
765
+ errors.push(`Conflict-copy upload failed for ${renamedRel}: ${e.message}`);
766
+ }
767
+ applied++;
768
+ };
769
+ for (let chunkStart = 0; chunkStart < uploadQueue.length; chunkStart += UPLOAD_INIT_BATCH_SIZE) {
770
+ const chunk = uploadQueue.slice(chunkStart, chunkStart + UPLOAD_INIT_BATCH_SIZE);
771
+ // Stat + hash once per file; the same numbers feed init, the baseline,
772
+ // and the bar. Files that vanished or escaped the root drop out here.
773
+ const prepared = [];
774
+ for (const a of chunk) {
775
+ if (a.path.length > UPLOAD_MAX_PATH_CHARS) {
776
+ errors.push(`Upload failed for ${a.path}: path exceeds ${UPLOAD_MAX_PATH_CHARS} characters`);
777
+ onBytes?.(a.localSize ?? 0);
778
+ continue;
779
+ }
780
+ let full;
781
+ try {
782
+ full = resolveInRoot(root, a.path);
783
+ }
784
+ catch (e) {
785
+ errors.push(e.message);
786
+ onBytes?.(a.localSize ?? 0);
787
+ continue;
788
+ }
789
+ try {
790
+ const stat = statSync(full);
791
+ if (stat.size > UPLOAD_MAX_BYTES) {
792
+ errors.push(`Upload failed for ${a.path}: file exceeds the 30 GB upload limit`);
793
+ onBytes?.(a.localSize ?? 0);
749
794
  continue;
750
795
  }
751
- try {
752
- const result = await uploadOneFile(config.projectGuid, full, a.path, {
753
- expectedServerVersion: a.expectedServerVersion,
754
- onBytes,
755
- });
756
- const stat = statSync(full);
757
- const { sha256 } = await hashFile(full);
758
- baseline.files[a.path] = {
759
- size: stat.size, mtime: stat.mtime.toISOString(),
760
- sha256, serverVersion: result.serverVersion,
796
+ const sha256 = local.get(a.path)?.sha256 ?? (await hashFile(full)).sha256;
797
+ prepared.push({ a, full, size: stat.size, mtime: stat.mtime.toISOString(), sha256 });
798
+ }
799
+ catch (e) {
800
+ errors.push(`Upload failed for ${a.path}: ${e.message}`);
801
+ onBytes?.(a.localSize ?? 0);
802
+ }
803
+ }
804
+ if (!prepared.length)
805
+ continue;
806
+ let initResults;
807
+ try {
808
+ initResults = await uploadInitBatch(config.projectGuid, prepared.map(pr => ({
809
+ path: pr.a.path, size: pr.size, sha256: pr.sha256, mime: guessMime(pr.a.path),
810
+ ...(pr.a.expectedServerVersion !== undefined
811
+ ? { expected_server_version: pr.a.expectedServerVersion } : {}),
812
+ })));
813
+ }
814
+ catch (e) {
815
+ errors.push(`Upload batch failed: ${e.message}`);
816
+ for (const pr of prepared)
817
+ onBytes?.(pr.size);
818
+ continue;
819
+ }
820
+ const byPath = new Map(prepared.map(pr => [pr.a.path, pr]));
821
+ const conflicted = [];
822
+ const ready = [];
823
+ for (const r of initResults) {
824
+ const pr = byPath.get(r.path);
825
+ if (!pr)
826
+ continue;
827
+ switch (r.status) {
828
+ case 'already_current':
829
+ baseline.files[pr.a.path] = {
830
+ size: pr.size, mtime: pr.mtime, sha256: pr.sha256, serverVersion: r.server_version,
761
831
  };
832
+ onBytes?.(pr.size);
762
833
  applied++;
834
+ break;
835
+ case 'conflict':
836
+ // No PUT happens for a file rejected at init - account its bytes now
837
+ // so the bar still reaches 100% (the conflict copy is extra work
838
+ // outside the byte budget). Complete-time conflicts differ: their PUT
839
+ // already reported the bytes, so that branch must NOT re-count.
840
+ onBytes?.(pr.size);
841
+ conflicted.push({ pr, current: r.current_server_version });
842
+ break;
843
+ case 'error':
844
+ errors.push(`Upload failed for ${r.path}: ${r.message}`);
845
+ onBytes?.(pr.size);
846
+ break;
847
+ default:
848
+ ready.push({ pr, init: r });
849
+ }
850
+ }
851
+ // S3 PUTs, UPLOAD_CONCURRENCY wide; collect upload-complete items as they land.
852
+ const toComplete = [];
853
+ let cursor = 0;
854
+ const workers = [];
855
+ for (let w = 0; w < Math.min(UPLOAD_CONCURRENCY, ready.length); w++) {
856
+ workers.push((async () => {
857
+ while (true) {
858
+ const idx = cursor++;
859
+ if (idx >= ready.length)
860
+ return;
861
+ const { pr, init } = ready[idx];
862
+ try {
863
+ const fields = await transferToS3(pr.full, pr.size, guessMime(pr.a.path), init, { onBytes });
864
+ toComplete.push({ pr, item: {
865
+ upload_guid: init.upload_guid, ...fields,
866
+ ...(pr.a.expectedServerVersion !== undefined
867
+ ? { expected_server_version: pr.a.expectedServerVersion } : {}),
868
+ } });
869
+ }
870
+ catch (err) {
871
+ errors.push(`Upload failed for ${pr.a.path}: ${err.message}`);
872
+ }
763
873
  }
764
- catch (err) {
765
- if (err instanceof UploadConflictError) {
766
- // Remote moved under us. Fetch the current remote bytes and
767
- // downgrade this path to a conflict: rename local, write remote,
768
- // re-upload the rename.
769
- const currentBytes = await fetchOne(config.projectGuid, a.path);
770
- const renamedRel = conflictedCopyName(a.path);
771
- let renamedFull;
772
- try {
773
- renamedFull = resolveInRoot(root, renamedRel);
774
- }
775
- catch (e) {
776
- errors.push(e.message);
777
- continue;
778
- }
779
- try {
780
- renameSync(full, renamedFull);
781
- }
782
- catch (e) {
783
- errors.push(`Rename failed for ${a.path}: ${e.message}`);
784
- continue;
785
- }
786
- if (currentBytes) {
787
- mkdirSync(dirname(full), { recursive: true });
788
- writeFileSync(full, currentBytes);
789
- const stat = statSync(full);
790
- baseline.files[a.path] = {
791
- size: stat.size, mtime: stat.mtime.toISOString(),
792
- sha256: '', // will re-hash on next sync
793
- serverVersion: err.currentServerVersion ?? 0,
794
- };
795
- }
796
- try {
797
- const result = await uploadOneFile(config.projectGuid, renamedFull, renamedRel, { expectedServerVersion: null });
798
- const stat = statSync(renamedFull);
799
- const { sha256 } = await hashFile(renamedFull);
800
- baseline.files[renamedRel] = {
801
- size: stat.size, mtime: stat.mtime.toISOString(),
802
- sha256, serverVersion: result.serverVersion,
874
+ })());
875
+ }
876
+ await Promise.all(workers);
877
+ if (toComplete.length) {
878
+ const byGuid = new Map(toComplete.map(c => [c.item.upload_guid, c.pr]));
879
+ try {
880
+ for (const r of await uploadCompleteBatch(config.projectGuid, toComplete.map(c => c.item))) {
881
+ const pr = byGuid.get(r.upload_guid);
882
+ if (!pr)
883
+ continue;
884
+ switch (r.status) {
885
+ case 'completed':
886
+ baseline.files[pr.a.path] = {
887
+ size: pr.size, mtime: pr.mtime, sha256: pr.sha256, serverVersion: r.server_version,
803
888
  };
804
- }
805
- catch (e) {
806
- errors.push(`Conflict-copy upload failed for ${renamedRel}: ${e.message}`);
807
- }
808
- applied++;
809
- }
810
- else {
811
- errors.push(`Upload failed for ${a.path}: ${err.message}`);
889
+ applied++;
890
+ break;
891
+ case 'conflict':
892
+ conflicted.push({ pr, current: r.current_server_version });
893
+ break;
894
+ default:
895
+ errors.push(`Upload failed for ${pr.a.path}: ${r.message}`);
812
896
  }
813
897
  }
814
898
  }
815
- })());
899
+ catch (e) {
900
+ errors.push(`Upload batch failed: ${e.message}`);
901
+ }
902
+ }
903
+ // Conflict downgrades are rare - handle them one at a time.
904
+ for (const { pr, current } of conflicted) {
905
+ await downgradeToConflict(pr.a, pr.full, current);
906
+ }
816
907
  }
817
- await Promise.all(workers);
818
908
  // ── Deletes pass ──
819
909
  for (const a of plannedToApply) {
820
910
  if (a.kind === 'delete-local') {
@@ -22,7 +22,10 @@ export function bootstrap(version, quiet = false) {
22
22
  }
23
23
  if (!quiet)
24
24
  process.stderr.write(`Setting up gipity local install at ~/.gipity/local (one-time)...\n`);
25
- const res = spawnSync('npm', ['install', '--no-audit', '--no-fund', `gipity@${version}`], {
25
+ // --ignore-scripts: don't run install lifecycle hooks (gipity ships
26
+ // precompiled, deps need no build), so a compromised package can't execute
27
+ // code during this self-managed install.
28
+ const res = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts', `gipity@${version}`], {
26
29
  cwd: LOCAL_DIR,
27
30
  stdio: ['ignore', 'pipe', 'pipe'],
28
31
  encoding: 'utf-8',
@@ -34,7 +34,10 @@ async function fetchLatestVersion() {
34
34
  return json.version;
35
35
  }
36
36
  function installVersion(version) {
37
- const res = spawnSync('npm', ['install', '--silent', '--no-audit', '--no-fund', `gipity@${version}`], {
37
+ // --ignore-scripts: this runs unattended in the background, so don't let a
38
+ // compromised package's install lifecycle hooks execute. gipity ships
39
+ // precompiled (dist/) and its deps need no build step, so nothing is lost.
40
+ const res = spawnSync('npm', ['install', '--silent', '--no-audit', '--no-fund', '--ignore-scripts', `gipity@${version}`], {
38
41
  cwd: LOCAL_DIR,
39
42
  stdio: 'ignore',
40
43
  });
package/dist/upload.js CHANGED
@@ -3,8 +3,20 @@ import { extname } from 'path';
3
3
  import { createHash } from 'crypto';
4
4
  import { post, putToPresignedUrl, ApiError } from './api.js';
5
5
  // Concurrency: parallel files in a batch + parallel parts within one multipart file.
6
- export const UPLOAD_CONCURRENCY = 4;
6
+ // File-level parallelism is high because most files are small: the cost of a
7
+ // small file is round-trip latency, not bandwidth, so wider = proportionally
8
+ // faster. S3 PUTs go direct to S3; the API only sees batched init/complete.
9
+ export const UPLOAD_CONCURRENCY = 16;
7
10
  const MULTIPART_PART_CONCURRENCY = 4;
11
+ /** Files per upload-init-batch / upload-complete-batch call. Must not exceed
12
+ * the server's UPLOAD_BATCH_MAX_ITEMS (200). */
13
+ export const UPLOAD_INIT_BATCH_SIZE = 100;
14
+ // Server-side per-file caps (PRESIGNED_UPLOAD_MAX_BYTES and the upload-init
15
+ // path length in platform). The batch routes Zod-validate the whole array, so
16
+ // one over-limit file would 400 its entire chunk - pre-check per file instead
17
+ // and fail just that file, matching the old single-endpoint behavior.
18
+ export const UPLOAD_MAX_BYTES = 30 * 1024 * 1024 * 1024;
19
+ export const UPLOAD_MAX_PATH_CHARS = 1000;
8
20
  // Keep in sync with server's guessMime in platform/server/src/services/vfs/path-helpers.ts
9
21
  const MIME_BY_EXT = {
10
22
  '.html': 'text/html', '.htm': 'text/html',
@@ -118,14 +130,44 @@ export async function uploadOneFile(projectGuid, localPath, virtualPath, opts =
118
130
  throw err;
119
131
  }
120
132
  const data = init.data;
121
- // Skip-if-identical fast path.
133
+ // Skip-if-identical fast path. Count the bytes as "done" - the server has
134
+ // them - so a caller's progress bar still reaches 100%.
122
135
  if ('already_current' in data && data.already_current) {
136
+ opts.onBytes?.(size);
123
137
  return { status: 'skipped', size, guid: data.guid, serverVersion: data.server_version };
124
138
  }
125
- const completeBody = { upload_guid: data.upload_guid };
139
+ const fields = await transferToS3(localPath, size, mime, data, opts);
140
+ const completeBody = { upload_guid: data.upload_guid, ...fields };
126
141
  if (opts.expectedServerVersion !== undefined) {
127
142
  completeBody.expected_server_version = opts.expectedServerVersion;
128
143
  }
144
+ let comp;
145
+ try {
146
+ comp = await post(`/projects/${projectGuid}/files/upload-complete`, completeBody);
147
+ }
148
+ catch (err) {
149
+ if (err instanceof ApiError && err.statusCode === 409) {
150
+ const current = typeof err.data?.current_server_version === 'number'
151
+ ? err.data.current_server_version : null;
152
+ throw new UploadConflictError(current, virtualPath);
153
+ }
154
+ throw err;
155
+ }
156
+ return {
157
+ status: data.resumed ? 'resumed' : 'uploaded',
158
+ size: comp.data.size,
159
+ guid: comp.data.guid,
160
+ version: comp.data.version,
161
+ serverVersion: comp.data.server_version,
162
+ };
163
+ }
164
+ /**
165
+ * Move one file's bytes to S3 for an initialized upload: a single presigned
166
+ * PUT, or the multipart part fan-out (including server-driven resume).
167
+ * Returns the field upload-complete needs - `etag` for single-part, `parts`
168
+ * for multipart. Reports progress through opts.onBytes.
169
+ */
170
+ export async function transferToS3(localPath, size, mime, data, opts = {}) {
129
171
  // Single-part (covers fresh + resumed PUT - single PUT is idempotent on the staging key).
130
172
  if (data.method === 'PUT') {
131
173
  const etag = await withRetry('PUT', async () => {
@@ -133,26 +175,7 @@ export async function uploadOneFile(projectGuid, localPath, virtualPath, opts =
133
175
  return putToPresignedUrl(data.url, stream, size, data.headers?.['Content-Type'] ?? mime);
134
176
  });
135
177
  opts.onBytes?.(size);
136
- completeBody.etag = etag;
137
- let comp;
138
- try {
139
- comp = await post(`/projects/${projectGuid}/files/upload-complete`, completeBody);
140
- }
141
- catch (err) {
142
- if (err instanceof ApiError && err.statusCode === 409) {
143
- const current = typeof err.data?.current_server_version === 'number'
144
- ? err.data.current_server_version : null;
145
- throw new UploadConflictError(current, virtualPath);
146
- }
147
- throw err;
148
- }
149
- return {
150
- status: data.resumed ? 'resumed' : 'uploaded',
151
- size: comp.data.size,
152
- guid: comp.data.guid,
153
- version: comp.data.version,
154
- serverVersion: comp.data.server_version,
155
- };
178
+ return { etag };
156
179
  }
157
180
  // Multipart - start with any parts that already landed (resume case).
158
181
  const partSize = data.part_size;
@@ -193,25 +216,14 @@ export async function uploadOneFile(projectGuid, localPath, virtualPath, opts =
193
216
  }
194
217
  // Sort by part_number so server CompleteMultipartUpload sees ascending order.
195
218
  completed.sort((a, b) => a.part_number - b.part_number);
196
- completeBody.parts = completed;
197
- let comp;
198
- try {
199
- comp = await post(`/projects/${projectGuid}/files/upload-complete`, completeBody);
200
- }
201
- catch (err) {
202
- if (err instanceof ApiError && err.statusCode === 409) {
203
- const current = typeof err.data?.current_server_version === 'number'
204
- ? err.data.current_server_version : null;
205
- throw new UploadConflictError(current, virtualPath);
206
- }
207
- throw err;
208
- }
209
- return {
210
- status: data.resumed ? 'resumed' : 'uploaded',
211
- size: comp.data.size,
212
- guid: comp.data.guid,
213
- version: comp.data.version,
214
- serverVersion: comp.data.server_version,
215
- };
219
+ return { parts: completed };
220
+ }
221
+ export async function uploadInitBatch(projectGuid, files) {
222
+ const res = await post(`/projects/${projectGuid}/files/upload-init-batch`, { files });
223
+ return res.data.results;
224
+ }
225
+ export async function uploadCompleteBatch(projectGuid, items) {
226
+ const res = await post(`/projects/${projectGuid}/files/upload-complete-batch`, { items });
227
+ return res.data.results;
216
228
  }
217
229
  //# sourceMappingURL=upload.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.387",
3
+ "version": "1.0.389",
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",
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "commander": "^13.0.0",
21
+ "ignore": "^7.0.5",
21
22
  "tar-stream": "^3.1.8"
22
23
  },
23
24
  "devDependencies": {