gipity 1.0.386 → 1.0.388

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 CHANGED
@@ -8,27 +8,33 @@ This CLI connects [Claude Code](https://claude.ai/claude-code) to Gipity's cloud
8
8
 
9
9
  ## Getting Started
10
10
 
11
- You need **Node.js 18+** (which includes npm) and **Claude Code**.
11
+ One line installs everything. It sets up Node 18+ (if you don't already have it) and the Gipity CLI, with no sudo required:
12
12
 
13
13
  ```bash
14
- # 1. Install Node.js (if you don't have it)
14
+ # macOS / Linux / WSL
15
+ curl -fsSL https://gipity.ai/install.sh | bash
15
16
 
16
- # macOS
17
- brew install node
17
+ # Windows (PowerShell)
18
+ irm https://gipity.ai/install.ps1 | iex
19
+ ```
20
+
21
+ Then launch your coding agent wired into Gipity:
22
+
23
+ ```bash
24
+ gipity claude
25
+ ```
18
26
 
19
- # Windows - download the installer from https://nodejs.org
27
+ `gipity claude` walks you through login, project setup, and launches Claude Code. Using Codex, Gemini, or Cursor instead? Run `gipity init`.
20
28
 
21
- # Linux (Ubuntu/Debian)
22
- curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install -y nodejs
29
+ ### Prefer npm
23
30
 
24
- # 2. Install Gipity CLI and Claude Code
25
- npm install -g gipity @anthropic-ai/claude-code
31
+ If you already have **Node.js 18+** you can install directly:
26
32
 
27
- # 3. Go
28
- gipity claude
33
+ ```bash
34
+ npm install -g gipity
29
35
  ```
30
36
 
31
- That's it. `claude` walks you through login, project setup, and launches Claude Code.
37
+ If that fails with `EACCES`, your npm global prefix is root-owned. Don't reach for `sudo`: point npm at a user-owned prefix instead (`npm config set prefix ~/.npm-global` and add `~/.npm-global/bin` to your `PATH`), or just use the one-line installer above, which does this for you. See https://docs.npmjs.com/resolving-eacces-permissions-errors.
32
38
 
33
39
  ## Updates
34
40
 
package/dist/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Readable } from 'stream';
2
2
  import * as tar from 'tar-stream';
3
3
  import { getAuth, refreshTokenIfNeeded } from './auth.js';
4
- import { getConfig, getApiBaseOverride, requireConfig, saveConfig } from './config.js';
4
+ import { resolveApiBase, requireConfig, saveConfig } from './config.js';
5
5
  export class ApiError extends Error {
6
6
  statusCode;
7
7
  code;
@@ -27,7 +27,7 @@ async function getHeaders() {
27
27
  };
28
28
  }
29
29
  function baseUrl() {
30
- return getApiBaseOverride() || getConfig()?.apiBase || 'https://a.gipity.ai';
30
+ return resolveApiBase();
31
31
  }
32
32
  /** Exposed so streaming consumers (SSE) can build URLs without re-implementing
33
33
  * the override / config resolution. */
@@ -101,6 +101,9 @@ export async function postForTarEntries(path, body) {
101
101
  export function put(path, body) {
102
102
  return request('PUT', path, body);
103
103
  }
104
+ export function patch(path, body) {
105
+ return request('PATCH', path, body);
106
+ }
104
107
  export function del(path, body) {
105
108
  return request('DELETE', path, body);
106
109
  }
package/dist/auth.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, chmodSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { decodeJwtExp } from './utils.js';
@@ -33,8 +33,16 @@ export function readAuthFresh() {
33
33
  }
34
34
  }
35
35
  export function saveAuth(data) {
36
- mkdirSync(AUTH_DIR, { recursive: true });
37
- writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2));
36
+ // Lock down to owner-only: this file holds the account access + 7-day refresh
37
+ // tokens, so a default 0644/0755 would let any other local user read them.
38
+ // (The relay state file already does this; auth.json is the more sensitive of
39
+ // the two.) chmod after write to also tighten any pre-existing loose file.
40
+ mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
41
+ writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
42
+ try {
43
+ chmodSync(AUTH_FILE, 0o600);
44
+ }
45
+ catch { /* best-effort on platforms without chmod */ }
38
46
  cached = data;
