gipity 1.0.422 → 1.0.425

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.
@@ -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);
@@ -129,6 +169,11 @@ sandboxCommand
129
169
  .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')
130
170
  .option('--timeout <seconds>', 'Execution timeout in seconds', '30')
131
171
  .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])
172
+ // Commander maps a `--no-` prefixed flag to the un-prefixed camelCase name
173
+ // (`opts.syncOutput`); the explicit `[]` default stops commander's negated-
174
+ // boolean convention from defaulting it to `true`. Collected as an array so
175
+ // the flag is repeatable.
176
+ .option('--no-sync-output <glob>', 'Do not persist run outputs matching this glob back to the project (repeatable). For byproducts you want to inspect once but never keep, e.g. --no-sync-output "docs/preview*". Supports *, **, and dir/ prefixes.', (v, prev) => [...prev, v], [])
132
177
  .option('--json', 'Output as JSON')
133
178
  .addHelpText('after', `
134
179
  By default the whole project is auto-mirrored into /work/ (up to 1 GB) -
@@ -217,11 +262,19 @@ GCC/Rust).
217
262
  process.exit(1);
218
263
  }
219
264
  }
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 });
265
+ // Language precedence: interpreter token > --language > file extension >
266
+ // unambiguous-command-line heuristic (bash). resolveLanguage() exits when
267
+ // nothing pins one and the input isn't command-shaped, rather than guessing.
268
+ // This runs BEFORE the project sync and the server round trip below, so a
269
+ // missing language costs nothing but the message.
270
+ const language = resolveLanguage({ langFromInterp, langOpt: opts.language, filePath, inlineCode });
271
+ // Validate --no-sync-output globs while we're still pre-network: an empty
272
+ // pattern (e.g. a quoting mishap) would silently match nothing server-side.
273
+ const noSyncOutput = opts.syncOutput ?? [];
274
+ if (noSyncOutput.some((g) => !g.trim())) {
275
+ console.error(clrError('--no-sync-output requires a non-empty glob (e.g. --no-sync-output "docs/preview*")'));
276
+ process.exit(1);
277
+ }
225
278
  // Args are good - now it's worth resolving (and announcing) the project.
226
279
  const { config } = await resolveProjectContext();
227
280
  const timeout = parseInt(opts.timeout, 10);
@@ -256,6 +309,10 @@ GCC/Rust).
256
309
  timeout: isNaN(timeout) ? 30 : timeout,
257
310
  input_files: opts.input,
258
311
  cwd,
312
+ // The filter must run SERVER-side (in the output extractor): skipping
313
+ // only in the CLI would still write the files into project storage and
314
+ // make every later sync propose deleting them.
315
+ noSyncOutput: noSyncOutput.length ? noSyncOutput : undefined,
259
316
  });
260
317
  const res = opts.json
261
318
  ? await doRun()
@@ -292,6 +349,11 @@ GCC/Rust).
292
349
  for (const f of res.data.outputFiles)
293
350
  console.log(`${f}`);
294
351
  }
352
+ if (res.data.skippedOutputFiles && res.data.skippedOutputFiles.length > 0) {
353
+ console.log(dim('\nNot persisted (--no-sync-output):'));
354
+ for (const f of res.data.skippedOutputFiles)
355
+ console.log(dim(`${f}`));
356
+ }
295
357
  if (res.data.exitCode !== 0) {
296
358
  // No "did you mean another language?" hint is needed: the language is now
297
359
  // always something the caller pinned, never a silent default we chose.
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { get, post } from '../api.js';
3
- import { requireConfig } from '../config.js';
3
+ import { requireConfig, getProjectRoot } 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
6
  // Absolute poll ceiling - the server reaps stalled runs (~65 min) well before
@@ -15,6 +15,22 @@ function statusIcon(status) {
15
15
  return muted('→');
16
16
  return muted('?');
17
17
  }
18
+ /** When the runner finds nothing, list the test-looking files it deliberately
19
+ * skipped (kit tests under src/packages/, .spec.* files) so "no tests" never
20
+ * contradicts what's on disk, and point at the sandbox as the way to run
21
+ * frontend/kit module tests (cli#120/cli#121). */
22
+ function printSkippedCandidates(skipped) {
23
+ if (skipped.length === 0)
24
+ return;
25
+ console.log('');
26
+ console.log(muted(`Found ${skipped.length} test-looking file${skipped.length === 1 ? '' : 's'} that gipity test does not run:`));
27
+ for (const s of skipped.slice(0, 10)) {
28
+ console.log(muted(` ${s.path} (${s.reason})`));
29
+ }
30
+ if (skipped.length > 10)
31
+ console.log(muted(` ... and ${skipped.length - 10} more`));
32
+ console.log(muted('Run frontend/kit module tests in the sandbox: gipity sandbox run bash "node <file>"'));
33
+ }
18
34
  // Long-run hint for non-TTY runs: after this, print a one-time "not hung" + faster-path hint.
19
35
  const LONG_RUN_MS = 60000;
20
36
  async function pollTestStatus(projectGuid, runGuid, opts) {
@@ -169,15 +185,40 @@ export const testCommand = new Command('test')
169
185
  process.exit(1);
170
186
  }
171
187
  if (!filterPath && data.total === 0 && data.results.length === 0) {
172
- console.log(muted('No tests ran - this app has no tests/*.test.js files.'));
188
+ // Name the searched root: discovery is server-side over the synced
189
+ // project tree's tests/, never the shell cwd - an agent running from a
190
+ // subdirectory must not read this as "the project has no tests" for
191
+ // the wrong reason (cli#120).
192
+ const root = getProjectRoot();
193
+ console.log(muted(`No tests ran - no tests/*.test.js files under the project root${root ? ` (${root})` : ''}.`));
173
194
  console.log(muted('gipity test runs server-function tests only; a frontend-only app (no functions/) has nothing here to run.'));
174
195
  console.log(muted('Verify a frontend app by driving the live page: gipity page inspect <url> (or gipity page eval).'));
196
+ // Enumerate test-looking files the runner deliberately skips (kit
197
+ // tests, .spec.*) so this answer never contradicts what's on disk.
198
+ try {
199
+ const listRes = await get(`/projects/${config.projectGuid}/test/list`);
200
+ printSkippedCandidates(listRes.data.skipped ?? []);
201
+ }
202
+ catch { /* the skipped list is advisory - never fail the run over it */ }
175
203
  return;
