gipity 1.0.384 → 1.0.387

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.
@@ -18,12 +18,33 @@ async function inspectClient(url, waitMs, i) {
18
18
  }
19
19
  const MAX_HOLD_MS = 15_000; // keep each in-page await under the ~20s browser action timeout
20
20
  const MIN_HOLD_MS = 1_000;
21
- /** Splice per-client values into a user expression. `{{label}}` the client's
22
- * label, `{{i}}` → its 0-based index. Plain string replace (no regex) so the
23
- * expression's own characters are never treated as patterns. */
21
+ /** Splice per-client values into a user string (URL, --action, or --observe).
22
+ * `{{label}}` → the client's label, `{{i}}` → its 0-based index. Plain string
23
+ * replace (no regex) so the string's own characters are never treated as
24
+ * patterns. */
24
25
  function subst(expr, label, i) {
25
26
  return expr.split('{{label}}').join(label).split('{{i}}').join(String(i));
26
27
  }
28
+ /** Collect any `{{...}}` placeholders the runner does NOT recognize, so an
29
+ * invented token (e.g. `{{name}}`) is flagged instead of passing through
30
+ * verbatim into every client's URL/expression. */
31
+ function unknownTokens(...strings) {
32
+ const out = new Set();
33
+ for (const s of strings) {
34
+ for (const m of (s ?? '').match(/\{\{[^}]*\}\}/g) ?? []) {
35
+ if (m !== '{{i}}' && m !== '{{label}}')
36
+ out.add(m);
37
+ }
38
+ }
39
+ return [...out];
40
+ }
41
+ /** One-time warning (to stderr, so --json stdout stays clean) for
42
+ * unrecognized placeholders left as-is. */
43
+ function warnUnknownTokens(unknown) {
44
+ if (unknown.length === 0)
45
+ return;
46
+ console.error(warning(`⚠ Unrecognized placeholder ${unknown.join(', ')} left as-is — only {{i}} (0-based client index) and {{label}} are substituted per client. Set per-client values with --labels and reference them as {{label}}.`));
47
+ }
27
48
  /** Build the statement-body script one client runs: do the one-time action,
28
49
  * then sample `observe` `samples` times across `holdMs`, stamping in-page
29
50
  * start/end so the caller can confirm the clients overlapped. */
