gipity 1.0.413 → 1.0.416

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.
@@ -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
  }
@@ -140,7 +140,7 @@ const analyzeCommand = new Command('analyze')
140
140
  // Parent namespace. One capability today (analyze); namespaced for a lean
141
141
  // top-level surface and room for future text operations.
142
142
  export const textCommand = new Command('text')
143
- .description('Analyze text')
143
+ .description('Deterministic text analysis (counts, substrings) - no LLM needed')
144
144
  .addHelpText('after', `\nDeterministic char/word counts, frequency, and occurrences (${brand('text analyze')}). Local, no network.`)
145
145
  .addCommand(analyzeCommand);
146
146
  textCommand.action(() => {
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';
@@ -124,25 +127,55 @@ const program = new Command();
124
127
  // global value-option precedes it (e.g. `gipity --api-base X workflow create
125
128
  // --from Y`). enablePositionalOptions draws the boundary at the first command.
126
129
  program.enablePositionalOptions();
127
- // ── Command groups (logical ordering within each) ──────────────────────
128
- const commonGroup = [skillCommand, projectCommand, addCommand, removeCommand, deployCommand];
129
- const connectGroup = [claudeCommand, setupCommand, relayCommand];
130
- const projectGroup = [domainCommand, statusCommand, initCommand];
131
- const filesGroup = [fileCommand, storageCommand, syncCommand, pushCommand, uploadCommand];
132
- const appBuildingGroup = [testCommand, fnCommand, serviceCommand, secretsCommand, notifyCommand, paymentsCommand, jobCommand, dbCommand, logsCommand, workflowCommand, realtimeCommand, rbacCommand, auditCommand, recordsCommand];
133
- const utilitiesGroup = [pageCommand, sandboxCommand, generateCommand, emailCommand, gmailCommand, locationCommand, textCommand];
134
- const agentGroup = [chatCommand, memoryCommand, agentCommand, approvalCommand, bugCommand];
135
- const setupGroup = [loginCommand, logoutCommand, tokenCommand, creditsCommand, doctorCommand, updateCommand, uninstallCommand];
130
+ // ── Command groups (lifecycle order; logical ordering within each) ─────
131
+ // Section names deliberately match the vocabulary of the generated CLAUDE.md
132
+ // primer and the skill docs ("build loop: add → edit → deploy → page
133
+ // inspect"), so an agent's first `--help` reads in the same terms as the rest
134
+ // of its context. Humans get the same win: sections follow the order you
135
+ // actually use them - orient, build, wire the backend, then everything else.
136
+ const startGroup = [statusCommand, initCommand, skillCommand, projectCommand];
137
+ const buildGroup = [addCommand, removeCommand, saveCommand, loadCommand, deployCommand, pageCommand, testCommand];
138
+ const backendGroup = [dbCommand, fnCommand, secretsCommand, logsCommand, jobCommand, workflowCommand];
139
+ const servicesGroup = [serviceCommand, generateCommand, notifyCommand, paymentsCommand, realtimeCommand, recordsCommand, rbacCommand, auditCommand, domainCommand, tokenCommand];
140
+ const filesGroup = [syncCommand, fileCommand, pushCommand, uploadCommand, storageCommand];
141
+ const gipGroup = [chatCommand, memoryCommand, agentCommand, approvalCommand, gmailCommand];
142
+ const utilitiesGroup = [sandboxCommand, emailCommand, locationCommand, textCommand, bugCommand];
143
+ const connectGroup = [loginCommand, logoutCommand, claudeCommand, setupCommand, relayCommand, githubCommand, creditsCommand, doctorCommand, updateCommand, uninstallCommand];
136
144
  const HELP_SECTIONS = [
137
- { title: 'Common', cmds: commonGroup },
138
- { title: 'Connect', cmds: connectGroup },
139
- { title: 'Project', cmds: projectGroup },
145
+ { title: 'Start here', cmds: startGroup },
146
+ { title: 'App build & ship', cmds: buildGroup },
147
+ { title: 'App backend', cmds: backendGroup },
148
+ { title: 'App services', cmds: servicesGroup },
140
149
  { title: 'Files', cmds: filesGroup },
141
- { title: 'App building', cmds: appBuildingGroup },
150
+ { title: 'Gip (cloud agent)', cmds: gipGroup },
142
151
  { title: 'Utilities', cmds: utilitiesGroup },
143
- { title: 'Agent', cmds: agentGroup },
144
- { title: 'Setup', cmds: setupGroup },
152
+ { title: 'Connect & setup', cmds: connectGroup },
145
153
  ];
154
+ // Per-command deep-docs cross-links, rendered as a "Docs:" epilog on each
155
+ // command's --help AND carried in the `help --json` manifest. This is the
156
+ // bridge agents actually need - from "found the command" to "know the API
157
+ // pattern" - without duplicating skill content in help text (skills stay the
158
+ // single source of truth; this maps names only). Keys must be real skill
159
+ // names (`gipity skill list`).
160
+ const SKILL_DOCS = {
161
+ save: 'app-import',
162
+ load: 'app-import',
163
+ github: 'app-import',
164
+ deploy: 'deploy',
165
+ page: 'app-debugging',
166
+ test: 'app-testing',
167
+ db: 'app-database',
168
+ fn: 'app-development',
169
+ service: 'service-call',
170
+ notify: 'app-notify',
171
+ payments: 'app-payments',
172
+ realtime: 'app-realtime',
173
+ job: 'jobs',
174
+ sandbox: 'sandbox-tools',
175
+ gmail: 'google-services',
176
+ email: 'email',
177
+ location: 'location',
178
+ };
146
179
  program
147
180
  .name('gipity')
148
181
  .description(`${brand(bold('Gipity CLI'))} ${dim('-')} ${GIPITY_TAGLINE.replace(/\.$/, '')}\n\n ${dim('Hosting, databases, deploys, workflows, code execution, and monitoring - one place, agent-tuned. Pair with Claude Code or use standalone.')}`)
@@ -204,14 +237,72 @@ program.configureHelp({
204
237
  lines.push('');
205
238
  }
206
239
  lines.push(dim(`Run "${cmd.name()} <command> --help" for details on a specific command.`));
240
+ lines.push(dim(`Deep docs: "${cmd.name()} skill list". Machine-readable manifest: "${cmd.name()} help --json".`));
207
241
  lines.push('');
208
242
  return lines.join('\n');
209
243
  },
210
244
  });
