gipity 1.0.387 → 1.0.388
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 +18 -12
- package/dist/banner.js +11 -9
- package/dist/colors.js +109 -9
- package/dist/commands/approval.js +10 -0
- package/dist/commands/claude.js +4 -0
- package/dist/commands/fn.js +22 -1
- package/dist/commands/skill.js +27 -1
- package/dist/commands/test.js +10 -0
- package/dist/commands/workflow.js +57 -3
- package/dist/config.js +25 -16
- package/dist/index.js +9 -2
- package/dist/knowledge.js +3 -0
- package/dist/sync.js +183 -93
- package/dist/updater/bootstrap.js +4 -1
- package/dist/updater/check.js +4 -1
- package/dist/upload.js +55 -43
- package/package.json +2 -1
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
|
-
|
|
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
|
-
#
|
|
14
|
+
# macOS / Linux / WSL
|
|
15
|
+
curl -fsSL https://gipity.ai/install.sh | bash
|
|
15
16
|
|
|
16
|
-
#
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install -y nodejs
|
|
29
|
+
### Prefer npm
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
npm install -g gipity @anthropic-ai/claude-code
|
|
31
|
+
If you already have **Node.js 18+** you can install directly:
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
gipity
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g gipity
|
|
29
35
|
```
|
|
30
36
|
|
|
31
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
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 = (
|
|
80
|
-
const _dk = (
|
|
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 = (
|
|
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
|
-
//
|
|
6
|
-
|
|
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 (
|
|
90
|
+
export function makeFg(r, g, b) {
|
|
91
|
+
if (COLOR_LEVEL === 0)
|
|
12
92
|
return identity;
|
|
13
|
-
|
|
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 (
|
|
103
|
+
export function makeBg(r, g, b) {
|
|
104
|
+
if (COLOR_LEVEL === 0)
|
|
17
105
|
return identity;
|
|
18
|
-
|
|
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 (
|
|
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];
|
package/dist/commands/claude.js
CHANGED
|
@@ -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) => {
|
package/dist/commands/fn.js
CHANGED
|
@@ -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
|
package/dist/commands/skill.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/test.js
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
724
|
-
//
|
|
725
|
-
//
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
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',
|
package/dist/updater/check.js
CHANGED
|
@@ -34,7 +34,10 @@ async function fetchLatestVersion() {
|
|
|
34
34
|
return json.version;
|
|
35
35
|
}
|
|
36
36
|
function installVersion(version) {
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.388",
|
|
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": {
|