@@ -98,11 +119,22 @@ function fmtSamples(samples) {
98
119
  async function runInteractive(url, observe, opts) {
99
120
  const clients = Math.max(1, parseInt(opts.clients, 10) || 2);
100
121
  const stagger = opts.stagger != null ? Math.max(0, parseInt(opts.stagger, 10) || 0) : 0;
101
- const hold = Math.min(MAX_HOLD_MS, Math.max(MIN_HOLD_MS, parseInt(opts.hold, 10) || 8000));
122
+ const rawHold = parseInt(opts.hold, 10) || 8000;
123
+ const hold = Math.min(MAX_HOLD_MS, Math.max(MIN_HOLD_MS, rawHold));
124
+ if (rawHold > MAX_HOLD_MS) {
125
+ // Surface the clamp (to stderr, so --json stdout stays clean) instead of
126
+ // leaving the agent to infer it from the printed "hold Nms" line.
127
+ console.error(warning(`--hold ${rawHold}ms exceeds the ${MAX_HOLD_MS}ms per-client cap (each client samples inside one browser eval, bounded by the server's eval budget) — using ${MAX_HOLD_MS}ms. ` +
128
+ `Co-launch every role in this one command (put {{label}}/{{i}} in the URL) so all clients overlap for the whole window; a separately-started background client overlaps only the sliver of its window that lines up.`));
129
+ }
102
130
  const samples = Math.min(30, Math.max(2, parseInt(opts.samples, 10) || 6));
103
131
  const settle = opts.waitFor ? 200 : 1000;
104
132
  const labels = (opts.labels ? String(opts.labels).split(',').map((s) => s.trim()) : []).filter(Boolean);
105
133
  const labelFor = (i) => labels[i] ?? `client-${i}`;
134
+ // Only {{label}} and {{i}} are substituted. Warn once on any other {{token}}
135
+ // (a natural guess like {{name}} or {{index}}) so it isn't sent literally to
136
+ // every client — the silent wrong-behavior trap of identical clients.
137
+ warnUnknownTokens(unknownTokens(url, opts.action, observe));
106
138
  if (!opts.json) {
107
139
  console.log(`${brand('Page test')} ${muted('(interactive)')} ${bold(url)}`);
108
140
  console.log(muted(`${clients} client(s), stagger ${stagger}s, hold ${hold}ms, ${samples} samples each`));
@@ -113,8 +145,12 @@ async function runInteractive(url, observe, opts) {
113
145
  await sleep(i * stagger * 1000);
114
146
  if (!opts.json)
115
147
  console.log(muted(`client ${i} (${labelFor(i)}) joining`));
148
+ // {{label}}/{{i}} substitute into the URL too, so one invocation can launch
149
+ // asymmetric roles concurrently (e.g. ?role={{label}} with --labels host,join)
150
+ // and the overlap check still confirms they coexisted.
151
+ const clientUrl = subst(url, labelFor(i), i);
116
152
  const expr = buildHarness(opts.action ? subst(opts.action, labelFor(i), i) : undefined, subst(observe, labelFor(i), i), labelFor(i), hold, samples);
117
- return observeClient(url, expr, i, labelFor(i), settle, hold, opts.waitFor);
153
+ return observeClient(clientUrl, expr, i, labelFor(i), settle, hold, opts.waitFor);
118
154
  })());
119
155
  }
120
156
  const results = (await Promise.all(runs)).sort((a, b) => a.i - b.i);
@@ -159,6 +195,9 @@ async function runPassive(url, opts) {
159
195
  const clients = Math.max(1, parseInt(opts.clients, 10) || 2);
160
196
  const stagger = opts.stagger != null ? Math.max(0, parseInt(opts.stagger, 10) || 0) : 12;
161
197
  const wait = Math.min(30000, Math.max(2000, parseInt(opts.wait, 10) || 24000));
198
+ const labels = (opts.labels ? String(opts.labels).split(',').map((s) => s.trim()) : []).filter(Boolean);
199
+ const labelFor = (i) => labels[i] ?? `client-${i}`;
200
+ warnUnknownTokens(unknownTokens(url));
162
201
  if (!opts.json) {
163
202
  console.log(`${brand('Page test')} ${bold(url)}`);
164
203
  console.log(`${muted(`${clients} client(s), stagger ${stagger}s, ${wait}ms open each`)}`);
@@ -169,7 +208,7 @@ async function runPassive(url, opts) {
169
208
  await sleep(i * stagger * 1000);
170
209
  if (!opts.json)
171
210
  console.log(`${muted(`client ${i}${i === 0 ? ' (first)' : ''} starting`)}`);
172
- return inspectClient(url, wait, i);
211
+ return inspectClient(subst(url, labelFor(i), i), wait, i);
173
212
  })());
174
213
  }
175
214
  const results = (await Promise.all(runs)).sort((a, b) => a.i - b.i);
