gipity 1.0.412 → 1.0.414

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
@@ -211,6 +211,51 @@ export async function download(path, retried = false) {
211
211
  }
212
212
  return Buffer.from(await res.arrayBuffer());
213
213
  }
214
+ /** Download raw bytes plus the response headers - for binary endpoints whose
215
+ * metadata rides headers (e.g. `GET /projects/:guid/export`, where
216
+ * `X-Gip-Skipped` and the Content-Disposition filename matter to the caller).
217
+ * Non-2xx responses are parsed as standard JSON errors. */
218
+ export async function downloadWithHeaders(path, retried = false) {
219
+ const url = `${baseUrl()}${path}`;
220
+ const res = await fetch(url, {
221
+ headers: { ...clientHeaders(), 'Authorization': `Bearer ${await bearerToken()}` },
222
+ });
223
+ if (await shouldRetryAfter401(res.status, retried)) {
224
+ return downloadWithHeaders(path, true);
225
+ }
226
+ if (!res.ok) {
227
+ const json = await res.json().catch(() => ({ error: { code: 'UNKNOWN', message: res.statusText } }));
228
+ const err = json.error || { code: 'UNKNOWN', message: res.statusText };
229
+ throw new ApiError(res.status, err.code, with401Hint(res.status, err.message), json.data);
230
+ }
231
+ return { buffer: Buffer.from(await res.arrayBuffer()), headers: res.headers };
232
+ }
233
+ /** POST raw bytes (e.g. a .gip bundle) and parse the JSON response, returning
234
+ * the HTTP status alongside it so callers can tell a clean 201 from a 207
235
+ * partial success. The timeout scales with the body size (same policy as the
236
+ * presigned PUT path) so a large-but-progressing upload isn't cut off. */
237
+ export async function postBinary(path, body, contentType = 'application/zip', retried = false) {
238
+ const url = `${baseUrl()}${path}`;
239
+ const headers = {
240
+ ...clientHeaders(),
241
+ 'Authorization': `Bearer ${await bearerToken()}`,
242
+ 'Content-Type': contentType,
243
+ };
244
+ const res = await fetchWithTimeout(url, {
245
+ method: 'POST',
246
+ headers,
247
+ body: new Uint8Array(body),
248
+ }, putTimeoutMs(body.length), `POST ${path}`);
249
+ if (await shouldRetryAfter401(res.status, retried)) {
250
+ return postBinary(path, body, contentType, true);
251
+ }
252
+ if (!res.ok) {
253
+ const json = await res.json().catch(() => ({ error: { code: 'UNKNOWN', message: res.statusText } }));
254
+ const err = json.error || { code: 'UNKNOWN', message: res.statusText };
255
+ throw new ApiError(res.status, err.code, with401Hint(res.status, err.message), json.data);
256
+ }
257
+ return { status: res.status, json: await res.json() };
258
+ }
214
259
  /** Download a response as a Node.js Readable stream */