176
204
  }
177
205
  if (data.status === 'failed' && data.errorMessage) {
178
206
  console.log(clrError(`Run failed: ${data.errorMessage}`));
179
207
  console.log('');
180
208
  }
209
+ // Failures recap. Results stream incrementally above, but agents usually
210
+ // read this output through a `tail` window, so anything printed early is
211
+ // lost - restate every failure (name + assertion message) right before
212
+ // the summary, where it's guaranteed to survive truncation from the top.
213
+ if (data.failed > 0) {
214
+ console.log(clrError('Failures:'));
215
+ for (const r of data.results.filter(r => r.status === 'failed')) {
216
+ const where = r.path ? `${r.path}/` : '';
217
+ console.log(` ${statusIcon('failed')} ${where}${r.name || '(unnamed test - the file may have crashed outside a test)'}`);
218
+ console.log(` ${clrError(r.error || `(no assertion message recorded - inspect with: gipity test status ${runGuid} --json)`)}`);
219
+ }
220
+ console.log('');
221
+ }
181
222
  // Summary
182
223
  const parts = [];
183
224
  if (data.passed > 0)
@@ -255,9 +296,17 @@ testCommand
255
296
  return;
256
297
  }
257
298
  if (total === 0) {
299
+ // Discovery is server-side over the synced project tree's tests/ -
300
+ // never the shell cwd. Say which root was searched so this can't be
301
+ // misread as a definitive project-level fact from a subdirectory
302
+ // (cli#120), and list any test-looking files deliberately skipped
303
+ // (kit tests, .spec.*) instead of asserting none exist (cli#121).
304
+ const root = getProjectRoot();
258
305
  console.log(muted(pathFilter
259
306
  ? `No test files matched filter: ${pathFilter}`
260
- : 'No test files found. Add *.test.js files under tests/.'));
307
+ : `No test files found under tests/ at the project root${root ? ` (${root})` : ''}. Add *.test.js files under tests/.`));
308
+ if (!pathFilter)
309
+ printSkippedCandidates(res.data.skipped ?? []);
261
310
  return;
