gipity 1.0.422 → 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) {
@@ -35,19 +35,57 @@ const PIN_LANGUAGE_HELP = [
35
35
  ' gipity sandbox run --language bash "<code>" # explicit flag (js | py | bash)',
36
36
  ' gipity sandbox run --file script.sh # inferred from the file extension',
37
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
+ }
38
70
  /**
39
71
  * Resolve the language from an explicit signal, or exit.
40
72
  *
41
- * Precedence: interpreter token > --language > --file extension.
73
+ * Precedence: interpreter token > --language > --file extension > the
74
+ * command-line heuristic above (bash).
42
75
  *
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.)
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.)
51
89
  */
52
90
  export function resolveLanguage(opts) {
53
91
  const explicit = opts.langFromInterp
@@ -68,6 +106,8 @@ export function resolveLanguage(opts) {
68
106
  console.error(dim(`Pass --language explicitly:\n gipity sandbox run --language py --file ${opts.filePath}`));
69
107
  process.exit(1);
70
108
  }
109
+ if (opts.inlineCode && looksLikeShellCommand(opts.inlineCode))
110
+ return 'bash';
71
111
  console.error(clrError('No language specified for inline code.'));
72
112
  console.error(dim(`Pin it one of three ways:\n${PIN_LANGUAGE_HELP}`));
73
113
  process.exit(1);
@@ -217,11 +257,12 @@ GCC/Rust).
217
257
  process.exit(1);
218
258
  }
219
259
  }
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 });
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 });
225
266
  // Args are good - now it's worth resolving (and announcing) the project.
226
267
  const { config } = await resolveProjectContext();
227
268
  const timeout = parseInt(opts.timeout, 10);
@@ -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).