@@ -222,14 +261,14 @@ async function runPassive(url, opts) {
222
261
  // just because the clients never actually ran together.
223
262
  export const pageTestCommand = new Command('test')
224
263
  .description('Multi-client realtime check: load a URL in N concurrent headless clients; flag console errors, or drive an action and observe shared state (--observe)')
225
- .argument('<url>', 'Deployed URL to load in every client')
264
+ .argument('<url>', 'Deployed URL to load in every client. {{label}}/{{i}} substitute per client in both modes (e.g. ?name=Bot{{i}}, or ?role={{label}} with --labels host,join), so one invocation can give each client a distinct role.')
226
265
  .option('--clients <n>', 'Number of headless clients to launch', '2')
227
266
  .option('--stagger <s>', 'Seconds between client starts (passive default 12; interactive default 0)')
228
267
  .option('--wait <ms>', 'Passive mode: ms each client stays open after load (max 30000)', '24000')
229
268
  // Interactive mode (--observe drives it):
230
269
  .option('--observe <expr>', 'Interactive: JS expression sampled in each client to read shared state (e.g. presence count). Switches on interactive mode.')
231
270
  .option('--action <expr>', 'Interactive: one-time JS run in each client before observing (e.g. fill a name + submit). {{label}}/{{i}} are substituted per client.')
232
- .option('--labels <csv>', 'Interactive: per-client labels substituted for {{label}} (default client-0, client-1, …)')
271
+ .option('--labels <csv>', 'Per-client labels substituted for {{label}} in the URL/--action/--observe (default client-0, client-1, …)')
233
272
  .option('--hold <ms>', `Interactive: total observe window per client (${MIN_HOLD_MS}-${MAX_HOLD_MS}ms)`, '8000')
234
273
  .option('--samples <k>', 'Interactive: number of observations across the hold window (2-30)', '6')
235
274
  .option('--wait-for <selector>', 'Interactive: wait for this CSS selector before running --action (deterministic readiness gate)')
@@ -239,12 +278,22 @@ Examples:
239
278
  # Passive: load in 3 staggered clients, flag console errors
240
279
  gipity page test "https://dev.gipity.ai/me/app/" --clients 3 --stagger 8
241
280
 
281
+ # Per-client URL params: each client joins under a distinct name (Bot0, Bot1, …)
282
+ gipity page test "https://dev.gipity.ai/me/app/?name=Bot{{i}}" --clients 2
283
+
242
284
  # Interactive: two concurrent clients each join with a name, then watch the
243
285
  # live presence count. The command confirms the clients actually overlapped.
244
286
  gipity page test "https://dev.gipity.ai/me/app/" --clients 2 \\
245
287
  --action "document.querySelector('#name').value='{{label}}'; document.querySelector('form').requestSubmit();" \\
246
288
  --observe "document.querySelectorAll('.present').length" \\
247
- --labels Alice,Bob`)
289
+ --labels Alice,Bob
290
+
291
+ # Asymmetric roles in ONE invocation: {{label}} in the URL routes client 0 to
292
+ # host and client 1 to join. They overlap in time (verified), so the joiner
293
+ # observes the live state the host is driving — no background-process dance.
294
+ gipity page test "https://dev.gipity.ai/me/app/?test-action={{label}}" --clients 2 \\
295
+ --labels host,join \\
296
+ --observe "document.querySelector('[data-screen]')?.dataset.screen"`)
248
297
  .action((url, opts) => run('Page test', async () => {
249
298
  if (opts.observe) {
250
299
  await runInteractive(url, opts.observe, opts);
@@ -1,9 +1,13 @@
1
1
  import { Command } from 'commander';
2
- import { get, post, put, del } from '../api.js';
2
+ import { get, post, put, patch, del } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { bold, muted } from '../colors.js';
5
5
  import { run, printList, printResult } from '../helpers/index.js';
6
6
  import { confirm } from '../utils.js';
7
+ // All commands hit the app API (https://a.gipity.ai/api/<guid>/records/...),
8
+ // which authorizes the logged-in owner via their Bearer token. (The native
9
+ // Records API is the only records surface that exists server-side; there is no
10
+ // /projects/<guid>/records mirror.)
7
11
  export const recordsCommand = new Command('records')
8
12
  .description('Manage records');
9
13
  recordsCommand
@@ -12,8 +16,38 @@ recordsCommand
12
16
  .option('--json', 'Output as JSON')
13
17
  .action((opts) => run('List', async () => {
14
18
  const config = requireConfig();
15
- const res = await get(`/projects/${config.projectGuid}/records-config`);
16
- printList(res.data, opts, 'No tables configured for Records API.', t => `${bold(t.table_name)} ${muted(t.auth_level)} ${muted(`pk=${t.primary_key_column}`)} ${muted(`db=${t.database_name}`)}`);
19
+ const res = await get(`/api/${config.projectGuid}/records-config`);
20
+ printList(res.data, opts, 'No tables configured for Records API. Configure one with `gipity records config <table> --auth <level>`.', t => `${bold(t.table_name)} ${muted(t.auth_level)} ${muted(`pk=${t.primary_key_column}`)} ${muted(`db=${t.database_name}`)}`);
21
+ }));
22
+ recordsCommand
23
+ .command('config <table>')
24
+ .description('Show or set a table\'s Records API config (auth level, search, etc.)')
25
+ .option('--auth <level>', 'Auth level: public (anonymous writes), member (sign-in), or user')
26
+ .option('--searchable <bool>', 'Enable full-text search (true/false)')
27
+ .option('--primary-key <col>', 'Primary key column (default: id)')
28
+ .option('--soft-delete <col>', 'Soft-delete column (pass "none" to clear)')
29
+ .option('--json', 'Output as JSON')
30
+ .action((table, opts) => run('Config', async () => {
31
+ const config = requireConfig();
32
+ const base = `/api/${config.projectGuid}/records/${table}/config`;
33
+ // No setter flags → just show the current config.
34
+ const setting = opts.auth || opts.searchable !== undefined || opts.primaryKey || opts.softDelete;
35
+ if (!setting) {
36
+ const res = await get(base);
37
+ printResult(JSON.stringify(res.data, null, 2), opts, res.data);
38
+ return;
39
+ }
40
+ const body = {};
41
+ if (opts.auth)
42
+ body.auth_level = opts.auth;
43
+ if (opts.searchable !== undefined)
44
+ body.searchable = /^(true|1|on|yes)$/i.test(String(opts.searchable));
45
+ if (opts.primaryKey)
46
+ body.primary_key_column = opts.primaryKey;
47
+ if (opts.softDelete)
48
+ body.soft_delete_column = opts.softDelete === 'none' ? null : opts.softDelete;
49
+ const res = await patch(base, body);
50
+ printResult(`Configured "${table}": auth=${res.data.auth_level}, searchable=${res.data.searchable}, pk=${res.data.primary_key_column}`, opts, res.data);
17
51
  }));
18
52
  recordsCommand
19
53
  .command('query <table>')
@@ -35,7 +69,7 @@ recordsCommand
35
69
  params.set('offset', opts.offset);
36
70
  if (opts.fields)
37
71
  params.set('fields', opts.fields);
38
- const res = await get(`/projects/${config.projectGuid}/records/${table}?${params}`);
72
+ const res = await get(`/api/${config.projectGuid}/records/${table}?${params}`);
39
73
  if (opts.json) {
40
74
  console.log(JSON.stringify(res));
41
75
  }
@@ -54,7 +88,7 @@ recordsCommand
54
88
  .option('--json', 'Output as JSON')
55
89
  .action((table, id, opts) => run('Get', async () => {
56
90
  const config = requireConfig();
57
- const res = await get(`/projects/${config.projectGuid}/records/${table}/${id}`);
91
+ const res = await get(`/api/${config.projectGuid}/records/${table}/${id}`);
58
92
  console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
59
93
  }));
60
94
  recordsCommand
@@ -65,7 +99,7 @@ recordsCommand
65
99
  .action((table, opts) => run('Create', async () => {
66
100
  const config = requireConfig();
67
101
  const data = JSON.parse(opts.data);
68
- const res = await post(`/projects/${config.projectGuid}/records/${table}`, data);
102
+ const res = await post(`/api/${config.projectGuid}/records/${table}`, data);
69
103
  printResult(`Created: ${JSON.stringify(res.data)}`, opts, res.data);
70
104
  }));
71
105
  recordsCommand
@@ -76,7 +110,7 @@ recordsCommand
76
110
  .action((table, id, opts) => run('Update', async () => {
77
111
  const config = requireConfig();
78
112
  const data = JSON.parse(opts.data);
79
- const res = await put(`/projects/${config.projectGuid}/records/${table}/${id}`, data);
113
+ const res = await put(`/api/${config.projectGuid}/records/${table}/${id}`, data);
80
114
  printResult(`Updated: ${JSON.stringify(res.data)}`, opts, res.data);
81
115
  }));
82
116
  recordsCommand
@@ -88,7 +122,7 @@ recordsCommand
88
122
  return;
89
123
  }
90
124
  const config = requireConfig();
91
- await del(`/projects/${config.projectGuid}/records/${table}/${id}`);
125
+ await del(`/api/${config.projectGuid}/records/${table}/${id}`);
92
126
  printResult('Deleted.', { json: false });
93
127
  }));