262
311
  }
263
312
  console.log(bold(`Test files${pathFilter ? ` (filter: ${pathFilter})` : ''}: ${total}`));
@@ -29,6 +29,25 @@ 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
+ /** Print each step's status, tokens, model, error and output. A run line alone
33
+ * says a run finished, not what it did — without the steps you can't tell a
34
+ * workflow that wrote a row from one that silently skipped every step. */
35
+ function printStepRuns(steps, emptyNote) {
36
+ if (steps.length === 0) {
37
+ console.log(` ${muted(emptyNote)}`);
38
+ return;
39
+ }
40
+ for (const s of steps) {
41
+ const statusColor = s.status === 'completed' ? success : s.status === 'failed' ? clrError : muted;
42
+ const model = s.model_used ? ` ${muted(`[${s.model_used}]`)}` : '';
43
+ console.log(` ${s.step_order}. ${statusColor(s.status)} ${s.tokens_used ?? 0} tokens${model}`);
44
+ if (s.error_message)
45
+ console.log(` ${clrError(s.error_message)}`);
46
+ if (s.output_json !== null && s.output_json !== undefined) {
47
+ console.log(JSON.stringify(s.output_json, null, 2).split('\n').map(l => ` ${l}`).join('\n'));
48
+ }
49
+ }
50
+ }
32
51
  const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled']);
33
52
  const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
34
53
  /**
@@ -155,6 +174,10 @@ workflowCommand
155
174
  console.log(formatRunLine(r));
156
175
  if (r.error_message)
157
176
  console.log(` ${clrError(r.error_message)}`);
177
+ // The whole point of --wait is to see what the run did. The detail endpoint
178
+ // we just polled already carries the steps, so show them rather than make
179
+ // the caller re-query the database to find out whether anything happened.
180
+ printStepRuns(r.step_runs ?? [], '(no steps recorded)');
158
181
  }
159
182
  if (r.status !== 'completed')
160
183
  process.exit(1);
@@ -176,22 +199,7 @@ workflowCommand
176
199
  return;
177
200
  }
178
201
  console.log(formatRunLine(r));
179
- const steps = r.step_runs ?? [];
180
- if (steps.length === 0) {
181
- console.log(' (no steps recorded)');
182
- return;
183
- }
184
- for (const s of steps) {
185
- const statusColor = s.status === 'completed' ? success : s.status === 'failed' ? clrError : muted;
186
- const model = s.model_used ? ` ${muted(`[${s.model_used}]`)}` : '';
187
- console.log(` ${s.step_order}. ${statusColor(s.status)} ${s.tokens_used ?? 0} tokens${model}`);
188
- if (s.error_message)
189
- console.log(` ${clrError(s.error_message)}`);
190
- if (s.output_json !== null && s.output_json !== undefined) {
191
- const pretty = JSON.stringify(s.output_json, null, 2).split('\n').map(l => ` ${l}`).join('\n');
192
- console.log(pretty);
193
- }
194
- }
202
+ printStepRuns(r.step_runs ?? [], '(no steps recorded)');
195
203
  return;
196
204
  }
197
205
  const res = await get(`/workflows/${wf.short_guid}/runs`);
@@ -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).