gipity 1.0.400 → 1.0.402

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
@@ -158,6 +158,18 @@ export async function downloadStream(path) {
158
158
  }
159
159
  return Readable.fromWeb(res.body);
160
160
  }
161
+ // A presigned PUT has no built-in deadline: `fetch` will wait forever if S3
162
+ // accepts the connection then stalls mid-body. That can't surface as a retry
163
+ // (a hang never throws), so one stalled PUT wedges an entire deploy/sync while
164
+ // it holds the project lock. Bound every PUT with a throughput-scaled deadline:
165
+ // a generous floor for tiny files plus time for the body at a conservative
166
+ // assumed-minimum rate, so a genuinely-progressing large upload survives but a
167
+ // true stall aborts and lets withRetry() retry it.
168
+ const PUT_TIMEOUT_FLOOR_MS = 120_000; // 2 min minimum, regardless of size
169
+ const PUT_MIN_BYTES_PER_SEC = 256 * 1024; // assume the link sustains ≥256 KB/s
170
+ export function putTimeoutMs(contentLength) {
171
+ return Math.max(PUT_TIMEOUT_FLOOR_MS, Math.ceil(contentLength / PUT_MIN_BYTES_PER_SEC) * 1000);
172
+ }
161
173
  /**
162
174
  * PUT raw bytes to a presigned URL (no auth header - the URL is signed).
163
175
  * Supports a Buffer or a Readable stream body. Returns the response ETag header
@@ -174,12 +186,25 @@ export async function putToPresignedUrl(url, body, contentLength, contentType) {
174
186
  const fetchBody = isStream
175
187
  ? Readable.toWeb(body)
176
188
  : new Uint8Array(body);
177
- const res = await fetch(url, {
178
- method: 'PUT',
179
- headers,
180
- body: fetchBody,
181
- ...(isStream ? { duplex: 'half' } : {}),
182
- });
189
+ const timeoutMs = putTimeoutMs(contentLength);
190
+ let res;
191
+ try {
192
+ res = await fetch(url, {
193
+ method: 'PUT',
194
+ headers,
195
+ body: fetchBody,
196
+ signal: AbortSignal.timeout(timeoutMs),
197
+ ...(isStream ? { duplex: 'half' } : {}),
198
+ });
199
+ }
200
+ catch (err) {
201
+ // A timeout aborts with a TimeoutError/AbortError. Surface it as a 408 so
202
+ // withRetry() treats it as transient and retries the PUT.
203
+ if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
204
+ throw new ApiError(408, 'S3_UPLOAD_TIMEOUT', `S3 PUT stalled (no completion in ${Math.round(timeoutMs / 1000)}s)`);
205
+ }
206
+ throw err;
207
+ }
183
208
  if (!res.ok) {
184
209
  const text = await res.text().catch(() => res.statusText);
185
210
  throw new ApiError(res.status, 'S3_UPLOAD', `S3 PUT failed: ${res.status} ${text.slice(0, 200)}`);
package/dist/auth.js CHANGED
@@ -70,6 +70,24 @@ export function sessionExpired() {
70
70
  return false; // undecodable - let the refresh path decide
71
71
  return Date.now() > exp * 1000;
72
72
  }
73
+ /** True when the access token is currently past its expiry. Unlike
74
+ * sessionExpired() (which only inspects the refresh token's lifetime), this
75
+ * reflects whether the NEXT authenticated request can actually succeed right
76
+ * now. Meaningful only when read AFTER refreshTokenIfNeeded(): a normal
77
+ * expired access token gets silently renewed, so a token that is STILL expired
78
+ * after a refresh attempt means the renewal failed — the refresh token was
79
+ * rejected (genuinely lapsed, or rotated away by a sibling process sharing this
80
+ * auth.json) — and re-login is required. Used to keep the up-front "Logged in"
81
+ * message from contradicting a 401 on the very next call. */
82
+ export function accessTokenExpired() {
83
+ const auth = getAuth();
84
+ if (!auth)
85
+ return true;
86
+ const t = new Date(auth.expiresAt).getTime();
87
+ if (isNaN(t))
88
+ return false; // unparseable - let the live 401 path decide
89
+ return Date.now() >= t;
90
+ }
73
91
  const delay = (ms) => new Promise(r => setTimeout(r, ms));
74
92
  // ─── Cross-process refresh lock ────────────────────────────────
75
93
  // Serializes token refreshes across every `gipity` process that shares this
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Claude Code install + detection helpers, shared by `gipity claude` (which
3
+ * auto-ensures Claude Code before launching) and `gipity doctor` (which reports
4
+ * Claude state). Centralizing here means a GUI/installer — e.g. the desktop
5
+ * onboarding client — drives Claude setup through the CLI instead of
6
+ * re-implementing it.
7
+ *
8
+ * The plan (`claudeInstallPlan`) is pure + unit-tested; actually running
9
+ * `which`/`npm` happens in the helpers below and in the command layer.
10
+ */
11
+ import { execSync } from 'child_process';
12
+ import { existsSync } from 'fs';
13
+ import { homedir } from 'os';
14
+ import { join } from 'path';
15
+ export const CLAUDE_PACKAGE = '@anthropic-ai/claude-code';
16
+ /** Pure: the platform-appropriate detect + install commands. Unit-tested. */
17
+ export function claudeInstallPlan(platformOverride) {
18
+ const plat = platformOverride ?? process.platform;
19
+ return {
20
+ checkCmd: plat === 'win32' ? 'where claude' : 'which claude',
21
+ installArgv: ['npm', 'install', '-g', CLAUDE_PACKAGE],
22
+ };
23
+ }
24
+ /** Whether the `claude` binary resolves on PATH. */
25
+ export function isClaudeInstalled(platformOverride) {
26
+ try {
27
+ execSync(claudeInstallPlan(platformOverride).checkCmd, { stdio: 'ignore' });
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /**
35
+ * Best-effort, positive-evidence-only check for "Claude Code is logged in."
36
+ *
37
+ * NOTE: on macOS the OAuth token can live in the Keychain, which we can't read
38
+ * non-interactively — so a `false` here may be a false-negative. Callers should
39
+ * treat `false` as "offer the login step" rather than "definitely logged out."
40
+ * (Tracked as an open question in the desktop onboarding spec.)
41
+ */
42
+ export function isClaudeAuthenticated() {
43
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY)
44
+ return true;
45
+ return existsSync(join(homedir(), '.claude', '.credentials.json'));
46
+ }
47
+ /**
48
+ * Definitive auth check: actually run a tiny headless `claude -p` ping. If it
49
+ * returns output, Claude Code is authenticated. This is the reliable check
50
+ * (unlike `isClaudeAuthenticated`'s file/env heuristic) but it's **billed** (a
51
+ * real LLM call) and slow (network + model latency), so it's opt-in — use it at
52
+ * a decision point (e.g. confirming the login step took), not on every poll.
53
+ */
54
+ export function probeClaudeAuthenticated() {
55
+ if (!isClaudeInstalled())
56
+ return false;
57
+ try {
58
+ const out = execSync('claude -p "Reply with the single word: PONG"', {
59
+ timeout: 60_000,
60
+ stdio: ['ignore', 'pipe', 'ignore'],
61
+ encoding: 'utf-8',
62
+ });
63
+ return typeof out === 'string' && out.trim().length > 0;
64
+ }
65
+ catch {
66
+ // Non-zero exit (unauthenticated, network error, etc.) → treat as not auth'd.
67
+ return false;
68
+ }
69
+ }
70
+ /**
71
+ * Install Claude Code via npm if it isn't already on PATH. Idempotent: a no-op
72
+ * (beyond the PATH check) when already present, unless `force`. `quiet`
73
+ * suppresses npm's output (for headless/GUI callers); default streams it.
74
+ */
75
+ export function ensureClaudeInstalled(opts = {}) {
76
+ if (!opts.force && isClaudeInstalled())
77
+ return { installed: true, alreadyPresent: true };
78
+ const { installArgv } = claudeInstallPlan();
79
+ try {
80
+ execSync(installArgv.join(' '), { stdio: opts.quiet ? 'ignore' : 'inherit' });
81
+ }
82
+ catch {
83
+ // Fall through to a definitive PATH re-check rather than trusting the throw.
84
+ }
85
+ return { installed: isClaudeInstalled(), alreadyPresent: false };
86
+ }
87
+ //# sourceMappingURL=claude-setup.js.map
@@ -1,11 +1,11 @@
1
1
  import { Command } from 'commander';
2
2
  import { join, dirname, resolve, basename } from 'path';
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync } from 'fs';
4
- import { execSync, spawn } from 'child_process';
4
+ import { spawn } from 'child_process';
5
5
  import { homedir } from 'os';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { resolveCommand } from '../platform.js';