94
128
  //# sourceMappingURL=records.js.map
@@ -0,0 +1,42 @@
1
+ import { Command } from 'commander';
2
+ import { post } from '../api.js';
3
+ import { requireConfig } from '../config.js';
4
+ import { sync } from '../sync.js';
5
+ import { success, muted } from '../colors.js';
6
+ import { run } from '../helpers/index.js';
7
+ import { confirm } from '../utils.js';
8
+ export const removeCommand = new Command('remove')
9
+ .description('Remove an installed kit from the project (inverse of `gipity add <kit>`).')
10
+ .argument('<kit>', 'Kit key/directory under src/packages/ to remove')
11
+ .option('-y, --yes', 'Skip the confirmation prompt')
12
+ .option('--json', 'Output as JSON')
13
+ .action((kit, opts) => run('Remove', async () => {
14
+ const config = requireConfig();
15
+ if (!opts.yes && !opts.json) {
16
+ if (!await confirm(`Remove the "${kit}" kit (its files, import-map entries, and gipity.yaml wiring)?`)) {
17
+ console.log('Cancelled.');
18
+ return;
19
+ }
20
+ }
21
+ const res = await post(`/projects/${config.projectGuid}/remove`, { name: kit });
22
+ // Force the pull so the kit's deletions land locally without tripping the
23
+ // bulk-deletion guard - the removal is an explicit, user-invoked action.
24
+ const syncResult = await sync({ interactive: false, force: true });
25
+ const data = res.data;
26
+ if (opts.json) {
27
+ console.log(JSON.stringify({ ...data, synced: syncResult.applied }));
28
+ return;
29
+ }
30
+ console.log(success(`Removed the "${data.kit}" kit.`));
31
+ for (const r of data.removed)
32
+ console.log(muted(` - ${r}`));
33
+ if (data.notes?.length) {
34
+ console.log('');
35
+ for (const n of data.notes)
36
+ console.log(n);
37
+ }
38
+ if (syncResult.applied > 0) {
39
+ console.log(`\nPulled ${syncResult.applied} change(s) to local.`);
40
+ }
41
+ }));
42
+ //# sourceMappingURL=remove.js.map
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { readFileSync } from 'fs';
2
+ import { readFileSync, existsSync, statSync } from 'fs';
3
3
  import { dirname, extname, relative } from 'path';