211
245
  for (const cmd of HELP_SECTIONS.flatMap(s => s.cmds)) {
212
246
  configureHelp(cmd);
247
+ // "Docs:" epilog bridging this command's --help to its skill doc.
248
+ const skill = SKILL_DOCS[cmd.name()];
249
+ if (skill)
250
+ cmd.addHelpText('after', `Docs: gipity skill read ${skill}\n`);
213
251
  program.addCommand(cmd);
214
252
  }
253
+ // ── `gipity help [command]` + `help --json` machine-readable manifest ───
254
+ // The JSON manifest is generated from the SAME commander registry that
255
+ // renders human help, so it cannot drift: name, args, options, subcommands,
256
+ // group, and the skill cross-link per command. Meant for agents and tooling
257
+ // that want the full surface in one parseable shot instead of N --help calls.
258
+ program.helpCommand(false); // replace the builtin so we can add --json
259
+ function manifestCommand(c) {
260
+ return {
261
+ name: c.name(),
262
+ aliases: c.aliases().slice(),
263
+ description: c.description(),
264
+ usage: `gipity ${fullCommandPath(c)} ${c.usage()}`.trim(),
265
+ args: c.registeredArguments.map(a => ({
266
+ name: a.name(),
267
+ required: a.required,
268
+ description: a.description || '',
269
+ })),
270
+ options: c.options.filter(o => !o.hidden).map(o => ({ flags: o.flags, description: o.description })),
271
+ subcommands: c.commands.filter(sc => !sc._hidden).map(manifestCommand),
272
+ skill: SKILL_DOCS[c.name()] ?? null,
273
+ };
274
+ }
275
+ function fullCommandPath(c) {
276
+ const parts = [];
277
+ for (let cur = c; cur && cur.parent; cur = cur.parent)
278
+ parts.unshift(cur.name());
279
+ return parts.join(' ');
280
+ }
281
+ program.addCommand(new Command('help')
282
+ .description('Show help; --json emits the full command manifest for agents/tools')
283
+ .argument('[command]', 'Show help for this command')
284
+ .option('--json', 'Output every command (args, options, subcommands, docs links) as JSON')
285
+ .action((name, opts) => {
286
+ if (opts.json) {
287
+ const manifest = {
288
+ name: 'gipity',
289
+ version: pkg.version,
290
+ docs: 'Run `gipity skill list` for deep task docs; each command may carry a `skill` cross-link.',
291
+ sections: HELP_SECTIONS.map(s => ({
292
+ title: s.title,
293
+ commands: s.cmds.map(manifestCommand),
294
+ })),
295
+ };
296
+ console.log(JSON.stringify(manifest, null, 2));
297
+ return;
298
+ }
299
+ if (name) {
300
+ const target = program.commands.find(c => c.name() === name || c.aliases().includes(name));
301
+ if (target)
302
+ target.help();
303
+ }
304
+ program.help();
305
+ }));
215
306
  // ── Malformed invocation → print the command's help inline, error LAST ──
