gipity 1.0.386 → 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.
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' },
@@ -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,
@@ -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';
@@ -384,7 +384,7 @@ export const claudeCommand = new Command('claude')
384
384
  accountSlug,
385
385
  agentGuid,
386
386
  conversationGuid: null,
387
- apiBase: getApiBaseOverride() || 'https://a.gipity.ai',
387
+ apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
388
388
  ignore: DEFAULT_SYNC_IGNORE,
389
389
  });
390
390
  console.log(`\n Using ${projectDir}`);
@@ -470,7 +470,7 @@ export const claudeCommand = new Command('claude')
470
470
  err?.code === 'ETIMEDOUT' || err?.cause?.code === 'ECONNREFUSED' ||
471
471
  err?.cause?.code === 'ENOTFOUND' || err?.cause?.code === 'ETIMEDOUT';
472
472
  if (isConnectionError) {
473
- const apiBase = getApiBaseOverride() || 'https://a.gipity.ai';
473
+ const apiBase = getApiBaseOverride() || DEFAULT_API_BASE;
474
474
  console.error(` ${clrError(`Could not connect to ${apiBase}`)}`);
475
475
  console.error(` ${muted('Check your connection and try again.')}`);
476
476
  process.exit(1);
@@ -554,7 +554,7 @@ export const claudeCommand = new Command('claude')
554
554
  accountSlug,
555
555
  agentGuid,
556
556
  conversationGuid: null,
557
- apiBase: getApiBaseOverride() || 'https://a.gipity.ai',
557
+ apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
558
558
  ignore: DEFAULT_SYNC_IGNORE,
559
559
  });
560
560
  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')