4
4
  import { post } from '../api.js';
5
5
  import { resolveProjectContext, getConfigPath } from '../config.js';
@@ -14,6 +14,19 @@ const LANG_MAP = {
14
14
  bash: 'bash',
15
15
  sh: 'bash',
16
16
  };
17
+ // Interpreter tokens accepted at the head of a `run <interpreter> <file>`
18
+ // invocation (e.g. `gipity sandbox run python build_report.py`), mirroring how
19
+ // you'd launch a script locally. Maps each token to the canonical language.
20
+ const INTERPRETERS = {
21
+ python: 'python',
22
+ python3: 'python',
23
+ py: 'python',
24
+ node: 'javascript',
25
+ js: 'javascript',
26
+ javascript: 'javascript',
27
+ bash: 'bash',
28
+ sh: 'bash',
29
+ };
17
30
  /** Project-relative path from the process cwd, or undefined when there's
18
31
  * no local config (one-off mode) or the cwd is at/above the project root. */
19
32
  function resolveRelativeCwd() {
@@ -29,7 +42,7 @@ function resolveRelativeCwd() {
29
42
  export const sandboxCommand = new Command('sandbox')
30
43
  .description('Run code in a sandbox');
31
44
  sandboxCommand
32
- .command('run [code]')
45
+ .command('run [args...]')
33
46
  .description('Run code')
34
47
  .option('--language <language>', 'Language: js, py, or bash', 'js')
35
48
  .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')
@@ -59,6 +72,8 @@ Examples:
59
72
 
60
73
  # Run a script file directly (language inferred from .py)
61
74
  $ gipity sandbox run --file build_report.py
75
+ $ gipity sandbox run python build_report.py # same thing, interpreter shorthand
76
+ $ gipity sandbox run bash "echo hi; ffmpeg -version" # inline, language pinned
62
77
 
63
78
  # Surgical: only these files are mirrored in
64
79
  $ gipity sandbox run --language bash \\
@@ -73,31 +88,62 @@ Pre-installed: Python (pandas, numpy, matplotlib, Pillow, scipy, bs4),
73
88
  CLI tools (ImageMagick, FFmpeg, webp/cwebp, optipng, jq, pandoc, exiftool,
74
89
  GCC/Rust).
75
90
  `)
76
- .action((code, opts, command) => run('Sandbox', async () => {
91
+ .action((args = [], opts, command) => run('Sandbox', async () => {
77
92
  const { config } = await resolveProjectContext();
78
- if (code !== undefined && opts.file) {
93
+ // Resolve the positional args into either inline code or a script-file path.
94
+ // `run <interpreter> <file>` (e.g. `run python build_report.py`) is the natural
95
+ // mental model, so accept it: a leading interpreter token + a path becomes
96
+ // --file with the language pinned by the interpreter. A single positional is
97
+ // inline code, same as before.
98
+ let inlineCode;
99
+ let filePath = opts.file;
100
+ let langFromInterp;
101
+ if (args.length >= 2 && INTERPRETERS[args[0].toLowerCase()] !== undefined) {
102
+ langFromInterp = INTERPRETERS[args[0].toLowerCase()];
103
+ const rest = args.slice(1).join(' ');
104
+ // `run python build_report.py` -> a script file; `run bash "echo hi"` -> inline code.
105
+ if (existsSync(rest) && statSync(rest).isFile())
106
+ filePath = rest;
107
+ else
108
+ inlineCode = rest;
109
+ }
110
+ else if (args.length === 1) {
111
+ inlineCode = args[0];
112
+ }
113
+ else if (args.length > 1) {
114
+ console.error(clrError('Unrecognized invocation. Pass inline code as a single quoted arg, a script with --file <path>, or use the `run <python|node|bash> <file>` shorthand.'));
115
+ process.exit(1);
116
+ }
117
+ if (inlineCode !== undefined && filePath) {
79
118
  console.error(clrError('Pass either an inline <code> arg or --file <path>, not both'));
80
119
  process.exit(1);
81
120
  }
82
- if (code === undefined && !opts.file) {
121
+ if (inlineCode === undefined && !filePath) {
83
122
  console.error(clrError('Provide an inline <code> arg or --file <path>'));
84
123
  process.exit(1);
85
124
  }
86
- let source = code;
87
- if (opts.file) {
125
+ let source = inlineCode;
126
+ if (filePath) {
88
127
  try {
89
- source = readFileSync(opts.file, 'utf8');
128
+ source = readFileSync(filePath, 'utf8');
90
129
  }
91
130
  catch {
92
- console.error(clrError(`Cannot read file: ${opts.file}`));
131
+ console.error(clrError(`Cannot read file: ${filePath}`));
93
132
  process.exit(1);
94
133
  }
95
134
  }
96
- // Infer language from the file extension unless --language was given explicitly.
97
- const fromExt = opts.file && command.getOptionValueSource('language') === 'default'
98
- ? LANG_MAP[extname(opts.file).slice(1).toLowerCase()]
135
+ // Language precedence: interpreter token > file extension (unless --language
136
+ // was passed explicitly) > the --language value (default js).
137
+ const fromExt = filePath && !langFromInterp && command.getOptionValueSource('language') === 'default'
138
+ ? LANG_MAP[extname(filePath).slice(1).toLowerCase()]
99
139
  : undefined;
100
- const language = fromExt || LANG_MAP[opts.language] || opts.language;
140
+ const language = langFromInterp || fromExt || LANG_MAP[opts.language] || opts.language;
141
+ // True when the run fell back to the implicit JS default - nothing in the
142
+ // command shape pinned a language. Used to explain the execution mode if a
143
+ // shell/Python snippet gets parsed as JavaScript and blows up (see hint below).
144
+ const usedDefaultJs = !langFromInterp
145
+ && command.getOptionValueSource('language') === 'default'
146
+ && language === 'javascript';
101
147
  if (!['javascript', 'python', 'bash'].includes(language)) {
102
148
  console.error(clrError(`Invalid language: ${opts.language}. Use: js, py, or bash`));
103
149
  process.exit(1);
@@ -138,8 +184,15 @@ GCC/Rust).
138
184
  for (const f of res.data.outputFiles)
139
185
  console.log(`${f}`);
140
186
  }
141
- if (res.data.exitCode !== 0)
187
+ if (res.data.exitCode !== 0) {
188
+ // A SyntaxError / CJS-loader trace under the implicit JS default almost
189
+ // always means the input was shell or Python that got run as JavaScript.
190
+ // The raw Node stack trace never says which mode ran, so name it.
191
+ if (usedDefaultJs && /SyntaxError|cjs\/loader|wrapSafe/.test(res.data.stderr || '')) {
192
+ 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`.'));
193
+ }
142
194
  process.exit(res.data.exitCode);
195
+ }
143
196
  }