216
307
  // When an agent guesses the wrong shape (excess args, unknown command/option,
217
308
  // missing arg), don't make it run `--help` as a second trip: render that exact
package/dist/knowledge.js CHANGED
@@ -107,7 +107,9 @@ mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <s
107
107
  ## CLI quick reference
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
+ Rename for findability: \`gipity project rename <name>\` renames the current project's display name (the slug and deployed URLs never change); \`gipity chat rename <title>\` renames the current chat's tab title. Both are the display label users scan to switch between tabs — retitle a chat when the conversation clearly shifts to a new topic (sparingly, not every turn), and keep every project/chat title SHORT: 2-4 words, ≤40 characters, no trailing punctuation (e.g. "Stripe checkout", "Tetris game").
110
111
  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").
112
+ 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
113
  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
114
  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
115
  Run \`gipity --help\` for the full list. Use \`--help\` on any command for details.
@@ -180,6 +182,7 @@ App development skills:
180
182
  - \`app-database\` - app Postgres database: migrations, the db helper, transactions, table permissions
181
183
  - \`app-debugging\` - debug a deployed app: page inspect/eval, screenshots, function logs
182
184
  - \`app-development\` - functions, database, and API
185
+ - \`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
186
  - \`app-testing\` - testing deployed app functions (ctx.fn.call/callAs, the isolated test DB)
184
187
  - \`deploy\` - the deploy pipeline & gipity.yaml manifest
185
188
  - \`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
@@ -12,12 +12,12 @@
12
12
  * All the non-interactive primitives live in `setup.ts` (`pairDevice`,
13
13
  * `startDaemon`, `installAutostart`); this module is only the prompts + copy.
14
14
  */
15
- import { hostname } from 'os';
15
+ import { readFileSync } from 'node:fs';
16
16
  import { prompt, confirm } from '../utils.js';
17
17
  import { bold, brand, dim, success, error as clrError, muted } from '../colors.js';
18
18
  import * as state from './state.js';
19
19
  import { UnsupportedPlatformError } from './installers.js';
20
- import { pairDevice, startDaemon, installAutostart } from './setup.js';
20
+ import { pairDevice, startDaemon, installAutostart, friendlyDeviceName } from './setup.js';
21
21
  /** Spawn a fresh `gipity relay run` detached from this process. Fire-and-forget.
22
22
  * Re-exported from the shared `setup` core so existing importers (`claude.ts`)
23
23
  * keep their import path. */
@@ -27,6 +27,21 @@ export const ensureDaemonRunning = startDaemon;
27
27
  * ended up enabled (or was already), `false` if the user declined or a step
28
28
  * failed. Non-interactive flows (e.g. `gipity claude -p`) must not call this.
29
29
  */
30
+ /** True inside Windows Subsystem for Linux (either env marker or kernel
31
+ * string). Used to explain autostart failures accurately - WSL ships without
32
+ * a systemd user session unless the user opts in via /etc/wsl.conf. */
33
+ function isWsl() {
34
+ if (process.platform !== 'linux')
35
+ return false;
36
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP)
37
+ return true;
38
+ try {
39
+ return /microsoft/i.test(readFileSync('/proc/version', 'utf-8'));
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
30
45
  export async function runRelaySetup(opts) {
31
46
  const alreadyAnswered = state.getRelayEnabled() !== undefined;
32
47
  // `gipity claude` first-run: honor the user's prior choice and don't re-ask.
@@ -35,6 +50,36 @@ export async function runRelaySetup(opts) {
35
50
  ensureDaemonRunning();
36
51
  return state.isRelayEnabled();
37
52
  }
53
+ // We only ever REGISTER an unregistered machine. If this computer already has
54
+ // a device, re-running setup must not silently create a second one — nor
55
+ // quietly reuse the old name after asking for a new one (the original bug:
56
+ // you'd type a new name and it would still show the old one). Surface the
57
+ // existing registration plainly and point at revoke as the way to start over.
58
+ const existingDevice = state.getDevice();
59
+ if (existingDevice) {
60
+ // Keep setup's promise: leave the relay enabled and running.
61
+ state.setRelayEnabled(true);
62
+ if (!state.isPaused())
63
+ ensureDaemonRunning();
64
+ if (opts.mode === 'run-now') {
65
+ console.log(` ${bold('This computer is already set up as a relay')}`);
66
+ console.log('');
67
+ console.log(` ${success('Registered as')} ${bold(existingDevice.name)} ${muted(`(${existingDevice.guid})`)}`);
68
+ if (state.isPaused()) {
69
+ console.log(` ${dim('The relay is paused — resume it with')} ${brand('gipity relay resume')}${dim('.')}`);
70
+ }
71
+ else {
72
+ console.log(` ${dim('The relay is running. Open')} ${brand('gipity.ai')}${dim(' and type `/claude` to drive Claude Code here.')}`);
73
+ }
74
+ console.log('');
75
+ console.log(` ${dim('To register this computer again — for example under a different name —')}`);
76
+ console.log(` ${dim('unregister it first, then re-run setup:')}`);
77
+ console.log(` ${brand('gipity relay revoke')} ${dim('# unpairs this computer and removes the login service')}`);
78
+ console.log(` ${brand('gipity setup')} ${dim('# register it again (asks for a new name)')}`);
79
+ console.log('');
80
+ }
81
+ return true;
82
+ }
38
83
  // Header. `gipity setup` frames it as the deliberate action it is; the
39
84
  // `gipity claude` first-run frames it as an optional add-on it's offering.
40
85
  if (opts.mode === 'run-now') {
@@ -59,8 +104,8 @@ export async function runRelaySetup(opts) {
59
104
  console.log('');
60
105
  return false;
61
106
  }
62
- // Device name - show hostname as the default; Enter accepts.
63
- const defaultName = hostname() || 'my-pc';
107
+ // Device name - show a friendly default (owner + device kind); Enter accepts.
108
+ const defaultName = friendlyDeviceName();
64
109
  const rawName = await prompt(` Device name [${bold(defaultName)}]: `);
65
110
  const name = (rawName || defaultName).trim();
66
111
  if (!name || name.length > 100) {
@@ -94,7 +139,18 @@ export async function runRelaySetup(opts) {
94
139
  try {
95
140
  const res = installAutostart();
96
141
  if (!res.ok) {
97
- console.log(` ${muted('Autostart install returned non-zero - you can run')} ${brand('gipity relay install')} ${muted('later.')}`);
142
+ if (isWsl()) {
143
+ // WSL: `systemctl --user` fails unless systemd is enabled in
144
+ // /etc/wsl.conf, so name the actual cause + the fix instead of a
145
+ // generic non-zero. The relay itself is unaffected (it's already
146
+ // running this session, and `gipity claude` restarts it).
147
+ console.log(` ${muted('Auto-start needs systemd, which this WSL distro has off. The relay still runs -')}`);
148
+ console.log(` ${muted('it started just now and `gipity claude` restarts it. For boot-time auto-start:')}`);
149
+ console.log(` ${muted('add [boot] systemd=true to /etc/wsl.conf, restart WSL, then run')} ${brand('gipity relay install')}${muted('.')}`);
150
+ }
151
+ else {
152
+ console.log(` ${muted('Autostart install returned non-zero - you can run')} ${brand('gipity relay install')} ${muted('later.')}`);
153
+ }
98
154
  }
99
155
  else {
100
156
  console.log(` ${success('Auto-start installed.')} ${dim(res.summary)}`);
@@ -109,6 +165,14 @@ export async function runRelaySetup(opts) {
109
165
  }
110
166
  }
111
167
  }
168
+ // Diagnostics consent - default on, clearly opt-out. Only ask once.
169
+ if (state.getDiagnosticsConsent() === undefined) {
170
+ const diag = await confirm(' Share anonymous diagnostics (CPU/GPU/memory/disk/versions) to improve reliability?', { default: 'yes' });
171
+ state.setDiagnosticsConsent(diag);
172
+ console.log(` ${dim(diag
173
+ ? 'Thanks - no personal data or file paths are ever sent. Turn off anytime with `gipity relay diagnostics off`.'
174
+ : 'Diagnostics off. Turn on anytime with `gipity relay diagnostics on`.')}`);
175
+ }
112
176
  console.log('');
113
177
  console.log(` ${success(`Registered as ${bold(device.name)} (${device.guid}).`)}`);
114
178
  console.log(` ${dim('In the Gipity web CLI, type `/claude` to dispatch messages to this PC.')}`);