gipity 1.0.405 → 1.0.407

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,6 +1,6 @@
1
1
  import { Readable } from 'stream';
2
2
  import * as tar from 'tar-stream';
3
- import { getAuth, refreshTokenIfNeeded } from './auth.js';
3
+ import { getAuth, refreshTokenIfNeeded, forceRefreshAccessToken } from './auth.js';
4
4
  import { resolveApiBase, requireConfig, saveConfig } from './config.js';
5
5
  import { clientHeaders } from './client-context.js';
6
6
  export class ApiError extends Error {
@@ -53,6 +53,40 @@ async function bearerToken() {
53
53
  throw new Error('Not authenticated. Run: gipity login');
54
54
  return auth.accessToken;
55
55
  }
56
+ /** True when a long-lived agent API token (GIPITY_TOKEN) is in play. Such a token
57
+ * is static — it can't be refreshed — so the 401 self-heal path must skip it and
58
+ * the "run: gipity login" hint must not be shown (that's the wrong recovery for
59
+ * env-token callers). */
60
+ function usingEnvToken() {
61
+ return !!process.env.GIPITY_TOKEN?.trim();
62
+ }
63
+ // A transient 401 on a session token means the access token the server just saw
64
+ // is dead NOW — it lapsed at the refresh boundary, a sibling `gipity` process
65
+ // sharing this auth.json rotated the single-use refresh token out from under us,
66
+ // or clock skew put us past expiry while the 5-min refresh buffer still read
67
+ // "fresh". refreshTokenIfNeeded's fast-path skips a locally-fresh token, so we
68
+ // force a refresh and replay the call once. This self-heals the intermittent
69
+ // "Session expired" that surfaced on `page eval` (a kickoff POST plus a long GET
70
+ // poll loop crosses the refresh boundary many times). These two helpers carry
71
+ // that behavior to EVERY authenticated entry point — including the streaming and
72
+ // download paths (downloadStream → sync, postForTarEntries → page screenshot)
73
+ // that bypass request() — so one raced 401 can't hard-fail them either.
74
+ /** Decide whether to replay an authenticated call after a 401: force one token
75
+ * refresh and return true if the caller should retry. False for an env-token
76
+ * caller (static, unrefreshable), an already-retried call, a non-401, or an
77
+ * unrecoverable session — the caller then surfaces the error. */
78
+ async function shouldRetryAfter401(status, retried) {
79
+ if (status !== 401 || retried || usingEnvToken())
80
+ return false;
81
+ return forceRefreshAccessToken();
82
+ }
83
+ /** Append a re-login hint to a 401 message for session users. A 401 that
84
+ * survived the refresh-and-retry is an unrecoverable session (the refresh token
85
+ * is gone too); env-token callers authenticate differently, so don't misdirect
86
+ * them. */
87
+ function with401Hint(status, message) {
88
+ return status === 401 && !usingEnvToken() ? `${message} — run: gipity login` : message;
89
+ }
56
90
  async function getHeaders() {
57
91
  return {
58
92
  ...clientHeaders(),
@@ -74,7 +108,7 @@ export async function getAuthHeader() {
74
108
  const headers = await getHeaders();
75
109
  return headers.Authorization;
76
110
  }
77
- async function request(method, path, body) {
111
+ async function request(method, path, body, retrying = false) {
78
112
  const headers = await getHeaders();
79
113
  const url = `${baseUrl()}${path}`;
80
114
  const res = await fetchWithTimeout(url, {
@@ -82,10 +116,13 @@ async function request(method, path, body) {
82
116
  headers,
83
117
  body: body ? JSON.stringify(body) : undefined,
84
118
  }, REQUEST_TIMEOUT_MS, `${method} ${path}`);
119
+ if (await shouldRetryAfter401(res.status, retrying)) {
120
+ return request(method, path, body, true);
121
+ }
85
122
  if (!res.ok) {
86
123
  const json = await res.json().catch(() => ({ error: { code: 'UNKNOWN', message: res.statusText } }));
87
124
  const err = json.error || { code: 'UNKNOWN', message: res.statusText };
88
- throw new ApiError(res.status, err.code, err.message, json.data);
125
+ throw new ApiError(res.status, err.code, with401Hint(res.status, err.message), json.data);
89
126
  }
90
127
  return res.json();
91
128
  }
@@ -97,7 +134,7 @@ export function post(path, body) {
97
134
  }
98
135
  /** POST JSON and consume the response as a tar stream, returning each entry as
99
136
  * { name, buffer } in arrival order. Non-2xx responses are parsed as JSON errors. */
100
- export async function postForTarEntries(path, body) {
137
+ export async function postForTarEntries(path, body, retried = false) {
101
138
  const headers = await getHeaders();
102
139
  const url = `${baseUrl()}${path}`;
103
140
  const res = await fetch(url, {
@@ -105,10 +142,13 @@ export async function postForTarEntries(path, body) {
105
142
  headers,
106
143
  body: body ? JSON.stringify(body) : undefined,
107
144
  });
145
+ if (await shouldRetryAfter401(res.status, retried)) {
146
+ return postForTarEntries(path, body, true);
147
+ }
108
148
  if (!res.ok) {
109
149
  const json = await res.json().catch(() => ({ error: { code: 'UNKNOWN', message: res.statusText } }));
110
150
  const err = json.error || { code: 'UNKNOWN', message: res.statusText };
111
- throw new ApiError(res.status, err.code, err.message, json.data);
151
+ throw new ApiError(res.status, err.code, with401Hint(res.status, err.message), json.data);
112
152
  }
113
153
  if (!res.body)
114
154
  throw new ApiError(500, 'EMPTY_RESPONSE', 'Server returned no body');
@@ -158,18 +198,21 @@ export async function sendMessage(message) {
158
198
  return res.data.content;
159
199
  }
160
200
  /** Download a file as raw bytes (no JSON parsing) */
161
- export async function download(path) {
201
+ export async function download(path, retried = false) {
162
202
  const url = `${baseUrl()}${path}`;
163
203
  const res = await fetch(url, {
164
204
  headers: { ...clientHeaders(), 'Authorization': `Bearer ${await bearerToken()}` },
165
205
  });
206
+ if (await shouldRetryAfter401(res.status, retried)) {
207
+ return download(path, true);
208
+ }
166
209
  if (!res.ok) {
167
- throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
210
+ throw new ApiError(res.status, 'DOWNLOAD_ERROR', with401Hint(res.status, `Download failed: ${res.statusText}`));
168
211
  }
169
212
  return Buffer.from(await res.arrayBuffer());
170
213
  }
171
214
  /** Download a response as a Node.js Readable stream */
172
- export async function downloadStream(path) {
215
+ export async function downloadStream(path, retried = false) {
173
216
  const { Readable } = await import('stream');
174
217
  const url = `${baseUrl()}${path}`;
175
218
  // Bound time-to-first-byte only: abort if no RESPONSE within the header window,
@@ -194,8 +237,11 @@ export async function downloadStream(path) {
194
237
  finally {
195
238
  clearTimeout(headerTimer);
196
239
  }
240
+ if (await shouldRetryAfter401(res.status, retried)) {
241
+ return downloadStream(path, true);
242
+ }
197
243
  if (!res.ok) {
198
- throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
244
+ throw new ApiError(res.status, 'DOWNLOAD_ERROR', with401Hint(res.status, `Download failed: ${res.statusText}`));
199
245
  }
200
246
  return Readable.fromWeb(res.body);
201
247
  }
package/dist/auth.js CHANGED
@@ -149,15 +149,22 @@ export async function acquireRefreshLock() {
149
149
  * others wait, re-read the file, and ADOPT the freshly-rotated token. If the lock
150
150
  * can't be had in time we still try (re-reading first), so this never hangs a command.
151
151
  * Stays void / never throws / never clears auth: a genuine dead token still flows to
152
- * the caller's existing 401 path (which messages "run: gipity login"). */
153
- export async function refreshTokenIfNeeded() {
152
+ * the caller's existing 401 path (which messages "run: gipity login").
153
+ *
154
+ * `force` is for the post-401 recovery path: when the server has just REJECTED the
155
+ * access token, the local 5-min freshness check is no longer trustworthy (clock
156
+ * skew put us past expiry while the buffer still read "fresh", or a sibling rotated
157
+ * the refresh token out from under us). Force skips every "looks fresh, skip the
158
+ * refresh" shortcut and performs the refresh round-trip unconditionally, so a token
159
+ * the server considers dead actually gets replaced instead of re-sent. */
160
+ export async function refreshTokenIfNeeded(force = false) {
154
161
  const auth = readAuthFresh(); // never the cache — a sibling may have rotated
155
162
  if (!auth)
156
163
  return; // not logged in - caller throws the clean error
157
164
  cached = auth;
158
165
  const buffer = 5 * 60 * 1000; // refresh 5 min before the access token lapses
159
166
  const fresh = (a) => Date.now() <= new Date(a.expiresAt).getTime() - buffer;
160
- if (fresh(auth))
167
+ if (!force && fresh(auth))
161
168
  return; // fast path: token still good, no lock needed
162
169
  // If the refresh token itself has expired, re-login is genuinely required; leave the
163
170
  // expired auth in place so the caller's existing 401 path prompts `gipity login`.
@@ -167,9 +174,11 @@ export async function refreshTokenIfNeeded() {
167
174
  // Serialize with sibling processes so we don't race the single-use refresh token.
168
175
  const release = await acquireRefreshLock();
169
176
  try {
170
- // Re-check under the lock: a sibling may have refreshed while we waited.
177
+ // Re-check under the lock: a sibling may have refreshed while we waited. Under
178
+ // force we skip this adopt — a locally-"fresh" token is exactly what the server
179
+ // just rejected, so we must actually refresh rather than re-adopt it.
171
180
  const held = readAuthFresh();
172
- if (held && fresh(held)) {
181
+ if (!force && held && fresh(held)) {
173
182
  cached = held;
174
183
  return;
175
184
  }
@@ -177,7 +186,7 @@ export async function refreshTokenIfNeeded() {
177
186
  const apiBase = resolveApiBase();
178
187
  for (let attempt = 1; attempt <= 3; attempt++) {
179
188
  const cur = readAuthFresh(); // a sibling may have just refreshed for us
180
- if (cur && fresh(cur)) {
189
+ if (!force && cur && fresh(cur)) {
181
190
  cached = cur;
182
191
  return;
183
192
  }
@@ -225,4 +234,16 @@ export async function refreshTokenIfNeeded() {
225
234
  release();
226
235
  }
227
236
  }
237
+ /** Recover from a live 401 on a session token: force a refresh round-trip
238
+ * (bypassing the local freshness fast-path, since the server has already
239
+ * rejected the token the local clock still thinks is fine) and report whether
240
+ * we now hold a usable, unexpired access token. The API layer calls this once
241
+ * on a 401 and retries the request only when it returns true — turning a
242
+ * transient/raced "Session expired" into a self-healing retry instead of a hard
243
+ * failure. Returns false when the refresh token itself is gone (genuine
244
+ * re-login required), so the caller surfaces the error instead of looping. */
245
+ export async function forceRefreshAccessToken() {
246
+ await refreshTokenIfNeeded(true);
247
+ return !accessTokenExpired();
248
+ }
228
249
  //# sourceMappingURL=auth.js.map
@@ -849,6 +849,16 @@ export const claudeCommand = new Command('claude')
849
849
  cwd: process.cwd(),
850
850
  env: childEnv,
851
851
  });
852
+ // A spawn failure (missing/non-executable binary) arrives asynchronously via
853
+ // 'error', which the surrounding try/catch can't catch - without this handler
854
+ // Node crashes with a raw stack trace. claude is normally installed above, so
855
+ // this is a defensive fallback.
856
+ child.on('error', (err) => {
857
+ console.error(`\n ${clrError(err.code === 'ENOENT'
858
+ ? `Claude Code not found. Install it: npm install -g ${CLAUDE_PACKAGE}`
859
+ : `Failed to launch Claude Code: ${err.message}`)}`);
860
+ process.exit(1);
861
+ });
852
862
  child.on('exit', (code) => {
853
863
  const doneLine = `Done (${formatElapsed(Date.now() - runStart)})`;
854
864
  if (nonInteractive)
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { get } from '../api.js';
4
- import { brand, muted, success, error as clrError } from '../colors.js';
4
+ import { brand, muted } from '../colors.js';
5
5
  import { run, printList } from '../helpers/index.js';
6
6
  const PRICING_URL = 'https://prompt.gipity.ai/pricing';
7
7
  function openInBrowser(url) {
@@ -12,10 +12,17 @@ function openInBrowser(url) {
12
12
  process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] :
13
13
  ['xdg-open', [url]];
14
14
  try {
15
- spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
15
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
16
+ // A missing/non-executable launcher (ENOENT, or EACCES on WSL/minimal Linux
17
+ // where xdg-open isn't runnable) is reported ASYNCHRONOUSLY via an 'error'
18
+ // event - not a throw - so the try/catch alone can't stop it. With no
19
+ // listener Node escalates it to an unhandled 'error' and crashes the whole
20
+ // process. Swallow it; the caller already printed the URL as the fallback.
21
+ child.on('error', () => { });
22
+ child.unref();
16
23
  }
17
24
  catch {
18
- // Spawn failed (no xdg-open on minimal Linux, etc.) - caller prints the URL.
25
+ // Synchronous spawn failure - caller prints the URL.
19
26
  }
20
27
  }
21
28
  export const creditsCommand = new Command('credits')
@@ -50,40 +57,13 @@ creditsCommand
50
57
  openInBrowser(PRICING_URL);
51
58
  console.log(muted("If your browser didn't open automatically, copy the URL above."));
52
59
  }));
53
- creditsCommand
54
- .command('status')
55
- .description('Show billing/Stripe configuration health (admin only)')
56
- .option('--json', 'Output as JSON')
57
- // optsWithGlobals(): the parent `credits` command also declares --json, which
58
- // commander binds to the parent - so read merged opts to see the flag here.
59
- .action((_opts, cmd) => run('Billing status', async () => {
60
- const opts = cmd.optsWithGlobals();
61
- const res = await get('/admin/billing-status');
62
- const s = res.data;
63
- if (opts.json) {
64
- console.log(JSON.stringify(s));
65
- return;
66
- }
67
- const mark = (ok) => (ok ? success('✓') : clrError('✗'));
68
- console.log(`Stripe mode: ${brand(s.mode)}`);
69
- console.log(`Secret key: ${mark(s.secretKeyConfigured)}`);
70
- console.log(`Webhook secret: ${mark(s.webhookSecretConfigured)} ${muted(`endpoint ${s.webhookPath}`)}`);
71
- console.log(`Credit packs: ${mark(s.packsConfigured)}${s.packsError ? ` ${clrError(s.packsError)}` : ''}`);
72
- for (const p of s.packs) {
73
- console.log(` ${p.name} ${muted(p.priceId)}${p.hidden ? muted(' [hidden]') : ''}`);
74
- }
75
- console.log(`Test pack ($1): ${mark(s.testPackConfigured)} ${muted('set STRIPE_PACK_TEST_PRICE_ID to enable a cheap purchase smoke test')}`);
76
- console.log('Subscription plans:');
77
- for (const pl of s.subscriptionPlans) {
78
- console.log(` ${pl.name} (${pl.tier}) ${mark(pl.priceIdConfigured)}`);
79
- }
80
- }));
81
60
  creditsCommand
82
61
  .command('usage')
83
62
  .description('Show recent usage')
84
63
  .option('--limit <n>', 'Number of entries', '20')
85
64
  .option('--json', 'Output as JSON')
86
- // optsWithGlobals(): parent `credits` also declares --json (see status above).
65
+ // optsWithGlobals(): the parent `credits` command also declares --json, which
66
+ // commander binds to the parent - so read merged opts to see the flag here.
87
67
  .action((_opts, cmd) => run('Usage', async () => {
88
68
  const opts = cmd.optsWithGlobals();
89
69
  const limit = parseInt(opts.limit, 10) || 20;
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { get, post, del } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
- import { error as clrError, bold, muted, success } from '../colors.js';
4
+ import { error as clrError, bold, muted, success, warning } from '../colors.js';
5
5
  import { run, printList, emitField } from '../helpers/index.js';
6
6
  import { confirm } from '../utils.js';
7
7
  export const fnCommand = new Command('fn')
@@ -30,8 +30,16 @@ fnCommand
30
30
  const dur = log.duration_ms != null ? `${log.duration_ms}ms` : '?';
31
31
  const ts = new Date(log.created_at).toLocaleString();
32
32
  const statusColor = log.status === 'success' ? success : log.status === 'error' ? clrError : muted;
33
- const line = `${statusColor(log.status)} ${dur} ${muted(log.trigger_type || 'http')} ${muted(ts)}`;
34
- return log.error_message ? `${line}\n${clrError(`error: ${log.error_message}`)}` : line;
33
+ let line = `${statusColor(log.status)} ${dur} ${muted(log.trigger_type || 'http')} ${muted(ts)}`;
34
+ if (log.error_message)
35
+ line += `\n${clrError(`error: ${log.error_message}`)}`;
36
+ // Captured console.log/warn/error output for this invocation.
37
+ for (const entry of (log.logs ?? [])) {
38
+ const lvlColor = entry.level === 'error' ? clrError : entry.level === 'warn' ? warning : muted;
39
+ const tag = entry.level === 'log' ? '' : `${entry.level}: `;
40
+ line += `\n ${lvlColor(`${tag}${entry.message}`)}`;
41
+ }
42
+ return line;
35
43
  });
36
44
  }));
37
45
  fnCommand
@@ -240,6 +240,15 @@ jobCommand
240
240
  }
241
241
  const runArgs = ['run', ...sharedArgs, '--entrypoint', 'sh', opts.image, '-c', shellCmd];
242
242
  const child = spawn('docker', runArgs, { stdio: 'inherit' });
243
+ // A missing `docker` binary is reported asynchronously via 'error' (not a
244
+ // throw), so a try/catch can't stop it - without this handler Node crashes
245
+ // with a raw stack trace instead of a clear message.
246
+ child.on('error', (err) => {
247
+ console.error(clrError(err.code === 'ENOENT'
248
+ ? 'Docker not found. Install Docker to use `gipity job run-local`.'
249
+ : `Failed to launch docker: ${err.message}`));
250
+ process.exit(1);
251
+ });
243
252
  child.on('exit', (code) => {
244
253
  if (code === 0)
245
254
  console.error(muted(`[run-local] ${success('done')}`));
@@ -31,6 +31,13 @@ logsCommand
31
31
  const trigger = muted(log.trigger_type.padEnd(8));
32
32
  const err = log.error_message ? ` ${clrError(`"${log.error_message}"`)}` : '';
33
33
  console.log(`${muted(time)} ${status} ${dur} ${trigger}${err}`);
34
+ // Captured console.log/warn/error output for this invocation, indented
35
+ // under its summary line. Empty for calls that printed nothing.
36
+ for (const line of log.logs ?? []) {
37
+ const lvlColor = line.level === 'error' ? clrError : line.level === 'warn' ? warning : muted;
38
+ const tag = line.level === 'log' ? '' : `${line.level}: `;
39
+ console.log(`${muted(' │')} ${lvlColor(`${tag}${line.message}`)}`);
40
+ }
34
41
  }
35
42
  }));
36
43
  logsCommand
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { get, post } from '../api.js';
3
- import { requireConfig } from '../config.js';
3
+ import { resolveProjectContext } from '../config.js';
4
4
  import { bold, muted, success, warning } from '../colors.js';
5
5
  import { run } from '../helpers/index.js';
6
6
  export const notifyCommand = new Command('notify')
@@ -12,9 +12,10 @@ notifyCommand
12
12
  .option('--title <text>', 'Notification title', 'Test notification')
13
13
  .option('--body <text>', 'Notification body', 'Hello from Gipity Notify 👋')
14
14
  .option('--url <url>', 'URL to open when the notification is clicked')
15
+ .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
15
16
  .option('--json', 'Output raw JSON')
16
17
  .action((opts) => run('Notify', async () => {
17
- const config = requireConfig();
18
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
18
19
  const to = opts.to === 'all' ? 'all' : (opts.to.includes(',') ? opts.to.split(',').map((s) => s.trim()) : opts.to);
19
20
  const notification = { title: opts.title, body: opts.body };
20
21
  if (opts.url)
@@ -37,9 +38,10 @@ notifyCommand
37
38
  notifyCommand
38
39
  .command('subs')
39
40
  .description('Show how many push subscriptions this app has, per user')
41
+ .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
40
42
  .option('--json', 'Output raw JSON')
41
43
  .action((opts) => run('Notify', async () => {
42
- const config = requireConfig();
44
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
43
45
  const res = await get(`/api/${config.projectGuid}/services/notify/subs`);
44
46
  if (opts.json) {
45
47
  console.log(JSON.stringify(res.data));
@@ -54,9 +56,10 @@ notifyCommand
54
56
  .command('rm')
55
57
  .description("Remove a user's push subscriptions (e.g. on account deletion)")
56
58
  .requiredOption('--user <guid>', 'User guid whose subscriptions to remove')
59
+ .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
57
60
  .option('--json', 'Output raw JSON')
58
61
  .action((opts) => run('Notify', async () => {
59
- const config = requireConfig();
62
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
60
63
  const res = await post(`/api/${config.projectGuid}/services/notify/remove`, { user_guid: opts.user });
61
64
  if (opts.json) {
62
65
  console.log(JSON.stringify(res.data));
@@ -64,14 +64,21 @@ export const pageInspectCommand = new Command('inspect')
64
64
  };
65
65
  const res = await post(`/tools/browser/inspect`, inspectBody);
66
66
  const b = res.data;
67
- // ── Strip the platform's own instrumentation noise first ──
68
- // Every deployed page loads Gipity's injected analytics SDK, which POSTs to
69
- // Gipity's traffic/error log endpoints (`/api/<guid>/log/traffic|error`).
70
- // Those are platform infrastructure, not the app's resources, so when one
71
- // fails it surfaces as a failed resource on the Gipity host PLUS a generic,
72
- // URL-less "Failed to load resource" console erroridentical noise on
73
- // essentially every deployed app. Drop both so an agent inspecting the app
74
- // it just built sees only its own code's resources, not the platform's.
67
+ // ── Move resource-load failures out of the console, where they're noise ──
68
+ // A sub-resource that returns an HTTP 4xx/5xx is reported twice: once in
69
+ // `failedResources` (CDP network layer — carries the full URL, method, and
70
+ // status) and once as a generic, URL-less console echo ("Failed to load
71
+ // resource: the server responded with a status of 404 ()"). The echo names
72
+ // nothing, so it can't be triaged on its own and only duplicates worse
73
+ // what `failedResources` already says with a URL. Drop one echo per failed
74
+ // resource we actually have, so every attributed failure (a real app 404 AND
75
+ // the platform's own injected-SDK telemetry-beacon 404, which 404s on
76
+ // essentially every deployed page) is surfaced exactly once, under Failed
77
+ // resources, with its URL — never as a bare, unattributable console line.
78
+ // Count BEFORE stripping the platform log endpoints below, so their echoes go
79
+ // too. Any SURPLUS echo (a failure the network drain missed, leaving
80
+ // failedResources short) is KEPT — a real problem is never hidden just because
81
+ // we couldn't name it.
75
82
  const isPlatformLog = (entry) => {
76
83
  const urlPart = entry.replace(/\s*\([^)]*\)\s*$/, '');
77
84
  try {
@@ -82,22 +89,22 @@ export const pageInspectCommand = new Command('inspect')
82
89
  return false;
83
90
  }
84
91
  };
85
- const platformFailures = (b.failedResources || []).filter(isPlatformLog);
86
- b.failedResources = (b.failedResources || []).filter((r) => !isPlatformLog(r));
87
- // Each failed platform POST also emits exactly one generic, URL-less
88
- // "Failed to load resource" console error. Drop one per platform failure —
89
- // the text is identical, so removing by count is exact and any genuine app
90
- // 404 keeps its own (indistinguishable) line.
91
- let platformConsoleToDrop = platformFailures.length;
92
- if (platformConsoleToDrop > 0) {
92
+ let resourceEchoesToDrop = (b.failedResources || []).length;
93
+ if (resourceEchoesToDrop > 0) {
94
+ const isHttpStatusResourceError = (l) => /^error:\s*Failed to load resource: the server responded with a status of \d{3}/i.test(l);
93
95
  b.console = (b.console || []).filter((l) => {
94
- if (platformConsoleToDrop > 0 && /^error:\s*Failed to load resource:/i.test(l)) {
95
- platformConsoleToDrop--;
96
+ if (resourceEchoesToDrop > 0 && isHttpStatusResourceError(l)) {
97
+ resourceEchoesToDrop--;
96
98
  return false;
97
99
  }
98
100
  return true;
99
101
  });
100
102
  }
103
+ // The injected analytics SDK POSTs to `/log/{traffic,error}` on the Gipity
104
+ // host; a failure there is platform infrastructure, not the app's, so strip it
105
+ // from the failed-resource list entirely (its console echo is already gone,
106
+ // dropped above with every other attributed failure).
107
+ b.failedResources = (b.failedResources || []).filter((r) => !isPlatformLog(r));
101
108
  // Pull message-less cross-origin "Script error." lines out first. They carry
102
109
  // no source/stack, so they're never actionable as app-code defects, and on a
103
110
  // Gipity-deployed page the platform's own injected SDK is itself a
@@ -102,7 +102,7 @@ export const pageScreenshotCommand = new Command('screenshot')
102
102
  // is applied in the merge instead.
103
103
  .option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms (default: 1000)')
104
104
  .option('--action <js>', 'Run JS in the page before capturing — e.g. click a button to enter a state ("document.getElementById(\'play\').click()"). Runs after the post-load delay, then settles again before the shot.')
105
- .option('--full', 'Capture the full scrollable page (default: viewport only)')
105
+ .option('--full', 'Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.')
106
106
  .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
107
107
  .option('--device <names>', `Viewport preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag)`, appendOption, [])
108
108
  .option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)', appendOption, [])
@@ -242,16 +242,18 @@ export const pageScreenshotCommand = new Command('screenshot')
242
242
  console.log(`${label('Screenshot file')} ${success(savedFiles[i])}`);
243
243
  }
244
244
  }));
245
- // `screenshot` captures the page AFTER load + settle (+ optional --action). It does
246
- // NOT scroll or wait for a selector before capture (agents reach for --scroll/
247
- // --selector and get an unknown-option detour). State the supported levers right
248
- // here, so the help (rendered on any bad flag, and this 'after' block survives
249
- // `| tail`/`| grep`) ends the hunt in one shot. --action covers "click, then shoot";
250
- // --full + crop covers off-screen regions; `page eval` reads data without a picture.
245
+ // `screenshot` captures the page AFTER load + settle (+ optional --action). There
246
+ // is no scroll-to-a-position or wait-for-a-selector lever (agents reach for
247
+ // --scroll/--selector and get an unknown-option detour) but `--full` does walk
248
+ // the page top→bottom→top before the shot so scroll-reveal content paints in.
249
+ // State the supported levers right here, so the help (rendered on any bad flag,
250
+ // and this 'after' block survives `| tail`/`| grep`) ends the hunt in one shot.
251
+ // --action covers "click, then shoot"; --full + crop covers off-screen regions
252
+ // (and triggers reveals); `page eval` reads data without a picture.
251
253
  pageScreenshotCommand.addHelpText('after', `
252
254
  Examples:
253
255
  gipity page screenshot "https://dev.gipity.ai/me/app/"
254
- gipity page screenshot "https://dev.gipity.ai/me/app/" --full # whole scrollable page
256
+ gipity page screenshot "https://dev.gipity.ai/me/app/" --full # whole scrollable page (scroll-reveal sections triggered)
255
257
  gipity page screenshot "https://dev.gipity.ai/me/app/" --device mobile,desktop
256
258
  gipity page screenshot "https://dev.gipity.ai/me/app/" \\
257
259
  --action "document.getElementById('play').click()" # capture an in-game frame
@@ -302,6 +302,14 @@ relayCommand
302
302
  process.exit(1);
303
303
  }
304
304
  const child = spawn(tailCmd, ['-f', '-n', '0', path], { stdio: 'inherit' });
305
+ // `tail` missing surfaces asynchronously via 'error' (not a throw); without
306
+ // this handler Node would crash with a raw stack trace.
307
+ child.on('error', (err) => {
308
+ console.error(clrError(err.code === 'ENOENT'
309
+ ? "`tail` not found - can't follow the log on this system."
310
+ : `Failed to follow log: ${err.message}`));
311
+ process.exit(1);
312
+ });
305
313
  process.on('SIGINT', () => child.kill('SIGINT'));
306
314
  child.on('exit', code => process.exit(code ?? 0));
307
315
  }
@@ -190,6 +190,11 @@ GCC/Rust).
190
190
  if (res.data.autoMirrorSkipped) {
191
191
  console.error(dim(`Note: ${res.data.autoMirrorSkipped.reason}`));
192
192
  }
193
+ if (res.data.mirrorWarnings && res.data.mirrorWarnings.length > 0) {
194
+ console.error(dim(`Note: ${res.data.mirrorWarnings.length} project file(s) could not be mirrored into the sandbox and were skipped:`));
195
+ for (const w of res.data.mirrorWarnings)
196
+ console.error(dim(` - ${w}`));
197
+ }
193
198
  if (res.data.stdout)
194
199
  console.log(res.data.stdout);
195
200
  if (res.data.stderr)
@@ -0,0 +1,73 @@
1
+ import { Command } from 'commander';
2
+ import { get, put, del } from '../api.js';
3
+ import { resolveProjectContext } from '../config.js';
4
+ import { bold, muted, success } from '../colors.js';
5
+ import { run } from '../helpers/index.js';
6
+ /** Build the query string for a scope. Account scope is project-independent;
7
+ * project scope needs the linked app's guid. */
8
+ async function scopeQuery(opts) {
9
+ if (opts.account)
10
+ return 'scope=account';
11
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
12
+ return `scope=project&app_guid=${config.projectGuid}`;
13
+ }
14
+ export const secretsCommand = new Command('secrets')
15
+ .description('Encrypted secrets (API keys, tokens) for your app — read in functions via secrets.get("NAME")');
16
+ secretsCommand
17
+ .command('list')
18
+ .alias('ls')
19
+ .description('List secret names (never values). Project secrets by default; --account for account-wide')
20
+ .option('--account', 'Account-wide secrets shared across all your projects')
21
+ .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
22
+ .option('--json', 'Output raw JSON')
23
+ .action((opts) => run('Secrets', async () => {
24
+ const res = await get(`/secrets?${await scopeQuery(opts)}`);
25
+ if (opts.json) {
26
+ console.log(JSON.stringify(res.data));
27
+ return;
28
+ }
29
+ const scope = opts.account ? 'account-wide' : 'this project';
30
+ if (res.data.length === 0) {
31
+ console.log(muted(`No ${scope} secrets yet. Add one with: gipity secrets set NAME VALUE${opts.account ? ' --account' : ''}`));
32
+ return;
33
+ }
34
+ console.log(bold(`${res.data.length} ${scope} secret${res.data.length === 1 ? '' : 's'}:`));
35
+ for (const s of res.data) {
36
+ const masked = s.preview ? muted(`…${s.preview}`) : muted('(hidden)');
37
+ console.log(` ${s.name} ${masked} ${muted(`updated ${new Date(s.updated_at).toLocaleDateString()}`)}`);
38
+ }
39
+ }));
40
+ secretsCommand
41
+ .command('set <name> <value>')
42
+ .description('Create or update a secret (UPPER_SNAKE_CASE name). Encrypted at rest; never echoed back')
43
+ .option('--account', 'Store account-wide (shared across all your projects)')
44
+ .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
45
+ .option('--json', 'Output raw JSON')
46
+ .action((name, value, opts) => run('Secrets', async () => {
47
+ const res = await put(`/secrets?${await scopeQuery(opts)}`, { name, value });
48
+ if (opts.json) {
49
+ console.log(JSON.stringify(res.data));
50
+ return;
51
+ }
52
+ const scope = opts.account ? 'account-wide' : 'this project';
53
+ console.log(success(`✓ Secret '${res.data.name}' saved (encrypted, ${scope}). Read it in a function with secrets.get('${res.data.name}').`));
54
+ }));
55
+ secretsCommand
56
+ .command('rm <name>')
57
+ .alias('delete')
58
+ .description('Delete a secret')
59
+ .option('--account', 'Delete from account-wide secrets')
60
+ .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
61
+ .option('--json', 'Output raw JSON')
62
+ .action((name, opts) => run('Secrets', async () => {
63
+ const res = await del(`/secrets/${encodeURIComponent(name)}?${await scopeQuery(opts)}`);
64
+ if (opts.json) {
65
+ console.log(JSON.stringify(res.data));
66
+ return;
67
+ }
68
+ if (res.data.deleted)
69
+ console.log(success(`✓ Secret '${name}' deleted.`));
70
+ else
71
+ console.log(muted(`No secret named '${name}' found.`));
72
+ }));
73
+ //# sourceMappingURL=secrets.js.map
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { get, post, put, del } from '../api.js';
3
- import { requireConfig } from '../config.js';
3
+ import { getConfig, requireConfig } from '../config.js';
4
4
  import { success, error as clrError, muted, bold } from '../colors.js';
5
5
  import { run, printList, printResult } from '../helpers/index.js';
6
6
  // `--json` is declared on the parent `workflow` command (so the bare list
@@ -60,14 +60,24 @@ async function waitForRun(wfGuid, prevGuid, timeoutSec) {
60
60
  }
61
61
  async function listWorkflows(opts) {
62
62
  const res = await get('/workflows');
63
+ // The endpoint is account-wide. Default to the linked project so the list
64
+ // isn't drowned in every other project's workflows; --all (or running outside
65
+ // a linked project) shows everything.
66
+ const config = getConfig();
67
+ const scoped = !opts.all && config
68
+ ? { ...res, data: res.data.filter(w => w.project_slug === config.projectSlug) }
69
+ : res;
63
70
  if (opts.json) {
64
- console.log(JSON.stringify(res));
71
+ console.log(JSON.stringify(scoped));
65
72
  return;
66
73
  }
67
- if (res.meta) {
68
- console.log(`Active workflows: ${res.meta.activeCount}/${res.meta.activeLimit}`);
74
+ if (scoped.meta) {
75
+ console.log(`Active workflows: ${scoped.meta.activeCount}/${scoped.meta.activeLimit}`);
69
76
  }
70
- printList(res.data, opts, 'No workflows.', w => {
77
+ if (!opts.all && config) {
78
+ console.log(muted(`Project: ${config.projectSlug} (use --all for every project)`));
79
+ }
80
+ printList(scoped.data, opts, opts.all || !config ? 'No workflows.' : `No workflows in ${config.projectSlug}.`, w => {
71
81
  const statusText = w.is_active ? success('on') : clrError('off');
72
82
  const cron = w.cron_expression ? ` ${muted(`cron: ${w.cron_expression}`)}` : '';
73
83
  const table = w.trigger_table ? ` ${muted(`table: ${w.trigger_table}`)}` : '';
@@ -79,11 +89,13 @@ async function listWorkflows(opts) {
79
89
  export const workflowCommand = new Command('workflow')
80
90
  .description('Manage workflows')
81
91
  .option('--json', 'Output as JSON')
92
+ .option('--all', 'List workflows across all projects (default: the linked project only)')
82
93
  .action((_opts, cmd) => run('Workflow', () => listWorkflows(mergedOpts(cmd))));
83
94
  workflowCommand
84
95
  .command('list')
85
- .description('List workflows')
96
+ .description('List workflows (linked project by default; --all for every project)')
86
97
  .option('--json', 'Output as JSON')
98
+ .option('--all', 'List workflows across all projects')
87
99
  .action((_opts, cmd) => run('List', () => listWorkflows(mergedOpts(cmd))));
88
100
  workflowCommand
89
101
  .command('info <name>')
@@ -21,12 +21,20 @@ export function printCommandError(label, err) {
21
21
  if (err?.code === 'INSUFFICIENT_CREDITS') {
22
22
  console.error(clrError(`${label} failed - out of Gipity credits.`));
23
23
  console.error(err.message);
24
+ console.error("Run 'gipity credits' to see your balance, or 'gipity credits buy' to top up.");
24
25
  return;
25
26
  }
26
27
  console.error(clrError(`${label} failed: ${err.message}`));
27
28
  }
29
+ // Returns the action's promise so Commander can await it. This matters for the
30
+ // central output frame (installOutputFrame): Commander chains the `postAction`
31
+ // hook off whatever the action handler returns, and only waits when that value
32
+ // is thenable. Return `void` here and the trailing blank line fires before the
33
+ // command's async output has even printed — the frame's leading blank then reads
34
+ // as a double blank above the result, with none below it. Returning the promise
35
+ // keeps the frame's boundaries (one blank above, one below) correctly ordered.
28
36
  export function run(label, action) {
29
- action().catch((err) => {
37
+ return action().catch((err) => {
30
38
  printCommandError(label, err);
31
39
  process.exit(1);
32
40
  });
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ import { pageCommand } from './commands/page.js';
35
35
  import { recordsCommand } from './commands/records.js';
36
36
  import { fnCommand } from './commands/fn.js';
37
37
  import { serviceCommand } from './commands/service.js';
38
+ import { secretsCommand } from './commands/secrets.js';
38
39
  import { notifyCommand } from './commands/notify.js';
39
40
  import { paymentsCommand } from './commands/payments.js';
40
41
  import { jobCommand } from './commands/job.js';
@@ -126,7 +127,7 @@ const commonGroup = [skillCommand, projectCommand, addCommand, removeCommand, de
126
127
  const connectGroup = [claudeCommand, relayCommand];
127
128
  const projectGroup = [domainCommand, statusCommand, initCommand];
128
129
  const filesGroup = [fileCommand, syncCommand, pushCommand, uploadCommand];
129
- const appBuildingGroup = [testCommand, fnCommand, serviceCommand, notifyCommand, paymentsCommand, jobCommand, dbCommand, logsCommand, workflowCommand, realtimeCommand, rbacCommand, auditCommand, recordsCommand];
130
+ const appBuildingGroup = [testCommand, fnCommand, serviceCommand, secretsCommand, notifyCommand, paymentsCommand, jobCommand, dbCommand, logsCommand, workflowCommand, realtimeCommand, rbacCommand, auditCommand, recordsCommand];
130
131
  const utilitiesGroup = [pageCommand, sandboxCommand, generateCommand, emailCommand, gmailCommand, locationCommand, textCommand];
131
132
  const agentGroup = [chatCommand, memoryCommand, agentCommand, approvalCommand];
132
133
  const setupGroup = [loginCommand, logoutCommand, tokenCommand, creditsCommand, planCommand, doctorCommand, updateCommand, uninstallCommand];
@@ -310,5 +311,8 @@ if (mappedCmd) {
310
311
  process.exit(0);
311
312
  }
312
313
  }
313
- program.parse(normalizeAliases(process.argv, program));
314
+ // parseAsync (not parse) so commander awaits each command's returned promise
315
+ // before its postAction hook - that ordering is what keeps the output frame's
316
+ // trailing blank line after the command's async output instead of before it.
317
+ await program.parseAsync(normalizeAliases(process.argv, program));
314
318
  //# sourceMappingURL=index.js.map
package/dist/knowledge.js CHANGED
@@ -102,14 +102,14 @@ mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <s
102
102
 
103
103
  ## CLI quick reference
104
104
 
105
- Key commands: \`gipity add <template|kit>\`, \`gipity deploy dev\`, \`gipity sandbox run\`, \`gipity page inspect <url>\`, \`gipity page screenshot <url>\`, \`gipity db query "SQL"\`, \`gipity fn call <name>\`, \`gipity logs fn <name>\`, \`gipity email send --to <addr> --subject <s> --body <b>\` (sends as \`gipity@gipity.ai\`; omit \`--to\` to self-send), \`gipity skill read <name>\`.
105
+ Key commands: \`gipity add <template|kit>\`, \`gipity deploy dev\`, \`gipity sandbox run\`, \`gipity page inspect <url>\`, \`gipity page screenshot <url>\`, \`gipity db query "SQL"\`, \`gipity fn call <name>\`, \`gipity logs fn <name>\`, \`gipity secrets set <NAME> <value> [--account]\` (store an API key/token encrypted; read in functions via \`secrets.get('NAME')\` — never hardcode keys), \`gipity email send --to <addr> --subject <s> --body <b>\` (sends as \`gipity@gipity.ai\`; omit \`--to\` to self-send), \`gipity skill read <name>\`.
106
106
  Pull an existing remote project local (given its URL/slug): \`mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <slug>\` (adopts the matching project and syncs files down - this is the "clone").
107
107
  For deterministic text questions (letter/word counts, substring occurrences, nth word/char, anagrams), use \`gipity text analyze "<text>"\` - local and instant, no sandbox or LLM needed.
108
108
  Run \`gipity --help\` for the full list. Use \`--help\` on any command for details.
109
109
 
110
110
  Function return shape: \`gipity fn call\`, the in-test \`ctx.fn.call\`/\`callAs\`, and the client \`Gipity.fn\` all return your function's value **unwrapped** - read/assert \`result.field\`. Only raw HTTP/\`curl\` wraps it as \`{ data: ... }\`; never write \`result.data.field\` in a test.
111
111
 
112
- Tests write to your real DB: \`gipity test\` runs the test code sandboxed, but \`ctx.fn.call\`/\`callAs\` hit your actual deployed functions, which write to the same project database the app reads from - rows a test creates persist and surface on the live page. Register \`ctx.cleanup(fn)\` in any write-test to delete what it made; the harness runs every cleanup after the suite (even on failure).
112
+ Tests are isolated, not run against your live DB: \`gipity test\` points \`ctx.fn.call\`/\`callAs\` at a throwaway copy of your database (your \`migrations/\` + \`seeds/\`), reset (truncate + reseed) before every run - so test rows never reach the deployed app and you don't write teardown. Functions see \`ctx.isTest === true\` during a run (use it to skip your own rate limiting); the platform also suppresses \`notify()\` push so a suite can't spam subscribers. Reference data tests need goes in seed files; a runtime-written settings table that isn't seeded goes under \`test.preserve\` in \`gipity.yaml\`. Build per-file fixtures in \`setup(fn)\` \`ctx.fixtures\`, and namespace unique values with \`ctx.testId\`.
113
113
 
114
114
  ## Tool output is complete and synchronous
115
115
 
@@ -91,6 +91,9 @@ export function startDaemon() {
91
91
  detached: true,
92
92
  stdio: 'ignore',
93
93
  });
94
+ // A spawn 'error' arrives asynchronously and bypasses the try/catch; swallow
95
+ // it so a launch failure stays best-effort instead of crashing the process.
96
+ child.on('error', () => { });
94
97
  child.unref();
95
98
  }
96
99
  catch {
package/dist/sync.js CHANGED
@@ -347,12 +347,14 @@ async function downloadAll(projectGuid, onBytes) {
347
347
  return extractTarToMap(stream, DOWNLOAD_IDLE_MS, onBytes);
348
348
  }
349
349
  async function fetchOne(projectGuid, path) {
350
- // Exact single-file read first. The tree-tar endpoint below treats its `path`
351
- // as a DIRECTORY prefix, so a single root file (e.g. `gipity.yaml`) comes back
352
- // empty — which silently broke conflict restores and trapped sync in an
353
- // unresolvable delete-vs-newer loop. `/files/read` is the exact-path endpoint
350
+ // Exact single-file read first. `/files/read` is the exact-path endpoint
354
351
  // (what `gipity file cat` uses); it returns text content, reliable for the
355
- // config/code files that actually hit a restore. Binary falls through to tar.
352
+ // config/code files that actually hit a restore. Binary falls through to the
353
+ // tar path below, which now resolves an exact file path server-side (the
354
+ // /files/tree endpoint stats a non-directory `path` and packs that one file
355
+ // under its full name). Before that fix the tar endpoint treated `path` as a
356
+ // DIRECTORY prefix, so a single file came back empty and a dropped binary
357
+ // (e.g. an agent-generated image) could never be recovered.
356
358
  try {
357
359
  const res = await get(`/projects/${projectGuid}/files/read?path=${encodeURIComponent(path)}`);
358
360
  const content = res?.data?.content;
@@ -27,6 +27,9 @@ function startBackgroundUpdater() {
27
27
  stdio: 'ignore',
28
28
  env: process.env,
29
29
  });
30
+ // Async spawn 'error' bypasses the try/catch; swallow so the best-effort
31
+ // updater never crashes the CLI.
32
+ child.on('error', () => { });
30
33
  child.unref();
31
34
  }
32
35
  catch { /* updater is best-effort, never block */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.405",
3
+ "version": "1.0.407",
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",
@@ -13,7 +13,7 @@
13
13
  "postbuild": "node scripts/gen-build-info.mjs",
14
14
  "dev": "tsc --watch",
15
15
  "test": "npm run test:smoke",
16
- "test:smoke": "tsc && node --test dist/__tests__/utils.test.js dist/__tests__/colors.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__/auth-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__/client-context.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-token.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-doctor.test.js dist/__tests__/claude-setup.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
+ "test:smoke": "tsc && node --test dist/__tests__/utils.test.js dist/__tests__/colors.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__/auth-lock.test.js dist/__tests__/api-401-retry.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__/client-context.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-token.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-doctor.test.js dist/__tests__/claude-setup.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",
17
17
  "test:e2e": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-live.test.js dist/__tests__/cli-e2e-sync-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",
18
18
  "test:e2e:sync": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sync-live.test.js",
19
19
  "test:e2e:sandbox": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sandbox-live.test.js"