144
197
  }));
145
198
  //# sourceMappingURL=sandbox.js.map
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { join, resolve } from 'path';
4
4
  import { homedir } from 'os';
5
- import { getAuth, getTimeRemaining } from '../auth.js';
5
+ import { getAuth, sessionExpired } from '../auth.js';
6
6
  import { getConfig, liveUrl } from '../config.js';
7
7
  import { brand, success, warning, muted, error as clrError } from '../colors.js';
8
8
  import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, setupClaudeHooks, ensureGipityPlugin } from '../setup.js';
@@ -44,10 +44,11 @@ export const statusCommand = new Command('status')
44
44
  apiBase: config.apiBase,
45
45
  url: liveUrl(config),
46
46
  } : null,
47
+ // `valid` reflects the refresh token (the real session) - access
48
+ // tokens auto-renew, so their expiry must not read as "invalid".
47
49
  auth: auth ? {
48
50
  email: auth.email,
49
- expiresAt: auth.expiresAt,
50
- valid: new Date(auth.expiresAt).getTime() > Date.now(),
51
+ valid: !sessionExpired(),
51
52
  } : null,
52
53
  plugin: hookCheck,
53
54
  }, null, 2));
@@ -67,8 +68,11 @@ export const statusCommand = new Command('status')
67
68
  if (!auth) {
68
69
  console.log(`${muted('Auth:')} ${warning('not logged in. Run: gipity login')}`);
69
70
  }