8
- import { getAuth, saveAuth, sessionExpired } from '../auth.js';
8
+ import { getAuth, saveAuth, sessionExpired, accessTokenExpired, refreshTokenIfNeeded } from '../auth.js';
9
9
  import { get, post, publicPost, ApiError, getAccountSlug } from '../api.js';
10
10
  import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
11
11
  import { sync } from '../sync.js';
@@ -18,6 +18,7 @@ import { brand, bold, info, success, warning, error as clrError, muted } from '.
18
18
  import { createProgressReporter } from '../progress.js';
19
19
  import { printBanner } from '../banner.js';
20
20
  import { scanForAdoption, isLikelyEmpty, canAdoptCwd, formatCwdLabel, formatBytes, adoptCurrentDir, ADOPT_THRESHOLDS, } from '../adopt-cwd.js';
21
+ import { isClaudeInstalled, ensureClaudeInstalled, CLAUDE_PACKAGE } from '../claude-setup.js';
21
22
  const __clDir = dirname(fileURLToPath(import.meta.url));
22
23
  const __clPkg = JSON.parse(readFileSync(resolve(__clDir, '../../package.json'), 'utf-8'));
23
24
  /** Report a sync run to the user. Beyond the applied-changes line, this SURFACES
@@ -246,6 +247,21 @@ export const claudeCommand = new Command('claude')
246
247
  // a human at the terminal. Requires an existing .gipity.json - we
247
248
  // can't interactively pick or create a project in this mode.
248
249
  const rawArgs = process.argv.slice(process.argv.indexOf('claude') + 1);
250
+ // `gipity claude install` - explicit, GUI-callable Claude Code install.
251
+ // Handled here (leading positional) rather than as a Commander subcommand
252
+ // because this command uses allowUnknownOption/allowExcessArguments to
253
+ // pass everything through to `claude`; a real subcommand would risk that
254
+ // passthrough (which the relay daemon's `gipity claude -p` depends on).
255
+ if (rawArgs[0] === 'install') {
256
+ const r = ensureClaudeInstalled({ force: rawArgs.includes('--force') });
257
+ if (r.alreadyPresent)
258
+ console.log(` ${success('Claude Code already installed.')}`);
259
+ else if (r.installed)
260
+ console.log(` ${success('Claude Code installed.')}`);
261
+ else
262
+ console.log(` ${clrError('Could not install Claude Code.')} Install manually: npm install -g ${CLAUDE_PACKAGE}`);
263
+ process.exit(r.installed ? 0 : 1);
264
+ }
249
265
  const nonInteractive = rawArgs.some(a => a === '-p' || a === '--print' || a.startsWith('--print=') || a.startsWith('-p='));
250
266
  // Headless progress emitter: routes to stderr (stdout stays clean for
251
267
  // the child's result), drops the interactive 2-space indent, collapses
@@ -291,11 +307,35 @@ export const claudeCommand = new Command('claude')
291
307
  if (!nonInteractive) {
292
308
  printBanner({ version: __clPkg.version, email: auth?.email, cwd: process.cwd() });
293
309
  }
294
- if (auth && sessionExpired()) {
295
- // The cached auth.json exists but its refresh token has lapsed, so the
296
- // first API call below would 401 anyway. Re-login up front instead of
297
- // printing "Logged in" and immediately contradicting it with "session
298
- // expired" once the projects fetch fails.
310
+ // A GIPITY_TOKEN env token authenticates every request on its own, so the
311
+ // saved session's expiry is irrelevant when one is set never re-login or
312
+ // warn about expiry in that case.
313
+ const hasEnvToken = Boolean(process.env.GIPITY_TOKEN?.trim());
314
+ // For an interactive saved-session run, renew the access token UP FRONT so
315
+ // the login line we're about to print is actually true. A saved session
316
+ // can look valid locally (refresh token not past its JWT expiry, so
317
+ // sessionExpired() is false) yet be dead on the server — most often because
318
+ // a sibling process sharing this auth.json already consumed the single-use
319
+ // refresh token (GipRunner runs many gipity processes against one auth dir).
320
+ // refreshTokenIfNeeded() attempts the rotation here; if it fails, the
321
+ // access token stays expired and accessTokenExpired() catches it below.
322
+ if (auth && !hasEnvToken && !nonInteractive && !sessionExpired()) {
323
+ await refreshTokenIfNeeded();
324
+ auth = getAuth();
325
+ }
326
+ // Re-login is genuinely required when the refresh token has lapsed, OR the
327
+ // proactive renewal above could not produce a still-valid access token
328
+ // (refresh token rejected/rotated away). The access-token check is gated to
329
+ // interactive runs: headless runs can't prompt, and their downstream API
330
+ // call renews the token itself. Never triggered while a GIPITY_TOKEN env
331
+ // token is supplying auth.
332
+ const reloginRequired = auth != null && !hasEnvToken &&
333
+ (sessionExpired() || (!nonInteractive && accessTokenExpired()));
334
+ if (auth && reloginRequired) {
335
+ // The saved session is dead (refresh token lapsed, or rotated away by a
336
+ // sibling process), so the first API call below would 401. Re-login up
337
+ // front instead of printing "Logged in" and immediately contradicting it
338
+ // with "session expired" once the projects fetch fails.
299
339
  console.log(` ${muted('Your session expired. Let\'s sign you back in.')}\n`);
300
340
  auth = await interactiveLogin();
301
341
  }
@@ -608,15 +648,18 @@ export const claudeCommand = new Command('claude')
608
648
  console.log(` Done. cd ${process.cwd()} && gipity claude`);
609
649
  return;
610
650
  }
611
- // Check claude is installed
612
- try {
613
- const checkCmd = process.platform === 'win32' ? 'where claude' : 'which claude';
614
- execSync(checkCmd, { stdio: 'ignore' });
615
- }
616
- catch {
617
- console.log(' Claude Code not found. Install it: npm install -g @anthropic-ai/claude-code');
618
- console.log(` Then: cd ${process.cwd()} && claude`);
619
- return;
651
+ // Ensure Claude Code is installed - install it ourselves if missing so a
652
+ // GUI/installer (and terminal users) get it for free, rather than being
653
+ // told to run npm by hand.
654
+ if (!isClaudeInstalled()) {
655
+ console.log(' Claude Code not found - installing it now...');
656
+ const r = ensureClaudeInstalled();
657
+ if (!r.installed) {
658
+ console.log(` ${clrError('Could not install Claude Code.')} Install manually: npm install -g ${CLAUDE_PACKAGE}`);
659
+ console.log(` Then: cd ${process.cwd()} && claude`);
660
+ return;
661
+ }
662
+ console.log(` ${success('Claude Code installed.')}`);
620
663
  }
621
664
  // Ensure the Gipity plugin is actually installed at user scope (not just
622
665
  // enabled declaratively) so its capture + file-sync hooks load in this
@@ -3,6 +3,12 @@ import { existsSync, readFileSync, statSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { LOCAL_PKG_DIR, LOCAL_ENTRY, STATE_FILE, SETTINGS_FILE, UPDATE_LOG, readState, readSettings, updatesDisabled } from '../updater/state.js';
5
5
  import { bold, dim, success, warning, error as clrError, muted } from '../colors.js';
6
+ import { getAuth, sessionExpired } from '../auth.js';
7
+ import { isClaudeInstalled, isClaudeAuthenticated, probeClaudeAuthenticated } from '../claude-setup.js';
8
+ import * as relayState from '../relay/state.js';
9
+ import { planFor, UnsupportedPlatformError } from '../relay/installers.js';
10
+ import { resolveCliPath } from '../relay/setup.js';
11
+ const NODE_MIN_MAJOR = 18;
6
12
  function localVersion() {
7
13
  const pkgPath = join(LOCAL_PKG_DIR, 'package.json');
8
14
  if (!existsSync(pkgPath))
@@ -38,16 +44,94 @@ function rel(t) {
38
44
  return `${Math.floor(s / 3600)}h ago`;
39
45
  return `${Math.floor(s / 86400)}d ago`;
40
46
  }
47
+ /** Whether the relay's OS login-service unit file exists. Reflects "autostart
48
+ * has been installed" (by `relay setup`/`relay install`); cheap and poll-safe.
49
+ * null on an unsupported platform. */
50
+ function relayAutostartInstalled() {
51
+ try {
52
+ return existsSync(planFor({ cliPath: resolveCliPath() }).path);
53
+ }
54
+ catch (err) {
55
+ if (err instanceof UnsupportedPlatformError)
56
+ return null;
57
+ return null;
58
+ }
59
+ }
60
+ /**
61
+ * Snapshot of the local environment a GUI/installer needs to drive onboarding:
62
+ * Node, the Gipity CLI + login state, Claude Code install + auth, and the relay
63
+ * pairing/daemon state. The single "state of the world" the desktop onboarding
64
+ * client polls (`gipity doctor --json`).
65
+ */
66
+ export function gatherEnv(opts = {}) {
67
+ const nodeMajor = parseInt(process.versions.node.split('.')[0], 10) || 0;
68
+ const auth = getAuth();
69
+ const expired = auth ? sessionExpired() : false;
70
+ const device = relayState.getDevice();
71
+ const node = { ok: nodeMajor >= NODE_MIN_MAJOR, version: process.versions.node };
72
+ const gipity = {
73
+ installed: true, // we're running it
74
+ version: shimVersion(),
75
+ logged_in: !!auth && !expired,
76
+ email: auth?.email ?? null,
77
+ session_expired: expired,
78
+ };
79
+ const claude = { installed: isClaudeInstalled(), authenticated: false };
80
+ // Default: cheap heuristic (poll-safe). With --probe-claude: a real (billed)
81
+ // `claude -p` ping for a definitive answer.
82
+ claude.authenticated = claude.installed && (opts.probeClaude ? probeClaudeAuthenticated() : isClaudeAuthenticated());
83
+ const relay = {
84
+ paired: !!device,
85
+ running: relayState.isDaemonRunning(),
86
+ paused: relayState.isPaused(),
87
+ autostart: relayAutostartInstalled(),
88
+ device: device ? { name: device.name, guid: device.guid } : null,
89
+ };
90
+ const dis = updatesDisabled();
91
+ const updState = readState();
92
+ const cli = {
93
+ shim_version: shimVersion(),
94
+ local_version: localVersion(),
95
+ local_install_ok: existsSync(LOCAL_ENTRY),
96
+ auto_updates: !dis.disabled,
97
+ updates_disabled_reason: dis.disabled ? (dis.reason ?? null) : null,
98
+ last_check_at: updState.lastCheckAt,
99
+ last_error: updState.lastError,
100
+ };
101
+ const ready = node.ok && gipity.logged_in && claude.installed && claude.authenticated && relay.paired && relay.running;
102
+ return { ready, node, gipity, claude, relay, cli };
103
+ }
104
+ function yn(v) {
105
+ return v ? success('yes') : warning('no');
106
+ }
41
107
  export const doctorCommand = new Command('doctor')
