gipity 1.0.420 → 1.0.424

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.
@@ -3,13 +3,13 @@ import { resolve } from 'path';
3
3
  import { pushFile } from '../sync.js';
4
4
  import { error as clrError, success } from '../colors.js';
5
5
  export const pushCommand = new Command('push')
6
- .description('Push a file')
7
- .argument('<file>', 'File path to push')
6
+ .description('Push one or more files')
7
+ .argument('<files...>', 'File path(s) to push')
8
8
  .option('--quiet', 'Suppress output')
9
9
  .option('--background', 'Fork and exit immediately')
10
- .action(async (file, opts) => {
10
+ .action(async (files, opts) => {
11
11
  try {
12
- const fullPath = resolve(file);
12
+ const fullPaths = files.map(f => resolve(f));
13
13
  if (opts.background) {
14
14
  // Detach a background `gipity push` and exit immediately. Goes through
15
15
  // spawnCommand (Node binary running our own entry script - no IPC
@@ -17,16 +17,21 @@ export const pushCommand = new Command('push')
17
17
  // `windowsHide: true` so the detached child never flashes a console
18
18
  // window on Windows.
19
19
  const { spawnCommand } = await import('../platform.js');
20
- const child = spawnCommand(process.execPath, [process.argv[1], 'push', fullPath, '--quiet'], {
20
+ const child = spawnCommand(process.execPath, [process.argv[1], 'push', ...fullPaths, '--quiet'], {
21
21
  detached: true,
22
22
  stdio: 'ignore',
23
23
  });
24
24
  child.unref();
25
25
  return;
26
26
  }
27
- await pushFile(fullPath);
27
+ // Sequential on purpose: pushFile takes the per-project sync lock, so
28
+ // parallel pushes would just contend on it. One process pushing N files
29
+ // still beats the old N processes × N lock acquisitions.
30
+ for (const fullPath of fullPaths) {
31
+ await pushFile(fullPath);
32
+ }
28
33
  if (!opts.quiet) {
29
- console.log(success(`Pushed ${file}`));
34
+ console.log(success(files.length === 1 ? `Pushed ${files[0]}` : `Pushed ${files.length} files`));
30
35
  }
31
36
  }
32
37
  catch (err) {
@@ -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';
@@ -34,19 +35,57 @@ const PIN_LANGUAGE_HELP = [
34
35
  ' gipity sandbox run --language bash "<code>" # explicit flag (js | py | bash)',
35
36
  ' gipity sandbox run --file script.sh # inferred from the file extension',
36
37
  ].join('\n');
38
+ // Words that open a code statement, not a command line. A single positional
39
+ // starting with one of these is a JS/Python snippet whose language we must not
40
+ // guess (see resolveLanguage) - everything here is either invalid in bash or,
41
+ // worse, VALID in bash with a different meaning (`export FOO=1`, `for ...`).
42
+ const CODE_OPENERS = new Set([
43
+ 'const', 'let', 'var', 'function', 'async', 'await', 'import', 'export',
44
+ 'console', 'require', 'print', 'def', 'class', 'lambda', 'from', 'return',
45
+ 'for', 'while', 'if', 'try', 'with',
46
+ ]);
47
+ /**
48
+ * True when a bare inline string is unmistakably a shell COMMAND LINE
49
+ * (`node tests/game.test.js`, `ls -la`, `ffmpeg -i in.mp4 out.gif`) rather
50
+ * than a code snippet. Deliberately conservative: one line, starts with a
51
+ * plain command word (no parens/quotes/operators in it), not a code opener,
52
+ * not an assignment (`x = 1` is Python-or-bash-ambiguous). Anything that
53
+ * fails these checks still goes through the explicit-pin error below.
54
+ */
55
+ export function looksLikeShellCommand(code) {
56
+ if (code.includes('\n'))
57
+ return false;
58
+ const m = /^([A-Za-z0-9_.~/-]+)(\s|$)/.exec(code.trim());
59
+ if (!m)
60
+ return false;
61
+ const token = m[1].toLowerCase();
62
+ if (CODE_OPENERS.has(token))
63
+ return false;
64
+ if (/^\s*[A-Za-z_][A-Za-z0-9_]*\s*=/.test(code))
65
+ return false; // assignment
66
+ // A lone bare word (`foo`) is as likely a typo as a command; require either
67
+ // arguments after the word or a path-shaped word (`./build.sh`, `bin/run`).
68
+ return /\s\S/.test(code.trim()) || token.includes('/');
69
+ }
37
70
  /**
38
71
  * Resolve the language from an explicit signal, or exit.
39
72
  *
40
- * Precedence: interpreter token > --language > --file extension.
73
+ * Precedence: interpreter token > --language > --file extension > the
74
+ * command-line heuristic above (bash).
41
75
  *
42
- * There is no default. js/python/bash are mutually exclusive, and plenty of
43
- * snippets parse as more than one of them (`x = 1`, `a[0]`, `foo()`), so any
44
- * default silently runs some fraction of input in the wrong interpreter. The old
45
- * behavior defaulted to JavaScript, which meant a shell one-liner ran as JS and
46
- * died with a Node `SyntaxError` at `/work/_run.js` - after paying for a project
47
- * sync and a server round trip. Failing here instead costs nothing and says what
48
- * to type. (`docs/skills/sandbox-tools.md` used to carry a hand-written "always
49
- * pin the language" warning to work around this; the CLI enforces it now.)
76
+ * Beyond that there is no default. js/python/bash are mutually exclusive, and
77
+ * plenty of snippets parse as more than one of them (`x = 1`, `a[0]`, `foo()`),
78
+ * so a blanket default silently runs some fraction of input in the wrong
79
+ * interpreter. The old behavior defaulted to JavaScript, which meant a shell
80
+ * one-liner ran as JS and died with a Node `SyntaxError` at `/work/_run.js` -
81
+ * after paying for a project sync and a server round trip. The one shape we DO
82
+ * default is the unambiguous command line (`gipity sandbox run "node
83
+ * tests/game.test.js"`): it was the single most common rejected invocation,
84
+ * the CLI had everything it needed to run it, and no code snippet matches the
85
+ * heuristic. Ambiguous snippets still fail here, costing nothing and saying
86
+ * what to type. (`docs/skills/sandbox-tools.md` used to carry a hand-written
87
+ * "always pin the language" warning to work around this; the CLI enforces it
88
+ * now.)
50
89
  */
51
90
  export function resolveLanguage(opts) {
52
91
  const explicit = opts.langFromInterp
@@ -67,6 +106,8 @@ export function resolveLanguage(opts) {
67
106
  console.error(dim(`Pass --language explicitly:\n gipity sandbox run --language py --file ${opts.filePath}`));
68
107
  process.exit(1);
69
108
  }
109
+ if (opts.inlineCode && looksLikeShellCommand(opts.inlineCode))
110
+ return 'bash';
70
111
  console.error(clrError('No language specified for inline code.'));
71
112
  console.error(dim(`Pin it one of three ways:\n${PIN_LANGUAGE_HELP}`));
72
113
  process.exit(1);
@@ -216,21 +257,33 @@ GCC/Rust).
216
257
  process.exit(1);
217
258
  }
218
259
  }
219
- // Language precedence: interpreter token > --language > file extension.
220
- // There is deliberately no fallback: resolveLanguage() exits when nothing
221
- // pinned one, rather than guessing. This runs BEFORE the project sync and the
222
- // server round trip below, so a missing language costs nothing but the message.
223
- const language = resolveLanguage({ langFromInterp, langOpt: opts.language, filePath });
260
+ // Language precedence: interpreter token > --language > file extension >
261
+ // unambiguous-command-line heuristic (bash). resolveLanguage() exits when
262
+ // nothing pins one and the input isn't command-shaped, rather than guessing.
263
+ // This runs BEFORE the project sync and the server round trip below, so a
264
+ // missing language costs nothing but the message.
265
+ const language = resolveLanguage({ langFromInterp, langOpt: opts.language, filePath, inlineCode });
224
266
  // Args are good - now it's worth resolving (and announcing) the project.
225
267
  const { config } = await resolveProjectContext();
226
268
  const timeout = parseInt(opts.timeout, 10);
227
269
  const cwd = resolveRelativeCwd();
270
+ // A scratch path is never synced, so it can never reach the VFS the sandbox
271
+ // mirrors from - `--input tmp/frame.png` would fail inside the container with
272
+ // a bare "no such file". Catch it here, where we can say why.
273
+ const scratchInputs = (opts.input ?? []).filter((p) => shouldIgnore(p.replace(/\\/g, '/').replace(/^\.\//, ''), SCRATCH_IGNORE));
274
+ if (scratchInputs.length) {
275
+ console.error(clrError(`Scratch paths are never mirrored into the sandbox: ${scratchInputs.join(', ')}`));
276
+ console.error(dim(` ${SCRATCH_IGNORE.join(', ')} are ignored by sync, so the sandbox never sees them.`));
277
+ console.error(dim(' Stage inputs at a real project path (src/, docs/, assets/) and delete them afterward.'));
278
+ process.exit(1);
279
+ }
228
280
  // Push local working-tree changes up before executing. The sandbox mirrors
229
281
  // the *server* (VFS), not the local cwd, so any input staged outside Claude's
230
282
  // Write/Edit auto-push hook - a Bash `cp`/`ffmpeg`/redirect, or any external
231
283
  // process - would otherwise be invisible to the run and the first invocation
232
284
  // would silently miss its inputs. Syncing first makes the auto-mirror reflect
233
- // local state regardless of how files got there ("no manual copy needed").
285
+ // local state however files got there - with the one exception of the scratch
286
+ // namespaces above, which sync ignores and so the mirror never carries.
234
287
  // Bidirectional + CAS, so it's a cheap manifest check when nothing changed.
235
288
  // Symmetric with the post-run pull below. Skip in one-off mode (no project).
236
289
  if (getConfigPath()) {
@@ -38,12 +38,25 @@ function printUsage(d) {
38
38
  row('Dedup saved', formatBytes(s.dedupSavedBytes), `${s.dedupedObjects.toLocaleString()} shared`);
39
39
  row('Billed', formatBytes(s.physicalBytes), 'versions kept, dedup applied');
40
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;
41
46
  console.log('');
42
- console.log(`${bold('By project')} ${muted('(live files only)')}`);
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)}`);
43
51
  for (const p of d.projects) {
44
52
  const name = p.projectName ?? '(no project)';
45
53
  row(name.length > 16 ? `${name.slice(0, 15)}…` : name, formatBytes(p.liveBytes), `${p.liveFiles.toLocaleString()} files`);
46
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
+ }
47
60
  }
48
61
  const r = d.versionRetention;
49
62
  console.log('');
@@ -178,6 +178,19 @@ export const testCommand = new Command('test')
178
178
  console.log(clrError(`Run failed: ${data.errorMessage}`));
179
179
  console.log('');
180
180
  }
181
+ // Failures recap. Results stream incrementally above, but agents usually
182
+ // read this output through a `tail` window, so anything printed early is
183
+ // lost - restate every failure (name + assertion message) right before
184
+ // the summary, where it's guaranteed to survive truncation from the top.
185
+ if (data.failed > 0) {
186
+ console.log(clrError('Failures:'));
187
+ for (const r of data.results.filter(r => r.status === 'failed')) {
188
+ const where = r.path ? `${r.path}/` : '';
189
+ console.log(` ${statusIcon('failed')} ${where}${r.name || '(unnamed test - the file may have crashed outside a test)'}`);
190
+ console.log(` ${clrError(r.error || `(no assertion message recorded - inspect with: gipity test status ${runGuid} --json)`)}`);
191
+ }
192
+ console.log('');
193
+ }
181
194
  // Summary
182
195
  const parts = [];
183
196
  if (data.passed > 0)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * sync.ts - Shared sync-before-action helper.
3
3
  */
4
- import { sync } from '../sync.js';
4
+ import { sync, isLocalTreeClean } from '../sync.js';
5
5
  import { muted, error as clrError } from '../colors.js';
6
6
  import { createProgressReporter } from '../progress.js';
7
7
  /**
@@ -15,6 +15,13 @@ import { createProgressReporter } from '../progress.js';
15
15
  export async function syncBeforeAction(opts) {
16
16
  if (opts.sync === false)
17
17
  return;
18
+ // Nothing changed locally since the last sync → nothing to push, and the
19
+ // action (deploy etc.) reads server-side state anyway. Skip the whole sync
20
+ // round trip. The probe is local-only stat checks and conservative: any
21
+ // doubt (size/mtime moved, never synced, no baseline) falls through to a
22
+ // real sync. `--force` always takes the full path.
23
+ if (!opts.force && isLocalTreeClean())
24
+ return;
18
25
  // Pass a progress reporter so a large pre-action upload shows the transfer
19
26
  // bar instead of a silent pause (the reporter is a no-op on non-TTY / when
20
27
  // piped, so JSON and headless output stay clean).