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.
- package/dist/api.js +45 -0
- package/dist/client-context.js +1 -1
- package/dist/commands/chat.js +11 -5
- package/dist/commands/github.js +80 -0
- package/dist/commands/load.js +221 -0
- package/dist/commands/project.js +25 -14
- package/dist/commands/records.js +1 -1
- package/dist/commands/relay.js +29 -0
- package/dist/commands/save.js +90 -0
- package/dist/commands/service.js +1 -1
- package/dist/commands/setup.js +1 -1
- package/dist/commands/skill.js +1 -1
- package/dist/commands/status.js +27 -6
- package/dist/commands/text.js +1 -1
- package/dist/index.js +106 -15
- package/dist/knowledge.js +3 -0
- package/dist/relay/agent-token.js +6 -1
- package/dist/relay/daemon.js +21 -1
- package/dist/relay/diagnostics.js +173 -0
- package/dist/relay/onboarding.js +69 -5
- package/dist/relay/setup.js +29 -2
- package/dist/relay/state.js +18 -0
- package/dist/setup.js +39 -18
- package/package.json +2 -2
package/dist/commands/status.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
30
|
+
const install = userScopeInstallState();
|
|
31
|
+
if (!install.current)
|
|
31
32
|
missing.push('install');
|
|
32
|
-
|
|
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
|
|
98
|
+
// materialize (or update) the user-scope install so the hooks load.
|
|
93
99
|
ensureGipityPluginInstalled();
|
|
94
|
-
|
|
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
|
|
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/commands/text.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
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: '
|
|
138
|
-
{ title: '
|
|
139
|
-
{ title: '
|
|
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: '
|
|
150
|
+
{ title: 'Gip (cloud agent)', cmds: gipGroup },
|
|
142
151
|
{ title: 'Utilities', cmds: utilitiesGroup },
|
|
143
|
-
{ title: '
|
|
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
|
}
|
package/dist/relay/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/relay/onboarding.js
CHANGED
|
@@ -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 {
|
|
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
|
|
63
|
-
const defaultName =
|
|
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
|
-
|
|
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.')}`);
|