215
260
  export async function downloadStream(path, retried = false) {
216
261
  const { Readable } = await import('stream');
package/dist/auth.js CHANGED
@@ -166,6 +166,15 @@ export async function refreshTokenIfNeeded(force = false) {
166
166
  const fresh = (a) => Date.now() <= new Date(a.expiresAt).getTime() - buffer;
167
167
  if (!force && fresh(auth))
168
168
  return; // fast path: token still good, no lock needed
169
+ // The specific access token the caller saw (the one the server just rejected on the
170
+ // force path). A locally-"fresh" token EQUAL to this can't be trusted - it's exactly
171
+ // what got a 401 - but a DIFFERENT fresh token means a sibling successfully rotated
172
+ // while we waited, and we should adopt it instead of re-refreshing with the (now
173
+ // single-use-consumed) refresh token. Without this, N concurrent relay dispatches
174
+ // that all 401 at once serialize on the lock, the first refreshes, and every other
175
+ // one re-tries the consumed token and hard-fails with "Session expired".
176
+ const rejectedAccess = auth.accessToken;
177
+ const adoptable = (a) => !!a && fresh(a) && (!force || a.accessToken !== rejectedAccess);
169
178
  // If the refresh token itself has expired, re-login is genuinely required; leave the
170
179
  // expired auth in place so the caller's existing 401 path prompts `gipity login`.
171
180
  const refreshExp = decodeJwtExp(auth.refreshToken);
@@ -175,10 +184,10 @@ export async function refreshTokenIfNeeded(force = false) {
175
184
  const release = await acquireRefreshLock();
176
185
  try {
177
186
  // 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.
187
+ // force we adopt only a token DIFFERENT from the one that was just rejected (a
188
+ // sibling's freshly-rotated token), never the rejected one itself.
180
189
  const held = readAuthFresh();
181
- if (!force && held && fresh(held)) {
190
+ if (adoptable(held)) {
182
191
  cached = held;
183
192
  return;
184
193
  }
@@ -186,7 +195,7 @@ export async function refreshTokenIfNeeded(force = false) {
186
195
  const apiBase = resolveApiBase();
187
196
  for (let attempt = 1; attempt <= 3; attempt++) {
188
197
  const cur = readAuthFresh(); // a sibling may have just refreshed for us
189
- if (!force && cur && fresh(cur)) {
198
+ if (adoptable(cur)) {
190
199
  cached = cur;
191
200
  return;
192
201
  }
@@ -218,7 +227,7 @@ export async function refreshTokenIfNeeded(force = false) {
218
227
  // sibling's fresh token just landed; otherwise stop and let the caller re-login.
219
228
  if (res.status === 401 || res.status === 403) {
220
229
  const after = readAuthFresh();
221
- if (after && fresh(after)) {
230
+ if (adoptable(after)) {
222
231
  cached = after;
223
232
  return;
224
233
  }
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { resolve, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- function cliVersion() {
4
+ export function cliVersion() {
5
5
  try {
6
6
  const dir = dirname(fileURLToPath(import.meta.url));
7
7
  const pkg = JSON.parse(readFileSync(resolve(dir, '../package.json'), 'utf-8'));
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `gipity github <connect|status|repos|disconnect>` - manage the GitHub
3
+ * connection that powers `gipity load github:owner/repo`.
4
+ *
5
+ * Connect uses the GitHub App install flow: the URL is GitHub's own repo
6
+ * picker, so the user chooses exactly which repositories Gipity can read.
7
+ * Re-running connect doubles as the "grant more repositories" flow.
8
+ */
9
+ import { Command } from 'commander';
10
+ import { get, post } from '../api.js';
11
+ import { brand, bold, muted } from '../colors.js';
12
+ import { run, printList, printResult } from '../helpers/index.js';
13
+ export const githubCommand = new Command('github')
14
+ .description('Connect GitHub for repo imports')
15
+ .addHelpText('after', '\nConnect once, then import any shared repo with `gipity load github:owner/repo`.');
16
+ githubCommand
17
+ .command('connect')
18
+ .description('Connect your GitHub account - prints a link; GitHub lets you pick which repositories to share')
19
+ .option('--json', 'Output as JSON')
20
+ .action((opts) => run('Connect', async () => {
21
+ const res = await get('/services/github/authorize');
22
+ if (opts.json) {
23
+ console.log(JSON.stringify({ url: res.url }));
24
+ return;
25
+ }
26
+ console.log('Open this link in your browser to connect GitHub - GitHub lets you pick which repositories to share:\n');
27
+ console.log(brand(res.url));
28
+ console.log(muted('\nWhen done, run `gipity github status` to confirm.'));
29
+ console.log(muted('Re-run `gipity github connect` any time to grant more repositories.'));
30
+ }));
31
+ githubCommand
32
+ .command('status', { isDefault: true })
33
+ .description('Show whether GitHub is connected')
34
+ .option('--json', 'Output as JSON')
35
+ .action((opts) => run('Status', async () => {
36
+ const res = await get('/services/github/status');
37
+ if (opts.json) {
38
+ console.log(JSON.stringify(res.data));
39
+ return;
40
+ }
41
+ const s = res.data;
42
+ if (!s.connected) {
43
+ console.log('GitHub: not connected. Run `gipity github connect` to set it up.');
44
+ return;
45
+ }
46
+ console.log(`GitHub: ${bold('connected')}`);
47
+ if (s.email)
48
+ console.log(` Account: ${s.email}`);
49
+ if (s.connectedAt)
50
+ console.log(` Connected: ${new Date(s.connectedAt).toLocaleDateString()}`);
51
+ console.log(muted('\nList reachable repositories with `gipity github repos`.'));
52
+ console.log(muted('Import one with `gipity load github:owner/repo`.'));
53
+ }));
54
+ githubCommand
55
+ .command('repos')
56
+ .description('List the repositories your GitHub connection can reach')
57
+ .option('--json', 'Output as JSON')
58
+ .action((opts) => run('Repos', async () => {
59
+ const res = await get('/services/github/repos').catch((err) => {
60
+ if (err?.code === 'NOT_CONNECTED') {
61
+ throw new Error('GitHub is not connected - run `gipity github connect` first.');
62
+ }
63
+ throw err;
64
+ });
65
+ const width = res.data.length ? Math.max(...res.data.map(r => r.full_name.length)) : 0;
66
+ printList(res.data, opts, 'No repositories shared yet - run `gipity github connect` to grant some.', r => {
67
+ const vis = r.private ? 'private' : 'public ';
68
+ const desc = r.description ? ` ${muted(r.description)}` : '';
69
+ return `${r.full_name.padEnd(width)} ${muted(vis)} ${r.default_branch}${desc}`;
70
+ });
71
+ }));
72
+ githubCommand
73
+ .command('disconnect')
74
+ .description('Disconnect GitHub (imports via github: sources will stop working)')
75
+ .option('--json', 'Output as JSON')
76
+ .action((opts) => run('Disconnect', async () => {
77
+ await post('/services/github/disconnect');
78
+ printResult('GitHub disconnected.', opts, { disconnected: true });
79
+ }));
80
+ //# sourceMappingURL=github.js.map
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `gipity load <source>` - create a brand-new project from a .gip bundle or
3
+ * a GitHub repository.
4
+ *
5
+ * The counterpart of `gipity save`. Load ALWAYS creates a new project (the
6
+ * server retries slug collisions); it never merges into an existing one. Use
7
+ * `--inspect` to peek inside a source - metadata, contents, deploy phases,
8
+ * and each function's permission surface - without creating anything.
9
+ *
10
+ * Sources:
11
+ * - a local .gip file (from `gipity save`) - uploaded as raw zip bytes
12
+ * - `github:owner/repo[/sub/path][@ref]` or a github.com URL - resolved
13
+ * server-side through the user's GitHub connection (`gipity github connect`);
14
+ * the CLI sends a JSON `{source}` body, no repo bytes travel through the
15
+ * client. Both transports feed the same import pipeline and response shapes.
16
+ */
17
+ import fs from 'fs';
18
+ import os from 'os';
19
+ import path from 'path';
20
+ import { Command } from 'commander';
21
+ import { get, post, postBinary, getAccountSlug } from '../api.js';
22
+ import { success, muted, warning, bold, info, brand } from '../colors.js';
23
+ import { run } from '../helpers/index.js';
24
+ import { withSpinner } from '../progress.js';
25
+ import { formatBytes } from '../adopt-cwd.js';
26
+ import { getProjectsRoot } from '../relay/paths.js';
27
+ import { finalizeLocalProject } from '../project-setup.js';
28
+ /** Server transport cap for a .gip upload - checked locally first so a too-big
29
+ * bundle fails in milliseconds instead of after a full upload. */
30
+ const MAX_BUNDLE_BYTES = 200 * 1024 * 1024;
31
+ /** A source the server resolves via the user's GitHub connection: the
32
+ * `github:owner/repo[/sub/path][@ref]` shorthand or a github.com URL. */
33
+ function isGithubSource(source) {
34
+ return /^github:/i.test(source) || /^https?:\/\/(www\.)?github\.com\//i.test(source);
35
+ }
36
+ /** Append the recovery path when a GitHub-source call fails because the repo
37
+ * isn't reachable through the user's connection (not connected, or connected
38
+ * but this repo wasn't granted). The server's message names the problem; the
39
+ * hint names the fix. Re-throws always. */
40
+ function rethrowWithConnectHint(err) {
41
+ const e = err;
42
+ const msg = e?.message ?? '';
43
+ if (e?.code === 'NOT_CONNECTED' || /not covered by your GitHub connection|connect github/i.test(msg)) {
44
+ e.message = `${msg}\nRun \`gipity github connect\` to connect GitHub - re-run it any time to grant more repositories.`;
45
+ }
46
+ throw err;
47
+ }
48
+ function readBundle(source) {
49
+ const resolved = path.resolve(source.startsWith('~') ? os.homedir() + source.slice(1) : source);
50
+ if (!fs.existsSync(resolved)) {
51
+ throw new Error(`Bundle not found: ${resolved}`);
52
+ }
53
+ const stat = fs.statSync(resolved);
54
+ if (stat.isDirectory()) {
55
+ throw new Error(`${resolved} is a directory - pass a .gip file (create one with \`gipity save\`) or a github: source.`);
56
+ }
57
+ if (stat.size > MAX_BUNDLE_BYTES) {
58
+ throw new Error(`Bundle is ${formatBytes(stat.size)} - over the ${formatBytes(MAX_BUNDLE_BYTES)} upload cap. Move large assets to Gipity Storage and re-save.`);
59
+ }
60
+ return { resolved, buffer: fs.readFileSync(resolved) };
61
+ }
62
+ /** Manifest permission surface, one line per function - what an importing user
63
+ * needs to see before trusting a bundle. */
64
+ function printManifest(m) {
65
+ if (!m.found) {
66
+ console.log(muted('No gipity.yaml in the source - static app (files deploy only).'));
67
+ return;
68
+ }
69
+ if (!m.valid) {
70
+ console.log(warning(`gipity.yaml is present but invalid: ${m.error ?? 'unparseable'}`));
71
+ return;
72
+ }
73
+ console.log(`Deploy phases: ${m.phases.map(p => `${p.name} (${p.type})`).join(', ') || 'none'}`);
74
+ console.log(`Database: ${m.hasDatabase ? 'yes' : 'no'}`);
75
+ if (m.functions.length) {
76
+ console.log('');
77
+ console.log(bold('Functions and permissions:'));
78
+ const width = Math.max(...m.functions.map(f => f.name.length));
79
+ for (const f of m.functions) {
80
+ const parts = [`auth=${f.auth ?? 'public'}`];
81
+ if (f.tables?.length)
82
+ parts.push(`tables=${f.tables.join(',')}`);
83
+ if (f.fetch_domains?.length)
84
+ parts.push(`fetch=${f.fetch_domains.join(',')}`);
85
+ if (f.services?.length)
86
+ parts.push(`services=${f.services.join(',')}`);
87
+ console.log(` ${f.name.padEnd(width)} ${muted(parts.join(' '))}`);
88
+ }
89
+ }
90
+ }
91
+ function printInspect(d, source) {
92
+ if (d.meta) {
93
+ console.log(bold(d.meta.name));
94
+ if (d.meta.description)
95
+ console.log(muted(d.meta.description));
96
+ console.log(`Source: project ${d.meta.source_project}, exported ${d.meta.exported_at}`);
97
+ }
98
+ else {
99
+ console.log(bold(isGithubSource(source) ? source : path.basename(source)));
100
+ console.log(muted('No Gipity bundle metadata - imports as a plain file tree.'));
101
+ }
102
+ console.log(`Contents: ${d.fileCount} file(s), ${formatBytes(d.totalBytes)}`);
103
+ console.log(`Top level: ${d.topLevel.join(', ') || '(empty)'}`);
104
+ console.log('');
105
+ printManifest(d.manifest);
106
+ console.log('');
107
+ console.log(muted('Load it with `gipity load ' + source + '` - always creates a NEW project.'));
108
+ }
109
+ export const loadCommand = new Command('load')
110
+ .description('Create a new app from a .gip bundle or a GitHub repo')
111
+ .argument('<source>', 'A .gip file (from `gipity save`), github:owner/repo[/sub/path][@ref], or a github.com URL')
112
+ .option('--name <name>', 'Name for the new project (default: the source\'s saved name)')
113
+ .option('--inspect', 'Peek inside the source - contents, deploy phases, function permissions - without creating anything')
114
+ .option('--json', 'Output as JSON')
115
+ .addHelpText('after', '\nLoad always creates a NEW project - it never merges into an existing one.'
116
+ + '\nGitHub sources need a one-time `gipity github connect` (re-run it to grant more repos).')
117
+ .action((source, opts) => run('Load', async () => {
118
+ const github = isGithubSource(source);
119
+ if (!github && /^https?:\/\//i.test(source)) {
120
+ throw new Error('Only GitHub URLs are supported for now - pass github:owner/repo, a github.com URL, or a local .gip file.');
121
+ }
122
+ // Local .gip bundles upload raw zip bytes; GitHub sources send a tiny JSON
123
+ // body and the server pulls the tree through the user's connection.
124
+ let sourceLabel;
125
+ let doInspect;
126
+ let doImport;
127
+ if (github) {
128
+ sourceLabel = source;
129
+ doInspect = () => post('/projects/inspect', { source }).catch(rethrowWithConnectHint);
130
+ doImport = async () => {
131
+ const body = { source };
132
+ if (opts.name)
133
+ body.name = opts.name;
134
+ const res = await post('/projects/import', body).catch(rethrowWithConnectHint);
135
+ // The JSON helper hides the 201-vs-207 status; `failed` carries the
136
+ // same signal (the server only sets it on a partial write).
137
+ return { data: res.data, partial: !!res.data.failed?.length };
138
+ };
139
+ }
140
+ else {
141
+ const { resolved, buffer } = readBundle(source);
142
+ sourceLabel = path.basename(resolved);
143
+ doInspect = async () => (await postBinary('/projects/inspect', buffer)).json;
144
+ doImport = async () => {
145
+ const qs = new URLSearchParams();
146
+ if (opts.name)
147
+ qs.set('name', opts.name);
148
+ const { status, json } = await postBinary(`/projects/import${qs.size ? `?${qs}` : ''}`, buffer);
149
+ return { data: json.data, partial: status === 207 };
150
+ };
151
+ }
152
+ if (opts.inspect) {
153
+ const res = opts.json
154
+ ? await doInspect()
155
+ : await withSpinner('Inspecting…', doInspect, { done: null });
156
+ if (opts.json) {
157
+ console.log(JSON.stringify(res.data));
158
+ return;
159
+ }
160
+ printInspect(res.data, source);
161
+ return;
162
+ }
163
+ const { data, partial } = opts.json
164
+ ? await doImport()
165
+ : await withSpinner('Importing…', doImport, { done: null });
166
+ const project = data.project;
167
+ // Materialize a local dir and link it, exactly like `gipity project create`:
168
+ // `.gipity.json` is written directly inside the new dir, then a soft sync
169
+ // pulls the imported files down.
170
+ const dir = path.join(getProjectsRoot(), project.slug);
171
+ fs.mkdirSync(dir, { recursive: true });
172
+ const accountSlug = await getAccountSlug();
173
+ // Resolve the first assigned agent (if any) - not fatal if missing.
174
+ let agentGuid = '';
175
+ try {
176
+ const agents = await get(`/projects/${project.short_guid}/agents`);
177
+ if (agents.data.length > 0)
178
+ agentGuid = agents.data[0].short_guid;
179
+ }
180
+ catch {
181
+ // offline or no agents - non-fatal
182
+ }
183
+ const { applied } = await finalizeLocalProject({
184
+ dir,
185
+ projectGuid: project.short_guid,
186
+ projectSlug: project.slug,
187
+ projectName: project.name,
188
+ accountSlug,
189
+ agentGuid,
190
+ sync: 'soft',
191
+ interactive: false,
192
+ });
193
+ if (opts.json) {
194
+ console.log(JSON.stringify({
195
+ created: project.slug,
196
+ name: project.name,
197
+ guid: project.short_guid,
198
+ dir,
199
+ written: data.written,
200
+ failed: data.failed,
201
+ partial,
202
+ applied,
203
+ }));
204
+ return;
205
+ }
206
+ console.log(success(`Created "${project.name}" (${project.slug}) from ${sourceLabel}`));
207
+ console.log(`Wrote ${data.written} file(s) to the new project.`);
208
+ console.log(`Initialized ${info(dir)}`);
209
+ if (applied > 0)
210
+ console.log(`Pulled ${applied} file(s) to local.`);
211
+ if (partial || data.failed?.length) {
212
+ console.log('');
213
+ console.warn(warning(`Partial import - ${data.failed?.length ?? 0} file(s) could not be written:`));
214
+ for (const f of data.failed ?? [])
215
+ console.warn(warning(` ! ${f.path}: ${f.error}`));
216
+ console.warn(warning('The project was still created and linked; the files above are missing from it.'));
217
+ }
218
+ console.log('');
219
+ console.log(`${muted('Next:')} cd ${dir} && ${brand('gipity deploy dev')}`);
220
+ }));
221
+ //# sourceMappingURL=load.js.map
@@ -13,6 +13,7 @@ import { confirm } from '../utils.js';
13
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 { revokeRelayAgentToken } from '../relay/agent-token.js';
16
17
  import { UnsupportedPlatformError } from '../relay/installers.js';
17
18
  import { pairDevice, startDaemon, installAutostart, removeAutostart } from '../relay/setup.js';
18
19
  import { registerInstallCommands } from './relay-install.js';
@@ -224,6 +225,35 @@ relayCommand
224
225
  state.setPaused(false);
225
226
  console.log(success('Resumed.'));
226
227
  });
228
+ // ─── gipity relay diagnostics [on|off] ─────────────────────────────────
229
+ relayCommand
230
+ .command('diagnostics [state]')
231
+ .description('View or set sharing of anonymous host/version diagnostics (on|off)')
232
+ .action((arg) => {
233
+ if (arg === undefined) {
234
+ const stored = state.getDiagnosticsConsent();
235
+ const effective = state.diagnosticsConsented();
236
+ const label = stored === undefined ? 'on (default, not yet chosen)' : stored ? 'on' : 'off';
237
+ console.log(`Diagnostics sharing: ${effective ? success(label) : muted(label)}`);
238
+ if (effective !== (stored !== false)) {
239
+ console.log(muted(' (overridden off by GIPITY_NO_DIAGNOSTICS / DO_NOT_TRACK)'));
240
+ }
241
+ console.log(muted(' Non-PII only: CPU/GPU/memory/disk specs, OS + tool versions. No file paths, hostname, or username.'));
242
+ console.log(muted(' Set with `gipity relay diagnostics on` / `gipity relay diagnostics off`.'));
243
+ return;
244
+ }
245
+ const on = /^(on|true|yes|1|enable)$/i.test(arg);
246
+ const off = /^(off|false|no|0|disable)$/i.test(arg);
247
+ if (!on && !off) {
248
+ console.log(clrError("Expected 'on' or 'off'."));
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+ state.setDiagnosticsConsent(on);
253
+ console.log(on
254
+ ? `${success('Diagnostics sharing on.')} ${muted('Reported on the next daily heartbeat.')}`
255
+ : `${success('Diagnostics sharing off.')} ${muted('No host/version data will be sent.')}`);
256
+ });
227
257
  // ─── gipity relay rename <name> ────────────────────────────────────────
228
258
  relayCommand
229
259
  .command('rename <new-name>')
@@ -268,6 +298,12 @@ relayCommand
268
298
  console.error(clrError(`Server revoke failed: ${err?.message || err}`));
269
299
  console.error(muted('Local token cleared anyway. Visit the web CLI to confirm the server-side revoke.'));
270
300
  }
301
+ // Also revoke the relay's agent token (minted for spawned children).
302
+ // Best-effort: on failure it stays visible in `gipity token list`.
303
+ const agentTokenRevoked = await revokeRelayAgentToken();
304
+ if (!agentTokenRevoked && state.getAgentToken()) {
305
+ console.error(muted('Could not revoke the relay agent token - check `gipity token list`.'));
306
+ }
271
307
  state.clearDevice();
272
308
  // Remove the OS autostart unit too. Otherwise the login service relaunches
273
309
  // `relay run`, which - finding no device but a valid login - silently
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `gipity save [output]` - export the linked project as a portable .gip bundle.
3
+ *
4
+ * A .gip is a zip of the project's file tree plus a reserved
5
+ * `_gip/manifest.json` metadata entry. It pairs with `gipity load`, which
6
+ * creates a brand-new project from a bundle. Code-only: secrets, custom
7
+ * domains, chats, and database rows are never in a .gip.
8
+ */
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { createHash } from 'crypto';
12
+ import { Command } from 'commander';
13
+ import { downloadWithHeaders } from '../api.js';
14
+ import { requireConfig } from '../config.js';
15
+ import { success, muted, warning } from '../colors.js';
16
+ import { run } from '../helpers/index.js';
17
+ import { withSpinner } from '../progress.js';
18
+ import { formatBytes } from '../adopt-cwd.js';
19
+ /** Count archive entries by reading the zip end-of-central-directory record -
20
+ * cheap (no inflation, no dependency) and good enough for a summary line.
21
+ * Returns null when the record can't be found or the count is zip64-saturated,
22
+ * in which case the line is simply omitted. */
23
+ export function zipEntryCount(buf) {
24
+ const EOCD_SIG = 0x06054b50;
25
+ const MIN_EOCD = 22; // fixed EOCD size with an empty comment
26
+ if (buf.length < MIN_EOCD)
27
+ return null;
28
+ // The EOCD sits at the end, preceded only by an optional comment (<= 64KB).
29
+ const floor = Math.max(0, buf.length - MIN_EOCD - 0xffff);
30
+ for (let i = buf.length - MIN_EOCD; i >= floor; i--) {
31
+ if (buf.readUInt32LE(i) === EOCD_SIG) {
32
+ const n = buf.readUInt16LE(i + 10); // total central-directory records
33
+ return n === 0xffff ? null : n;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+ /** Pull the server's suggested filename out of Content-Disposition
39
+ * (`attachment; filename="<slug>.gip"`). The server slug is fresher than the
40
+ * locally cached one if the project was renamed. */
41
+ function dispositionFilename(headers) {
42
+ const m = /filename="([^"]+)"/.exec(headers.get('content-disposition') ?? '');
43
+ return m ? m[1] : null;
44
+ }
45
+ export const saveCommand = new Command('save')
46
+ .description('Save this app as a portable .gip bundle')
47
+ .argument('[output]', 'Output .gip path, or a directory to write <slug>.gip into (default: ./<slug>.gip)')
48
+ .option('--json', 'Output as JSON')
49
+ .addHelpText('after', '\nRestore anywhere with `gipity load <file>.gip` - it always creates a NEW project.')
50
+ .action((output, opts) => run('Save', async () => {
51
+ const config = requireConfig();
52
+ const doExport = () => downloadWithHeaders(`/projects/${config.projectGuid}/export`);
53
+ const { buffer, headers } = opts.json
54
+ ? await doExport()
55
+ : await withSpinner('Exporting…', doExport, { done: null });
56
+ const filename = dispositionFilename(headers) ?? `${config.projectSlug}.gip`;
57
+ let dest = path.resolve(output ?? filename);
58
+ if (output && fs.existsSync(dest) && fs.statSync(dest).isDirectory()) {
59
+ dest = path.join(dest, filename);
60
+ }
61
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
62
+ fs.writeFileSync(dest, buffer);
63
+ const sha256 = createHash('sha256').update(buffer).digest('hex');
64
+ const skipped = Number(headers.get('x-gip-skipped') ?? 0);
65
+ const entries = zipEntryCount(buffer);
66
+ if (opts.json) {
67
+ console.log(JSON.stringify({
68
+ path: dest,
69
+ bytes: buffer.length,
70
+ sha256,
71
+ ...(entries !== null ? { entries } : {}),
72
+ skipped,
73
+ }));
74
+ return;
75
+ }
76
+ console.log(success(`Saved ${path.basename(dest)}`));
77
+ console.log(` Path: ${dest}`);
78
+ console.log(` Size: ${formatBytes(buffer.length)}`);
79
+ if (entries !== null)
80
+ console.log(` Entries: ${entries} (including the bundle manifest)`);
81
+ console.log(` SHA-256: ${sha256}`);
82
+ if (skipped > 0) {
83
+ console.log('');
84
+ console.warn(warning(`${skipped} file(s) were skipped (unreadable or over 100 MB) - this bundle is partial.`));
85
+ console.warn(warning('Large assets belong in Gipity Storage, not in a code bundle.'));
86
+ }
87
+ console.log('');
88
+ console.log(muted('Restore anywhere with `gipity load ' + path.basename(dest) + '` (creates a new project).'));
89
+ }));
90
+ //# sourceMappingURL=save.js.map
@@ -5,7 +5,7 @@ import { homedir } from 'os';
5
5
  import { getAuth, sessionExpired } from '../auth.js';
6
6
  import { getConfig, liveUrl } from '../config.js';
7
7
  import { brand, success, warning, muted, error as clrError } from '../colors.js';
8
- import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, setupClaudeHooks, ensureGipityPlugin, ensureGipityPluginInstalled, userScopePluginCurrent } from '../setup.js';
8
+ import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, setupClaudeHooks, ensureGipityPlugin, ensureGipityPluginInstalled, userScopeInstallState } from '../setup.js';
9
9
  /** Hooks ship in the Gipity Claude Code plugin now. "Installed" means three
10
10
  * things must all hold: the user-scope settings register the marketplace and
11
11
  * enable the plugin (declarative), AND Claude Code actually has a user-scope
@@ -27,9 +27,15 @@ function checkGipityPlugin() {
27
27
  missing.push('marketplace');
28
28
  if (settings?.enabledPlugins?.[GIPITY_PLUGIN_ID] !== true)
29
29
  missing.push('plugin');
30
- if (!userScopePluginCurrent())
30
+ const install = userScopeInstallState();
31
+ if (!install.current)
31
32
  missing.push('install');
32
- return { missing, ok: missing.length === 0 };
33
+ // "stale" = declaratively enabled AND a user-scope install exists; it's just
34
+ // behind the version this CLI needs. The hooks still LOAD (at the old
35
+ // version), so this is a version-lag update, not a dead plugin - don't warn
36
+ // as if files aren't syncing at all. Only true when `install` is the sole gap.
37
+ const stale = missing.length === 1 && missing[0] === 'install' && install.exists;
38
+ return { missing, ok: missing.length === 0, stale };
33
39
  }
34
40
  export const statusCommand = new Command('status')
35
41
  .description('Show project and login status')
@@ -89,13 +95,28 @@ export const statusCommand = new Command('status')
89
95
  ensureGipityPlugin(true);
90
96
  setupClaudeHooks();
91
97
  // Re-enabling the declarative keys isn't enough on CC >=2.1.x - also
92
- // materialize the user-scope install so the hooks actually load.
98
+ // materialize (or update) the user-scope install so the hooks load.
93
99
  ensureGipityPluginInstalled();
94
- console.log(`${muted('Hooks:')} ${success('repaired - Gipity plugin re-enabled')}`);
100
+ // Re-check rather than claim success blindly: a stale user-scope
101
+ // install only advances via `plugin update`, and if `claude` is off
102
+ // PATH nothing changed at all - reporting "repaired" then would be a
103
+ // lie the next `gipity status` immediately contradicts.
104
+ const after = checkGipityPlugin();
105
+ if (after.ok) {
106
+ console.log(`${muted('Hooks:')} ${success('repaired - Gipity plugin enabled')}`);
107
+ }
108
+ else {
109
+ console.log(`${muted('Hooks:')} ${warning(`repair incomplete (still missing: ${after.missing.join(', ')})`)}`);
110
+ console.log(muted('Ensure `claude` is on PATH, then re-run. Restart Claude Code to load the update.'));
111
+ }
112
+ }
113
+ else if (hookCheck.stale) {
114
+ console.log(`${muted('Hooks:')} ${warning('Gipity plugin out of date (an update is available)')}`);
115
+ console.log(muted('Hooks still load, but run `gipity status --repair-hooks` to update to the latest.'));
95
116
  }
96
117
  else {
97
118
  console.log(`${muted('Hooks:')} ${warning(`Gipity plugin not enabled (missing: ${hookCheck.missing.join(', ')})`)}`);
98
- console.log(muted('Run `gipity status --repair-hooks` to re-enable.'));
119
+ console.log(muted('Run `gipity status --repair-hooks` to enable.'));
99
120
  console.log(muted('Without it, files don\'t auto-sync and web CLI dispatches can\'t show Claude Code output.'));
100
121
  }
101
122
  }
package/dist/index.js CHANGED
@@ -30,6 +30,8 @@ import { storageCommand } from './commands/storage.js';
30
30
  import { claudeCommand } from './commands/claude.js';
31
31
  import { addCommand } from './commands/add.js';
32
32
  import { removeCommand } from './commands/remove.js';
33
+ import { saveCommand } from './commands/save.js';
34
+ import { loadCommand } from './commands/load.js';
33
35
  import { logsCommand } from './commands/logs.js';
34
36
  import { pageCommand } from './commands/page.js';
35
37
  import { recordsCommand } from './commands/records.js';
@@ -39,6 +41,7 @@ import { secretsCommand } from './commands/secrets.js';
39
41
  import { notifyCommand } from './commands/notify.js';
40
42
  import { bugCommand } from './commands/bug.js';
41
43
  import { paymentsCommand } from './commands/payments.js';
44
+ import { githubCommand } from './commands/github.js';
42
45
  import { jobCommand } from './commands/job.js';
43
46
  import { rbacCommand } from './commands/rbac.js';
44
47
  import { auditCommand } from './commands/audit.js';
@@ -125,11 +128,11 @@ const program = new Command();
125
128
  // --from Y`). enablePositionalOptions draws the boundary at the first command.
126
129
  program.enablePositionalOptions();
127
130
  // ── Command groups (logical ordering within each) ──────────────────────
128
- const commonGroup = [skillCommand, projectCommand, addCommand, removeCommand, deployCommand];
131
+ const commonGroup = [skillCommand, projectCommand, addCommand, removeCommand, saveCommand, loadCommand, deployCommand];
129
132
  const connectGroup = [claudeCommand, setupCommand, relayCommand];
130
133
  const projectGroup = [domainCommand, statusCommand, initCommand];
131
134
  const filesGroup = [fileCommand, storageCommand, syncCommand, pushCommand, uploadCommand];
132
- const appBuildingGroup = [testCommand, fnCommand, serviceCommand, secretsCommand, notifyCommand, paymentsCommand, jobCommand, dbCommand, logsCommand, workflowCommand, realtimeCommand, rbacCommand, auditCommand, recordsCommand];
135
+ const appBuildingGroup = [testCommand, fnCommand, serviceCommand, secretsCommand, notifyCommand, paymentsCommand, githubCommand, jobCommand, dbCommand, logsCommand, workflowCommand, realtimeCommand, rbacCommand, auditCommand, recordsCommand];
133
136
  const utilitiesGroup = [pageCommand, sandboxCommand, generateCommand, emailCommand, gmailCommand, locationCommand, textCommand];
134
137
  const agentGroup = [chatCommand, memoryCommand, agentCommand, approvalCommand, bugCommand];
135
138
  const setupGroup = [loginCommand, logoutCommand, tokenCommand, creditsCommand, doctorCommand, updateCommand, uninstallCommand];
package/dist/knowledge.js CHANGED
@@ -108,6 +108,7 @@ mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <s
108
108
 
109
109
  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>\`.
110
110
  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").
111
+ Move whole apps in/out: \`gipity save\` (export this project as a portable \`.gip\` bundle), \`gipity load <file.gip | github:owner/repo>\` (import as a NEW project; \`--inspect\` to preview), \`gipity github connect\` (1-2 click GitHub access for imports). Porting a Vercel/Replit/Lovable app? Load the \`app-import\` skill first.
111
112
  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.
112
113
  Hit a platform bug or friction? File it in real time: \`gipity bug report --category <cli|deploy|template|kit|db|docs|skill|service|sandbox|other> --severity <S1|S2|S3|S4> --summary "<7 words max>" [--detail "<what failed + workaround>"]\` (see below).
113
114
  Run \`gipity --help\` for the full list. Use \`--help\` on any command for details.
@@ -180,6 +181,7 @@ App development skills:
180
181
  - \`app-database\` - app Postgres database: migrations, the db helper, transactions, table permissions
181
182
  - \`app-debugging\` - debug a deployed app: page inspect/eval, screenshots, function logs
182
183
  - \`app-development\` - functions, database, and API
184
+ - \`app-import\` - import apps from GitHub/.gip bundles (incl. Vercel/Replit/Lovable porting) and export any project as a portable .gip - app_import tool, gipity save/load
183
185
  - \`app-testing\` - testing deployed app functions (ctx.fn.call/callAs, the isolated test DB)
184
186
  - \`deploy\` - the deploy pipeline & gipity.yaml manifest
185
187
  - \`jobs\` - long-running CPU + GPU compute jobs (Python / Node / bash)
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Long-lived agent API token for relay-spawned children.
3
+ *
4
+ * The daemon itself authenticates with its device token, but the children it
5
+ * spawns (`gipity sync`, `gipity claude -p`) used to fall back to the shared
6
+ * session in ~/.gipity/auth.json - and with up to 6 concurrent dispatches,
7
+ * sibling processes raced each other on the session's SINGLE-USE refresh
8
+ * token, intermittently failing with "Session expired" mid-dispatch.
9
+ *
10
+ * Instead, the daemon holds a long-lived gip_at_* agent API token (minted
11
+ * once, stored in relay.json 0600 next to the device token) and exports it
12
+ * to every child as GIPITY_TOKEN. The CLI's auth layer prefers an env token
13
+ * over the session, so children authenticate statelessly - no refresh, no
14
+ * lock, no race. If minting fails (e.g. daemon started at boot with an
15
+ * expired session), we return null and children fall back to the session
16
+ * path, which is exactly the old behavior.
17
+ */
18
+ import { hostname } from 'os';
19
+ import { post, del } from '../api.js';
20
+ import { resolveApiBase } from '../config.js';
21
+ import * as state from './state.js';
22
+ /** Relay agent tokens carry a finite expiry so an orphaned one - left behind
23
+ * when a user deletes ~/.gipity by hand, losing the guid we'd revoke it with -
24
+ * self-expires instead of living forever. A live relay is unaffected:
25
+ * ensureRelayAgentToken() re-mints on the first 401 after expiry. */
26
+ const RELAY_AGENT_TOKEN_EXPIRY_DAYS = 60;
27
+ /** Validate a stored token against the API. Only a definitive 401 counts as
28
+ * invalid - transient failures (network, 5xx) must not discard a good token
29
+ * and trigger a re-mint storm. */
30
+ async function tokenValid(token) {
31
+ try {
32
+ const res = await fetch(`${resolveApiBase()}/users/me`, {
33
+ headers: { 'Authorization': `Bearer ${token}` },
34
+ });
35
+ return res.status !== 401;
36
+ }
37
+ catch {
38
+ return true; // network blip - assume still valid
39
+ }
40
+ }
41
+ /**
42
+ * Return the relay's agent token, minting one if absent or revoked. Minting
43
+ * uses the caller's normal session auth (the daemon process is logged in),
44
+ * so it can fail when the session is dead - callers treat null as "children
45
+ * use session auth" (graceful degradation to the old behavior).
46
+ */
47
+ export async function ensureRelayAgentToken() {
48
+ const existing = state.getAgentToken();
49
+ if (existing) {
50
+ if (await tokenValid(existing.token))
51
+ return existing.token;
52
+ state.setAgentToken(null, null); // revoked/expired - drop and re-mint
53
+ }
54
+ try {
55
+ const name = `Relay on ${state.getDevice()?.name ?? hostname()}`;
56
+ const res = await post('/auth/agent-tokens', { name, expiresInDays: RELAY_AGENT_TOKEN_EXPIRY_DAYS });
57
+ state.setAgentToken(res.data.token, res.data.shortGuid);
58
+ return res.data.token;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /** Best-effort server-side revocation of the relay's agent token (used when
65
+ * the device is unpaired). Local state is cleared by clearDevice() either
66
+ * way; a failed server call just leaves a dead-weight token the user can
67
+ * still see and revoke with `gipity token list` / `revoke`. */
68
+ export async function revokeRelayAgentToken() {
69
+ const existing = state.getAgentToken();
70
+ if (!existing?.guid)
71
+ return false;
72
+ try {
73
+ await del(`/auth/agent-tokens/${encodeURIComponent(existing.guid)}`);
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ //# sourceMappingURL=agent-token.js.map
@@ -15,8 +15,10 @@ import { IngestQueue } from './ingest-queue.js';
15
15
  import { DeltaAccumulator, DeltaBatcher } from './stream-delta.js';
16
16
  import { randomUUID } from 'crypto';
17
17
  import { deviceFetch, bridgeAbort as bridgeAbortImpl } from './device-http.js';
18
+ import { ensureRelayAgentToken } from './agent-token.js';
18
19
  import { redactEntries, redactString, normalizeSecrets } from './redact.js';
19
20
  import { getMachineId } from './machine-id.js';
21
+ import { collectDiagnostics } from './diagnostics.js';
20
22
  // Re-exported so the existing `relay-bridge-abort.test.ts` keeps working.
21
23
  // New callers should import from device-http.js directly.
22
24
  export const bridgeAbort = bridgeAbortImpl;
@@ -27,6 +29,10 @@ export const RELAY_LOG_PATH = join(homedir(), '.gipity', 'relay.log');
27
29
  // slightly after its own deadline; we accept that. Values can be overridden
28
30
  // by env for tests.
29
31
  const HEARTBEAT_INTERVAL_MS = parseInt(process.env.GIPITY_RELAY_HEARTBEAT_MS || '60000', 10);
32
+ // How often the heartbeat carries a fresh diagnostics snapshot (host specs +
33
+ // versions). The 60s liveness ping stays bare; only this cadence runs the
34
+ // costlier version/GPU probes. First tick fires immediately at daemon start.
35
+ const DIAGNOSTICS_INTERVAL_MS = parseInt(process.env.GIPITY_RELAY_DIAGNOSTICS_MS || String(24 * 60 * 60 * 1000), 10);
30
36
  const LONG_POLL_TIMEOUT_MS = parseInt(process.env.GIPITY_RELAY_POLL_TIMEOUT_MS || '35000', 10);
31
37
  const BACKOFF_BASE_MS = parseInt(process.env.GIPITY_RELAY_BACKOFF_BASE_MS || '1000', 10);
32
38
  const BACKOFF_MAX_MS = parseInt(process.env.GIPITY_RELAY_BACKOFF_MAX_MS || '30000', 10);
@@ -84,6 +90,19 @@ async function registerDevice() {
84
90
  // so routine runs don't spam the log, but `gipity relay run --verbose`
85
91
  // surfaces every dispatch decision for live troubleshooting.
86
92
  let verboseMode = process.env.GIPITY_RELAY_VERBOSE === '1';
93
+ // Agent token exported to spawned children as GIPITY_TOKEN (see agent-token.ts).
94
+ // Resolved once at daemon startup; null = fall back to shared session auth.
95
+ let relayAgentToken = null;
96
+ /** Environment for relay-spawned `gipity` children. Adds GIPITY_TOKEN when the
97
+ * daemon holds an agent token so children authenticate statelessly instead of
98
+ * racing siblings on the session's single-use refresh token. */
99
+ function childEnv(extra = {}) {
100
+ return {
101
+ ...process.env,
102
+ ...(relayAgentToken ? { GIPITY_TOKEN: relayAgentToken } : {}),
103
+ ...extra,
104
+ };
105
+ }
87
106
  function setVerbose(on) { verboseMode = verboseMode || on; }
88
107
  // ANSI helpers - only colorize when stderr is a TTY.
89
108
  const TTY = !!process.stderr.isTTY;
@@ -239,6 +258,18 @@ export async function run(opts = {}) {
239
258
  process.on('exit', releasePid);
240
259
  // Also release on our shutdown signals (exit handler sometimes doesn't fire).
241
260
  ctx.abort.signal.addEventListener('abort', releasePid, { once: true });
261
+ // Long-lived agent token for spawned children (gipity sync / gipity claude).
262
+ // With it, children authenticate via GIPITY_TOKEN - stateless, no refresh -
263
+ // instead of racing sibling processes on the session's single-use refresh
264
+ // token. Minting needs a live session; on failure children fall back to
265
+ // session auth (the old behavior), so the daemon still runs.
266
+ relayAgentToken = await ensureRelayAgentToken();
267
+ if (relayAgentToken) {
268
+ log('info', 'agent token ready - spawned children use GIPITY_TOKEN');
269
+ }
270
+ else {
271
+ log('warn', 'no agent token (mint failed or session dead) - children fall back to session auth');
272
+ }
242
273
  log('info', 'relay started', { device: device.guid, name: device.name, pid: process.pid });
243
274
  // Run all loops concurrently; exit when any returns (or abort fires).
244
275
  // Cancellation poller runs alongside the dispatch loop so user-initiated
@@ -258,9 +289,24 @@ async function heartbeatLoop(ctx) {
258
289
  // Log the "session expired" warning only on the transition into that state,
259
290
  // not every 60s, so a genuinely-lapsed session doesn't spam the relay log.
260
291
  let sessionWarnLogged = false;
292
+ // Diagnostics: attach a fresh snapshot on the first heartbeat and every
293
+ // DIAGNOSTICS_INTERVAL_MS after, but only if the user consented. Between
294
+ // refreshes the heartbeat is a bare liveness ping.
295
+ let lastDiagnosticsAt = 0;
261
296
  while (!ctx.abort.signal.aborted) {
262
297
  try {
263
- const r = await deviceFetch('POST', '/remote-devices/heartbeat', {}, 10_000, ctx.abort.signal);
298
+ let body = {};
299
+ if (state.diagnosticsConsented() && Date.now() - lastDiagnosticsAt >= DIAGNOSTICS_INTERVAL_MS) {
300
+ try {
301
+ body = { diagnostics: await collectDiagnostics() };
302
+ lastDiagnosticsAt = Date.now();
303
+ }
304
+ catch (err) {
305
+ // Never let a diagnostics probe failure block the liveness ping.
306
+ log('debug', 'diagnostics collection failed', { err: err?.message });
307
+ }
308
+ }
309
+ const r = await deviceFetch('POST', '/remote-devices/heartbeat', body, 10_000, ctx.abort.signal);
264
310
  if (r.status === 401) {
265
311
  log('warn', 'heartbeat 401 - device revoked, exiting clean');
266
312
  ctx.abort.abort('revoked');
@@ -1131,7 +1177,7 @@ async function spawnSync(cwd, timeoutMs, onSpawn) {
1131
1177
  return new Promise((resolve, reject) => {
1132
1178
  const child = spawnCommand(cmd, ['sync', '--json'], {
1133
1179
  cwd,
1134
- env: process.env,
1180
+ env: childEnv(),
1135
1181
  stdio: ['ignore', 'pipe', 'pipe'],
1136
1182
  });
1137
1183
  // Hand the child to the caller so it can register the sync in `running`,
@@ -1187,7 +1233,7 @@ export async function spawnGipityClaude(args, cwd, d, queue, meta) {
1187
1233
  // feed the ephemeral live-typing channel (see stream-delta.ts); whole
1188
1234
  // assistant/tool events still arrive unchanged for the persistent path.
1189
1235
  const fullArgs = [...args, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
1190
- const env = { ...process.env, GIPITY_CONVERSATION_GUID: d.conversation_guid };
1236
+ const env = childEnv({ GIPITY_CONVERSATION_GUID: d.conversation_guid });
1191
1237
  return new Promise((resolve, reject) => {
1192
1238
  const child = spawnCommand(cmd, fullArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
1193
1239
  // `exited` fires when the child fully unwinds (exit event). Callers
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Relay diagnostics snapshot: non-PII host + version telemetry the daemon
3
+ * reports on its heartbeat (once at startup, then ~daily). Sent as the
4
+ * heartbeat body `{ diagnostics }` and stored on the server as
5
+ * `remote_devices.diagnostics` (JSONB); the web client surfaces it in the
6
+ * Relays table (versions inline, host specs on hover).
7
+ *
8
+ * Design rules:
9
+ * - EVERY probe is best-effort: a failure yields undefined/null for that
10
+ * field, never a thrown error. `collectDiagnostics()` always resolves.
11
+ * - NO personally-identifying data: no paths, no hostname, no username -
12
+ * only counts and non-identifying machine specs.
13
+ * - Cheap in-memory `os.*` reads are gathered inline; the costlier probes
14
+ * (agent `--version` subprocesses, GPU detection) are bounded with a short
15
+ * timeout and only run when the daemon refreshes the snapshot (startup +
16
+ * ~daily), never on the plain 60s liveness ping.
17
+ *
18
+ * Wire shape mirrors packages/shared `RelayDiagnostics` on the platform side.
19
+ */
20
+ import { cpus, freemem, totalmem, loadavg, release, uptime, homedir } from 'os';
21
+ import { statfsSync, readdirSync } from 'fs';
22
+ import { join } from 'path';
23
+ import { resolveCommand, spawnCommand } from '../platform.js';
24
+ import { cliVersion } from '../client-context.js';
25
+ const PROBE_TIMEOUT_MS = 4000;
26
+ /** Extract a semver-ish token from a `--version` line ("2.0.14 (Claude Code)"
27
+ * → "2.0.14"). Returns undefined when nothing version-like is present.
28
+ * Exported for unit tests. */
29
+ export function parseVersion(out) {
30
+ const m = out.match(/\d+\.\d+(?:\.\d+)?(?:[-.\w]*)?/);
31
+ return m ? m[0] : undefined;
32
+ }
33
+ /**
34
+ * Run a command and capture stdout, ASYNC + non-blocking with a hard timeout.
35
+ * This must not be `spawnSync`: `collectDiagnostics()` runs inside the daemon's
36
+ * heartbeat loop, which shares one event loop with the dispatch + cancellation
37
+ * loops - a synchronous spawn would freeze all of them for the probe's
38
+ * duration. Resolves '' on spawn error, timeout (child SIGKILL'd), or non-zero
39
+ * exit with no output; never rejects.
40
+ */
41
+ function runProbe(cmd, args) {
42
+ return new Promise((resolve) => {
43
+ let out = '';
44
+ let settled = false;
45
+ const done = (s) => { if (!settled) {
46
+ settled = true;
47
+ resolve(s);
48
+ } };
49
+ let child;
50
+ try {
51
+ child = spawnCommand(resolveCommand(cmd), args, { stdio: ['ignore', 'pipe', 'ignore'] });
52
+ }
53
+ catch {
54
+ return done('');
55
+ }
56
+ const timer = setTimeout(() => { try {
57
+ child.kill('SIGKILL');
58
+ }
59
+ catch { /* gone */ } done(out); }, PROBE_TIMEOUT_MS);
60
+ child.stdout?.on('data', (d) => { out += d.toString(); });
61
+ child.on('error', () => { clearTimeout(timer); done(''); });
62
+ child.on('close', () => { clearTimeout(timer); done(out); });
63
+ });
64
+ }
65
+ /** Run `<cmd> --version` (bounded, best-effort). undefined if the command is
66
+ * absent, errors, or prints nothing version-like. */
67
+ async function probeVersion(cmd) {
68
+ return parseVersion(await runProbe(cmd, ['--version']));
69
+ }
70
+ /** Best-effort GPU description. Short-timeout, platform-specific, null on any
71
+ * failure - never blocks the snapshot. */
72
+ async function detectGpu() {
73
+ if (process.platform === 'darwin') {
74
+ const m = (await runProbe('system_profiler', ['SPDisplaysDataType'])).match(/Chipset Model:\s*(.+)/);
75
+ return m ? m[1].trim() : null;
76
+ }
77
+ if (process.platform === 'linux') {
78
+ // nvidia-smi is fast + precise when present; fall back to lspci.
79
+ const nvName = (await runProbe('nvidia-smi', ['--query-gpu=name', '--format=csv,noheader'])).split('\n')[0]?.trim();
80
+ if (nvName)
81
+ return nvName;
82
+ const line = (await runProbe('sh', ['-c', "lspci | grep -iE 'vga|3d|display'"])).split('\n')[0]?.trim();
83
+ if (!line)
84
+ return null;
85
+ // Strip the "00:02.0 VGA compatible controller: " prefix.
86
+ const after = line.split(/:\s/).slice(2).join(': ').trim();
87
+ return after || line;
88
+ }
89
+ if (process.platform === 'win32') {
90
+ const line = (await runProbe('wmic', ['path', 'win32_VideoController', 'get', 'name']))
91
+ .split('\n').map(l => l.trim()).filter(l => l && l !== 'Name')[0];
92
+ return line ?? null;
93
+ }
94
+ return null;
95
+ }
96
+ /** Free/total bytes on the projects volume (best-effort). */
97
+ function diskUsage() {
98
+ try {
99
+ if (typeof statfsSync !== 'function')
100
+ return undefined;
101
+ const st = statfsSync(join(homedir(), 'GipityProjects'), { bigint: false });
102
+ if (!st || !st.bsize)
103
+ return undefined;
104
+ return { total: st.bsize * st.blocks, free: st.bsize * st.bavail };
105
+ }
106
+ catch {
107
+ // Projects dir may not exist yet - try the home volume instead.
108
+ try {
109
+ const st = statfsSync(homedir(), { bigint: false });
110
+ return { total: st.bsize * st.blocks, free: st.bsize * st.bavail };
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ }
116
+ }
117
+ /** Count of directories under ~/GipityProjects (no names). */
118
+ function localProjectCount() {
119
+ try {
120
+ return readdirSync(join(homedir(), 'GipityProjects'), { withFileTypes: true })
121
+ .filter(e => e.isDirectory() && !e.name.startsWith('.')).length;
122
+ }
123
+ catch {
124
+ return undefined;
125
+ }
126
+ }
127
+ /**
128
+ * Gather the full snapshot. Cheap `os.*` reads plus the bounded subprocess
129
+ * probes. Always resolves (best-effort per field).
130
+ */
131
+ export async function collectDiagnostics() {
132
+ const cores = (() => { try {
133
+ return cpus();
134
+ }
135
+ catch {
136
+ return [];
137
+ } })();
138
+ // Run the subprocess probes concurrently (each already bounded + best-effort)
139
+ // so the whole snapshot costs one timeout, not the sum of four.
140
+ const [claude, codex, cursor, gpu] = await Promise.all([
141
+ probeVersion('claude'), probeVersion('codex'), probeVersion('cursor'), detectGpu(),
142
+ ]);
143
+ const agents = {};
144
+ if (claude)
145
+ agents.claude_code = claude;
146
+ if (codex)
147
+ agents.codex = codex;
148
+ if (cursor)
149
+ agents.cursor = cursor;
150
+ return {
151
+ collected_at: new Date().toISOString(),
152
+ gipity_version: cliVersion(),
153
+ node_version: process.versions.node,
154
+ os: { platform: process.platform, release: safe(() => release()), arch: process.arch },
155
+ cpu: { count: cores.length || undefined, model: cores[0]?.model?.trim() || undefined },
156
+ mem: { total: safe(() => totalmem()), free: safe(() => freemem()) },
157
+ load1: safe(() => Math.round(loadavg()[0] * 100) / 100),
158
+ uptime_s: safe(() => Math.round(uptime())),
159
+ disk: diskUsage(),
160
+ gpu,
161
+ agents: Object.keys(agents).length ? agents : undefined,
162
+ projects_local: localProjectCount(),
163
+ };
164
+ }
165
+ function safe(fn) {
166
+ try {
167
+ return fn();
168
+ }
169
+ catch {
170
+ return undefined;
171
+ }
172
+ }
173
+ //# sourceMappingURL=diagnostics.js.map
@@ -109,6 +109,14 @@ export async function runRelaySetup(opts) {
109
109
  }
110
110
  }
111
111
  }
112
+ // Diagnostics consent - default on, clearly opt-out. Only ask once.
113
+ if (state.getDiagnosticsConsent() === undefined) {
114
+ const diag = await confirm(' Share anonymous diagnostics (CPU/GPU/memory/disk/versions) to improve reliability?', { default: 'yes' });
115
+ state.setDiagnosticsConsent(diag);
116
+ console.log(` ${dim(diag
117
+ ? 'Thanks - no personal data or file paths are ever sent. Turn off anytime with `gipity relay diagnostics off`.'
118
+ : 'Diagnostics off. Turn on anytime with `gipity relay diagnostics on`.')}`);
119
+ }
112
120
  console.log('');
113
121
  console.log(` ${success(`Registered as ${bold(device.name)} (${device.guid}).`)}`);
114
122
  console.log(` ${dim('In the Gipity web CLI, type `/claude` to dispatch messages to this PC.')}`);
@@ -38,6 +38,9 @@ export function loadState() {
38
38
  paused: Boolean(raw.paused),
39
39
  relay_enabled: typeof raw.relay_enabled === 'boolean' ? raw.relay_enabled : undefined,
40
40
  onboard_shown: Boolean(raw.onboard_shown),
41
+ diagnostics_consent: typeof raw.diagnostics_consent === 'boolean' ? raw.diagnostics_consent : undefined,
42
+ agent_token: typeof raw.agent_token === 'string' ? raw.agent_token : null,
43
+ agent_token_guid: typeof raw.agent_token_guid === 'string' ? raw.agent_token_guid : null,
41
44
  };
42
45
  }
43
46
  catch {
@@ -73,8 +76,18 @@ export function setDevice(device) {
73
76
  mutate(s => { s.device = device; });
74
77
  }
75
78
  export function clearDevice() {
76
- // Forget the device → also clear pause flag (scoped to the device).
77
- mutate(s => { s.device = null; s.paused = false; });
79
+ // Forget the device → also clear the pause flag and the agent token
80
+ // (both scoped to the device). Server-side token revocation is the
81
+ // caller's job (best-effort, see revokeRelayAgentToken).
82
+ mutate(s => { s.device = null; s.paused = false; s.agent_token = null; s.agent_token_guid = null; });
83
+ }
84
+ // ─── Agent token (exported to spawned children as GIPITY_TOKEN) ────────
85
+ export function getAgentToken() {
86
+ const s = loadState();
87
+ return s.agent_token ? { token: s.agent_token, guid: s.agent_token_guid ?? null } : null;
88
+ }
89
+ export function setAgentToken(token, guid) {
90
+ mutate(s => { s.agent_token = token; s.agent_token_guid = guid; });
78
91
  }
79
92
  // ─── Pause ─────────────────────────────────────────────────────────────
80
93
  export function isPaused() {
@@ -101,6 +114,23 @@ export function isRelayEnabled() {
101
114
  export function setRelayEnabled(enabled) {
102
115
  mutate(s => { s.relay_enabled = enabled; });
103
116
  }
117
+ // ─── Diagnostics consent (tri-state, default-on) ───────────────────────
118
+ /** Stored preference: `undefined` = never asked; `true`/`false` = explicit. */
119
+ export function getDiagnosticsConsent() {
120
+ return loadState().diagnostics_consent;
121
+ }
122
+ export function setDiagnosticsConsent(consent) {
123
+ mutate(s => { s.diagnostics_consent = consent; });
124
+ }
125
+ /** Effective consent used by the daemon: default-on unless the user explicitly
126
+ * opted out OR a headless opt-out env var (GIPITY_NO_DIAGNOSTICS / DO_NOT_TRACK)
127
+ * is set. Truthy env value ("1"/"true"/anything non-empty) disables. */
128
+ export function diagnosticsConsented() {
129
+ const env = process.env.GIPITY_NO_DIAGNOSTICS ?? process.env.DO_NOT_TRACK;
130
+ if (env && env !== '0' && env.toLowerCase() !== 'false')
131
+ return false;
132
+ return loadState().diagnostics_consent !== false;
133
+ }
104
134
  // ─── Daemon PID file (lives at ~/.gipity/relay.pid) ────────────────────
105
135
  const RELAY_PID_FILE = join(RELAY_DIR, 'relay.pid');
106
136
  export function getDaemonPidPath() {
package/dist/setup.js CHANGED
@@ -244,27 +244,39 @@ function versionGte(have, want) {
244
244
  }
245
245
  return true;
246
246
  }
247
- /** True when Claude Code already records a USER-scope install of the Gipity
248
- * plugin at >= the version this CLI needs - the common case, letting the
249
- * caller skip the (slow) reinstall. Reads installed_plugins.json directly so
250
- * the check costs no subprocess. Exported so `gipity status` can tell an
251
- * actually-loaded plugin apart from one that's merely enabled-but-uninstalled
252
- * (which would otherwise read as a false-green "hooks enabled"). */
253
- export function userScopePluginCurrent() {
247
+ /** Parsed user-scope install state for the Gipity plugin, read straight from
248
+ * installed_plugins.json (no subprocess). `exists` is true when Claude Code
249
+ * records ANY user-scope install; `current` narrows that to one at >= the
250
+ * version this CLI needs. The two differ exactly when a stale install lags a
251
+ * plugin-version bump - the case that must be UPGRADED, not freshly installed
252
+ * (a bare `claude plugin install` no-ops on an already-present user install
253
+ * and never advances its version). */
254
+ export function userScopeInstallState() {
254
255
  try {
255
256
  const p = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
256
257
  const data = JSON.parse(readFileSync(p, 'utf-8'));
257
258
  const entries = data?.plugins?.[GIPITY_PLUGIN_ID];
258
259
  if (!Array.isArray(entries))
259
- return false;
260
- return entries.some((e) => e?.scope === 'user' &&
261
- typeof e?.version === 'string' &&
262
- versionGte(e.version, GIPITY_PLUGIN_VERSION));
260
+ return { exists: false, current: false };
261
+ const userEntries = entries.filter((e) => e?.scope === 'user');
262
+ return {
263
+ exists: userEntries.length > 0,
264
+ current: userEntries.some((e) => typeof e?.version === 'string' && versionGte(e.version, GIPITY_PLUGIN_VERSION)),
265
+ };
263
266
  }
264
267
  catch {
265
- return false;
268
+ return { exists: false, current: false };
266
269
  }
267
270
  }
271
+ /** True when Claude Code already records a USER-scope install of the Gipity
272
+ * plugin at >= the version this CLI needs - the common case, letting the
273
+ * caller skip the (slow) reinstall. Reads installed_plugins.json directly so
274
+ * the check costs no subprocess. Exported so `gipity status` can tell an
275
+ * actually-loaded plugin apart from one that's merely enabled-but-uninstalled
276
+ * (which would otherwise read as a false-green "hooks enabled"). */
277
+ export function userScopePluginCurrent() {
278
+ return userScopeInstallState().current;
279
+ }
268
280
  function claudeOnPath() {
269
281
  const probe = spawnSyncCommand(process.platform === 'win32' ? 'where' : 'which', ['claude'], {
270
282
  encoding: 'utf-8',
@@ -286,22 +298,31 @@ function claudeOnPath() {
286
298
  * never break `gipity claude`. Skips entirely when the user-scope install is
287
299
  * already current, so it shells out at most once per plugin-version bump. */
288
300
  export function ensureGipityPluginInstalled() {
289
- if (userScopePluginCurrent())
301
+ const state = userScopeInstallState();
302
+ if (state.current)
290
303
  return;
291
304
  if (!claudeOnPath())
292
305
  return;
293
- // Refresh the marketplace clone so `install` resolves the current version,
294
- // then (re)install at user scope - idempotent, and upgrades an older or
295
- // project-scoped install to the current one at user scope.
306
+ // Refresh the marketplace clone so install/update resolves the current version.
296
307
  // resolveCommand: on Windows `claude` is a .cmd shim that spawn can't launch
297
- // without an explicit path, so resolve it (otherwise the install silently
308
+ // without an explicit path, so resolve it (otherwise the command silently
298
309
  // ENOENTs and the plugin's hooks never land at user scope).
299
310
  const claudeCmd = resolveCommand('claude');
300
311
  spawnSyncCommand(claudeCmd, ['plugin', 'marketplace', 'update', GIPITY_MARKETPLACE_NAME], {
301
312
  stdio: 'ignore',
302
313
  timeout: 120_000,
303
314
  });
304
- spawnSyncCommand(claudeCmd, ['plugin', 'install', GIPITY_PLUGIN_ID, '--scope', 'user'], {
315
+ // A bare `plugin install` only materializes a MISSING install - on an
316
+ // already-present but stale user-scope install (the version this CLI just
317
+ // bumped past) it no-ops and leaves the old version registered, so
318
+ // userScopePluginCurrent() stays false forever and `gipity status` reports
319
+ // `missing: install` on every run. `plugin update` is the command that
320
+ // actually advances a registered user-scope install to the marketplace's
321
+ // current version; `install` is only right when nothing is installed yet.
322
+ const verb = state.exists
323
+ ? ['plugin', 'update', GIPITY_PLUGIN_ID, '--scope', 'user']
324
+ : ['plugin', 'install', GIPITY_PLUGIN_ID, '--scope', 'user'];
325
+ spawnSyncCommand(claudeCmd, verb, {
305
326
  stdio: 'ignore',
306
327
  timeout: 120_000,
307
328
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.412",
3
+ "version": "1.0.414",
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__/platform.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__/ingest-queue.test.js dist/__tests__/stream-delta.test.js dist/__tests__/phase-tracker.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-storage.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-setup.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__/platform.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-diagnostics.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__/ingest-queue.test.js dist/__tests__/stream-delta.test.js dist/__tests__/phase-tracker.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-storage.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-setup.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:smoke:quick": "node scripts/smoke-quick.mjs",
18
18
  "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-sync-stress-live.test.js dist/__tests__/cli-e2e-rollback-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",
19
19
  "test:e2e:sync": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sync-live.test.js",