42
- .description('Check install health')
43
- .action(() => {
108
+ .description('Check install + environment health')
109
+ .option('--json', 'Machine-readable environment report (for installers/GUIs)')
110
+ .option('--probe-claude', 'Verify Claude Code auth with a real (billed) `claude -p` ping instead of the cheap heuristic')
111
+ .action((opts) => {
112
+ const env = gatherEnv({ probeClaude: opts.probeClaude });
113
+ if (opts.json) {
114
+ console.log(JSON.stringify(env, null, 2));
115
+ return;
116
+ }
117
+ // ── Environment (what onboarding cares about) ──────────────────────
118
+ console.log(bold('Gipity - doctor'));
119
+ console.log('');
120
+ console.log(bold('Environment'));
121
+ console.log(`${muted('node ')} ${env.node.version} ${env.node.ok ? success('✓') : clrError(`(need ${NODE_MIN_MAJOR}+)`)}`);
122
+ console.log(`${muted('gipity login ')} ${env.gipity.logged_in ? success(`logged in as ${env.gipity.email}`) : (env.gipity.session_expired ? warning(`session expired (${env.gipity.email})`) : warning('not logged in'))}`);
123
+ console.log(`${muted('claude code ')} installed ${yn(env.claude.installed)} · authenticated ${yn(env.claude.authenticated)}`);
124
+ const autostartLabel = env.relay.autostart === null ? muted('n/a') : yn(env.relay.autostart);
125
+ console.log(`${muted('relay ')} paired ${yn(env.relay.paired)} · running ${yn(env.relay.running)} · autostart ${autostartLabel}${env.relay.paused ? warning(' · paused') : ''}${env.relay.device ? muted(` (${env.relay.device.name})`) : ''}`);
126
+ console.log(`${muted('ready ')} ${env.ready ? success('yes') : warning('no - run `gipity claude` (or the desktop app) to finish setup')}`);
127
+ // ── CLI install / update health ────────────────────────────────────
44
128
  const state = readState();
45
129
  const settings = readSettings();
46
130
  const dis = updatesDisabled();
47
131
  const local = localVersion();
48
132
  const localOk = existsSync(LOCAL_ENTRY);
49
- console.log(bold('Gipity CLI - doctor'));
50
133
  console.log('');
134
+ console.log(bold('CLI install'));
51
135
  console.log(`${muted('shim version ')} ${shimVersion()}`);
52
136
  console.log(`${muted('local version ')} ${local ?? dim('not installed')} ${localOk ? success('✓') : warning('(running from shim fallback)')}`);
53
137
  console.log(`${muted('local install ')} ${LOCAL_PKG_DIR}`);
@@ -60,6 +144,5 @@ export const doctorCommand = new Command('doctor')
60
144
  console.log(`${muted('update log ')} ${existsSync(UPDATE_LOG) ? `${UPDATE_LOG} (${statSync(UPDATE_LOG).size} bytes)` : dim('(none yet)')}`);
61
145
  console.log('');
62
146
  console.log(dim('Force an update with: gipity update'));
63
- console.log(dim('Disable auto-update: export DISABLE_AUTOUPDATER=1 (or set autoUpdates: false in settings.json)'));
64
147
  });
65
148
  //# sourceMappingURL=doctor.js.map
@@ -0,0 +1,54 @@
1
+ import { Command } from 'commander';
2
+ import { get, post } from '../api.js';
3
+ import { requireConfig } from '../config.js';
4
+ import { brand, bold, dim, muted } from '../colors.js';
5
+ import { run } from '../helpers/index.js';
6
+ function renderStatus(status) {
7
+ if (!status.connected) {
8
+ console.log('Stripe: not connected. Run `gipity payments connect` to set it up.');
9
+ return;
10
+ }
11
+ const charges = status.charges_enabled ? brand('enabled') : dim('disabled');
12
+ const payouts = status.payouts_enabled ? brand('enabled') : dim('disabled');
13
+ console.log(`Stripe: ${bold('connected')}`);
14
+ console.log(` Charges: ${charges}`);
15
+ console.log(` Payouts: ${payouts}`);
16
+ console.log(` Onboarding: ${status.details_submitted ? 'complete' : 'incomplete'}`);
17
+ if (!status.charges_enabled) {
18
+ console.log(muted('\nCharges are not enabled yet — finish Stripe onboarding via `gipity payments connect`.'));
19
+ }
20
+ }
21
+ export const paymentsCommand = new Command('payments')
22
+ .description('Connect Stripe so your app can charge its users (one-time + subscriptions)');
23
+ paymentsCommand
24
+ .command('connect')
25
+ .description('Start (or resume) Stripe onboarding for this app — prints a link to finish in your browser')
26
+ .option('--return-url <url>', 'Where Stripe redirects after onboarding')
27
+ .option('--json', 'Output as JSON')
28
+ .action((opts) => run('Payments', async () => {
29
+ const config = requireConfig();
30
+ const body = opts.returnUrl ? { returnUrl: opts.returnUrl } : {};
31
+ const res = await post(`/api/${config.projectGuid}/services/payments/connect`, body);
32
+ if (opts.json) {
33
+ console.log(JSON.stringify(res.data));
34
+ return;
35
+ }
36
+ console.log('Open this link to connect your Stripe account (bank + identity; no API keys to paste):\n');
37
+ console.log(brand(res.data.url));
38
+ console.log(muted('\nMoney lands in your Stripe account; Gipity takes a small platform fee.'));
39
+ console.log(muted('When done, run `gipity payments status` to confirm charges are enabled.'));
40
+ }));
41
+ paymentsCommand
42
+ .command('status', { isDefault: true })
43
+ .description('Show whether this app can take payments yet')
44
+ .option('--json', 'Output as JSON')
45
+ .action((opts) => run('Payments', async () => {
46
+ const config = requireConfig();
47
+ const res = await get(`/api/${config.projectGuid}/services/payments/status`);
48
+ if (opts.json) {
49
+ console.log(JSON.stringify(res.data));
50
+ return;
51
+ }
52
+ renderStatus(res.data);
53
+ }));
54
+ //# sourceMappingURL=payments.js.map
@@ -1,10 +1,8 @@
1
- import { spawnSync } from 'child_process';
2
- import { resolve, dirname } from 'path';
3
- import { mkdirSync, writeFileSync } from 'fs';
4
1
  import { confirm } from '../utils.js';
5
2
  import { bold, dim, success, error as clrError, muted, info } from '../colors.js';
6
3
  import * as state from '../relay/state.js';
7
4
  import { planFor, UnsupportedPlatformError } from '../relay/installers.js';
5
+ import { installAutostart, removeAutostart, resolveCliPath } from '../relay/setup.js';
8
6
  function requirePaired() {
9
7
  const device = state.getDevice();
10
8
  if (!device) {
@@ -13,27 +11,6 @@ function requirePaired() {
13
11
  }
14
12
  return device;
15
13
  }
16
- /** Absolute path to the currently-running `gipity` CLI. Embedded in the
17
- * service unit so the launchd/systemd/Task Scheduler entry re-launches
18
- * the same binary even if PATH changes. */
19
- function resolveCliPath() {
20
- return resolve(process.argv[1] ?? 'gipity');
21
- }
22
- /** Run a sequence of argv commands directly (no shell). Returns true if all
23
- * succeeded. Spawns each command's stdio inherited so the user sees the
24
- * service manager's output verbatim. */
25
- function runArgvSequence(cmds, { failFast }) {
26
- let allOk = true;
27
- for (const argv of cmds) {
28
- const r = spawnSync(argv[0], argv.slice(1), { stdio: 'inherit' });
29
- if (r.status !== 0) {
30
- allOk = false;
31
- if (failFast)
32
- return false;
33
- }
34
- }
35
- return allOk;
36
- }
37
14
  export function registerInstallCommands(relayCommand) {
38
15
  relayCommand
39
16
  .command('install')
@@ -41,10 +18,9 @@ export function registerInstallCommands(relayCommand) {
41
18
  .option('--print', 'Print the service-unit file and the commands, but don\'t run them')
42
19
  .action(async (opts) => {
43
20
  requirePaired();
44
- const cliPath = resolveCliPath();
45
21
  let plan;
46
22
  try {
47
- plan = planFor({ cliPath });
23
+ plan = planFor({ cliPath: resolveCliPath() });
48
24
  }
49
25
  catch (err) {
50
26
  if (err instanceof UnsupportedPlatformError) {
@@ -69,10 +45,11 @@ export function registerInstallCommands(relayCommand) {
69
45
  console.log(muted('Cancelled. (Use --print to preview without installing.)'));
70
46
  return;
71
47
  }
72
- mkdirSync(dirname(plan.path), { recursive: true });
73
- writeFileSync(plan.path, plan.content);
74
- console.log(success(`Wrote ${plan.path}`));
75
- if (!runArgvSequence(plan.enableCmds, { failFast: true })) {
48
+ // Shared helper writes the unit + runs the enable commands; `inherit`
49
+ // surfaces launchctl/systemctl/schtasks output for this interactive path.
50
+ const res = installAutostart({ stdio: 'inherit' });
51
+ console.log(success(`Wrote ${res.path}`));
52
+ if (!res.ok) {
76
53
  console.error(clrError(`Couldn't enable autostart. Try manually: ${plan.enableDisplay}`));
77
54
  process.exit(1);
78
55
  }
@@ -89,9 +66,21 @@ export function registerInstallCommands(relayCommand) {
89
66
  console.error(clrError('Usage: gipity relay autostart <on|off>'));
90
67
  process.exit(1);
91
68
  }
92
- let plan;
93
69
  try {
94
- plan = planFor({ cliPath: resolveCliPath() });
70
+ if (want === 'on') {
71
+ const plan = planFor({ cliPath: resolveCliPath() });
72
+ console.log(`${info('Running:')} ${dim(plan.enableDisplay)}`);
73
+ const res = installAutostart({ stdio: 'inherit' });
74
+ if (!res.ok) {
75
+ console.error(clrError('Command failed.'));
76
+ process.exit(1);
77
+ }
78
+ }
79
+ else {
80
+ // Disable is best-effort - the task may already be stopped.
81
+ const { plan } = removeAutostart({ stdio: 'inherit' });
82
+ console.log(`${info('Running:')} ${dim(plan.disableDisplay)}`);
83
+ }
95
84
  }
96
85
  catch (err) {
97
86
  if (err instanceof UnsupportedPlatformError) {
@@ -101,15 +90,6 @@ export function registerInstallCommands(relayCommand) {
101
90
  else
102
91
  throw err;
103
92
  }
104
- const cmds = want === 'on' ? plan.enableCmds : plan.disableCmds;
105
- const display = want === 'on' ? plan.enableDisplay : plan.disableDisplay;
106
- console.log(`${info('Running:')} ${dim(display)}`);
107
- // Disable is best-effort (the task may already be stopped); enable is fail-fast.
108
- const ok = runArgvSequence(cmds, { failFast: want === 'on' });
109
- if (!ok && want === 'on') {
110
- console.error(clrError('Command failed.'));
111
- process.exit(1);
112
- }
113
93
  console.log(success(`Autostart ${want}.`));
114
94
  });
115
95
  }
@@ -8,14 +8,98 @@
8
8
  import { Command } from 'commander';
9
9
  import { existsSync, readFileSync, unlinkSync } from 'fs';
10
10
  import { spawn } from 'child_process';
11
- import { post } from '../api.js';
11
+ import { post, ApiError } from '../api.js';
12
12
  import { confirm } from '../utils.js';
13
- import { bold, brand, success, error as clrError, muted, } from '../colors.js';
13
+ import { bold, brand, dim, success, error as clrError, muted, } from '../colors.js';
14
14
  import * as state from '../relay/state.js';
15
15
  import * as daemon from '../relay/daemon.js';
16
+ import { UnsupportedPlatformError } from '../relay/installers.js';
17
+ import { pairDevice, startDaemon, installAutostart } from '../relay/setup.js';
16
18
  import { registerInstallCommands } from './relay-install.js';
17
19
  export const relayCommand = new Command('relay')
18
20
  .description('Pair with the web CLI');
21
+ // ─── gipity relay setup ────────────────────────────────────────────────
22
+ // Non-interactive pair + (optionally) start + autostart, for installers and
23
+ // GUIs (e.g. the desktop onboarding client) that can't drive prompts. The
24
+ // interactive equivalent is the first-run block in `gipity claude`.
25
+ relayCommand
26
+ .command('setup')
27
+ .description('Pair this machine and start the relay - non-interactive (for installers/GUIs)')
28
+ .option('--name <name>', 'Device name shown in the web CLI (default: this machine\'s hostname)')
29
+ .option('--no-start', 'Pair only; do not start the relay daemon now')
30
+ .option('--no-autostart', 'Skip the OS login service (use when a supervising app owns the daemon)')
31
+ .option('--force', 'Re-pair even if already paired (revokes the old device first)')
32
+ .option('--json', 'Machine-readable output')
33
+ .action(async (opts) => {
34
+ const fail = (code, message) => {
35
+ if (opts.json) {
36
+ console.log(JSON.stringify({ ok: false, code, error: message }));
37
+ }
38
+ else {
39
+ console.error(clrError(message));
40
+ if (code === 'not_authenticated')
41
+ console.error(muted('Run `gipity login` first.'));
42
+ }
43
+ process.exit(1);
44
+ };
45
+ // 1. Pair (idempotent unless --force).
46
+ let device;
47
+ try {
48
+ device = await pairDevice({ name: opts.name, force: opts.force });
49
+ }
50
+ catch (err) {
51
+ if (err instanceof ApiError && err.statusCode === 401) {
52
+ return fail('not_authenticated', 'Not logged in - cannot pair this machine.');
53
+ }
54
+ return fail('pair_failed', `Could not pair: ${err?.message || err}`);
55
+ }
56
+ // 2. Start the daemon now (unless --no-start).
57
+ let daemonStarted = false;
58
+ if (opts.start) {
59
+ startDaemon();
60
+ daemonStarted = true;
61
+ }
62
+ // 3. Install OS autostart (unless --no-autostart). Option B desktop clients
63
+ // pass --no-autostart because the app itself supervises `relay run`.
64
+ const autostart = { requested: opts.autostart, installed: false, supported: true, summary: '' };
65
+ if (opts.autostart) {
66
+ try {
67
+ const res = installAutostart();
68
+ autostart.installed = res.ok;
69
+ autostart.summary = res.summary;
70
+ }
71
+ catch (err) {
72
+ if (err instanceof UnsupportedPlatformError) {
73
+ autostart.supported = false;
74
+ }
75
+ else {
76
+ return fail('autostart_failed', `Autostart install failed: ${err?.message || err}`);
77
+ }
78
+ }
79
+ }
80
+ // 4. Report.
81
+ if (opts.json) {
82
+ console.log(JSON.stringify({
83
+ ok: true,
84
+ device: { guid: device.guid, name: device.name, platform: device.platform, reused: device.reused },
85
+ daemon_started: daemonStarted,
86
+ autostart,
87
+ }));
88
+ return;
89
+ }
90
+ console.log(success(`${device.reused ? 'Already paired' : 'Paired'} as ${bold(device.name)} ${muted(`(${device.guid})`)}.`));
91
+ if (daemonStarted)
92
+ console.log(success('Relay started.'));
93
+ if (autostart.requested) {
94
+ if (!autostart.supported)
95
+ console.log(muted(`Auto-start not supported on ${process.platform}; skipped.`));
96
+ else if (autostart.installed)
97
+ console.log(`${success('Auto-start installed.')} ${dim(autostart.summary)}`);
98
+ else
99
+ console.log(muted('Auto-start enable returned non-zero - retry with `gipity relay install`.'));
100
+ }
101
+ console.log(dim('In the Gipity web CLI, type `/claude` to dispatch messages to this machine.'));
102
+ });
19
103
  // ─── gipity relay status ───────────────────────────────────────────────
20
104
  relayCommand
21
105
  .command('status')
@@ -24,10 +108,13 @@ relayCommand
24
108
  .action((opts) => {
25
109
  const s = state.loadState();
26
110
  if (opts.json) {
27
- // Redact the token - no reason for scripts to see it.
111
+ // Redact the token - no reason for scripts to see it. `daemon_running`
112
+ // lets a supervising app (the desktop client) poll liveness and decide
113
+ // whether to (re)spawn `relay run` without parsing the PID file itself.
28
114
  const safe = {
29
115
  ...s,
30
116
  device: s.device ? { ...s.device, token: '***' } : null,
117
+ daemon_running: state.isDaemonRunning(),
31
118
  };
32
119
  console.log(JSON.stringify(safe, null, 2));
33
120
  return;
@@ -40,6 +127,7 @@ relayCommand
40
127
  console.log(`${bold('Platform:')} ${s.device.platform}`);
41
128
  console.log(`${bold('Paired:')} ${s.device.paired_at}`);
42
129
  console.log(`${bold('Paused:')} ${s.paused ? 'yes' : 'no'}`);
130
+ console.log(`${bold('Running:')} ${state.isDaemonRunning() ? 'yes' : 'no'}`);
43
131
  });
44
132
  // ─── gipity relay run ──────────────────────────────────────────────────
45
133
  relayCommand
@@ -24,6 +24,7 @@ export const FLAG_ALIASES = {
24
24
  '--ratio': '--aspect-ratio',
25
25
  '--res': '--resolution',
26
26
  '--desc': '--description',
27
+ '--body': '--data',
27
28
  '--src': '--source-dir',
28
29
  '--srcdir': '--source-dir',
29
30
  '--parallel': '--concurrency',
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 { paymentsCommand } from './commands/payments.js';
38
39
  import { jobCommand } from './commands/job.js';
39
40
  import { rbacCommand } from './commands/rbac.js';
40
41
  import { auditCommand } from './commands/audit.js';
@@ -124,7 +125,7 @@ const commonGroup = [skillCommand, projectCommand, addCommand, removeCommand, de
124
125
  const connectGroup = [claudeCommand, relayCommand];
125
126
  const projectGroup = [domainCommand, statusCommand, initCommand];
126
127
  const filesGroup = [fileCommand, syncCommand, pushCommand, uploadCommand];
127
- const appBuildingGroup = [testCommand, fnCommand, serviceCommand, jobCommand, dbCommand, logsCommand, workflowCommand, realtimeCommand, rbacCommand, auditCommand, recordsCommand];
128
+ const appBuildingGroup = [testCommand, fnCommand, serviceCommand, paymentsCommand, jobCommand, dbCommand, logsCommand, workflowCommand, realtimeCommand, rbacCommand, auditCommand, recordsCommand];
128
129
  const utilitiesGroup = [pageCommand, sandboxCommand, generateCommand, emailCommand, gmailCommand, locationCommand, textCommand];
129
130
  const agentGroup = [chatCommand, memoryCommand, agentCommand, approvalCommand];
130
131
  const setupGroup = [loginCommand, logoutCommand, tokenCommand, creditsCommand, planCommand, doctorCommand, updateCommand, uninstallCommand];
@@ -222,16 +223,47 @@ function fullCommandName(cmd) {
222
223
  parts.unshift(c.name());
223
224
  return parts.join(' ');
224
225
  }
226
+ // Resolve the deepest (sub)command the user actually targeted by walking the
227
+ // command tree against the leading positional tokens of argv (skipping flags,
228
+ // and a root value-option's value). Used at error time to render the RIGHT
229
+ // command's help — see enableHelpAfterError below for why we can't just capture
230
+ // the command in a closure.
231
+ function resolveTargetCommand(argv) {
232
+ const args = argv.slice(2);
233
+ const rootValueFlags = new Set(program.options.filter(o => o.long && o.required).map(o => o.long));
234
+ let cmd = program;
235
+ for (let i = 0; i < args.length; i++) {
236
+ const tok = args[i];
237
+ if (tok.startsWith('-')) {
238
+ if (!tok.includes('=') && rootValueFlags.has(tok))
239
+ i++; // consume its value
240
+ continue;
241
+ }
242
+ const next = cmd.commands.find(c => c.name() === tok || c.aliases().includes(tok));
243
+ if (!next)
244
+ break; // first non-subcommand operand ends the command chain
245
+ cmd = next;
246
+ }
247
+ return cmd;
248
+ }
225
249
  function enableHelpAfterError(cmd) {
226
250
  cmd.configureOutput({
227
- // Commander calls this on the offending (sub)command. We render that exact
228
- // command's full help (via outputHelp, so addHelpText blocks are included)
229
- // FIRST, then write the one-line error LAST. Both go to the same writeErr
230
- // stream synchronously, so the order holds. We do NOT call
251
+ // Render the offending command's full help (via outputHelp, so addHelpText
252
+ // blocks are included) FIRST, then the one-line error LAST. Both go to the
253
+ // same writeErr stream synchronously, so the order holds. We do NOT call
231
254
  // showHelpAfterError - that would render help a second time, before the error.
255
+ //
256
+ // We must resolve the target command from argv rather than capturing `cmd`
257
+ // here: commander shares ONE _outputConfiguration object across a command
258
+ // and all its subcommands (copyInheritedSettings copies it by reference, and
259
+ // configureOutput mutates it in place). So every subcommand's closure would
260
+ // clobber its siblings', leaving only the last-registered subcommand's — and
261
+ // an unknown option on `fn call` would print `fn delete`'s help. Installing
262
+ // one identical, self-resolving handler everywhere sidesteps the clobber.
232
263
  outputError: (str, write) => {
233
- write(`Showing \`${fullCommandName(cmd)} --help\`:\n\n`);
234
- cmd.outputHelp({ error: true });
264
+ const target = resolveTargetCommand(process.argv);
265
+ write(`Showing \`${fullCommandName(target)} --help\`:\n\n`);
266
+ target.outputHelp({ error: true });
235
267
  write(`\n${str.replace(/\n+$/, '')}\n`);
236
268
  },
237
269
  });
package/dist/knowledge.js CHANGED
@@ -21,6 +21,7 @@ Templates:
21
21
  - \`api\` - Backend service, webhook, data pipeline, chatbot, cron job - no frontend
22
22
  - \`karaoke-captions\` - Forced-alignment app - karaoke captions, subtitle timing, language learning, dubbing alignment
23
23
  - \`outreach-agent\` - AI outreach / drip-email funnel - reach a list of people with personalized, human-approved emails that auto-send on a schedule and a self-improving agent that learns from your edits
24
+ - \`paid-app\` - App that charges users money - SaaS subscription, paid membership, digital product store, "Pro" upgrade, paywalled content (Stripe one-time + subscriptions)
24
25
  When unsure, default to \`web-simple\`. After adding the template, edit the generated files, then \`gipity deploy dev\`.
25
26
  Only skip this on a build request if the user explicitly says not to.
26
27
 
@@ -38,7 +39,8 @@ Kits are reusable building blocks added to an existing app, not whole templates
38
39
  - \`gipity add records\` - Registry-driven records: declare objects/fields as data, get generic CRUD functions with validation, full-text search, soft delete, ACTOR provenance, and an audit event spine - every write is transactional (row + event). Field types include relations ({id,label}), currency, emails/phones/links composites. Ships backend functions + migrations. Needs a database (web-fullstack/api template).
39
40
  - \`gipity add views\` - Generic UI over records-kit objects: sortable/filterable table with full-text search, create/edit/delete forms with type-appropriate widgets, kanban board with drag-to-update. Renders entirely from the field registry - zero per-object UI code. Requires the records kit.
40
41
  - \`gipity add agent-api\` - Make your app agent-operable: named API keys (kit_api_keys) let agents and scripts write through the records kit's single write path with AGENT/API actor attribution - machine writes land on the same audit spine as human edits. Requires the records kit.
41
- - \`gipity add contacts\` - Source-agnostic contact data layer for lead-gen/CRM apps: import people from LinkedIn CSV + Gmail + pasted lists, resolve duplicates into one person while keeping EVERY value from every source with provenance (multi-valued attributes, never overwrites). Exact email/URL auto-merge; fuzzy name+company goes to a human merge-review queue (reversible). Re-imports detect job changes and emit signals. User-definable tags, full-text search, and a transactional event spine. Ships backend functions + migrations. Needs a database (web-fullstack/api template).`;
42
+ - \`gipity add contacts\` - Source-agnostic contact data layer for lead-gen/CRM apps: import people from LinkedIn CSV + Gmail + pasted lists, resolve duplicates into one person while keeping EVERY value from every source with provenance (multi-valued attributes, never overwrites). Exact email/URL auto-merge; fuzzy name+company goes to a human merge-review queue (reversible). Re-imports detect job changes and emit signals. User-definable tags, full-text search, and a transactional event spine. Ships backend functions + migrations. Needs a database (web-fullstack/api template).
43
+ - \`gipity add stripe\` - Charge your app's end-users for one-time purchases and subscriptions via Stripe. Owner connects their own Stripe account through Gipity-hosted onboarding (no API keys to paste); money lands in their account, Gipity takes a small platform fee. Ships a buy-button / pricing component, a subscription-status helper for gating UI, a webhook-verified fulfillment function, and the payments/subscriptions tables. The platform brokers checkout + signature-verified webhooks. Needs a database (web-fullstack/api template).`;
42
44
  export const SKILLS_CONTENT = `# Gipity Integration
43
45
 
44
46
  Gipity is the cloud platform your project runs on - hosting, databases, deployment, file storage, code execution, workflows, and monitoring. Gip is the cloud agent that runs on Gipity.
@@ -119,6 +121,19 @@ Write files locally - the Gipity Claude Code plugin's hooks auto-push every save
119
121
 
120
122
  To keep local-only material (research clones, scratch data, vendored references) in the project directory without syncing or deploying it, list it in a \`.gipityignore\` at the project root - gitignore-style, one pattern per line, \`#\` comments. Ignored paths are invisible to sync in both directions; anything that already synced before being ignored stays on the server until you delete it.
121
123
 
124
+ ### Where files go: deploy only ships \`src/\`
125
+
126
+ Deploy is opt-in, not opt-out: the \`files\` phase uploads **only** what's under \`src/\` (plus \`functions/\` and \`migrations/\` as backend, not CDN files). Anything else at the project root is kept but never deployed. Put each kind of file in the right bucket so scratch and reference material can't bloat a deploy:
127
+
128
+ - **\`src/\`** - the app itself. Synced **and** deployed to the CDN. Only app code, assets, and pages belong here.
129
+ - **\`tmp/\`** - ephemeral scratch: file conversions, intermediate outputs, design staging. **Already ignored** (never synced, never deployed) - the one place to do throwaway work. Use this single root. (\`*_tmp/\` dirs and \`.gipityscratch/\` are auto-ignored too, as a safety net, so legacy scattered scratch like \`_vsd_tmp/\` can't leak - but write new scratch to \`tmp/\`, not scattered dirs.)
130
+ - **\`docs/\`** - reference material you want to keep: UI/architecture diagrams, design decks, notes, ADRs. Synced and versioned on the server (backed up, rollback-able) but **never deployed**, because it's outside \`src/\`. This is the home for "keep forever, don't ship" artifacts.
131
+ - **\`tests/\`** - \`*.test.js\` suites. Synced, run by \`gipity test\`, never deployed.
132
+
133
+ Rule of thumb: shipping to users → \`src/\`; keep as reference → \`docs/\`; throwaway → \`tmp/\`.
134
+
135
+ Watch for **bulky output dirs dropped loose at the root** (e.g. \`out/\`, \`vsd_out/\`, \`renders/\`). Unlike scratch, those are NOT ignored - they sync on every push and re-hash on every deploy, which is the classic cause of a slow, bloated deploy. Move them into \`docs/\` if you want to keep them or \`tmp/\` if they're disposable.
136
+
122
137
  ## Skills (detailed documentation)
123
138
 
124
139
  Run \`gipity skill list\` to see every skill. Run \`gipity skill read <name>\` to read one. Load the relevant skill before starting a task - they have the correct API patterns, code examples, and common mistakes.
@@ -130,6 +145,7 @@ App services skills (load before calling \`/services/*\` endpoints):
130
145
  - \`app-image\` - text-to-image only (no input image / editing); providers, sizes, aspect ratios
131
146
  - \`app-llm\` - chat completions, streaming, image input
132
147
  - \`app-location\` - user location & reverse geocoding for deployed apps (first-party - no third-party geocoder)
148
+ - \`app-payments\` - charge end-users real money - Stripe one-time purchases & subscriptions, via the stripe kit (gipity add stripe)
133
149
  - \`app-realtime\` - Gipity Realtime rooms, relay vs state
134
150
  - \`app-tts\` - voices, multi-speaker, languages
135
151
  - \`app-video\` - Gipity Video: models, aspect, resolution
@@ -5,43 +5,16 @@
5
5
  * daemon that auto-starts on every subsequent `gipity claude` invocation,
6
6
  * and - if they said yes to the last question - also starts at OS login.
7
7
  */
8
- import { hostname, platform as osPlatform } from 'os';
9
- import { spawn, spawnSync } from 'child_process';
10
- import { resolve, dirname } from 'path';
11
- import { mkdirSync, writeFileSync } from 'fs';
12
- import { post } from '../api.js';
8
+ import { hostname } from 'os';
13
9
  import { prompt, confirm } from '../utils.js';
14
10
  import { bold, brand, dim, success, error as clrError, muted, info } from '../colors.js';
15
11
  import * as state from './state.js';
16
- import { planFor, UnsupportedPlatformError } from './installers.js';
17
- import { getMachineId } from './machine-id.js';
18
- /** Normalize Node's `os.platform()` to what the backend accepts. */
19
- function mapPlatform(p) {
20
- if (p === 'darwin' || p === 'linux' || p === 'win32')
21
- return p;
22
- return 'linux';
23
- }
24
- /** Path to the currently-running `gipity` CLI (for embedding in service units). */
25
- function resolveCliPath() {
26
- return resolve(process.argv[1] ?? 'gipity');
27
- }
28
- /** Spawn a fresh `gipity relay run` detached from this process. Fire-and-forget. */
29
- export function ensureDaemonRunning() {
30
- if (state.isDaemonRunning())
31
- return;
32
- try {
33
- // Launch via the current Node binary + the CLI entry script. The previous
34
- // `shell: true` on Windows ran the .js path through the shell, where the
35
- // file association (Windows Script Host, not Node) would mis-handle it.
36
- // process.execPath is cross-platform and needs no shell.
37
- const child = spawn(process.execPath, [resolveCliPath(), 'relay', 'run'], {
38
- detached: true,
39
- stdio: 'ignore',
40
- });
41
- child.unref();
42
- }
43
- catch { /* best-effort */ }
44
- }
12
+ import { UnsupportedPlatformError } from './installers.js';
13
+ import { pairDevice, startDaemon, installAutostart } from './setup.js';
14
+ /** Spawn a fresh `gipity relay run` detached from this process. Fire-and-forget.
15
+ * Re-exported from the shared `setup` core so existing importers (`claude.ts`)
16
+ * keep their import path. */
17
+ export const ensureDaemonRunning = startDaemon;
45
18
  /**
46
19
  * First-run prompt block. Idempotent: if the user has already answered
47
20
  * (`relay_enabled` is a boolean), this is a no-op. Non-interactive flows
@@ -75,13 +48,11 @@ export async function maybeOfferRelayOn() {
75
48
  state.setRelayEnabled(false);
76
49
  return;
77
50
  }
78
- // Create the device directly (user-auth, no pair code).
79
- let token;
80
- let shortGuid;
51
+ // Create the device directly (user-auth, no pair code). `pairDevice` writes
52
+ // the device + flips relay_enabled on; we only have to render the outcome.
53
+ let device;
81
54
  try {
82
- const res = await post('/remote-devices', { name, platform: mapPlatform(osPlatform()), machine_id: getMachineId() });
83
- token = res.data.token;
84
- shortGuid = res.data.short_guid;
55
+ device = await pairDevice({ name });
85
56
  }
86
57
  catch (err) {
87
58
  console.error(`\n ${clrError(`Could not create device: ${err?.message || err}`)}`);
@@ -89,41 +60,21 @@ export async function maybeOfferRelayOn() {
89
60
  state.setRelayEnabled(false);
90
61
  return;
91
62
  }
92
- state.setDevice({
93
- guid: shortGuid,
94
- name,
95
- platform: mapPlatform(osPlatform()),
96
- token,
97
- paired_at: new Date().toISOString(),
98
- });
99
- state.setRelayEnabled(true);
100
63
  // Start the daemon for this session.
101
64
  const startNow = await confirm(' Start the relay now (and on future `gipity claude` runs)?', { default: 'yes' });
102
65
  if (startNow) {
103
- ensureDaemonRunning();
66
+ startDaemon();
104
67
  }
105
68
  // Offer OS-level autostart (launchd / systemd --user / Task Scheduler).
106
69
  const autostartOs = await confirm(' Also start at OS login (auto-start with Windows / macOS / Linux)?', { default: 'yes' });
107
70
  if (autostartOs) {
108
71
  try {
109
- const plan = planFor({ cliPath: resolveCliPath() });
110
- mkdirSync(dirname(plan.path), { recursive: true });
111
- writeFileSync(plan.path, plan.content);
112
- // Run argv directly - no shell - so paths with spaces / shell metas
113
- // can't break out. Fail-fast on the first non-zero exit.
114
- let allOk = true;
115
- for (const argv of plan.enableCmds) {
116
- const r = spawnSync(argv[0], argv.slice(1), { stdio: 'ignore' });
117
- if (r.status !== 0) {
118
- allOk = false;
119
- break;
120
- }
121
- }
122
- if (!allOk) {
72
+ const res = installAutostart();
73
+ if (!res.ok) {
123
74
  console.log(` ${muted('Autostart install returned non-zero - you can run')} ${brand('gipity relay install')} ${muted('later.')}`);
124
75
  }
125
76
  else {
126
- console.log(` ${success('Auto-start installed.')} ${dim(plan.summary)}`);
77
+ console.log(` ${success('Auto-start installed.')} ${dim(res.summary)}`);
127
78
  }
128
79
  }
129
80
  catch (err) {
@@ -136,7 +87,7 @@ export async function maybeOfferRelayOn() {
136
87
  }
137
88
  }
138
89
  console.log('');
139
- console.log(` ${success(`Registered as ${bold(name)} (${shortGuid}).`)}`);
90
+ console.log(` ${success(`Registered as ${bold(device.name)} (${device.guid}).`)}`);
140
91
  console.log(` ${dim('In the Gipity web CLI, type `/claude` to dispatch messages to this PC.')}`);
141
92
  console.log('');
142
93
  void info;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Headless relay setup primitives — the non-interactive core shared by:
3
+ * - the first-run `gipity claude` onboarding (`onboarding.ts`),
4
+ * - the `gipity relay setup` command (installer/GUI entry point),
5
+ * - `gipity relay install` / `gipity relay autostart` (`relay-install.ts`).
6
+ *
7
+ * Keeping pairing + autostart here means a supervising app (e.g. the desktop
8
+ * onboarding client) can drive the whole relay through one code path —
9
+ * `pairDevice()` → `startDaemon()` / `installAutostart()` — without replaying
10
+ * the interactive prompts that live in `onboarding.ts`. No `console` output
11
+ * and no `process.exit` in this module: callers own all UX.
12
+ */
13
+ import { spawn, spawnSync } from 'child_process';
14
+ import { hostname, platform as osPlatform } from 'os';
15
+ import { resolve, dirname } from 'path';
16
+ import { mkdirSync, writeFileSync } from 'fs';
17
+ import { post } from '../api.js';
18
+ import * as state from './state.js';
19
+ import { planFor } from './installers.js';
20
+ import { getMachineId } from './machine-id.js';
21
+ /** Normalize Node's `os.platform()` to what the backend accepts. */
22
+ export function mapPlatform(p) {
23
+ if (p === 'darwin' || p === 'linux' || p === 'win32')
24
+ return p;
25
+ return 'linux';
26
+ }
27
+ /** Absolute path to the currently-running `gipity` CLI. Embedded in service
28
+ * units so launchd/systemd/Task Scheduler re-launch the same binary even if
29
+ * PATH changes. */
30
+ export function resolveCliPath() {
31
+ return resolve(process.argv[1] ?? 'gipity');
32
+ }
33
+ /**
34
+ * Create + persist a relay device, marking the relay opted-in. Idempotent
35
+ * unless `force`: if this machine is already paired and `!force`, the
36
+ * existing device is returned untouched (no server call). With `force`, the
37
+ * old device is revoked server-side (best-effort) before re-pairing.
38
+ *
39
+ * Throws on a bad name or if the `/remote-devices` call fails (e.g. 401 when
40
+ * the user isn't logged in) — callers translate that into their own UX.
41
+ */
42
+ export async function pairDevice(opts = {}) {
43
+ const existing = state.getDevice();
44
+ if (existing && !opts.force) {
45
+ state.setRelayEnabled(true);
46
+ return {
47
+ guid: existing.guid,
48
+ name: existing.name,
49
+ platform: mapPlatform(existing.platform),
50
+ reused: true,
51
+ };
52
+ }
53
+ if (existing && opts.force) {
54
+ try {
55
+ await post(`/remote-devices/${encodeURIComponent(existing.guid)}/revoke`, {});
56
+ }
57
+ catch {
58
+ // Best-effort: a stale server-side device is harmless; proceed to re-pair.
59
+ }
60
+ state.clearDevice();
61
+ }
62
+ const name = (opts.name?.trim() || hostname() || 'my-pc').trim();
63
+ if (!name || name.length > 100) {
64
+ throw new Error('Device name must be 1–100 non-whitespace characters.');
65
+ }
66
+ const plat = mapPlatform(osPlatform());
67
+ const res = await post('/remote-devices', { name, platform: plat, machine_id: getMachineId() });
68
+ state.setDevice({
69
+ guid: res.data.short_guid,
70
+ name,
71
+ platform: plat,
72
+ token: res.data.token,
73
+ paired_at: new Date().toISOString(),
74
+ });
75
+ state.setRelayEnabled(true);
76
+ return { guid: res.data.short_guid, name, platform: plat, reused: false };
77
+ }
78
+ /**
79
+ * Spawn a fresh `gipity relay run` detached from this process. No-op if the
80
+ * daemon is already running. Fire-and-forget — best-effort, never throws.
81
+ *
82
+ * Launches via the current Node binary + CLI entry script (not a shell): the
83
+ * previous `shell: true` on Windows ran the `.js` path through Windows Script
84
+ * Host instead of Node. `process.execPath` is cross-platform and needs no shell.
85
+ */
86
+ export function startDaemon() {
87
+ if (state.isDaemonRunning())
88
+ return;
89
+ try {
90
+ const child = spawn(process.execPath, [resolveCliPath(), 'relay', 'run'], {
91
+ detached: true,
92
+ stdio: 'ignore',
93
+ });
94
+ child.unref();
95
+ }
96
+ catch {
97
+ /* best-effort */
98
+ }
99
+ }
100
+ /**
101
+ * Write the platform service-unit and enable it (start at OS login,
102
+ * auto-restart on crash). Throws `UnsupportedPlatformError` from `planFor` on
103
+ * an OS we don't generate a unit for. Returns `ok: false` if a service-manager
104
+ * command exits non-zero (the file is still written, so the caller can point
105
+ * the user at a manual retry).
106
+ *
107
+ * `stdio` defaults to `'ignore'` (silent, for headless/GUI callers); the
108
+ * interactive `relay install` passes `'inherit'` so the user sees
109
+ * launchctl/systemctl/schtasks output verbatim.
110
+ */
111
+ export function installAutostart(opts = {}) {
112
+ const stdio = opts.stdio ?? 'ignore';
113
+ const plan = planFor({ cliPath: resolveCliPath() });
114
+ mkdirSync(dirname(plan.path), { recursive: true });
115
+ writeFileSync(plan.path, plan.content);
116
+ let ok = true;
117
+ for (const argv of plan.enableCmds) {
118
+ const r = spawnSync(argv[0], argv.slice(1), { stdio });
119
+ if (r.status !== 0) {
120
+ ok = false;
121
+ break;
122
+ }
123
+ }
124
+ return { ok, summary: plan.summary, path: plan.path };
125
+ }
126
+ /**
127
+ * Best-effort removal of the OS autostart unit. Disable is not fail-fast (the
128
+ * service may already be stopped); we run every command and report whether all
129
+ * succeeded. Throws `UnsupportedPlatformError` on an unsupported OS.
130
+ */
131
+ export function removeAutostart(opts = {}) {
132
+ const stdio = opts.stdio ?? 'ignore';
133
+ const plan = planFor({ cliPath: resolveCliPath() });
134
+ let ok = true;
135
+ for (const argv of plan.disableCmds) {
136
+ const r = spawnSync(argv[0], argv.slice(1), { stdio });
137
+ if (r.status !== 0)
138
+ ok = false;
139
+ }
140
+ return { ok, plan };
141
+ }
142
+ //# sourceMappingURL=setup.js.map
package/dist/setup.js CHANGED
@@ -39,8 +39,23 @@ export const PRIMER_FILES = {
39
39
  /** Aider's config file. We write/merge a `read:` entry into it so aider loads
40
40
  * AGENTS.md - a per-workstation artifact like the primers, never synced. */
41
41
  export const AIDER_CONF_FILE = '.aider.conf.yml';
42
+ /** Project-local scratch namespaces: file conversions, intermediate outputs,
43
+ * design staging - work the agent wants on disk but should never sync or
44
+ * deploy. These MUST mirror the sandbox's `isEphemeralSandboxPath` denylist
45
+ * (`platform/server/src/services/sandbox/no-persist.ts`) so the same dirs are
46
+ * treated as throwaway everywhere: a sandbox run refuses to persist them, and
47
+ * `gipity sync`/deploy refuses to upload them. Keeping the two in lockstep is
48
+ * what makes scratch coherent across the platform - update both together.
49
+ * `tmp/` is the one we teach agents to use (see knowledge.ts "Files and sync");
50
+ * `*_tmp/` and `.gipityscratch/` are caught defensively so legacy/scattered
51
+ * scratch (the `_vsd_tmp/`/`_convert_tmp/` dirs that bloated past deploys)
52
+ * can't leak in either. Reference material to KEEP (diagrams, decks, ADRs) goes
53
+ * in `docs/` instead - synced and versioned, but outside `src/` so it's never
54
+ * deployed. Gitignore-glob form, matched by the `ignore` package in config.ts. */
55
+ export const SCRATCH_IGNORE = ['tmp/', '.tmp/', '*_tmp/', '.gipityscratch/'];
42
56
  export const DEFAULT_SYNC_IGNORE = [
43
57
  'node_modules', '.git', '.gipity.json', '.gipity/', '.claude/', '.gitignore', AIDER_CONF_FILE,
58
+ ...SCRATCH_IGNORE,
44
59
  ...new Set(Object.values(PRIMER_FILES)),
45
60
  ];
46
61
  /** True if `name` (a top-level dir entry) is a workstation artifact that
@@ -480,7 +495,9 @@ export const SUPPORTED_TOOLS = [
480
495
  export const DEFAULT_TOOLS = SUPPORTED_TOOLS.filter(t => !t.optIn);
481
496
  export function setupGitignore() {
482
497
  const gitignorePath = resolve(process.cwd(), '.gitignore');
483
- const entries = ['.gipity/', '.gipity.json'];
498
+ // Sync already skips the scratch namespaces (DEFAULT_SYNC_IGNORE); ignore them
499
+ // in git too so ephemeral conversion/staging work never gets committed.
500
+ const entries = ['.gipity/', '.gipity.json', ...SCRATCH_IGNORE];
484
501
  if (existsSync(gitignorePath)) {
485
502
  let content = readFileSync(gitignorePath, 'utf-8');
486
503
  // Split on \r?\n so a CRLF .gitignore (the Windows default) doesn't leave a
package/dist/sync.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * `name (conflict from <host> YYYY-MM-DD-HHMMSS).ext` and then uploaded on
22
22
  * the next pass so every client sees it. No content merging, ever.
23
23
  */
24
- import { writeFileSync, mkdirSync, existsSync, statSync, unlinkSync, readdirSync, rmdirSync, readFileSync, renameSync, openSync, closeSync } from 'fs';
24
+ import { writeFileSync, mkdirSync, existsSync, statSync, unlinkSync, readdirSync, rmdirSync, readFileSync, renameSync, openSync, closeSync, utimesSync } from 'fs';
25
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';
@@ -54,6 +54,41 @@ function projectDir() {
54
54
  // ─── Advisory lock ─────────────────────────────────────────────
55
55
  const LOCK_WAIT_MS = 30_000;
56
56
  const LOCK_POLL_MS = 500;
57
+ // While a holder works it refreshes the lock's mtime on this cadence; a lock
58
+ // whose mtime is older than the stale window is treated as abandoned and
59
+ // reclaimed even if a process with its PID still exists. This catches the two
60
+ // cases a dead-PID check misses: a CPU-wedged holder that stopped heartbeating,
61
+ // and PID reuse (some unrelated process now owns the old holder's PID). The
62
+ // stale window must stay comfortably larger than the heartbeat so a briefly
63
+ // busy holder isn't robbed mid-run.
64
+ const LOCK_HEARTBEAT_MS = 15_000;
65
+ export const LOCK_STALE_MS = 90_000;
66
+ /** Decide whether an existing lock file is reclaimable. Exported for tests.
67
+ * Reclaim when: the file is empty/garbage (holder crashed between creating the
68
+ * lock and writing its PID), the holder PID is dead, or the lock's heartbeat
69
+ * went silent past {@link LOCK_STALE_MS}. A live, freshly-heartbeating holder
70
+ * is never reclaimed. */
71
+ export function isLockReclaimable(path, now = Date.now()) {
72
+ let raw;
73
+ let mtimeMs;
74
+ try {
75
+ raw = readFileSync(path, 'utf-8').trim();
76
+ mtimeMs = statSync(path).mtimeMs;
77
+ }
78
+ catch {
79
+ return false; // can't read it (likely already gone / racing) - retry, don't steal
80
+ }
81
+ const pid = parseInt(raw, 10);
82
+ if (!raw || !pid || isNaN(pid))
83
+ return true; // empty/garbage = crashed mid-create
84
+ try {
85
+ process.kill(pid, 0);
86
+ }
87
+ catch {
88
+ return true;
89
+ } // holder PID is dead
90
+ return now - mtimeMs > LOCK_STALE_MS; // alive but heartbeat went silent
91
+ }
57
92
  /** Acquire the per-project sync lock. Returns a release function. Exported for tests. */
58
93
  export async function acquireLock() {
59
94
  const path = lockPath();
@@ -64,30 +99,30 @@ export async function acquireLock() {
64
99
  const fd = openSync(path, 'wx');
65
100
  writeFileSync(fd, String(process.pid));
66
101
  closeSync(fd);
67
- return () => { try {
102
+ // Heartbeat: keep the lock's mtime fresh so peers can distinguish a live
103
+ // holder from an abandoned one. unref() so it never holds the process open.
104
+ const beat = setInterval(() => {
105
+ try {
106
+ utimesSync(path, new Date(), new Date());
107
+ }
108
+ catch { /* lock gone */ }
109
+ }, LOCK_HEARTBEAT_MS);
110
+ beat.unref?.();
111
+ return () => { clearInterval(beat); try {
68
112
  unlinkSync(path);
69
113
  }
70
114
  catch { /* already gone */ } };
71
115
  }
72
116
  catch {
73
- // Either lock exists, or the race gave us a transient error. Check for
74
- // staleness (holder PID is dead treat as unlocked).
75
- try {
76
- const pid = parseInt(readFileSync(path, 'utf-8').trim(), 10);
77
- if (pid && !isNaN(pid)) {
78
- try {
79
- process.kill(pid, 0);
80
- }
81
- catch {
82
- try {
83
- unlinkSync(path);
84
- }
85
- catch { /* race */ }
86
- continue;
87
- }
117
+ // Lock exists (or the race gave a transient error). Reclaim it if the
118
+ // holder is dead/abandoned; otherwise wait and retry.
119
+ if (isLockReclaimable(path)) {
120
+ try {
121
+ unlinkSync(path);
88
122
  }
123
+ catch { /* race - someone else got it */ }
124
+ continue;
89
125
  }
90
- catch { /* couldn't read - retry */ }
91
126
  if (Date.now() - start > LOCK_WAIT_MS) {
92
127
  throw new Error(`Another sync is in progress (${path}). Waited ${LOCK_WAIT_MS / 1000}s. ` +
93
128
  `Remove the file manually if you're sure no sync is running.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.400",
3
+ "version": "1.0.402",
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-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__/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"