gipity 1.0.419 → 1.0.422
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 +2 -2
- package/dist/adopt-cwd.js +9 -6
- package/dist/commands/deploy.js +5 -5
- package/dist/commands/page-inspect.js +3 -0
- package/dist/commands/page-screenshot.js +50 -9
- package/dist/commands/sandbox.js +110 -28
- package/dist/commands/storage.js +56 -1
- package/dist/knowledge.js +1 -1
- package/dist/sync.js +40 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ gipity sync down # Pull remote changes
|
|
|
138
138
|
| `gipity chat <message>` | Send a message to your Gipity agent |
|
|
139
139
|
| `gipity db` | Query, list, create, or drop project databases |
|
|
140
140
|
| `gipity memory` | Read/write agent and project memory |
|
|
141
|
-
| `gipity sandbox run <code>` | Execute code in a sandboxed container |
|
|
141
|
+
| `gipity sandbox run <code> --lang <js\|py\|bash>` | Execute code in a sandboxed container (language is required) |
|
|
142
142
|
| `gipity project` | List, create, switch, or delete projects |
|
|
143
143
|
| `gipity agent` | List, create, switch, or configure agents |
|
|
144
144
|
| `gipity approval` | List, create, answer, or cancel pending approvals |
|
|
@@ -208,7 +208,7 @@ gipity memory write design_notes "use dark theme" --project
|
|
|
208
208
|
Run code in a sandboxed Docker container with no network access. JavaScript, Python, and Bash.
|
|
209
209
|
|
|
210
210
|
```bash
|
|
211
|
-
gipity sandbox run "console.log('Hello')"
|
|
211
|
+
gipity sandbox run "console.log('Hello')" --lang js
|
|
212
212
|
gipity sandbox run "import pandas; print(pandas.__version__)" --lang py
|
|
213
213
|
gipity sandbox run "echo hello" --lang bash
|
|
214
214
|
```
|
package/dist/adopt-cwd.js
CHANGED
|
@@ -175,16 +175,19 @@ export function formatCwdLabel(cwd) {
|
|
|
175
175
|
return norm;
|
|
176
176
|
return parts.slice(-2).join('/');
|
|
177
177
|
}
|
|
178
|
-
/** Format byte count handling KB/MB/GB. utils.formatSize tops out at MB,
|
|
179
|
-
* which prints "1024.0 MB" for the 1 GB refuse threshold.
|
|
178
|
+
/** Format byte count handling KB/MB/GB/TB. utils.formatSize tops out at MB,
|
|
179
|
+
* which prints "1024.0 MB" for the 1 GB refuse threshold. TB matters for
|
|
180
|
+
* storage quotas, where the top plan is a flat 1 TB. */
|
|
180
181
|
export function formatBytes(n) {
|
|
181
182
|
if (n < 1024)
|
|
182
183
|
return `${n} B`;
|
|
183
|
-
if (n < 1024
|
|
184
|
+
if (n < 1024 ** 2)
|
|
184
185
|
return `${(n / 1024).toFixed(1)} KB`;
|
|
185
|
-
if (n < 1024
|
|
186
|
-
return `${(n /
|
|
187
|
-
|
|
186
|
+
if (n < 1024 ** 3)
|
|
187
|
+
return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
|
188
|
+
if (n < 1024 ** 4)
|
|
189
|
+
return `${(n / 1024 ** 3).toFixed(2)} GB`;
|
|
190
|
+
return `${(n / 1024 ** 4).toFixed(2)} TB`;
|
|
188
191
|
}
|
|
189
192
|
/** Adopt `cwd` as a Gipity project. Mirrors `gipity init`'s flow:
|
|
190
193
|
* 1. Slug = slugify(basename(cwd)) - provided by caller (so caller can
|
package/dist/commands/deploy.js
CHANGED
|
@@ -80,11 +80,11 @@ export const deployCommand = new Command('deploy')
|
|
|
80
80
|
const failedPhases = d.phases?.filter(p => p.status === 'failed') ?? [];
|
|
81
81
|
if (failedPhases.length > 0) {
|
|
82
82
|
// The database phase can fail on the account-wide database cap, whose
|
|
83
|
-
// server message ("Maximum of N databases reached. Drop one
|
|
84
|
-
// names no command. The droppable databases live in OTHER
|
|
85
|
-
// the default project-scoped `gipity db list` shows nothing
|
|
86
|
-
// caller straight at the account-wide list + drop path so
|
|
87
|
-
// dead-end (or reach for raw DB access) to free a slot.
|
|
83
|
+
// server message ("Maximum of N databases reached (you have N). Drop one
|
|
84
|
+
// first.") names no command. The droppable databases live in OTHER
|
|
85
|
+
// projects, so the default project-scoped `gipity db list` shows nothing
|
|
86
|
+
// — point the caller straight at the account-wide list + drop path so
|
|
87
|
+
// they don't dead-end (or reach for raw DB access) to free a slot.
|
|
88
88
|
if (failedPhases.some(p => /databases? reached|database (cap|limit)/i.test(p.summary))) {
|
|
89
89
|
console.log('');
|
|
90
90
|
console.log(muted('Free a slot under the account database cap:'));
|
|
@@ -4,6 +4,7 @@ import { formatSize } from '../utils.js';
|
|
|
4
4
|
import { brand, bold, error as clrError, warning, muted, info } from '../colors.js';
|
|
5
5
|
import { run } from '../helpers/index.js';
|
|
6
6
|
import { capWaitMs } from './page-eval.js';
|
|
7
|
+
import { TOUCH_DEVICES as INSPECT_DEVICES, resolveTouchDevice } from './page-screenshot.js';
|
|
7
8
|
/** A console line is an error-level entry (page error or console.error). */
|
|
8
9
|
const isErrorLine = (line) => /^error:/i.test(line);
|
|
9
10
|
/** A message-less, cross-origin "Script error." The throwing <script> lacks
|
|
@@ -39,6 +40,7 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
39
40
|
.option('--no-truncate', 'Show full URLs instead of truncating long ones with middle-ellipsis')
|
|
40
41
|
.option('--all', 'Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail')
|
|
41
42
|
.option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps run headlessly (audio is a built-in tone, not real speech)')
|
|
43
|
+
.option('--device <name>', `Inspect as a real touch device: ${INSPECT_DEVICES.join(', ')}. Emulates touch events, mobile user-agent and DPR, so a touch-gated mobile layout is what gets inspected.`)
|
|
42
44
|
.option('--auth', 'Load the page signed in as you (your Gipity account), so pages behind a Sign-in-with-Gipity login are reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
|
|
43
45
|
// Hidden redirect: agents reach for `page inspect --screenshot`. We don't take
|
|
44
46
|
// an image here (`page screenshot` is the single path for that) — just point there.
|
|
@@ -60,6 +62,7 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
60
62
|
waitForSelector: opts.waitFor || undefined,
|
|
61
63
|
waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
|
|
62
64
|
fakeMedia: opts.fakeMedia || undefined,
|
|
65
|
+
device: opts.device ? resolveTouchDevice(opts.device) : undefined,
|
|
63
66
|
auth: opts.auth || undefined,
|
|
64
67
|
};
|
|
65
68
|
const res = await post(`/tools/browser/inspect`, inspectBody);
|
|
@@ -7,13 +7,20 @@ import { brand, bold, muted, success } from '../colors.js';
|
|
|
7
7
|
import { formatSize } from '../utils.js';
|
|
8
8
|
import { run } from '../helpers/index.js';
|
|
9
9
|
import { withSpinner } from '../progress.js';
|
|
10
|
+
/** `mobile` and `tablet` name a real handset/tablet rather than just a window size.
|
|
11
|
+
* The server emulates it end to end - mobile user-agent, DPR, and crucially TOUCH,
|
|
12
|
+
* so a page that gates its mobile UI on `'ontouchstart' in window` or
|
|
13
|
+
* `navigator.maxTouchPoints` renders that UI instead of its desktop layout. The
|
|
14
|
+
* dimensions here mirror the device's own metrics (reported in meta.json).
|
|
15
|
+
* `device` values must be ones the server's BROWSER_DEVICES accepts. */
|
|
10
16
|
const DEVICE_PRESETS = {
|
|
11
17
|
default: { width: 1280, height: 720 },
|
|
12
18
|
desktop: { width: 1920, height: 1080 },
|
|
13
19
|
laptop: { width: 1366, height: 768 },
|
|
14
|
-
tablet: { width:
|
|
15
|
-
mobile: { width:
|
|
20
|
+
tablet: { width: 820, height: 1180, deviceScaleFactor: 2, device: 'iPad' },
|
|
21
|
+
mobile: { width: 393, height: 852, deviceScaleFactor: 3, device: 'iPhone 15' },
|
|
16
22
|
};
|
|
23
|
+
const TOUCH_DEVICE_ALIASES = { mobile: 'iPhone 15', tablet: 'iPad' };
|
|
17
24
|
const LABEL_WIDTH = 18; // "Screenshot dims:" + trailing space
|
|
18
25
|
function label(text) {
|
|
19
26
|
return muted((text + ':').padEnd(LABEL_WIDTH));
|
|
@@ -77,14 +84,43 @@ function parseViewportString(s) {
|
|
|
77
84
|
}
|
|
78
85
|
return dpr ? { width, height, deviceScaleFactor: dpr } : { width, height };
|
|
79
86
|
}
|
|
87
|
+
/** Device metrics for the exact server device names, so `--device "Pixel 9"` works
|
|
88
|
+
* alongside the friendly `mobile`/`tablet` presets. Mirrors agent-browser's own
|
|
89
|
+
* descriptors; the server re-applies them, these are for filenames + meta. */
|
|
90
|
+
const EXACT_DEVICE_VIEWPORTS = {
|
|
91
|
+
'iPhone 15': { width: 393, height: 852, deviceScaleFactor: 3, device: 'iPhone 15' },
|
|
92
|
+
'iPhone 16': { width: 393, height: 852, deviceScaleFactor: 3, device: 'iPhone 16' },
|
|
93
|
+
'iPhone 16 Pro': { width: 402, height: 874, deviceScaleFactor: 3, device: 'iPhone 16 Pro' },
|
|
94
|
+
'iPhone 17': { width: 402, height: 874, deviceScaleFactor: 3, device: 'iPhone 17' },
|
|
95
|
+
'iPad': { width: 820, height: 1180, deviceScaleFactor: 2, device: 'iPad' },
|
|
96
|
+
'iPad Pro': { width: 1024, height: 1366, deviceScaleFactor: 2, device: 'iPad Pro' },
|
|
97
|
+
'Pixel 9': { width: 412, height: 923, deviceScaleFactor: 2.625, device: 'Pixel 9' },
|
|
98
|
+
'Galaxy S25': { width: 360, height: 800, deviceScaleFactor: 3, device: 'Galaxy S25' },
|
|
99
|
+
};
|
|
100
|
+
/** Emulatable devices the server accepts (its BROWSER_DEVICES). Shared with
|
|
101
|
+
* `page inspect` so both commands take the same names. */
|
|
102
|
+
export const TOUCH_DEVICES = Object.keys(EXACT_DEVICE_VIEWPORTS);
|
|
103
|
+
/** Case-insensitively resolve a device name or `mobile`/`tablet` alias to a server
|
|
104
|
+
* device name. Used by `page inspect`, which takes one device rather than viewports. */
|
|
105
|
+
export function resolveTouchDevice(name) {
|
|
106
|
+
const key = name.trim().toLowerCase();
|
|
107
|
+
if (TOUCH_DEVICE_ALIASES[key])
|
|
108
|
+
return TOUCH_DEVICE_ALIASES[key];
|
|
109
|
+
const exact = TOUCH_DEVICES.find((d) => d.toLowerCase() === key);
|
|
110
|
+
if (exact)
|
|
111
|
+
return exact;
|
|
112
|
+
throw new Error(`Unknown --device: "${name}" (known: mobile, tablet, ${TOUCH_DEVICES.join(', ')})`);
|
|
113
|
+
}
|
|
80
114
|
function resolveDevice(name) {
|
|
81
115
|
const key = name.trim().toLowerCase();
|
|
82
116
|
const preset = DEVICE_PRESETS[key];
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
if (preset)
|
|
118
|
+
return preset;
|
|
119
|
+
const exact = TOUCH_DEVICES.find((d) => d.toLowerCase() === key);
|
|
120
|
+
if (exact)
|
|
121
|
+
return EXACT_DEVICE_VIEWPORTS[exact];
|
|
122
|
+
const available = [...Object.keys(DEVICE_PRESETS), ...TOUCH_DEVICES].join(', ');
|
|
123
|
+
throw new Error(`Unknown --device preset: "${name}" (known: ${available})`);
|
|
88
124
|
}
|
|
89
125
|
function splitCsv(values) {
|
|
90
126
|
if (!values || values.length === 0)
|
|
@@ -104,7 +140,7 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
104
140
|
.option('--action <js>', 'Run JS in the page before capturing — e.g. click a button to enter a state ("document.getElementById(\'play\').click()"). Runs after the post-load delay, then settles again before the shot.')
|
|
105
141
|
.option('--full', 'Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.')
|
|
106
142
|
.option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
|
|
107
|
-
.option('--device <names>', `
|
|
143
|
+
.option('--device <names>', `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag). mobile/tablet emulate a real touch device — touch events, mobile user-agent, DPR — so touch-gated mobile UI actually renders.`, appendOption, [])
|
|
108
144
|
.option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)', appendOption, [])
|
|
109
145
|
.option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
|
|
110
146
|
.option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly (audio is a built-in tone, not real speech)')
|
|
@@ -194,6 +230,8 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
194
230
|
width: s.viewport.width,
|
|
195
231
|
height: s.viewport.height,
|
|
196
232
|
device_scale_factor: s.viewport.deviceScaleFactor,
|
|
233
|
+
...(s.viewport.device ? { device: s.viewport.device } : {}),
|
|
234
|
+
touch: !!s.viewport.touch,
|
|
197
235
|
},
|
|
198
236
|
width: s.width,
|
|
199
237
|
height: s.height,
|
|
@@ -215,6 +253,8 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
215
253
|
console.log(`${label('Web page status')} ${meta.status}`);
|
|
216
254
|
if (meta.performance)
|
|
217
255
|
console.log(`${label('Web page perf')} ${fmtPerformance(meta.performance)}`);
|
|
256
|
+
if (s.viewport.device)
|
|
257
|
+
console.log(`${label('Emulated device')} ${s.viewport.device} ${muted('(touch events, mobile user-agent)')}`);
|
|
218
258
|
const sizePart = formatSize(s.screenshotSizeBytes) + (meta.full ? ' (full page)' : '');
|
|
219
259
|
console.log(`${label('Screenshot size')} ${sizePart}`);
|
|
220
260
|
if (s.width && s.height)
|
|
@@ -234,7 +274,8 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
234
274
|
for (let i = 0; i < meta.screenshots.length; i++) {
|
|
235
275
|
const s = meta.screenshots[i];
|
|
236
276
|
const dims = `${s.viewport.width}×${s.viewport.height}${s.viewport.deviceScaleFactor > 1 ? ` @${s.viewport.deviceScaleFactor}x` : ''}`;
|
|
237
|
-
|
|
277
|
+
const deviceTag = s.viewport.device ? muted(` — ${s.viewport.device}, touch`) : '';
|
|
278
|
+
console.log(`\n${brand('@ ' + dims)}${deviceTag}`);
|
|
238
279
|
const sizePart = formatSize(s.screenshotSizeBytes) + (meta.full ? ' (full page)' : '');
|
|
239
280
|
console.log(`${label('Screenshot size')} ${sizePart}`);
|
|
240
281
|
if (s.width && s.height)
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -2,7 +2,8 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { readFileSync, existsSync, statSync } from 'fs';
|
|
3
3
|
import { dirname, extname, relative } from 'path';
|
|
4
4
|
import { post } from '../api.js';
|
|
5
|
-
import { resolveProjectContext, getConfigPath } from '../config.js';
|
|
5
|
+
import { resolveProjectContext, getConfigPath, shouldIgnore } from '../config.js';
|
|
6
|
+
import { SCRATCH_IGNORE } from '../setup.js';
|
|
6
7
|
import { sync } from '../sync.js';
|
|
7
8
|
import { error as clrError, dim } from '../colors.js';
|
|
8
9
|
import { run } from '../helpers/index.js';
|
|
@@ -28,6 +29,81 @@ const INTERPRETERS = {
|
|
|
28
29
|
bash: 'bash',
|
|
29
30
|
sh: 'bash',
|
|
30
31
|
};
|
|
32
|
+
/** The three ways to pin a language, shown whenever none was pinned. */
|
|
33
|
+
const PIN_LANGUAGE_HELP = [
|
|
34
|
+
' gipity sandbox run bash "<code>" # interpreter token (bash | python | node)',
|
|
35
|
+
' gipity sandbox run --language bash "<code>" # explicit flag (js | py | bash)',
|
|
36
|
+
' gipity sandbox run --file script.sh # inferred from the file extension',
|
|
37
|
+
].join('\n');
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the language from an explicit signal, or exit.
|
|
40
|
+
*
|
|
41
|
+
* Precedence: interpreter token > --language > --file extension.
|
|
42
|
+
*
|
|
43
|
+
* There is no default. js/python/bash are mutually exclusive, and plenty of
|
|
44
|
+
* snippets parse as more than one of them (`x = 1`, `a[0]`, `foo()`), so any
|
|
45
|
+
* default silently runs some fraction of input in the wrong interpreter. The old
|
|
46
|
+
* behavior defaulted to JavaScript, which meant a shell one-liner ran as JS and
|
|
47
|
+
* died with a Node `SyntaxError` at `/work/_run.js` - after paying for a project
|
|
48
|
+
* sync and a server round trip. Failing here instead costs nothing and says what
|
|
49
|
+
* to type. (`docs/skills/sandbox-tools.md` used to carry a hand-written "always
|
|
50
|
+
* pin the language" warning to work around this; the CLI enforces it now.)
|
|
51
|
+
*/
|
|
52
|
+
export function resolveLanguage(opts) {
|
|
53
|
+
const explicit = opts.langFromInterp
|
|
54
|
+
?? (opts.langOpt ? LANG_MAP[opts.langOpt.toLowerCase()] ?? opts.langOpt : undefined);
|
|
55
|
+
if (explicit && !['javascript', 'python', 'bash'].includes(explicit)) {
|
|
56
|
+
console.error(clrError(`Invalid language: ${opts.langOpt}. Use: js, py, or bash`));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
if (explicit)
|
|
60
|
+
return explicit;
|
|
61
|
+
const fromExt = opts.filePath
|
|
62
|
+
? LANG_MAP[extname(opts.filePath).slice(1).toLowerCase()]
|
|
63
|
+
: undefined;
|
|
64
|
+
if (fromExt)
|
|
65
|
+
return fromExt;
|
|
66
|
+
if (opts.filePath) {
|
|
67
|
+
console.error(clrError(`Cannot infer the language of ${opts.filePath} from its extension (expected .js, .py, or .sh).`));
|
|
68
|
+
console.error(dim(`Pass --language explicitly:\n gipity sandbox run --language py --file ${opts.filePath}`));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
console.error(clrError('No language specified for inline code.'));
|
|
72
|
+
console.error(dim(`Pin it one of three ways:\n${PIN_LANGUAGE_HELP}`));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
/** Truncate one arg for echoing back, so a 2 KB fragment doesn't flood the terminal. */
|
|
76
|
+
function preview(arg) {
|
|
77
|
+
const flat = arg.replace(/\s+/g, ' ').trim();
|
|
78
|
+
return flat.length > 60 ? `${flat.slice(0, 57)}...` : flat;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Inline code arrived as several positional args, which means the caller's shell
|
|
82
|
+
* split it before the CLI ever saw it. The old message ("Unrecognized
|
|
83
|
+
* invocation") just restated the rules and left the caller to guess which one it
|
|
84
|
+
* broke - an agent that hits this typically retries the same mangled quoting.
|
|
85
|
+
*
|
|
86
|
+
* So: show the fragments we actually received (the split point is the evidence),
|
|
87
|
+
* then name the usual cause. On PowerShell a double-quoted string interpolates
|
|
88
|
+
* `$(...)` and backslash is NOT an escape character, so a POSIX-style
|
|
89
|
+
* `"... $(cmd) ... \"$f\" ..."` one-liner both runs `cmd` locally and terminates
|
|
90
|
+
* the string early at `\"`. That is exactly how one quoted arg becomes many.
|
|
91
|
+
*/
|
|
92
|
+
function explainSplitArgs(args) {
|
|
93
|
+
const looksInterpolated = args.some(a => a.includes('$(') || a.includes('`'));
|
|
94
|
+
const lines = [
|
|
95
|
+
clrError(`Inline code must be a single argument, but ${args.length} were received:`),
|
|
96
|
+
...args.slice(0, 4).map((a, i) => dim(` ${i + 1}: ${preview(a)}`)),
|
|
97
|
+
...(args.length > 4 ? [dim(` ... and ${args.length - 4} more`)] : []),
|
|
98
|
+
'',
|
|
99
|
+
'Your shell split the code before the CLI saw it.',
|
|
100
|
+
];
|
|
101
|
+
if (looksInterpolated) {
|
|
102
|
+
lines.push(dim('In PowerShell a double-quoted string interpolates $(...), and backslash does not'), dim('escape a quote - so a POSIX one-liner runs its subshell locally and ends early.'));
|
|
103
|
+
}
|
|
104
|
+
lines.push('', 'Fix, in order of preference:', dim(' 1. Put the code in a file (best for anything with quotes, $(...), or newlines):'), ' gipity sandbox run --file script.sh', dim(' 2. Use the interpreter shorthand on a file:'), ' gipity sandbox run bash script.sh', dim(' 3. Keep it inline, but as ONE argument your shell will not split:'), " gipity sandbox run --language bash 'echo hi'");
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
|
31
107
|
/** Project-relative path from the process cwd, or undefined when there's
|
|
32
108
|
* no local config (one-off mode) or the cwd is at/above the project root. */
|
|
33
109
|
function resolveRelativeCwd() {
|
|
@@ -45,7 +121,11 @@ export const sandboxCommand = new Command('sandbox')
|
|
|
45
121
|
sandboxCommand
|
|
46
122
|
.command('run [args...]')
|
|
47
123
|
.description('Run code')
|
|
48
|
-
|
|
124
|
+
// No default. js/python/bash are mutually exclusive and the same snippet can
|
|
125
|
+
// parse as more than one of them, so an implicit default silently runs code in
|
|
126
|
+
// the wrong interpreter. Every invocation must pin the language via this flag,
|
|
127
|
+
// an interpreter token, or a --file extension - see resolveLanguage().
|
|
128
|
+
.option('--language <language>', 'Language: js, py, or bash (required unless pinned by an interpreter token or --file extension)')
|
|
49
129
|
.option('--file <path>', 'Read the code body from a file instead of the inline <code> arg; --language is inferred from the extension when not given')
|
|
50
130
|
.option('--timeout <seconds>', 'Execution timeout in seconds', '30')
|
|
51
131
|
.option('--input <path>', 'Narrow to specific project files instead of auto-mirroring the whole tree (repeatable). Use this only for >1 GB projects or when you want surgical control.', (v, prev) => [...(prev ?? []), v])
|
|
@@ -89,8 +169,12 @@ Pre-installed: Python (pandas, numpy, matplotlib, Pillow, scipy, bs4),
|
|
|
89
169
|
CLI tools (ImageMagick, FFmpeg, webp/cwebp, optipng, jq, pandoc, exiftool,
|
|
90
170
|
GCC/Rust).
|
|
91
171
|
`)
|
|
92
|
-
.action((args = [], opts
|
|
93
|
-
|
|
172
|
+
.action((args = [], opts) => run('Sandbox', async () => {
|
|
173
|
+
// Everything below this point is pure argument validation - it reads the local
|
|
174
|
+
// filesystem and nothing else. It runs BEFORE resolveProjectContext() so a
|
|
175
|
+
// malformed invocation fails on the spot instead of first paying a project
|
|
176
|
+
// lookup (and printing a misleading "→ (project: …)" banner) only to reject
|
|
177
|
+
// the args a moment later.
|
|
94
178
|
// Resolve the positional args into either inline code or a script-file path.
|
|
95
179
|
// `run <interpreter> <file>` (e.g. `run python build_report.py`) is the natural
|
|
96
180
|
// mental model, so accept it: a leading interpreter token + a path becomes
|
|
@@ -112,7 +196,7 @@ GCC/Rust).
|
|
|
112
196
|
inlineCode = args[0];
|
|
113
197
|
}
|
|
114
198
|
else if (args.length > 1) {
|
|
115
|
-
console.error(
|
|
199
|
+
console.error(explainSplitArgs(args));
|
|
116
200
|
process.exit(1);
|
|
117
201
|
}
|
|
118
202
|
if (inlineCode !== undefined && filePath) {
|
|
@@ -133,30 +217,32 @@ GCC/Rust).
|
|
|
133
217
|
process.exit(1);
|
|
134
218
|
}
|
|
135
219
|
}
|
|
136
|
-
// Language precedence: interpreter token > file extension
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// command shape pinned a language. Used to explain the execution mode if a
|
|
144
|
-
// shell/Python snippet gets parsed as JavaScript and blows up (see hint below).
|
|
145
|
-
const usedDefaultJs = !langFromInterp
|
|
146
|
-
&& command.getOptionValueSource('language') === 'default'
|
|
147
|
-
&& language === 'javascript';
|
|
148
|
-
if (!['javascript', 'python', 'bash'].includes(language)) {
|
|
149
|
-
console.error(clrError(`Invalid language: ${opts.language}. Use: js, py, or bash`));
|
|
150
|
-
process.exit(1);
|
|
151
|
-
}
|
|
220
|
+
// Language precedence: interpreter token > --language > file extension.
|
|
221
|
+
// There is deliberately no fallback: resolveLanguage() exits when nothing
|
|
222
|
+
// pinned one, rather than guessing. This runs BEFORE the project sync and the
|
|
223
|
+
// server round trip below, so a missing language costs nothing but the message.
|
|
224
|
+
const language = resolveLanguage({ langFromInterp, langOpt: opts.language, filePath });
|
|
225
|
+
// Args are good - now it's worth resolving (and announcing) the project.
|
|
226
|
+
const { config } = await resolveProjectContext();
|
|
152
227
|
const timeout = parseInt(opts.timeout, 10);
|
|
153
228
|
const cwd = resolveRelativeCwd();
|
|
229
|
+
// A scratch path is never synced, so it can never reach the VFS the sandbox
|
|
230
|
+
// mirrors from - `--input tmp/frame.png` would fail inside the container with
|
|
231
|
+
// a bare "no such file". Catch it here, where we can say why.
|
|
232
|
+
const scratchInputs = (opts.input ?? []).filter((p) => shouldIgnore(p.replace(/\\/g, '/').replace(/^\.\//, ''), SCRATCH_IGNORE));
|
|
233
|
+
if (scratchInputs.length) {
|
|
234
|
+
console.error(clrError(`Scratch paths are never mirrored into the sandbox: ${scratchInputs.join(', ')}`));
|
|
235
|
+
console.error(dim(` ${SCRATCH_IGNORE.join(', ')} are ignored by sync, so the sandbox never sees them.`));
|
|
236
|
+
console.error(dim(' Stage inputs at a real project path (src/, docs/, assets/) and delete them afterward.'));
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
154
239
|
// Push local working-tree changes up before executing. The sandbox mirrors
|
|
155
240
|
// the *server* (VFS), not the local cwd, so any input staged outside Claude's
|
|
156
241
|
// Write/Edit auto-push hook - a Bash `cp`/`ffmpeg`/redirect, or any external
|
|
157
242
|
// process - would otherwise be invisible to the run and the first invocation
|
|
158
243
|
// would silently miss its inputs. Syncing first makes the auto-mirror reflect
|
|
159
|
-
// local state
|
|
244
|
+
// local state however files got there - with the one exception of the scratch
|
|
245
|
+
// namespaces above, which sync ignores and so the mirror never carries.
|
|
160
246
|
// Bidirectional + CAS, so it's a cheap manifest check when nothing changed.
|
|
161
247
|
// Symmetric with the post-run pull below. Skip in one-off mode (no project).
|
|
162
248
|
if (getConfigPath()) {
|
|
@@ -207,12 +293,8 @@ GCC/Rust).
|
|
|
207
293
|
console.log(`${f}`);
|
|
208
294
|
}
|
|
209
295
|
if (res.data.exitCode !== 0) {
|
|
210
|
-
//
|
|
211
|
-
// always
|
|
212
|
-
// The raw Node stack trace never says which mode ran, so name it.
|
|
213
|
-
if (usedDefaultJs && /SyntaxError|cjs\/loader|wrapSafe/.test(res.data.stderr || '')) {
|
|
214
|
-
console.error(dim('Hint: ran as JavaScript (the default). For a shell command pass `--language bash` (or `gipity sandbox run bash "<cmd>"`); for Python pass `--language py`.'));
|
|
215
|
-
}
|
|
296
|
+
// No "did you mean another language?" hint is needed: the language is now
|
|
297
|
+
// always something the caller pinned, never a silent default we chose.
|
|
216
298
|
process.exit(res.data.exitCode);
|
|
217
299
|
}
|
|
218
300
|
}
|
package/dist/commands/storage.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { get, patch } from '../api.js';
|
|
3
|
-
import {
|
|
3
|
+
import { formatBytes } from '../adopt-cwd.js';
|
|
4
|
+
import { bold, brand, muted, warning } from '../colors.js';
|
|
4
5
|
import { run } from '../helpers/index.js';
|
|
5
6
|
/** Parse a CLI flag value as a positive whole number, or throw a friendly error.
|
|
6
7
|
* The server is the source of truth for the plan-cap bound; this just rejects
|
|
@@ -22,8 +23,62 @@ function printRetention(d, updated) {
|
|
|
22
23
|
console.log(` ${muted(`days: ${daysNote}, copies: ${countNote}`)}`);
|
|
23
24
|
console.log(muted(`Plan allows up to ${d.maxDays} days / ${d.maxCount} copies.`));
|
|
24
25
|
}
|
|
26
|
+
function row(label, value, note = '') {
|
|
27
|
+
console.log(` ${label.padEnd(16)} ${value.padStart(10)}${note ? ` ${muted(note)}` : ''}`);
|
|
28
|
+
}
|
|
29
|
+
function printUsage(d) {
|
|
30
|
+
const over = d.usedBytes > d.quotaBytes;
|
|
31
|
+
const pct = d.quotaBytes > 0 ? Math.round((d.usedBytes / d.quotaBytes) * 100) : 0;
|
|
32
|
+
const headline = `${formatBytes(d.usedBytes)} of ${formatBytes(d.quotaBytes)} used (${pct}%)`;
|
|
33
|
+
console.log(`Storage: ${over ? warning(headline) : brand(headline)}`);
|
|
34
|
+
console.log('');
|
|
35
|
+
const s = d.storage;
|
|
36
|
+
row('Live files', formatBytes(s.liveBytes), `${s.liveFiles.toLocaleString()} files`);
|
|
37
|
+
row('All versions', formatBytes(s.versionedBytes), `${s.versionCount.toLocaleString()} versions`);
|
|
38
|
+
row('Dedup saved', formatBytes(s.dedupSavedBytes), `${s.dedupedObjects.toLocaleString()} shared`);
|
|
39
|
+
row('Billed', formatBytes(s.physicalBytes), 'versions kept, dedup applied');
|
|
40
|
+
if (d.projects.length > 0) {
|
|
41
|
+
// The server returns only the biggest projects. Say so, and account for the
|
|
42
|
+
// rest - a list that looks exhaustive but isn't sends people hunting for
|
|
43
|
+
// space in the wrong place.
|
|
44
|
+
const totals = d.projectTotals;
|
|
45
|
+
const hidden = totals.count - d.projects.length;
|
|
46
|
+
console.log('');
|
|
47
|
+
const scope = hidden > 0
|
|
48
|
+
? `(top ${d.projects.length} of ${totals.count.toLocaleString()}, live files only)`
|
|
49
|
+
: '(live files only)';
|
|
50
|
+
console.log(`${bold('By project')} ${muted(scope)}`);
|
|
51
|
+
for (const p of d.projects) {
|
|
52
|
+
const name = p.projectName ?? '(no project)';
|
|
53
|
+
row(name.length > 16 ? `${name.slice(0, 15)}…` : name, formatBytes(p.liveBytes), `${p.liveFiles.toLocaleString()} files`);
|
|
54
|
+
}
|
|
55
|
+
if (hidden > 0) {
|
|
56
|
+
const shownBytes = d.projects.reduce((n, p) => n + p.liveBytes, 0);
|
|
57
|
+
const shownFiles = d.projects.reduce((n, p) => n + p.liveFiles, 0);
|
|
58
|
+
row(`…and ${hidden.toLocaleString()} more`, formatBytes(totals.liveBytes - shownBytes), `${(totals.liveFiles - shownFiles).toLocaleString()} files`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const r = d.versionRetention;
|
|
62
|
+
console.log('');
|
|
63
|
+
console.log(muted(`Version retention: ${r.days} days / ${r.count} copies (plan allows up to ${r.maxDays} / ${r.maxCount}).`));
|
|
64
|
+
if (over) {
|
|
65
|
+
console.log(warning('Over quota. Delete files you no longer need, or keep less history with `gipity storage retention`.'));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
25
68
|
export const storageCommand = new Command('storage')
|
|
26
69
|
.description('Manage file storage settings');
|
|
70
|
+
storageCommand
|
|
71
|
+
.command('usage')
|
|
72
|
+
.description('Show what is using your storage, per project and live vs. version history')
|
|
73
|
+
.option('--json', 'Output as JSON')
|
|
74
|
+
.action((opts) => run('Usage', async () => {
|
|
75
|
+
const res = await get('/users/me/storage');
|
|
76
|
+
if (opts.json) {
|
|
77
|
+
console.log(JSON.stringify(res.data));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
printUsage(res.data);
|
|
81
|
+
}));
|
|
27
82
|
storageCommand
|
|
28
83
|
.command('retention')
|
|
29
84
|
.description('View or adjust your file version-retention policy')
|
package/dist/knowledge.js
CHANGED
|
@@ -154,7 +154,7 @@ To keep local-only material (research clones, scratch data, vendored references)
|
|
|
154
154
|
Deploy is opt-in, not opt-out: the \`files\` phase uploads **only** what's under \`src/\` (plus \`functions/\` and \`migrations/\` as backend, not CDN files). Anything else at the project root is kept but never deployed. Put each kind of file in the right bucket so scratch and reference material can't bloat a deploy:
|
|
155
155
|
|
|
156
156
|
- **\`src/\`** - the app itself. Synced **and** deployed to the CDN. Only app code, assets, and pages belong here.
|
|
157
|
-
- **\`tmp/\`** - ephemeral scratch: file conversions, intermediate outputs, design staging. **Already ignored** (never synced, never deployed) - the one place to do throwaway work. Use this single root. (\`*_tmp/\` dirs and \`.gipityscratch/\` are auto-ignored too, as a safety net, so legacy scattered scratch like \`_vsd_tmp/\` can't leak - but write new scratch to \`tmp/\`, not scattered dirs.)
|
|
157
|
+
- **\`tmp/\`** - ephemeral scratch: file conversions, intermediate outputs, design staging. **Already ignored** (never synced, never deployed) - the one place to do throwaway work. Use this single root. (\`*_tmp/\` dirs and \`.gipityscratch/\` are auto-ignored too, as a safety net, so legacy scattered scratch like \`_vsd_tmp/\` can't leak - but write new scratch to \`tmp/\`, not scattered dirs.) Because it never syncs, \`tmp/\` is also never mirrored into the sandbox: **don't stage \`gipity sandbox run\` inputs here** - the sandbox won't see them. Stage inputs under \`src/\`/\`docs/\` and delete them after.
|
|
158
158
|
- **\`docs/\`** - reference material you want to keep: UI/architecture diagrams, design decks, notes, ADRs. Synced and versioned on the server (backed up, rollback-able) but **never deployed**, because it's outside \`src/\`. This is the home for "keep forever, don't ship" artifacts.
|
|
159
159
|
- **\`tests/\`** - \`*.test.js\` suites. Synced, run by \`gipity test\`, never deployed. \`gipity test list [path]\` lists the test files (and what a filter selects) without running them.
|
|
160
160
|
|
package/dist/sync.js
CHANGED
|
@@ -1179,6 +1179,9 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
|
|
|
1179
1179
|
// turns "files we failed to fetch" into "delete those files." Skip ALL deletes
|
|
1180
1180
|
// this run and let a clean sync replan them once the pull succeeds.
|
|
1181
1181
|
let deletesSkippedIncomplete = 0;
|
|
1182
|
+
// Directories that lost a file to a delete-local this run. Only these are
|
|
1183
|
+
// candidates for empty-dir cleanup — see cleanupEmptyDirs.
|
|
1184
|
+
const dirsTouchedByDelete = new Set();
|
|
1182
1185
|
for (const a of plannedToApply) {
|
|
1183
1186
|
if (downloadIncomplete && (a.kind === 'delete-local' || a.kind === 'delete-remote')) {
|
|
1184
1187
|
deletesSkippedIncomplete++;
|
|
@@ -1199,11 +1202,13 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
|
|
|
1199
1202
|
}
|
|
1200
1203
|
try {
|
|
1201
1204
|
unlinkSync(full);
|
|
1205
|
+
dirsTouchedByDelete.add(dirname(full));
|
|
1202
1206
|
delete baseline.files[a.path];
|
|
1203
1207
|
applied++;
|
|
1204
1208
|
}
|
|
1205
1209
|
catch (err) {
|
|
1206
1210
|
if (err.code === 'ENOENT') {
|
|
1211
|
+
dirsTouchedByDelete.add(dirname(full));
|
|
1207
1212
|
delete baseline.files[a.path];
|
|
1208
1213
|
applied++;
|
|
1209
1214
|
}
|
|
@@ -1273,8 +1278,8 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
|
|
|
1273
1278
|
errors.push(`Skipped ${deletesSkippedIncomplete} deletion${deletesSkippedIncomplete === 1 ? '' : 's'} because the download was incomplete - ` +
|
|
1274
1279
|
`nothing was deleted. Re-run \`gipity sync\` once the pull finishes to apply any real deletions.`);
|
|
1275
1280
|
}
|
|
1276
|
-
// Clean up
|
|
1277
|
-
cleanupEmptyDirs(root,
|
|
1281
|
+
// Clean up local directories emptied by this run's delete-local actions.
|
|
1282
|
+
cleanupEmptyDirs(root, dirsTouchedByDelete);
|
|
1278
1283
|
// Adopt agreed content-match state into the baseline, and prune stale entries.
|
|
1279
1284
|
// `plan()` is pure, so it emits these as lists and the mutation happens here.
|
|
1280
1285
|
// Adopting (FIX M1) makes sync convergent - a locally+remotely-identical file
|
|
@@ -1296,43 +1301,51 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
|
|
|
1296
1301
|
deferredDeletes: skippedByGuard,
|
|
1297
1302
|
};
|
|
1298
1303
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1304
|
+
/**
|
|
1305
|
+
* Remove directories that THIS run's delete-local actions left empty.
|
|
1306
|
+
*
|
|
1307
|
+
* Scoped to `emptiedDirs` on purpose. The previous version walked the whole
|
|
1308
|
+
* project tree and removed every empty directory it found, which also deleted
|
|
1309
|
+
* ones the user had just created: `mkdir -p src/images/monsters` followed a few
|
|
1310
|
+
* tool calls later by a write into it failed with ENOENT, because a full sync
|
|
1311
|
+
* had pruned the directory in between. The VFS stores files only, so a freshly
|
|
1312
|
+
* created empty directory has no remote counterpart and looked indistinguishable
|
|
1313
|
+
* from "deleted on the server."
|
|
1314
|
+
*
|
|
1315
|
+
* Pruning only where sync actually removed a file preserves the original intent
|
|
1316
|
+
* — don't leave an empty husk behind after a remote delete — while leaving every
|
|
1317
|
+
* other directory alone. An unrelated empty directory is now simply never
|
|
1318
|
+
* sync's business.
|
|
1319
|
+
*
|
|
1320
|
+
* Prunes upward, since removing `a/b/c` can leave `a/b` empty too. A directory
|
|
1321
|
+
* holding only sync-ignored entries is not empty on disk, so it survives — the
|
|
1322
|
+
* same outcome the old ignore-aware walk produced.
|
|
1323
|
+
*/
|
|
1324
|
+
function cleanupEmptyDirs(root, emptiedDirs) {
|
|
1325
|
+
const isEmpty = (dir) => {
|
|
1302
1326
|
try {
|
|
1303
|
-
|
|
1327
|
+
return readdirSync(dir).length === 0;
|
|
1304
1328
|
}
|
|
1305
1329
|
catch {
|
|
1306
1330
|
return false;
|
|
1307
1331
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
if (entry.isDirectory()) {
|
|
1317
|
-
if (!walk(full))
|
|
1318
|
-
kept++;
|
|
1319
|
-
}
|
|
1320
|
-
else {
|
|
1321
|
-
kept++;
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
if (kept === 0 && dir !== root) {
|
|
1332
|
+
};
|
|
1333
|
+
// `emptiedDirs` holds dirname()s of resolveInRoot() results, which are
|
|
1334
|
+
// lexically resolved — resolve the root the same way, or the containment
|
|
1335
|
+
// guard below never matches and nothing is ever pruned.
|
|
1336
|
+
const rootResolved = resolve(root);
|
|
1337
|
+
for (const start of emptiedDirs) {
|
|
1338
|
+
let dir = resolve(start);
|
|
1339
|
+
while (dir !== rootResolved && dir.startsWith(rootResolved + sep) && isEmpty(dir)) {
|
|
1325
1340
|
try {
|
|
1326
1341
|
rmdirSync(dir);
|
|
1327
|
-
return true;
|
|
1328
1342
|
}
|
|
1329
1343
|
catch {
|
|
1330
|
-
|
|
1344
|
+
break;
|
|
1331
1345
|
}
|
|
1346
|
+
dir = dirname(dir);
|
|
1332
1347
|
}
|
|
1333
|
-
return false;
|
|
1334
1348
|
}
|
|
1335
|
-
walk(root);
|
|
1336
1349
|
}
|
|
1337
1350
|
// ─── Single-file push (used by `gipity push <file>`) ───────────
|
|
1338
1351
|
export async function pushFile(filePath) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gipity",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.422",
|
|
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",
|