71
+ else if (sessionExpired()) {
72
+ console.log(`${muted('Auth:')} ${warning(`session expired for ${auth.email}. Run: gipity login`)}`);
73
+ }
70
74
  else {
71
- console.log(`${muted('Auth:')} ${success(auth.email)} ${muted(`(${getTimeRemaining()})`)}`);
75
+ console.log(`${muted('Auth:')} ${success(auth.email)}`);
72
76
  }
73
77
  if (hookCheck) {
74
78
  if (hookCheck.ok) {
@@ -100,8 +100,11 @@ async function pollTestStatus(projectGuid, runGuid, opts) {
100
100
  const progress = data.totalFiles === 0
101
101
  ? 'starting up'
102
102
  : `${data.completedFiles}/${data.totalFiles} files`;
103
+ // "so far" so a heartbeat line, if captured on its own (tail/grep),
104
+ // can't be mistaken for the final tally — the partial count climbing
105
+ // toward the total previously read as "tests vanished".
103
106
  const tally = data.passed + data.failed > 0
104
- ? ` (${data.passed} passed${data.failed > 0 ? `, ${data.failed} failed` : ''})`
107
+ ? ` (${data.passed} passed${data.failed > 0 ? `, ${data.failed} failed` : ''} so far)`
105
108
  : '';
106
109
  console.log(muted(` … still running — ${progress}${tally}, ${elapsed}s elapsed`));
107
110
  if (now - startTime >= LONG_RUN_MS && !longRunHintShown) {
@@ -168,17 +171,24 @@ export const testCommand = new Command('test')
168
171
  if (data.skipped > 0)
169
172
  parts.push(muted(`${data.skipped} skipped`));
170
173
  console.log(`${parts.join(', ')} ${muted(`(${data.durationMs}ms)`)}`);
174
+ // The run's results are stored; re-fetch them (no re-run) by GUID.
175
+ console.log(muted(`Re-fetch this run's details (no re-run): gipity test status ${runGuid} --json`));
171
176
  if (data.failed > 0)
172
177
  process.exit(1);
173
178
  }));
174
179
  // ── Status subcommand (check on a running test) ──────────────────────
175
180
  testCommand
176
181
  .command('status')
177
- .description('Check a test run')
182
+ .alias('results')
183
+ .description('Fetch a finished (or running) test run by GUID — full per-test results, no re-run')
178
184
  .argument('<runGuid>', 'Test run GUID (e.g. tr_abc123)')
179
185
  .option('--json', 'Output as JSON')
180
186
  .option('--follow', 'Follow until complete (poll)')
181
- .action((runGuid, opts) => run('Status', async () => {
187
+ // optsWithGlobals: the parent `test` command also declares `--json`, and with
188
+ // root-level enablePositionalOptions commander attaches a post-subcommand
189
+ // `--json` to that parent — so the subcommand's own opts would miss it.
190
+ .action((runGuid, _o, command) => run('Status', async () => {
191
+ const opts = command.optsWithGlobals();
182
192
  const config = requireConfig();
183
193
  if (opts.follow) {
184
194
  const data = await pollTestStatus(config.projectGuid, runGuid, opts);
@@ -217,7 +227,9 @@ testCommand
217
227
  .description('Show recent runs')
218
228
  .option('--limit <n>', 'Number of runs to show', '10')
219
229
  .option('--json', 'Output as JSON')
220
- .action((opts) => run('History', async () => {
230
+ // optsWithGlobals: see the status subcommand — `--json` lands on the parent.
231
+ .action((_o, command) => run('History', async () => {
232
+ const opts = command.optsWithGlobals();
221
233
  const config = requireConfig();
222
234
  const res = await get(`/projects/${config.projectGuid}/test/history?limit=${opts.limit}`);
223
235
  if (opts.json) {
@@ -42,7 +42,7 @@ function formatProfile(a) {
42
42
  row('Sentences', a.sentences);
43
43
  row('Lines', a.lines);
44
44
  row('Paragraphs', a.paragraphs);
45
- if (a.words) {
45
+ if (a.longestWord) {
46
46
  row('Longest word', `${a.longestWord} (${[...a.longestWord].length})`);
47
47
  row('Shortest word', `${a.shortestWord} (${[...a.shortestWord].length})`);
48
48
  row('Avg word length', a.averageWordLength);