@@ -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
@@ -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
@@ -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) {
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from 'fs';
2
2
  import { dirname, resolve } from 'path';
3
3
  const CONFIG_FILE = '.gipity.json';
4
+ export const DEFAULT_API_BASE = 'https://a.gipity.ai';
4
5
  let cached = null;
5
6
  let cachedPath = null;
6
7
  /** Global --api-base override (set from root CLI option, takes precedence over config file) */
@@ -11,6 +12,49 @@ export function setApiBaseOverride(url) {
11
12
  export function getApiBaseOverride() {
12
13
  return apiBaseOverride;
13
14
  }
15
+ /**
16
+ * Hosts we will attach the account/device token to. `.gipity.json` is found by
17
+ * walking up from cwd, so its `apiBase` is attacker-controllable: cloning a repo
18
+ * (or installing a template) that ships `{"apiBase":"https://evil.example"}`
19
+ * would otherwise redirect the very next `gipity` command's bearer — and, on
20
+ * refresh, the 7-day refresh token — to that host. Tokens are account-global, so
21
+ * that's account takeover from merely cd-ing into a poisoned tree. Only Gipity
22
+ * hosts over https may receive tokens; the explicit `--api-base` flag is trusted
23
+ * (it's how local dev points at localhost). */
24
+ export function isAllowedApiHost(url) {
25
+ try {
26
+ const { protocol, hostname } = new URL(url);
27
+ if (protocol !== 'https:')
28
+ return false;
29
+ return hostname === 'gipity.ai' || hostname.endsWith('.gipity.ai');
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ const warnedHosts = new Set();
36
+ /**
37
+ * The API base for token-bearing requests. Precedence:
38
+ * 1. explicit `--api-base` flag (trusted — any host, enables local dev)
39
+ * 2. the project config's `apiBase`, but only if it's an allowed Gipity host
40
+ * 3. the production default
41
+ * A config `apiBase` that fails the allowlist is dropped (with a one-time
42
+ * warning) rather than trusted — see {@link isAllowedApiHost}. */
43
+ export function resolveApiBase() {
44
+ const override = getApiBaseOverride();
45
+ if (override)
46
+ return override;
47
+ const fromConfig = getConfig()?.apiBase;
48
+ if (fromConfig) {
49
+ if (isAllowedApiHost(fromConfig))
50
+ return fromConfig;
51
+ if (!warnedHosts.has(fromConfig)) {
52
+ warnedHosts.add(fromConfig);
53
+ console.error(`⚠ Ignoring untrusted apiBase "${fromConfig}" from .gipity.json — not a gipity.ai host. Using ${DEFAULT_API_BASE}.`);
54
+ }
55
+ }
56
+ return DEFAULT_API_BASE;
57
+ }
14
58
  /** Find .gipity.json starting from cwd and walking up */
15
59
  function findConfigPath() {
16
60
  let dir = process.cwd();
@@ -92,7 +136,7 @@ export async function resolveProjectContext(opts) {
92
136
  accountSlug,
93
137
  agentGuid: agents.data[0]?.short_guid ?? '',
94
138
  conversationGuid: null,
95
- apiBase: getApiBaseOverride() || 'https://a.gipity.ai',
139
+ apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
96
140
  ignore: [],
97
141
  },
98
142
  oneOff: true,
@@ -121,7 +165,7 @@ export async function resolveProjectContext(opts) {
121
165
  accountSlug: res.data.accountSlug,
122
166
  agentGuid: res.data.agentGuid ?? '',
123
167
  conversationGuid: null,
124
- apiBase: getApiBaseOverride() || 'https://a.gipity.ai',
168
+ apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
125
169
  ignore: [],
126
170
  },
127
171
  oneOff: true,
@@ -7,12 +7,14 @@ import { muted, error as clrError } from '../colors.js';
7
7
  * Sync local files with the server before an action (deploy, test, scaffold).
8
8
  * Respects --no-sync and --json flags. Non-interactive: bulk-deletion guard
9
9
  * blocks accidental wipes from hooks; user must run `gipity sync` manually
10
- * (or pass --force) to unblock.
10
+ * (or pass --force) to unblock. `--force` on the action (e.g. `deploy --force`)
11
+ * forwards to the sync so it bypasses the guard too - one flag, forceful across
12
+ * the board, matching `gipity sync --force`.
11
13
  */
12
14
  export async function syncBeforeAction(opts) {
13
15
  if (opts.sync === false)
14
16
  return;
15
- const result = await sync({ interactive: false });
17
+ const result = await sync({ interactive: false, force: opts.force });
16
18
  if (result.applied > 0 && !opts.json) {
17
19
  console.log(muted(`Synced ${result.applied} change${result.applied > 1 ? 's' : ''}`));
18
20
  }
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ import { planCommand } from './commands/plan.js';
28
28
  import { fileCommand } from './commands/file.js';
29
29
  import { claudeCommand } from './commands/claude.js';
30
30
  import { addCommand } from './commands/add.js';
31
+ import { removeCommand } from './commands/remove.js';
31
32
  import { logsCommand } from './commands/logs.js';
32
33
  import { pageCommand } from './commands/page.js';
33
34
  import { recordsCommand } from './commands/records.js';
@@ -100,7 +101,7 @@ const program = new Command();
100
101
  // --from Y`). enablePositionalOptions draws the boundary at the first command.
101
102
  program.enablePositionalOptions();
102
103
  // ── Command groups (logical ordering within each) ──────────────────────
103
- const commonGroup = [skillCommand, projectCommand, addCommand, deployCommand];
104
+ const commonGroup = [skillCommand, projectCommand, addCommand, removeCommand, deployCommand];
104
105
  const connectGroup = [claudeCommand, relayCommand];
105
106
  const projectGroup = [domainCommand, statusCommand, initCommand];
106
107
  const filesGroup = [fileCommand, syncCommand, pushCommand, uploadCommand];
@@ -0,0 +1,41 @@
1
+ // Host a local file as a public asset so a `gipity page eval` can fetch it
2
+ // in-page, then delete it afterwards. The eval body runs inside the browser and
3
+ // can only receive bulk/binary data over HTTP (the eval source itself is capped
4
+ // and goes through the OS argv limit), so to verify a render/parse path against
5
+ // a real fixture we upload it to the app's public file store (served from
6
+ // media.gipity.ai with permissive CORS) and hand the eval a URL to fetch.
7
+ //
8
+ // Uses the same presigned init -> PUT -> complete flow the app file-upload
9
+ // service exposes, with `public: true`. Cleanup goes through the matching
10
+ // DELETE /api/:appGuid/uploads/:guid, which removes the public object too.
11
+ import { readFileSync, statSync } from 'node:fs';
12
+ import { basename } from 'node:path';
13
+ import { post, del } from './api.js';
14
+ import { guessMime } from './upload.js';
15
+ /** Upload a local file to the app's public file store and return its URL. */
16
+ export async function uploadPublicFixture(projectGuid, localPath) {
17
+ const name = basename(localPath);
18
+ const size = statSync(localPath).size;
19
+ const contentType = guessMime(localPath);
20
+ const init = await post(`/api/${projectGuid}/uploads/init`, { filename: name, content_type: contentType, size, public: true });
21
+ // Fixtures use the single-part path; a multipart fixture would be unusually
22
+ // large for a verification asset, so steer the caller to a smaller file.
23
+ if (init.data.method !== 'PUT' || !init.data.url) {
24
+ throw new Error(`fixture "${name}" is too large to host as a verification asset (${size} bytes)`);
25
+ }
26
+ const res = await fetch(init.data.url, {
27
+ method: 'PUT',
28
+ headers: { 'Content-Type': contentType },
29
+ body: readFileSync(localPath),
30
+ });
31
+ if (!res.ok) {
32
+ throw new Error(`upload of fixture "${name}" failed: ${res.status} ${res.statusText}`);
33
+ }
34
+ const done = await post(`/api/${projectGuid}/uploads/complete`, { upload_guid: init.data.upload_guid });
35
+ return { guid: done.data.guid, url: done.data.url, name, localPath };
36
+ }
37
+ /** Delete a hosted fixture (public object + VFS node). */
38
+ export async function deleteFixture(projectGuid, guid) {
39
+ await del(`/api/${projectGuid}/uploads/${guid}`);
40
+ }
41
+ //# sourceMappingURL=page-fixtures.js.map
@@ -4,7 +4,7 @@
4
4
  * drop the Claude Code hooks/skills/gitignore into the target dir - consolidating
5
5
  * here keeps both call sites honest and the wording consistent.
6
6
  */
7
- import { clearConfigCache, saveConfigAt, getApiBaseOverride } from './config.js';
7
+ import { clearConfigCache, saveConfigAt, getApiBaseOverride, DEFAULT_API_BASE } from './config.js';
8
8
  import { sync } from './sync.js';
9
9
  import { createProgressReporter } from './progress.js';
10
10
  import { setupClaudeHooks, setupGitignore, DEFAULT_TOOLS, DEFAULT_SYNC_IGNORE } from './setup.js';
@@ -20,7 +20,7 @@ export async function finalizeLocalProject(opts) {
20
20
  accountSlug: opts.accountSlug,
21
21
  agentGuid: opts.agentGuid,
22
22
  conversationGuid: null,
23
- apiBase: getApiBaseOverride() || 'https://a.gipity.ai',
23
+ apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
24
24
  ignore: [...DEFAULT_SYNC_IGNORE],
25
25
  };
26
26
  saveConfigAt(opts.dir, config);
@@ -26,7 +26,7 @@ import { stat, readFile } from 'fs/promises';
26
26
  import { createInterface } from 'readline';
27
27
  import { homedir, hostname, platform as osPlatform } from 'os';
28
28
  import { join } from 'path';
29
- import { getApiBaseOverride, getConfig } from '../config.js';
29
+ import { getApiBaseOverride, DEFAULT_API_BASE } from '../config.js';
30
30
  import { getProjectsRoot } from './paths.js';
31
31
  import { setupClaudeHooks, setupClaudeMd, setupAgentsMd, setupGitignore, DEFAULT_SYNC_IGNORE } from '../setup.js';
32
32
  import { getAuth, readAuthFresh } from '../auth.js';
@@ -782,7 +782,7 @@ async function resolveCwdForProject(d) {
782
782
  }
783
783
  log('info', 'bootstrapping new project dir', { slug: d.project_slug, path });
784
784
  mkdirSync(path, { recursive: true });
785
- const apiBase = getApiBaseOverride() || getConfig()?.apiBase || 'https://a.gipity.ai';
785
+ const apiBase = getApiBaseOverride() || DEFAULT_API_BASE;
786
786
  writeFileSync(configPath, JSON.stringify({
787
787
  projectGuid: d.project_guid,
788
788
  projectSlug: d.project_slug,
@@ -8,10 +8,10 @@
8
8
  * `gipity login` / the relay onboarding flow and never leaves this file
9
9
  * or the Authorization header.
10
10
  */
11
- import { getApiBaseOverride, getConfig } from '../config.js';
11
+ import { resolveApiBase } from '../config.js';
12
12
  import * as state from './state.js';
13
13
  export function apiBase() {
14
- return getApiBaseOverride() || getConfig()?.apiBase || 'https://a.gipity.ai';
14
+ return resolveApiBase();
15
15
  }
16
16
  export function deviceToken() {
17
17
  const d = state.getDevice();
package/dist/sync.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * the next pass so every client sees it. No content merging, ever.
23
23
  */
24
24
  import { writeFileSync, mkdirSync, existsSync, statSync, unlinkSync, readdirSync, rmdirSync, readFileSync, renameSync, openSync, closeSync } from 'fs';
25
- import { join, relative, dirname, extname } from 'path';
25
+ import { join, relative, dirname, extname, resolve, sep } from 'path';
26
26
  import { hostname } from 'os';
27
27
  import { get, del, downloadStream, ApiError } from './api.js';
28
28
  import { requireConfig, shouldIgnore, getConfigPath } from './config.js';
@@ -189,6 +189,23 @@ async function ensureLocalHashes(root, local, paths) {
189
189
  function normalizeTreePath(p) {
190
190
  return p.replace(/^\/+/, '');
191
191
  }
192
+ /**
193
+ * Resolve a relative path against the project root and assert it stays inside.
194
+ * Remote-supplied paths (the server's `/files/tree`, tar entry names, and the
195
+ * conflict-rename targets derived from them) are untrusted: `normalizeTreePath`
196
+ * strips a leading slash but NOT `..` segments, so a path like
197
+ * `../../.ssh/authorized_keys` would otherwise resolve outside the project and
198
+ * be written/renamed/deleted there. The relay daemon runs `sync` unattended on
199
+ * every dispatch, so an unchecked traversal is arbitrary file write with no
200
+ * human in the loop. Throws on escape; callers skip the offending action. */
201
+ export function resolveInRoot(root, relPath) {
202
+ const rootResolved = resolve(root);
203
+ const full = resolve(rootResolved, relPath);
204
+ if (full !== rootResolved && !full.startsWith(rootResolved + sep)) {
205
+ throw new Error(`Refusing path outside project root: ${relPath}`);
206
+ }
207
+ return full;
208
+ }
192
209
  async function fetchRemote(projectGuid) {
193
210
  const res = await get(`/projects/${projectGuid}/files/tree`);
194
211
  const out = new Map();
@@ -223,6 +240,22 @@ async function downloadAll(projectGuid, onBytes) {
223
240
  });
224
241
  }
225
242
  async function fetchOne(projectGuid, path) {
243
+ // Exact single-file read first. The tree-tar endpoint below treats its `path`
244
+ // as a DIRECTORY prefix, so a single root file (e.g. `gipity.yaml`) comes back
245
+ // empty — which silently broke conflict restores and trapped sync in an
246
+ // unresolvable delete-vs-newer loop. `/files/read` is the exact-path endpoint
247
+ // (what `gipity file cat` uses); it returns text content, reliable for the
248
+ // config/code files that actually hit a restore. Binary falls through to tar.
249
+ try {
250
+ const res = await get(`/projects/${projectGuid}/files/read?path=${encodeURIComponent(path)}`);
251
+ const content = res?.data?.content;
252
+ if (typeof content === 'string' && isTextMime(res?.data?.mime, path)) {
253
+ return Buffer.from(content, 'utf-8');
254
+ }
255
+ }
256
+ catch {
257
+ /* fall through to the tar path */
258
+ }
226
259
  try {
227
260
  const stream = await downloadStream(`/projects/${projectGuid}/files/tree?content=tar&path=${encodeURIComponent(path)}`);
228
261
  const extract = tar.extract();
@@ -248,6 +281,14 @@ async function fetchOne(projectGuid, path) {
248
281
  return null;
249
282
  }
250
283
  }
284
+ // Treat a file as text (safe to round-trip through `/files/read`'s string body)
285
+ // from its mime or, failing that, a code/config extension. Binary needs the
286
+ // byte-exact tar path.
287
+ function isTextMime(mime, path) {
288
+ if (mime && (mime.startsWith('text/') || /(json|javascript|xml|yaml|x-sh|sql)/.test(mime)))
289
+ return true;
290
+ return /\.(js|mjs|cjs|ts|tsx|jsx|json|yaml|yml|sql|md|txt|html|css|svg|csv|env|sh|toml|ini)$/i.test(path);
291
+ }
251
292
  // ─── Classification ────────────────────────────────────────────
252
293
  function classifyLocal(info, base) {
253
294
  if (!info && !base)
@@ -373,9 +414,13 @@ export function plan(local, remote, baseline) {
373
414
  // deleted × absent → baseline is stale, drop it silently (no action)
374
415
  if (lSide === 'deleted' && rSide === 'absent')
375
416
  continue;
376
- // deleted × unchanged → delete remote
417
+ // deleted × unchanged → delete remote. Use the remote's CURRENT version for
418
+ // the optimistic-delete check, not the baseline's: the content can be equal
419
+ // (rSide 'unchanged' is sha-based) while the server version moved ahead - the
420
+ // baseline version would then fail the CAS and the delete would loop. The
421
+ // remote read we already have carries the live version.
377
422
  if (lSide === 'deleted' && rSide === 'unchanged') {
378
- actions.push({ path, kind: 'delete-remote', remoteSize: R.size, expectedServerVersion: B.serverVersion });
423
+ actions.push({ path, kind: 'delete-remote', remoteSize: R.size, expectedServerVersion: R.serverVersion });
379
424
  continue;
380
425
  }
381
426
  // deleted × modified → remote wins, restore locally — but only if the remote
@@ -603,7 +648,14 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
603
648
  errors.push(`Download missing: ${a.path}`);
604
649
  continue;
605
650
  }
606
- const full = join(root, a.path);
651
+ let full;
652
+ try {
653
+ full = resolveInRoot(root, a.path);
654
+ }
655
+ catch (e) {
656
+ errors.push(e.message);
657
+ continue;
658
+ }
607
659
  mkdirSync(dirname(full), { recursive: true });
608
660
  writeFileSync(full, buf);
609
661
  const stat = statSync(full);
@@ -619,8 +671,16 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
619
671
  // upload the renamed copy. If the upload of the renamed copy fails, we
620
672
  // still keep the rename on disk - next sync picks it up as "added".
621
673
  for (const a of conflictQueue) {
622
- const full = join(root, a.path);
623
- const renamed = join(root, a.renamedLocalTo);
674
+ let full;
675
+ let renamed;
676
+ try {
677
+ full = resolveInRoot(root, a.path);
678
+ renamed = resolveInRoot(root, a.renamedLocalTo);
679
+ }
680
+ catch (e) {
681
+ errors.push(e.message);
682
+ continue;
683
+ }
624
684
  try {
625
685
  mkdirSync(dirname(renamed), { recursive: true });
626
686
  renameSync(full, renamed);
@@ -680,7 +740,14 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
680
740
  if (idx >= uploadQueue.length)
681
741
  return;
682
742
  const a = uploadQueue[idx];
683
- const full = join(root, a.path);
743
+ let full;
744
+ try {
745
+ full = resolveInRoot(root, a.path);
746
+ }
747
+ catch (e) {
748
+ errors.push(e.message);
749
+ continue;
750
+ }
684
751
  try {
685
752
  const result = await uploadOneFile(config.projectGuid, full, a.path, {
686
753
  expectedServerVersion: a.expectedServerVersion,
@@ -701,8 +768,16 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
701
768
  // re-upload the rename.
702
769
  const currentBytes = await fetchOne(config.projectGuid, a.path);
703
770
  const renamedRel = conflictedCopyName(a.path);
771
+ let renamedFull;
772
+ try {
773
+ renamedFull = resolveInRoot(root, renamedRel);
774
+ }
775
+ catch (e) {
776
+ errors.push(e.message);
777
+ continue;
778
+ }
704
779
  try {
705
- renameSync(full, join(root, renamedRel));
780
+ renameSync(full, renamedFull);
706
781
  }
707
782
  catch (e) {
708
783
  errors.push(`Rename failed for ${a.path}: ${e.message}`);
@@ -719,9 +794,9 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
719
794
  };
720
795
  }
721
796
  try {
722
- const result = await uploadOneFile(config.projectGuid, join(root, renamedRel), renamedRel, { expectedServerVersion: null });
723
- const stat = statSync(join(root, renamedRel));
724
- const { sha256 } = await hashFile(join(root, renamedRel));
797
+ const result = await uploadOneFile(config.projectGuid, renamedFull, renamedRel, { expectedServerVersion: null });
798
+ const stat = statSync(renamedFull);
799
+ const { sha256 } = await hashFile(renamedFull);
725
800
  baseline.files[renamedRel] = {
726
801
  size: stat.size, mtime: stat.mtime.toISOString(),
727
802
  sha256, serverVersion: result.serverVersion,
@@ -744,9 +819,9 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
744
819
  for (const a of plannedToApply) {
745
820
  if (a.kind === 'delete-local') {
746
821
  try {
747
- unlinkSync(join(root, a.path));
822
+ unlinkSync(resolveInRoot(root, a.path));
748
823
  }
749
- catch { /* already gone */ }
824
+ catch { /* already gone or outside root */ }
750
825
  delete baseline.files[a.path];
751
826
  applied++;
752
827
  }
@@ -773,7 +848,7 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
773
848
  const buf = await fetchOne(config.projectGuid, a.path);
774
849
  if (!buf)
775
850
  throw new Error('remote bytes unavailable');
776
- const full = join(root, a.path);
851
+ const full = resolveInRoot(root, a.path);
777
852
  mkdirSync(dirname(full), { recursive: true });
778
853
  writeFileSync(full, buf);
779
854
  const stat = statSync(full);
@@ -787,7 +862,13 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
787
862
  errors.push(`Could not delete ${a.path}: server has a newer version - restored the server copy locally (delete it again and re-sync to confirm)`);
788
863
  }
789
864
  catch {
790
- errors.push(`Could not delete ${a.path}: server has newer version - re-sync to resolve`);
865
+ // Restore failed (e.g. the bytes truly couldn't be fetched). DROP the
866
+ // baseline entry rather than leave a stale one: a stale entry re-plans
867
+ // the same impossible delete every run (the original loop). With no
868
+ // baseline, the next sync re-evaluates this path from scratch — as a
869
+ // remote 'added' it downloads cleanly, no loop.
870
+ delete baseline.files[a.path];
871
+ errors.push(`Could not delete ${a.path}: server has a newer version - reset its sync state; re-run \`gipity sync\` to pull the server copy.`);
791
872
  }
792
873
  }
793
874
  else if (err instanceof ApiError && err.statusCode === 404) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.386",
3
+ "version": "1.0.387",
4
4
  "description": "The full-stack platform tuned for AI agents. Database, storage, auth, functions, deploy, and drop-in kits - all agent-tuned. Pair with Claude Code or use standalone.",
5
5
  "bin": {
6
6
  "gipity": "dist/updater/shim.js",
@@ -12,7 +12,7 @@
12
12
  "build": "tsc && chmod +x dist/index.js dist/gipcc.js dist/gipccd.js dist/updater/shim.js dist/updater/check.js",
13
13
  "dev": "tsc --watch",
14
14
  "test": "npm run test:smoke",
15
- "test:smoke": "tsc && node --test dist/__tests__/utils.test.js dist/__tests__/config.test.js dist/__tests__/sync.test.js dist/__tests__/sync-apply.test.js dist/__tests__/sync-lock.test.js dist/__tests__/push-cas.test.js dist/__tests__/upload.test.js dist/__tests__/progress.test.js dist/__tests__/updater.test.js dist/__tests__/cli-smoke.test.js dist/__tests__/claude-noninteractive.test.js dist/__tests__/claude-trust.test.js dist/__tests__/relay-state.test.js dist/__tests__/relay-daemon.test.js dist/__tests__/relay-installers.test.js dist/__tests__/relay-bridge-abort.test.js dist/__tests__/relay-redact.test.js dist/__tests__/relay-machine-id.test.js dist/__tests__/stream-json.test.js dist/__tests__/relay-ingest-contract.test.js dist/__tests__/prompts.test.js dist/__tests__/capture-transcript.test.js dist/__tests__/flag-aliases.test.js dist/__tests__/adopt-cwd.test.js dist/__tests__/cli-cmd-agent.test.js dist/__tests__/cli-cmd-approval.test.js dist/__tests__/cli-cmd-audit.test.js dist/__tests__/cli-cmd-chat.test.js dist/__tests__/cli-cmd-credits.test.js dist/__tests__/cli-cmd-db.test.js dist/__tests__/cli-cmd-deploy.test.js dist/__tests__/cli-cmd-domain.test.js dist/__tests__/cli-cmd-email.test.js dist/__tests__/cli-cmd-file.test.js dist/__tests__/cli-cmd-fn.test.js dist/__tests__/cli-cmd-service.test.js dist/__tests__/cli-cmd-job.test.js dist/__tests__/cli-cmd-generate.test.js dist/__tests__/cli-cmd-gmail.test.js dist/__tests__/cli-cmd-info.test.js dist/__tests__/cli-cmd-init.test.js dist/__tests__/cli-cmd-location.test.js dist/__tests__/cli-cmd-text.test.js dist/__tests__/cli-cmd-login.test.js dist/__tests__/cli-cmd-logout.test.js dist/__tests__/cli-cmd-logs.test.js dist/__tests__/cli-cmd-memory.test.js dist/__tests__/cli-cmd-page.test.js dist/__tests__/cli-cmd-plan.test.js dist/__tests__/cli-cmd-project.test.js dist/__tests__/cli-cmd-rbac.test.js dist/__tests__/cli-cmd-realtime.test.js dist/__tests__/cli-cmd-records.test.js dist/__tests__/cli-cmd-relay.test.js dist/__tests__/cli-cmd-sandbox.test.js dist/__tests__/cli-cmd-add.test.js dist/__tests__/cli-cmd-skill.test.js dist/__tests__/cli-cmd-test.test.js dist/__tests__/cli-cmd-workflow.test.js dist/__tests__/setup-skills-block.test.js dist/__tests__/setup-hooks.test.js",
15
+ "test:smoke": "tsc && node --test dist/__tests__/utils.test.js dist/__tests__/config.test.js dist/__tests__/sync.test.js dist/__tests__/sync-apply.test.js dist/__tests__/sync-lock.test.js dist/__tests__/push-cas.test.js dist/__tests__/upload.test.js dist/__tests__/progress.test.js dist/__tests__/updater.test.js dist/__tests__/cli-smoke.test.js dist/__tests__/claude-noninteractive.test.js dist/__tests__/claude-trust.test.js dist/__tests__/relay-state.test.js dist/__tests__/relay-daemon.test.js dist/__tests__/relay-installers.test.js dist/__tests__/relay-bridge-abort.test.js dist/__tests__/relay-redact.test.js dist/__tests__/relay-machine-id.test.js dist/__tests__/stream-json.test.js dist/__tests__/relay-ingest-contract.test.js dist/__tests__/prompts.test.js dist/__tests__/capture-transcript.test.js dist/__tests__/flag-aliases.test.js dist/__tests__/adopt-cwd.test.js dist/__tests__/cli-cmd-agent.test.js dist/__tests__/cli-cmd-approval.test.js dist/__tests__/cli-cmd-audit.test.js dist/__tests__/cli-cmd-chat.test.js dist/__tests__/cli-cmd-credits.test.js dist/__tests__/cli-cmd-db.test.js dist/__tests__/cli-cmd-deploy.test.js dist/__tests__/cli-cmd-domain.test.js dist/__tests__/cli-cmd-email.test.js dist/__tests__/cli-cmd-file.test.js dist/__tests__/cli-cmd-fn.test.js dist/__tests__/cli-cmd-service.test.js dist/__tests__/cli-cmd-job.test.js dist/__tests__/cli-cmd-generate.test.js dist/__tests__/cli-cmd-gmail.test.js dist/__tests__/cli-cmd-info.test.js dist/__tests__/cli-cmd-init.test.js dist/__tests__/cli-cmd-location.test.js dist/__tests__/cli-cmd-text.test.js dist/__tests__/cli-cmd-login.test.js dist/__tests__/cli-cmd-logout.test.js dist/__tests__/cli-cmd-logs.test.js dist/__tests__/cli-cmd-memory.test.js dist/__tests__/cli-cmd-page.test.js dist/__tests__/cli-cmd-plan.test.js dist/__tests__/cli-cmd-project.test.js dist/__tests__/cli-cmd-rbac.test.js dist/__tests__/cli-cmd-realtime.test.js dist/__tests__/cli-cmd-records.test.js dist/__tests__/cli-cmd-relay.test.js dist/__tests__/cli-cmd-sandbox.test.js dist/__tests__/cli-cmd-add.test.js dist/__tests__/cli-cmd-remove.test.js dist/__tests__/cli-cmd-skill.test.js dist/__tests__/cli-cmd-test.test.js dist/__tests__/cli-cmd-workflow.test.js dist/__tests__/setup-skills-block.test.js dist/__tests__/setup-hooks.test.js",
16
16
  "test:e2e": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-live.test.js dist/__tests__/cli-e2e-services-media-live.test.js dist/__tests__/cli-e2e-workflow-live.test.js dist/__tests__/cli-e2e-sandbox-live.test.js dist/__tests__/cli-e2e-page-fetch-live.test.js dist/__tests__/cli-e2e-page-test-live.test.js",
17
17
  "test:e2e:sandbox": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sandbox-live.test.js"
18
18
  },