gipity 1.0.413 → 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');
@@ -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
@@ -225,6 +225,35 @@ relayCommand
225
225
  state.setPaused(false);
226
226
  console.log(success('Resumed.'));
227
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
+ });
228
257
  // ─── gipity relay rename <name> ────────────────────────────────────────
229
258
  relayCommand
230
259
  .command('rename <new-name>')
@@ -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)
@@ -19,6 +19,11 @@ import { hostname } from 'os';
19
19
  import { post, del } from '../api.js';
20
20
  import { resolveApiBase } from '../config.js';
21
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;
22
27
  /** Validate a stored token against the API. Only a definitive 401 counts as
23
28
  * invalid - transient failures (network, 5xx) must not discard a good token
24
29
  * and trigger a re-mint storm. */
@@ -48,7 +53,7 @@ export async function ensureRelayAgentToken() {
48
53
  }
49
54
  try {
50
55
  const name = `Relay on ${state.getDevice()?.name ?? hostname()}`;
51
- const res = await post('/auth/agent-tokens', { name });
56
+ const res = await post('/auth/agent-tokens', { name, expiresInDays: RELAY_AGENT_TOKEN_EXPIRY_DAYS });
52
57
  state.setAgentToken(res.data.token, res.data.shortGuid);
53
58
  return res.data.token;
54
59
  }
@@ -18,6 +18,7 @@ import { deviceFetch, bridgeAbort as bridgeAbortImpl } from './device-http.js';
18
18
  import { ensureRelayAgentToken } from './agent-token.js';
19
19
  import { redactEntries, redactString, normalizeSecrets } from './redact.js';
20
20
  import { getMachineId } from './machine-id.js';
21
+ import { collectDiagnostics } from './diagnostics.js';
21
22
  // Re-exported so the existing `relay-bridge-abort.test.ts` keeps working.
22
23
  // New callers should import from device-http.js directly.
23
24
  export const bridgeAbort = bridgeAbortImpl;
@@ -28,6 +29,10 @@ export const RELAY_LOG_PATH = join(homedir(), '.gipity', 'relay.log');
28
29
  // slightly after its own deadline; we accept that. Values can be overridden
29
30
  // by env for tests.
30
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);
31
36
  const LONG_POLL_TIMEOUT_MS = parseInt(process.env.GIPITY_RELAY_POLL_TIMEOUT_MS || '35000', 10);
32
37
  const BACKOFF_BASE_MS = parseInt(process.env.GIPITY_RELAY_BACKOFF_BASE_MS || '1000', 10);
33
38
  const BACKOFF_MAX_MS = parseInt(process.env.GIPITY_RELAY_BACKOFF_MAX_MS || '30000', 10);
@@ -284,9 +289,24 @@ async function heartbeatLoop(ctx) {
284
289
  // Log the "session expired" warning only on the transition into that state,
285
290
  // not every 60s, so a genuinely-lapsed session doesn't spam the relay log.
286
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;
287
296
  while (!ctx.abort.signal.aborted) {
288
297
  try {
289
- 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);
290
310
  if (r.status === 401) {
291
311
  log('warn', 'heartbeat 401 - device revoked, exiting clean');
292
312
  ctx.abort.abort('revoked');
@@ -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,7 @@ 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,
41
42
  agent_token: typeof raw.agent_token === 'string' ? raw.agent_token : null,
42
43
  agent_token_guid: typeof raw.agent_token_guid === 'string' ? raw.agent_token_guid : null,
43
44
  };
@@ -113,6 +114,23 @@ export function isRelayEnabled() {
113
114
  export function setRelayEnabled(enabled) {
114
115
  mutate(s => { s.relay_enabled = enabled; });
115
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
+ }
116
134
  // ─── Daemon PID file (lives at ~/.gipity/relay.pid) ────────────────────
117
135
  const RELAY_PID_FILE = join(RELAY_DIR, 'relay.pid');
118
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.413",
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",