39
47
  }
40
48
  export function clearAuth() {
@@ -73,7 +81,7 @@ export async function refreshTokenIfNeeded() {
73
81
  return; // not logged in, caller will handle
74
82
  try {
75
83
  const config = await import('./config.js');
76
- const apiBase = config.getApiBaseOverride() || config.getConfig()?.apiBase || 'https://a.gipity.ai';
84
+ const apiBase = config.resolveApiBase();
77
85
  const res = await fetch(`${apiBase}/auth/refresh`, {
78
86
  method: 'POST',
79
87
  headers: { 'Content-Type': 'application/json' },
package/dist/banner.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // ── Gipity CLI Startup Banner ──────────────────────────────────────────
2
2
  // Two-panel box showing all AI models, platform tools, and sandbox capabilities.
3
- import { brand, bold, faint, muted } from './colors.js';
3
+ import { brand, bold, faint, muted, fg } from './colors.js';
4
4
  // ── Static content ─────────────────────────────────────────────────────
5
5
  // ── Feature groups ────────────────────────────────────────────────────
6
6
  const AI_MODELS = [
@@ -38,11 +38,13 @@ const INFRASTRUCTURE = [
38
38
  'Deploy', 'Uploads', 'Rollback',
39
39
  ];
40
40
  // ── Egg color palette (shared) - gradient built around #FEA60E ───────
41
- const _hi = (s) => `\x1b[38;2;255;215;80m${s}\x1b[39m`; // golden highlight
42
- const _lt = (s) => `\x1b[38;2;254;190;45m${s}\x1b[39m`; // light
43
- const _br = (s) => `\x1b[38;2;254;166;14m${s}\x1b[39m`; // base - #FEA60E
44
- const _md = (s) => `\x1b[38;2;218;112;8m${s}\x1b[39m`; // medium-dark
45
- const _sh = (s) => `\x1b[38;2;170;68;4m${s}\x1b[39m`; // shadow
41
+ // Colors route through fg() so they downgrade (256 / 16 / none) on terminals
42
+ // without truecolor support instead of rendering as misparsed garbage.
43
+ const _hi = fg(255, 215, 80); // golden highlight
44
+ const _lt = fg(254, 190, 45); // light
45
+ const _br = fg(254, 166, 14); // base - #FEA60E
46
+ const _md = fg(218, 112, 8); // medium-dark
47
+ const _sh = fg(170, 68, 4); // shadow
46
48
  // Wobbly egg - asymmetric left/right, organic feel (8 rows)
47
49
  // Widths: 6 → 8 → 10 → 12 → 14 → 12 → 10 → 8 | Widest at row 5/8 (62%)
48
50
  function eggWobbly() {
@@ -76,8 +78,8 @@ function eggSym() {
76
78
  // Edge pairs: ▗/▖ cap → ▟/▙ expand → ▐/▌ straight → ▜/▛ contract → ▝/▘ cap
77
79
  function eggTall() {
78
80
  // Extra intermediate tones for a smoother gradient
79
- const _m1 = (s) => `\x1b[38;2;238;138;10m${s}\x1b[39m`; // base medium
80
- const _dk = (s) => `\x1b[38;2;195;88;6m${s}\x1b[39m`; // medium shadow
81
+ const _m1 = fg(238, 138, 10); // base to medium
82
+ const _dk = fg(195, 88, 6); // medium to shadow
81
83
  return [
82
84
  _lt('▗▄') + _br('██') + _md('▄▖'), // 6 - top cap
83
85
  _lt('▟') + _hi('█') + _br('████') + _m1('█▙'), // 8 - expanding, highlight
@@ -161,7 +163,7 @@ function buildLeftPanel(opts, panelW) {
161
163
  const nameDisplay = opts.email
162
164
  ? opts.email.split('@')[0].replace(/^./, c => c.toUpperCase())
163
165
  : null;
164
- const white = (s) => `\x1b[38;2;255;255;255m${s}\x1b[39m`;
166
+ const white = fg(255, 255, 255);
165
167
  const welcome = nameDisplay
166
168
  ? white(bold(`Welcome back ${nameDisplay}!`))
167
169
  : white(bold('Welcome to Gipity'));
package/dist/colors.js CHANGED
@@ -1,27 +1,127 @@
1
1
  // ── Gipity CLI Color System ─────────────────────────────────────────────
2
2
  // Centralized color definitions matching the Gipity platform palette.
3
3
  // All command files should import from here - no inline ANSI codes.
4
+ //
5
+ // Color depth is detected once at load. RGB colors automatically downgrade:
6
+ // level 3 → 24-bit truecolor \x1b[38;2;R;G;Bm
7
+ // level 2 → 256-color \x1b[38;5;Nm
8
+ // level 1 → 16-color \x1b[3Xm / \x1b[9Xm
9
+ // level 0 → no color (plain text)
10
+ // This avoids the failure mode where a terminal that does not understand the
11
+ // 24-bit escape misparses it and renders garbage (e.g. the orange egg coming
12
+ // out purple on consoles without truecolor support).
4
13
  const ESC = '\x1b';
5
- // Detect whether colors should be suppressed
6
- const noColor = !!process.env['NO_COLOR'] || !process.stdout.isTTY;
14
+ // ── Color-depth detection ───────────────────────────────────────────────
15
+ // 0 = none, 1 = 16-color, 2 = 256-color, 3 = truecolor. Mirrors the common
16
+ // supports-color / chalk heuristics, biased toward NOT emitting truecolor
17
+ // unless the terminal advertises it, so unknown terminals get a safe 256 or
18
+ // 16-color approximation instead of a misparsed 24-bit sequence.
19
+ function detectColorLevel() {
20
+ if (process.env['NO_COLOR'])
21
+ return 0;
22
+ // FORCE_COLOR overrides detection (matches Node / chalk semantics).
23
+ const force = process.env['FORCE_COLOR'];
24
+ if (force !== undefined) {
25
+ if (force === '0' || force === 'false')
26
+ return 0;
27
+ if (force === '3')
28
+ return 3;
29
+ if (force === '2')
30
+ return 2;
31
+ // '1', 'true', '' → at least basic color
32
+ if (force === '1' || force === 'true' || force === '')
33
+ return 1;
34
+ }
35
+ if (!process.stdout.isTTY)
36
+ return 0;
37
+ const term = (process.env['TERM'] || '').toLowerCase();
38
+ if (term === 'dumb')
39
+ return 0;
40
+ const colorterm = (process.env['COLORTERM'] || '').toLowerCase();
41
+ if (colorterm === 'truecolor' || colorterm === '24bit')
42
+ return 3;
43
+ const termProgram = process.env['TERM_PROGRAM'] || '';
44
+ if (termProgram === 'iTerm.app' || termProgram === 'vscode')
45
+ return 3;
46
+ if (termProgram === 'Apple_Terminal')
47
+ return 2;
48
+ if (/-256(color)?$/.test(term) || term.includes('256'))
49
+ return 2;
50
+ // Modern Windows consoles set COLORTERM (caught above). Older cmd.exe /
51
+ // conhost only do 16-color reliably.
52
+ if (process.platform === 'win32')
53
+ return 1;
54
+ if (term)
55
+ return 1;
56
+ return 0;
57
+ }
58
+ const COLOR_LEVEL = detectColorLevel();
7
59
  // Identity function for when colors are disabled
8
60
  const identity = (s) => s;
61
+ // ── RGB downgrade helpers ───────────────────────────────────────────────
62
+ // RGB → nearest xterm-256 palette index (6x6x6 cube + grayscale ramp).
63
+ function rgbTo256(r, g, b) {
64
+ if (r === g && g === b) {
65
+ if (r < 8)
66
+ return 16;
67
+ if (r > 248)
68
+ return 231;
69
+ return Math.round(((r - 8) / 247) * 24) + 232;
70
+ }
71
+ return (16 +
72
+ 36 * Math.round((r / 255) * 5) +
73
+ 6 * Math.round((g / 255) * 5) +
74
+ Math.round((b / 255) * 5));
75
+ }
76
+ // RGB → nearest 16-color SGR foreground code (30-37 / 90-97).
77
+ function rgbTo16(r, g, b) {
78
+ const value = Math.round((Math.max(r, g, b) / 255) * 3);
79
+ if (value === 0)
80
+ return 30;
81
+ let code = 30 +
82
+ ((Math.round(b / 255) << 2) |
83
+ (Math.round(g / 255) << 1) |
84
+ Math.round(r / 255));
85
+ if (value === 3)
86
+ code += 60; // bright variant
87
+ return code;
88
+ }
9
89
  // ── Low-level builders ──────────────────────────────────────────────────
10
- function makeFg(r, g, b) {
11
- if (noColor)
90
+ export function makeFg(r, g, b) {
91
+ if (COLOR_LEVEL === 0)
12
92
  return identity;
13
- return (s) => `${ESC}[38;2;${r};${g};${b}m${s}${ESC}[39m`;
93
+ if (COLOR_LEVEL === 3) {
94
+ return (s) => `${ESC}[38;2;${r};${g};${b}m${s}${ESC}[39m`;
95
+ }
96
+ if (COLOR_LEVEL === 2) {
97
+ const n = rgbTo256(r, g, b);
98
+ return (s) => `${ESC}[38;5;${n}m${s}${ESC}[39m`;
99
+ }
100
+ const code = rgbTo16(r, g, b);
101
+ return (s) => `${ESC}[${code}m${s}${ESC}[39m`;
14
102
  }
15
- function makeBg(r, g, b) {
16
- if (noColor)
103
+ export function makeBg(r, g, b) {
104
+ if (COLOR_LEVEL === 0)
17
105
  return identity;
18
- return (s) => `${ESC}[48;2;${r};${g};${b}m${s}${ESC}[49m`;
106
+ if (COLOR_LEVEL === 3) {
107
+ return (s) => `${ESC}[48;2;${r};${g};${b}m${s}${ESC}[49m`;
108
+ }
109
+ if (COLOR_LEVEL === 2) {
110
+ const n = rgbTo256(r, g, b);
111
+ return (s) => `${ESC}[48;5;${n}m${s}${ESC}[49m`;
112
+ }
113
+ const code = rgbTo16(r, g, b) + 10; // fg 30-97 → bg 40-107
114
+ return (s) => `${ESC}[${code}m${s}${ESC}[49m`;
19
115
  }
20
116
  function makeStyle(open, close) {
21
- if (noColor)
117
+ if (COLOR_LEVEL === 0)
22
118
  return identity;
23
119
  return (s) => `${ESC}[${open}m${s}${ESC}[${close}m`;
24
120
  }
121
+ // Convenience alias for callers that just want an RGB foreground (e.g. banner).
122
+ export const fg = makeFg;
123
+ // The detected level, exported for callers that want to branch on it.
124
+ export const colorLevel = COLOR_LEVEL;
25
125
  // ── Text style helpers ──────────────────────────────────────────────────
26
126
  export const bold = makeStyle(1, 22);
27
127
  export const dim = makeStyle(2, 22);
@@ -179,7 +179,8 @@ export const addCommand = new Command('add')
179
179
  }
180
180
  const { name: labelName, files } = buildLocalPayload(resolved);
181
181
  const kind = sniffPayloadKind(files);
182
- console.log(muted(`Uploading ${files.length} file(s) from ${resolved} (${kind}) ...`));
182
+ // Progress goes to stderr so `gipity add --json` keeps stdout pure JSON.
183
+ console.error(muted(`Uploading ${files.length} file(s) from ${resolved} (${kind}) ...`));
183
184
  body = {
184
185
  name: labelName,
185
186
  title: opts.title,
@@ -58,11 +58,21 @@ approvalCommand
58
58
  approvalCommand
59
59
  .command('answer <guid> [selection...]')
60
60
  .description('Answer an approval: a = approve, b = deny, c = ignore, or free text for text-type')
61
+ .option('--deny', 'Resolve as denied; any trailing text is sent as feedback (e.g. requested edits)')
61
62
  .option('--json', 'Output as JSON')
62
63
  .action((guid, selectionParts, opts) => run('Answer', async () => {
63
64
  if (!guid.startsWith('ap_')) {
64
65
  throw new Error('Expected approval guid like ap_xxxxxxxx');
65
66
  }
67
+ // Deny with optional free-text feedback works for every response type -
68
+ // it's how a workflow gate's revision loop gets the user's edits
69
+ // ({{gate.action}} == rejected + {{gate.human_response}}).
70
+ if (opts.deny) {
71
+ const feedback = selectionParts.join(' ').trim();
72
+ await post(`/approvals/${guid}/resolve`, { status: 'denied', response: feedback || undefined });
73
+ printResult(`Denied${feedback ? `: ${feedback}` : '.'}`, opts, { guid, status: 'denied' });
74
+ return;
75
+ }
66
76
  const detailRes = await get(`/approvals/${guid}`);
67
77
  const detail = detailRes.data;
68
78
  const first = selectionParts[0];
@@ -19,7 +19,7 @@ function resolveCommand(cmd) {
19
19
  }
20
20
  import { getAuth, saveAuth, clearAuth } from '../auth.js';
21
21
  import { get, post, publicPost, ApiError, getAccountSlug } from '../api.js';
22
- import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, getConfigPath } from '../config.js';
22
+ import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
23
23
  import { sync } from '../sync.js';
24
24
  import { slugify, setupClaudeHooks, setupClaudeMd, setupAgentsMd, setupGitignore, DEFAULT_SYNC_IGNORE, isSyncIgnored } from '../setup.js';
25
25
  import { buildProjectContextBlock as buildProjectContextBlockText, buildNewProjectPrompt, buildResumeWrap, buildFreshWrap, } from '../prompts.js';
@@ -219,6 +219,10 @@ export const claudeCommand = new Command('claude')
219
219
  .option('--project <slug>', 'Open an existing project by slug or id')
220
220
  .option('--here', 'Use the current directory instead of ~/GipityProjects/<slug>/')
221
221
  .option('--quiet', "Suppress Claude's live progress output (headless --new-project/--project runs)")
222
+ // Forwarded to `claude` via the unknown-arg passthrough below (NOT in the
223
+ // gipity strip lists). Declared here only so it shows up in --help — without
224
+ // this, callers can't discover that the session model is selectable.
225
+ .option('--model <model>', 'Model for the Claude session, forwarded to claude (e.g. sonnet, opus, or a full id like claude-sonnet-4-6)')
222
226
  .allowUnknownOption(true)
223
227
  .allowExcessArguments(true)
224
228
  .action(async (opts) => {
@@ -384,7 +388,7 @@ export const claudeCommand = new Command('claude')
384
388
  accountSlug,
385
389
  agentGuid,
386
390
  conversationGuid: null,
387
- apiBase: getApiBaseOverride() || 'https://a.gipity.ai',
391
+ apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
388
392
  ignore: DEFAULT_SYNC_IGNORE,
389
393
  });
390
394
  console.log(`\n Using ${projectDir}`);
@@ -470,7 +474,7 @@ export const claudeCommand = new Command('claude')
470
474
  err?.code === 'ETIMEDOUT' || err?.cause?.code === 'ECONNREFUSED' ||
471
475
  err?.cause?.code === 'ENOTFOUND' || err?.cause?.code === 'ETIMEDOUT';
472
476
  if (isConnectionError) {
473
- const apiBase = getApiBaseOverride() || 'https://a.gipity.ai';
477
+ const apiBase = getApiBaseOverride() || DEFAULT_API_BASE;
474
478
  console.error(` ${clrError(`Could not connect to ${apiBase}`)}`);
475
479
  console.error(` ${muted('Check your connection and try again.')}`);
476
480
  process.exit(1);
@@ -554,7 +558,7 @@ export const claudeCommand = new Command('claude')
554
558
  accountSlug,
555
559
  agentGuid,
556
560
  conversationGuid: null,
557
- apiBase: getApiBaseOverride() || 'https://a.gipity.ai',
561
+ apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
558
562
  ignore: DEFAULT_SYNC_IGNORE,
559
563
  });
560
564
  console.log(`\n Using ${projectDir}`);
@@ -20,7 +20,7 @@ export const deployCommand = new Command('deploy')
20
20
  .argument('[target]', 'dev or prod', 'dev')
21
21
  .option('--source-dir <dir>', 'Source directory to deploy from')
22
22
  .option('--only <phases>', 'Run only specific phases (comma-separated)')
23
- .option('--force', 'Re-run all phases, ignore checksums')
23
+ .option('--force', 'Re-run all phases (ignore checksums) and bypass the sync bulk-deletion guard')
24
24
  .option('--no-sync', 'Skip sync-up before deploy')
25
25
  .option('--optimize', 'Run build optimization')
26
26
  .option('--json', 'Output as JSON')
@@ -1,8 +1,9 @@
1
1
  import { Command } from 'commander';
2
- import { get, post } from '../api.js';
2
+ import { get, post, del } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { error as clrError, bold, muted, success } from '../colors.js';
5
5
  import { run, printList } from '../helpers/index.js';
6
+ import { confirm } from '../utils.js';
6
7
  export const fnCommand = new Command('fn')
7
8
  .description('Manage functions');
8
9
  fnCommand
@@ -45,4 +46,24 @@ fnCommand
45
46
  const res = await post(`/api/${config.projectGuid}/fn/${encodeURIComponent(name)}`, body);
46
47
  console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
47
48
  }));
49
+ fnCommand
50
+ .command('delete <name>')
51
+ .alias('rm')
52
+ .description('Delete a function')
53
+ .option('--yes', 'Skip confirmation')
54
+ .option('--json', 'Output as JSON')
55
+ .action((name, opts) => run('Delete', async () => {
56
+ const config = requireConfig();
57
+ if (!await confirm(`Delete function '${name}'? This cannot be undone.`, { skip: opts.yes })) {
58
+ console.log('Cancelled.');
59
+ return;
60
+ }
61
+ await del(`/projects/${config.projectGuid}/functions/${encodeURIComponent(name)}`);
62
+ if (opts.json) {
63
+ console.log(JSON.stringify({ name, deleted: true }));
64
+ }
65
+ else {
66
+ console.log(success(`Deleted function '${name}'.`));
67
+ }
68
+ }));
48
69
  //# sourceMappingURL=fn.js.map
@@ -3,6 +3,8 @@ import { Command } from 'commander';
3
3
  import { post, get, ApiError } from '../api.js';
4
4
  import { brand, bold, muted, warning } from '../colors.js';
5
5
  import { run } from '../helpers/index.js';
6
+ import { resolveProjectContext } from '../config.js';
7
+ import { uploadPublicFixture, deleteFixture } from '../page-fixtures.js';
6
8
  // Shown when an eval runs cleanly but returns nothing serializable. Turns a
7
9
  // bare/opaque `null` into a deterministic, actionable nudge so the agent shapes
8
10
  // a returnable value instead of guessing and retrying.
@@ -135,6 +137,7 @@ export const pageEvalCommand = new Command('eval')
135
137
  .argument('<url>', 'URL to load')
136
138
  .argument('[expr]', 'JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file.')
137
139
  .option('--file <path>', 'Read the script body from a file instead of the inline <expr> arg (mutually exclusive). Runs as an async function body, so top-level return/await work.')
140
+ .option('--fixture <path>', 'Host a local file and expose it to the eval as `fixtureUrl` (and under `fixtures` by basename) to fetch in-page. For verifying a render/parse path against a real binary (an MP3, an image) - no size limit, auto-deleted after the run. Repeat for several files (single-value so it never swallows the inline <expr>).', (val, prev) => [...prev, val], [])
138
141
  .option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)', '500')
139
142
  .option('--wait-for <selector>', 'Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)')
140
143
  .option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
@@ -161,30 +164,66 @@ export const pageEvalCommand = new Command('eval')
161
164
  const waitMs = capWaitMs(opts.wait, url);
162
165
  const parsedTimeout = parseInt(opts.waitTimeout, 10);
163
166
  const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5000;
164
- const kickoff = await post('/tools/browser/eval', {
165
- url, expr, waitMs,
166
- waitForSelector: opts.waitFor || undefined,
167
- waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
168
- });
169
- const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
170
- const { result, noValue } = normalizeEvalResult(d.result);
171
- const execTimeout = evalExecTimeoutMessage(d.result);
172
- if (execTimeout)
173
- throw new Error(execTimeout);
174
- if (opts.json) {
175
- console.log(JSON.stringify(noValue ? { ...d, result, hint: EVAL_NO_VALUE_HINT } : { ...d, result }));
176
- return;
167
+ // --fixture: host each file publicly, then splice `fixtures` / `fixtureUrl`
168
+ // into the eval scope so the page can fetch the bytes. The prelude makes the
169
+ // body a statement (const/return), so the server's expression form fails to
170
+ // parse and it falls back to the function-body form - which runs both inline
171
+ // exprs (wrapped in `return (...)`) and --file scripts. Cleanup in `finally`.
172
+ const fixturePaths = opts.fixture ?? [];
173
+ const hosted = [];
174
+ let projectGuid;
175
+ let sentExpr = expr;
176
+ try {
177
+ if (fixturePaths.length) {
178
+ const { config } = await resolveProjectContext({});
179
+ projectGuid = config.projectGuid;
180
+ for (const p of fixturePaths) {
181
+ console.log(muted(`Hosting fixture ${p}…`));
182
+ hosted.push(await uploadPublicFixture(projectGuid, p));
183
+ }
184
+ const map = {};
185
+ for (const h of hosted)
186
+ map[h.name] = h.url;
187
+ const prelude = `const fixtures=${JSON.stringify(map)};const fixtureUrl=${JSON.stringify(hosted[0].url)};`;
188
+ sentExpr = opts.file ? `${prelude}\n${expr}` : `${prelude}\nreturn (${expr});`;
189
+ }
190
+ const kickoff = await post('/tools/browser/eval', {
191
+ url, expr: sentExpr, waitMs,
192
+ waitForSelector: opts.waitFor || undefined,
193
+ waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
194
+ });
195
+ const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
196
+ const { result, noValue } = normalizeEvalResult(d.result);
197
+ const execTimeout = evalExecTimeoutMessage(d.result);
198
+ if (execTimeout)
199
+ throw new Error(execTimeout);
200
+ if (opts.json) {
201
+ console.log(JSON.stringify(noValue ? { ...d, result, hint: EVAL_NO_VALUE_HINT } : { ...d, result }));
202
+ return;
203
+ }
204
+ console.log(`${brand('Eval')} ${bold(d.url || url)}`);
205
+ if (d.navigationIncomplete) {
206
+ console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
207
+ }
208
+ if (hosted.length)
209
+ console.log(`${muted('Fixtures:')} ${hosted.map((h) => h.name).join(', ')}`);
210
+ console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${expr}`);
211
+ console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
212
+ if (noValue)
213
+ console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
214
+ if (d.truncated)
215
+ console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
177
216
  }
178
- console.log(`${brand('Eval')} ${bold(d.url || url)}`);
179
- if (d.navigationIncomplete) {
180
- console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
217
+ finally {
218
+ for (const h of hosted) {
219
+ try {
220
+ await deleteFixture(projectGuid, h.guid);
221
+ }
222
+ catch (err) {
223
+ console.error(warning(`⚠ Could not auto-delete fixture "${h.name}" (${h.guid}) — still hosted at ${h.url}: ${err.message}`));
224
+ }
225
+ }
181
226
  }
182
- console.log(opts.file ? `${muted('Script:')} ${opts.file}` : `${muted('Expression:')} ${expr}`);
183
- console.log(`\n${result.trim() ? result : muted('(empty result)')}`);
184
- if (noValue)
185
- console.log(muted(`\n${EVAL_NO_VALUE_HINT}`));
186
- if (d.truncated)
187
- console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
188
227
  }));
189
228
  // Each `page eval` call runs to completion before the next starts, so two evals
190
229
  // fired back-to-back never coexist in time - they CANNOT test whether two live
@@ -197,6 +236,10 @@ Examples:
197
236
  # Functionally test a page's own code paths: save a script that drives the UI
198
237
  # and returns a JSON-serializable result, then run it (no /tmp + shell quoting):
199
238
  gipity page eval "https://dev.gipity.ai/me/app/" --file ./tests/draw-flow.js --json
239
+ # Verify a render/parse path against a REAL file: --fixture hosts it, injects a
240
+ # fetch-able 'fixtureUrl', runs the eval, then deletes the hosted copy:
241
+ gipity page eval "https://dev.gipity.ai/me/app/" --fixture ./sample.mp3 \\
242
+ "(async()=>{ const b = await fetch(fixtureUrl).then(r=>r.arrayBuffer()); return window.App.parseId3(b); })()"
200
243
 
201
244
  The eval body runs under a ~20s in-page execution budget (its own await/setTimeout
202
245
  pauses count; --wait only sleeps BEFORE the eval and does not extend it). For a long
@@ -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