gipity 1.0.412 → 1.0.414
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +45 -0
- package/dist/auth.js +14 -5
- package/dist/client-context.js +1 -1
- package/dist/commands/github.js +80 -0
- package/dist/commands/load.js +221 -0
- package/dist/commands/relay.js +36 -0
- package/dist/commands/save.js +90 -0
- package/dist/commands/status.js +27 -6
- package/dist/index.js +5 -2
- package/dist/knowledge.js +2 -0
- package/dist/relay/agent-token.js +80 -0
- package/dist/relay/daemon.js +49 -3
- package/dist/relay/diagnostics.js +173 -0
- package/dist/relay/onboarding.js +8 -0
- package/dist/relay/state.js +32 -2
- package/dist/setup.js +39 -18
- package/package.json +2 -2
package/dist/api.js
CHANGED
|
@@ -211,6 +211,51 @@ export async function download(path, retried = false) {
|
|
|
211
211
|
}
|
|
212
212
|
return Buffer.from(await res.arrayBuffer());
|
|
213
213
|
}
|
|
214
|
+
/** Download raw bytes plus the response headers - for binary endpoints whose
|
|
215
|
+
* metadata rides headers (e.g. `GET /projects/:guid/export`, where
|
|
216
|
+
* `X-Gip-Skipped` and the Content-Disposition filename matter to the caller).
|
|
217
|
+
* Non-2xx responses are parsed as standard JSON errors. */
|
|
218
|
+
export async function downloadWithHeaders(path, retried = false) {
|
|
219
|
+
const url = `${baseUrl()}${path}`;
|
|
220
|
+
const res = await fetch(url, {
|
|
221
|
+
headers: { ...clientHeaders(), 'Authorization': `Bearer ${await bearerToken()}` },
|
|
222
|
+
});
|
|
223
|
+
if (await shouldRetryAfter401(res.status, retried)) {
|
|
224
|
+
return downloadWithHeaders(path, true);
|
|
225
|
+
}
|
|
226
|
+
if (!res.ok) {
|
|
227
|
+
const json = await res.json().catch(() => ({ error: { code: 'UNKNOWN', message: res.statusText } }));
|
|
228
|
+
const err = json.error || { code: 'UNKNOWN', message: res.statusText };
|
|
229
|
+
throw new ApiError(res.status, err.code, with401Hint(res.status, err.message), json.data);
|
|
230
|
+
}
|
|
231
|
+
return { buffer: Buffer.from(await res.arrayBuffer()), headers: res.headers };
|
|
232
|
+
}
|
|
233
|
+
/** POST raw bytes (e.g. a .gip bundle) and parse the JSON response, returning
|
|
234
|
+
* the HTTP status alongside it so callers can tell a clean 201 from a 207
|
|
235
|
+
* partial success. The timeout scales with the body size (same policy as the
|
|
236
|
+
* presigned PUT path) so a large-but-progressing upload isn't cut off. */
|
|
237
|
+
export async function postBinary(path, body, contentType = 'application/zip', retried = false) {
|
|
238
|
+
const url = `${baseUrl()}${path}`;
|
|
239
|
+
const headers = {
|
|
240
|
+
...clientHeaders(),
|
|
241
|
+
'Authorization': `Bearer ${await bearerToken()}`,
|
|
242
|
+
'Content-Type': contentType,
|
|
243
|
+
};
|
|
244
|
+
const res = await fetchWithTimeout(url, {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers,
|
|
247
|
+
body: new Uint8Array(body),
|
|
248
|
+
}, putTimeoutMs(body.length), `POST ${path}`);
|
|
249
|
+
if (await shouldRetryAfter401(res.status, retried)) {
|
|
250
|
+
return postBinary(path, body, contentType, true);
|
|
251
|
+
}
|
|
252
|
+
if (!res.ok) {
|
|
253
|
+
const json = await res.json().catch(() => ({ error: { code: 'UNKNOWN', message: res.statusText } }));
|
|
254
|
+
const err = json.error || { code: 'UNKNOWN', message: res.statusText };
|
|
255
|
+
throw new ApiError(res.status, err.code, with401Hint(res.status, err.message), json.data);
|
|
256
|
+
}
|
|
257
|
+
return { status: res.status, json: await res.json() };
|
|
258
|
+
}
|
|
214
259
|
/** Download a response as a Node.js Readable stream */
|
|
215
260
|
export async function downloadStream(path, retried = false) {
|
|
216
261
|
const { Readable } = await import('stream');
|
package/dist/auth.js
CHANGED
|
@@ -166,6 +166,15 @@ export async function refreshTokenIfNeeded(force = false) {
|
|
|
166
166
|
const fresh = (a) => Date.now() <= new Date(a.expiresAt).getTime() - buffer;
|
|
167
167
|
if (!force && fresh(auth))
|
|
168
168
|
return; // fast path: token still good, no lock needed
|
|
169
|
+
// The specific access token the caller saw (the one the server just rejected on the
|
|
170
|
+
// force path). A locally-"fresh" token EQUAL to this can't be trusted - it's exactly
|
|
171
|
+
// what got a 401 - but a DIFFERENT fresh token means a sibling successfully rotated
|
|
172
|
+
// while we waited, and we should adopt it instead of re-refreshing with the (now
|
|
173
|
+
// single-use-consumed) refresh token. Without this, N concurrent relay dispatches
|
|
174
|
+
// that all 401 at once serialize on the lock, the first refreshes, and every other
|
|
175
|
+
// one re-tries the consumed token and hard-fails with "Session expired".
|
|
176
|
+
const rejectedAccess = auth.accessToken;
|
|
177
|
+
const adoptable = (a) => !!a && fresh(a) && (!force || a.accessToken !== rejectedAccess);
|
|
169
178
|
// If the refresh token itself has expired, re-login is genuinely required; leave the
|
|
170
179
|
// expired auth in place so the caller's existing 401 path prompts `gipity login`.
|
|
171
180
|
const refreshExp = decodeJwtExp(auth.refreshToken);
|
|
@@ -175,10 +184,10 @@ export async function refreshTokenIfNeeded(force = false) {
|
|
|
175
184
|
const release = await acquireRefreshLock();
|
|
176
185
|
try {
|
|
177
186
|
// Re-check under the lock: a sibling may have refreshed while we waited. Under
|
|
178
|
-
// force we
|
|
179
|
-
//
|
|
187
|
+
// force we adopt only a token DIFFERENT from the one that was just rejected (a
|
|
188
|
+
// sibling's freshly-rotated token), never the rejected one itself.
|
|
180
189
|
const held = readAuthFresh();
|
|
181
|
-
if (
|
|
190
|
+
if (adoptable(held)) {
|
|
182
191
|
cached = held;
|
|
183
192
|
return;
|
|
184
193
|
}
|
|
@@ -186,7 +195,7 @@ export async function refreshTokenIfNeeded(force = false) {
|
|
|
186
195
|
const apiBase = resolveApiBase();
|
|
187
196
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
188
197
|
const cur = readAuthFresh(); // a sibling may have just refreshed for us
|
|
189
|
-
if (
|
|
198
|
+
if (adoptable(cur)) {
|
|
190
199
|
cached = cur;
|
|
191
200
|
return;
|
|
192
201
|
}
|
|
@@ -218,7 +227,7 @@ export async function refreshTokenIfNeeded(force = false) {
|
|
|
218
227
|
// sibling's fresh token just landed; otherwise stop and let the caller re-login.
|
|
219
228
|
if (res.status === 401 || res.status === 403) {
|
|
220
229
|
const after = readAuthFresh();
|
|
221
|
-
if (
|
|
230
|
+
if (adoptable(after)) {
|
|
222
231
|
cached = after;
|
|
223
232
|
return;
|
|
224
233
|
}
|
package/dist/client-context.js
CHANGED
|
@@ -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
|
package/dist/commands/relay.js
CHANGED
|
@@ -13,6 +13,7 @@ import { confirm } from '../utils.js';
|
|
|
13
13
|
import { bold, brand, dim, success, error as clrError, muted, } from '../colors.js';
|
|
14
14
|
import * as state from '../relay/state.js';
|
|
15
15
|
import * as daemon from '../relay/daemon.js';
|
|
16
|
+
import { revokeRelayAgentToken } from '../relay/agent-token.js';
|
|
16
17
|
import { UnsupportedPlatformError } from '../relay/installers.js';
|
|
17
18
|
import { pairDevice, startDaemon, installAutostart, removeAutostart } from '../relay/setup.js';
|
|
18
19
|
import { registerInstallCommands } from './relay-install.js';
|
|
@@ -224,6 +225,35 @@ relayCommand
|
|
|
224
225
|
state.setPaused(false);
|
|
225
226
|
console.log(success('Resumed.'));
|
|
226
227
|
});
|
|
228
|
+
// ─── gipity relay diagnostics [on|off] ─────────────────────────────────
|
|
229
|
+
relayCommand
|
|
230
|
+
.command('diagnostics [state]')
|
|
231
|
+
.description('View or set sharing of anonymous host/version diagnostics (on|off)')
|
|
232
|
+
.action((arg) => {
|
|
233
|
+
if (arg === undefined) {
|
|
234
|
+
const stored = state.getDiagnosticsConsent();
|
|
235
|
+
const effective = state.diagnosticsConsented();
|
|
236
|
+
const label = stored === undefined ? 'on (default, not yet chosen)' : stored ? 'on' : 'off';
|
|
237
|
+
console.log(`Diagnostics sharing: ${effective ? success(label) : muted(label)}`);
|
|
238
|
+
if (effective !== (stored !== false)) {
|
|
239
|
+
console.log(muted(' (overridden off by GIPITY_NO_DIAGNOSTICS / DO_NOT_TRACK)'));
|
|
240
|
+
}
|
|
241
|
+
console.log(muted(' Non-PII only: CPU/GPU/memory/disk specs, OS + tool versions. No file paths, hostname, or username.'));
|
|
242
|
+
console.log(muted(' Set with `gipity relay diagnostics on` / `gipity relay diagnostics off`.'));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const on = /^(on|true|yes|1|enable)$/i.test(arg);
|
|
246
|
+
const off = /^(off|false|no|0|disable)$/i.test(arg);
|
|
247
|
+
if (!on && !off) {
|
|
248
|
+
console.log(clrError("Expected 'on' or 'off'."));
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
state.setDiagnosticsConsent(on);
|
|
253
|
+
console.log(on
|
|
254
|
+
? `${success('Diagnostics sharing on.')} ${muted('Reported on the next daily heartbeat.')}`
|
|
255
|
+
: `${success('Diagnostics sharing off.')} ${muted('No host/version data will be sent.')}`);
|
|
256
|
+
});
|
|
227
257
|
// ─── gipity relay rename <name> ────────────────────────────────────────
|
|
228
258
|
relayCommand
|
|
229
259
|
.command('rename <new-name>')
|
|
@@ -268,6 +298,12 @@ relayCommand
|
|
|
268
298
|
console.error(clrError(`Server revoke failed: ${err?.message || err}`));
|
|
269
299
|
console.error(muted('Local token cleared anyway. Visit the web CLI to confirm the server-side revoke.'));
|
|
270
300
|
}
|
|
301
|
+
// Also revoke the relay's agent token (minted for spawned children).
|
|
302
|
+
// Best-effort: on failure it stays visible in `gipity token list`.
|
|
303
|
+
const agentTokenRevoked = await revokeRelayAgentToken();
|
|
304
|
+
if (!agentTokenRevoked && state.getAgentToken()) {
|
|
305
|
+
console.error(muted('Could not revoke the relay agent token - check `gipity token list`.'));
|
|
306
|
+
}
|
|
271
307
|
state.clearDevice();
|
|
272
308
|
// Remove the OS autostart unit too. Otherwise the login service relaunches
|
|
273
309
|
// `relay run`, which - finding no device but a valid login - silently
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gipity save [output]` - export the linked project as a portable .gip bundle.
|
|
3
|
+
*
|
|
4
|
+
* A .gip is a zip of the project's file tree plus a reserved
|
|
5
|
+
* `_gip/manifest.json` metadata entry. It pairs with `gipity load`, which
|
|
6
|
+
* creates a brand-new project from a bundle. Code-only: secrets, custom
|
|
7
|
+
* domains, chats, and database rows are never in a .gip.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { createHash } from 'crypto';
|
|
12
|
+
import { Command } from 'commander';
|
|
13
|
+
import { downloadWithHeaders } from '../api.js';
|
|
14
|
+
import { requireConfig } from '../config.js';
|
|
15
|
+
import { success, muted, warning } from '../colors.js';
|
|
16
|
+
import { run } from '../helpers/index.js';
|
|
17
|
+
import { withSpinner } from '../progress.js';
|
|
18
|
+
import { formatBytes } from '../adopt-cwd.js';
|
|
19
|
+
/** Count archive entries by reading the zip end-of-central-directory record -
|
|
20
|
+
* cheap (no inflation, no dependency) and good enough for a summary line.
|
|
21
|
+
* Returns null when the record can't be found or the count is zip64-saturated,
|
|
22
|
+
* in which case the line is simply omitted. */
|
|
23
|
+
export function zipEntryCount(buf) {
|
|
24
|
+
const EOCD_SIG = 0x06054b50;
|
|
25
|
+
const MIN_EOCD = 22; // fixed EOCD size with an empty comment
|
|
26
|
+
if (buf.length < MIN_EOCD)
|
|
27
|
+
return null;
|
|
28
|
+
// The EOCD sits at the end, preceded only by an optional comment (<= 64KB).
|
|
29
|
+
const floor = Math.max(0, buf.length - MIN_EOCD - 0xffff);
|
|
30
|
+
for (let i = buf.length - MIN_EOCD; i >= floor; i--) {
|
|
31
|
+
if (buf.readUInt32LE(i) === EOCD_SIG) {
|
|
32
|
+
const n = buf.readUInt16LE(i + 10); // total central-directory records
|
|
33
|
+
return n === 0xffff ? null : n;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** Pull the server's suggested filename out of Content-Disposition
|
|
39
|
+
* (`attachment; filename="<slug>.gip"`). The server slug is fresher than the
|
|
40
|
+
* locally cached one if the project was renamed. */
|
|
41
|
+
function dispositionFilename(headers) {
|
|
42
|
+
const m = /filename="([^"]+)"/.exec(headers.get('content-disposition') ?? '');
|
|
43
|
+
return m ? m[1] : null;
|
|
44
|
+
}
|
|
45
|
+
export const saveCommand = new Command('save')
|
|
46
|
+
.description('Save this app as a portable .gip bundle')
|
|
47
|
+
.argument('[output]', 'Output .gip path, or a directory to write <slug>.gip into (default: ./<slug>.gip)')
|
|
48
|
+
.option('--json', 'Output as JSON')
|
|
49
|
+
.addHelpText('after', '\nRestore anywhere with `gipity load <file>.gip` - it always creates a NEW project.')
|
|
50
|
+
.action((output, opts) => run('Save', async () => {
|
|
51
|
+
const config = requireConfig();
|
|
52
|
+
const doExport = () => downloadWithHeaders(`/projects/${config.projectGuid}/export`);
|
|
53
|
+
const { buffer, headers } = opts.json
|
|
54
|
+
? await doExport()
|
|
55
|
+
: await withSpinner('Exporting…', doExport, { done: null });
|
|
56
|
+
const filename = dispositionFilename(headers) ?? `${config.projectSlug}.gip`;
|
|
57
|
+
let dest = path.resolve(output ?? filename);
|
|
58
|
+
if (output && fs.existsSync(dest) && fs.statSync(dest).isDirectory()) {
|
|
59
|
+
dest = path.join(dest, filename);
|
|
60
|
+
}
|
|
61
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
62
|
+
fs.writeFileSync(dest, buffer);
|
|
63
|
+
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
|
64
|
+
const skipped = Number(headers.get('x-gip-skipped') ?? 0);
|
|
65
|
+
const entries = zipEntryCount(buffer);
|
|
66
|
+
if (opts.json) {
|
|
67
|
+
console.log(JSON.stringify({
|
|
68
|
+
path: dest,
|
|
69
|
+
bytes: buffer.length,
|
|
70
|
+
sha256,
|
|
71
|
+
...(entries !== null ? { entries } : {}),
|
|
72
|
+
skipped,
|
|
73
|
+
}));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
console.log(success(`Saved ${path.basename(dest)}`));
|
|
77
|
+
console.log(` Path: ${dest}`);
|
|
78
|
+
console.log(` Size: ${formatBytes(buffer.length)}`);
|
|
79
|
+
if (entries !== null)
|
|
80
|
+
console.log(` Entries: ${entries} (including the bundle manifest)`);
|
|
81
|
+
console.log(` SHA-256: ${sha256}`);
|
|
82
|
+
if (skipped > 0) {
|
|
83
|
+
console.log('');
|
|
84
|
+
console.warn(warning(`${skipped} file(s) were skipped (unreadable or over 100 MB) - this bundle is partial.`));
|
|
85
|
+
console.warn(warning('Large assets belong in Gipity Storage, not in a code bundle.'));
|
|
86
|
+
}
|
|
87
|
+
console.log('');
|
|
88
|
+
console.log(muted('Restore anywhere with `gipity load ' + path.basename(dest) + '` (creates a new project).'));
|
|
89
|
+
}));
|
|
90
|
+
//# sourceMappingURL=save.js.map
|
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/index.js
CHANGED
|
@@ -30,6 +30,8 @@ import { storageCommand } from './commands/storage.js';
|
|
|
30
30
|
import { claudeCommand } from './commands/claude.js';
|
|
31
31
|
import { addCommand } from './commands/add.js';
|
|
32
32
|
import { removeCommand } from './commands/remove.js';
|
|
33
|
+
import { saveCommand } from './commands/save.js';
|
|
34
|
+
import { loadCommand } from './commands/load.js';
|
|
33
35
|
import { logsCommand } from './commands/logs.js';
|
|
34
36
|
import { pageCommand } from './commands/page.js';
|
|
35
37
|
import { recordsCommand } from './commands/records.js';
|
|
@@ -39,6 +41,7 @@ import { secretsCommand } from './commands/secrets.js';
|
|
|
39
41
|
import { notifyCommand } from './commands/notify.js';
|
|
40
42
|
import { bugCommand } from './commands/bug.js';
|
|
41
43
|
import { paymentsCommand } from './commands/payments.js';
|
|
44
|
+
import { githubCommand } from './commands/github.js';
|
|
42
45
|
import { jobCommand } from './commands/job.js';
|
|
43
46
|
import { rbacCommand } from './commands/rbac.js';
|
|
44
47
|
import { auditCommand } from './commands/audit.js';
|
|
@@ -125,11 +128,11 @@ const program = new Command();
|
|
|
125
128
|
// --from Y`). enablePositionalOptions draws the boundary at the first command.
|
|
126
129
|
program.enablePositionalOptions();
|
|
127
130
|
// ── Command groups (logical ordering within each) ──────────────────────
|
|
128
|
-
const commonGroup = [skillCommand, projectCommand, addCommand, removeCommand, deployCommand];
|
|
131
|
+
const commonGroup = [skillCommand, projectCommand, addCommand, removeCommand, saveCommand, loadCommand, deployCommand];
|
|
129
132
|
const connectGroup = [claudeCommand, setupCommand, relayCommand];
|
|
130
133
|
const projectGroup = [domainCommand, statusCommand, initCommand];
|
|
131
134
|
const filesGroup = [fileCommand, storageCommand, syncCommand, pushCommand, uploadCommand];
|
|
132
|
-
const appBuildingGroup = [testCommand, fnCommand, serviceCommand, secretsCommand, notifyCommand, paymentsCommand, jobCommand, dbCommand, logsCommand, workflowCommand, realtimeCommand, rbacCommand, auditCommand, recordsCommand];
|
|
135
|
+
const appBuildingGroup = [testCommand, fnCommand, serviceCommand, secretsCommand, notifyCommand, paymentsCommand, githubCommand, jobCommand, dbCommand, logsCommand, workflowCommand, realtimeCommand, rbacCommand, auditCommand, recordsCommand];
|
|
133
136
|
const utilitiesGroup = [pageCommand, sandboxCommand, generateCommand, emailCommand, gmailCommand, locationCommand, textCommand];
|
|
134
137
|
const agentGroup = [chatCommand, memoryCommand, agentCommand, approvalCommand, bugCommand];
|
|
135
138
|
const setupGroup = [loginCommand, logoutCommand, tokenCommand, creditsCommand, doctorCommand, updateCommand, uninstallCommand];
|
package/dist/knowledge.js
CHANGED
|
@@ -108,6 +108,7 @@ mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <s
|
|
|
108
108
|
|
|
109
109
|
Key commands: \`gipity add <template|kit>\`, \`gipity deploy dev\`, \`gipity sandbox run\`, \`gipity page inspect <url>\`, \`gipity page screenshot <url>\`, \`gipity db query "SQL"\`, \`gipity fn call <name>\`, \`gipity logs fn <name>\`, \`gipity secrets set <NAME> <value> [--account]\` (store an API key/token encrypted; read in functions via \`secrets.get('NAME')\` — never hardcode keys), \`gipity email send --to <addr> --subject <s> --body <b>\` (sends as \`gipity@gipity.ai\`; omit \`--to\` to self-send), \`gipity skill read <name>\`.
|
|
110
110
|
Pull an existing remote project local (given its URL/slug): \`mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <slug>\` (adopts the matching project and syncs files down - this is the "clone").
|
|
111
|
+
Move whole apps in/out: \`gipity save\` (export this project as a portable \`.gip\` bundle), \`gipity load <file.gip | github:owner/repo>\` (import as a NEW project; \`--inspect\` to preview), \`gipity github connect\` (1-2 click GitHub access for imports). Porting a Vercel/Replit/Lovable app? Load the \`app-import\` skill first.
|
|
111
112
|
For deterministic text questions (letter/word counts, substring occurrences, nth word/char, anagrams), use \`gipity text analyze "<text>"\` - local and instant, no sandbox or LLM needed.
|
|
112
113
|
Hit a platform bug or friction? File it in real time: \`gipity bug report --category <cli|deploy|template|kit|db|docs|skill|service|sandbox|other> --severity <S1|S2|S3|S4> --summary "<7 words max>" [--detail "<what failed + workaround>"]\` (see below).
|
|
113
114
|
Run \`gipity --help\` for the full list. Use \`--help\` on any command for details.
|
|
@@ -180,6 +181,7 @@ App development skills:
|
|
|
180
181
|
- \`app-database\` - app Postgres database: migrations, the db helper, transactions, table permissions
|
|
181
182
|
- \`app-debugging\` - debug a deployed app: page inspect/eval, screenshots, function logs
|
|
182
183
|
- \`app-development\` - functions, database, and API
|
|
184
|
+
- \`app-import\` - import apps from GitHub/.gip bundles (incl. Vercel/Replit/Lovable porting) and export any project as a portable .gip - app_import tool, gipity save/load
|
|
183
185
|
- \`app-testing\` - testing deployed app functions (ctx.fn.call/callAs, the isolated test DB)
|
|
184
186
|
- \`deploy\` - the deploy pipeline & gipity.yaml manifest
|
|
185
187
|
- \`jobs\` - long-running CPU + GPU compute jobs (Python / Node / bash)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-lived agent API token for relay-spawned children.
|
|
3
|
+
*
|
|
4
|
+
* The daemon itself authenticates with its device token, but the children it
|
|
5
|
+
* spawns (`gipity sync`, `gipity claude -p`) used to fall back to the shared
|
|
6
|
+
* session in ~/.gipity/auth.json - and with up to 6 concurrent dispatches,
|
|
7
|
+
* sibling processes raced each other on the session's SINGLE-USE refresh
|
|
8
|
+
* token, intermittently failing with "Session expired" mid-dispatch.
|
|
9
|
+
*
|
|
10
|
+
* Instead, the daemon holds a long-lived gip_at_* agent API token (minted
|
|
11
|
+
* once, stored in relay.json 0600 next to the device token) and exports it
|
|
12
|
+
* to every child as GIPITY_TOKEN. The CLI's auth layer prefers an env token
|
|
13
|
+
* over the session, so children authenticate statelessly - no refresh, no
|
|
14
|
+
* lock, no race. If minting fails (e.g. daemon started at boot with an
|
|
15
|
+
* expired session), we return null and children fall back to the session
|
|
16
|
+
* path, which is exactly the old behavior.
|
|
17
|
+
*/
|
|
18
|
+
import { hostname } from 'os';
|
|
19
|
+
import { post, del } from '../api.js';
|
|
20
|
+
import { resolveApiBase } from '../config.js';
|
|
21
|
+
import * as state from './state.js';
|
|
22
|
+
/** Relay agent tokens carry a finite expiry so an orphaned one - left behind
|
|
23
|
+
* when a user deletes ~/.gipity by hand, losing the guid we'd revoke it with -
|
|
24
|
+
* self-expires instead of living forever. A live relay is unaffected:
|
|
25
|
+
* ensureRelayAgentToken() re-mints on the first 401 after expiry. */
|
|
26
|
+
const RELAY_AGENT_TOKEN_EXPIRY_DAYS = 60;
|
|
27
|
+
/** Validate a stored token against the API. Only a definitive 401 counts as
|
|
28
|
+
* invalid - transient failures (network, 5xx) must not discard a good token
|
|
29
|
+
* and trigger a re-mint storm. */
|
|
30
|
+
async function tokenValid(token) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`${resolveApiBase()}/users/me`, {
|
|
33
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
34
|
+
});
|
|
35
|
+
return res.status !== 401;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return true; // network blip - assume still valid
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Return the relay's agent token, minting one if absent or revoked. Minting
|
|
43
|
+
* uses the caller's normal session auth (the daemon process is logged in),
|
|
44
|
+
* so it can fail when the session is dead - callers treat null as "children
|
|
45
|
+
* use session auth" (graceful degradation to the old behavior).
|
|
46
|
+
*/
|
|
47
|
+
export async function ensureRelayAgentToken() {
|
|
48
|
+
const existing = state.getAgentToken();
|
|
49
|
+
if (existing) {
|
|
50
|
+
if (await tokenValid(existing.token))
|
|
51
|
+
return existing.token;
|
|
52
|
+
state.setAgentToken(null, null); // revoked/expired - drop and re-mint
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const name = `Relay on ${state.getDevice()?.name ?? hostname()}`;
|
|
56
|
+
const res = await post('/auth/agent-tokens', { name, expiresInDays: RELAY_AGENT_TOKEN_EXPIRY_DAYS });
|
|
57
|
+
state.setAgentToken(res.data.token, res.data.shortGuid);
|
|
58
|
+
return res.data.token;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Best-effort server-side revocation of the relay's agent token (used when
|
|
65
|
+
* the device is unpaired). Local state is cleared by clearDevice() either
|
|
66
|
+
* way; a failed server call just leaves a dead-weight token the user can
|
|
67
|
+
* still see and revoke with `gipity token list` / `revoke`. */
|
|
68
|
+
export async function revokeRelayAgentToken() {
|
|
69
|
+
const existing = state.getAgentToken();
|
|
70
|
+
if (!existing?.guid)
|
|
71
|
+
return false;
|
|
72
|
+
try {
|
|
73
|
+
await del(`/auth/agent-tokens/${encodeURIComponent(existing.guid)}`);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=agent-token.js.map
|
package/dist/relay/daemon.js
CHANGED
|
@@ -15,8 +15,10 @@ import { IngestQueue } from './ingest-queue.js';
|
|
|
15
15
|
import { DeltaAccumulator, DeltaBatcher } from './stream-delta.js';
|
|
16
16
|
import { randomUUID } from 'crypto';
|
|
17
17
|
import { deviceFetch, bridgeAbort as bridgeAbortImpl } from './device-http.js';
|
|
18
|
+
import { ensureRelayAgentToken } from './agent-token.js';
|
|
18
19
|
import { redactEntries, redactString, normalizeSecrets } from './redact.js';
|
|
19
20
|
import { getMachineId } from './machine-id.js';
|
|
21
|
+
import { collectDiagnostics } from './diagnostics.js';
|
|
20
22
|
// Re-exported so the existing `relay-bridge-abort.test.ts` keeps working.
|
|
21
23
|
// New callers should import from device-http.js directly.
|
|
22
24
|
export const bridgeAbort = bridgeAbortImpl;
|
|
@@ -27,6 +29,10 @@ export const RELAY_LOG_PATH = join(homedir(), '.gipity', 'relay.log');
|
|
|
27
29
|
// slightly after its own deadline; we accept that. Values can be overridden
|
|
28
30
|
// by env for tests.
|
|
29
31
|
const HEARTBEAT_INTERVAL_MS = parseInt(process.env.GIPITY_RELAY_HEARTBEAT_MS || '60000', 10);
|
|
32
|
+
// How often the heartbeat carries a fresh diagnostics snapshot (host specs +
|
|
33
|
+
// versions). The 60s liveness ping stays bare; only this cadence runs the
|
|
34
|
+
// costlier version/GPU probes. First tick fires immediately at daemon start.
|
|
35
|
+
const DIAGNOSTICS_INTERVAL_MS = parseInt(process.env.GIPITY_RELAY_DIAGNOSTICS_MS || String(24 * 60 * 60 * 1000), 10);
|
|
30
36
|
const LONG_POLL_TIMEOUT_MS = parseInt(process.env.GIPITY_RELAY_POLL_TIMEOUT_MS || '35000', 10);
|
|
31
37
|
const BACKOFF_BASE_MS = parseInt(process.env.GIPITY_RELAY_BACKOFF_BASE_MS || '1000', 10);
|
|
32
38
|
const BACKOFF_MAX_MS = parseInt(process.env.GIPITY_RELAY_BACKOFF_MAX_MS || '30000', 10);
|
|
@@ -84,6 +90,19 @@ async function registerDevice() {
|
|
|
84
90
|
// so routine runs don't spam the log, but `gipity relay run --verbose`
|
|
85
91
|
// surfaces every dispatch decision for live troubleshooting.
|
|
86
92
|
let verboseMode = process.env.GIPITY_RELAY_VERBOSE === '1';
|
|
93
|
+
// Agent token exported to spawned children as GIPITY_TOKEN (see agent-token.ts).
|
|
94
|
+
// Resolved once at daemon startup; null = fall back to shared session auth.
|
|
95
|
+
let relayAgentToken = null;
|
|
96
|
+
/** Environment for relay-spawned `gipity` children. Adds GIPITY_TOKEN when the
|
|
97
|
+
* daemon holds an agent token so children authenticate statelessly instead of
|
|
98
|
+
* racing siblings on the session's single-use refresh token. */
|
|
99
|
+
function childEnv(extra = {}) {
|
|
100
|
+
return {
|
|
101
|
+
...process.env,
|
|
102
|
+
...(relayAgentToken ? { GIPITY_TOKEN: relayAgentToken } : {}),
|
|
103
|
+
...extra,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
87
106
|
function setVerbose(on) { verboseMode = verboseMode || on; }
|
|
88
107
|
// ANSI helpers - only colorize when stderr is a TTY.
|
|
89
108
|
const TTY = !!process.stderr.isTTY;
|
|
@@ -239,6 +258,18 @@ export async function run(opts = {}) {
|
|
|
239
258
|
process.on('exit', releasePid);
|
|
240
259
|
// Also release on our shutdown signals (exit handler sometimes doesn't fire).
|
|
241
260
|
ctx.abort.signal.addEventListener('abort', releasePid, { once: true });
|
|
261
|
+
// Long-lived agent token for spawned children (gipity sync / gipity claude).
|
|
262
|
+
// With it, children authenticate via GIPITY_TOKEN - stateless, no refresh -
|
|
263
|
+
// instead of racing sibling processes on the session's single-use refresh
|
|
264
|
+
// token. Minting needs a live session; on failure children fall back to
|
|
265
|
+
// session auth (the old behavior), so the daemon still runs.
|
|
266
|
+
relayAgentToken = await ensureRelayAgentToken();
|
|
267
|
+
if (relayAgentToken) {
|
|
268
|
+
log('info', 'agent token ready - spawned children use GIPITY_TOKEN');
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
log('warn', 'no agent token (mint failed or session dead) - children fall back to session auth');
|
|
272
|
+
}
|
|
242
273
|
log('info', 'relay started', { device: device.guid, name: device.name, pid: process.pid });
|
|
243
274
|
// Run all loops concurrently; exit when any returns (or abort fires).
|
|
244
275
|
// Cancellation poller runs alongside the dispatch loop so user-initiated
|
|
@@ -258,9 +289,24 @@ async function heartbeatLoop(ctx) {
|
|
|
258
289
|
// Log the "session expired" warning only on the transition into that state,
|
|
259
290
|
// not every 60s, so a genuinely-lapsed session doesn't spam the relay log.
|
|
260
291
|
let sessionWarnLogged = false;
|
|
292
|
+
// Diagnostics: attach a fresh snapshot on the first heartbeat and every
|
|
293
|
+
// DIAGNOSTICS_INTERVAL_MS after, but only if the user consented. Between
|
|
294
|
+
// refreshes the heartbeat is a bare liveness ping.
|
|
295
|
+
let lastDiagnosticsAt = 0;
|
|
261
296
|
while (!ctx.abort.signal.aborted) {
|
|
262
297
|
try {
|
|
263
|
-
|
|
298
|
+
let body = {};
|
|
299
|
+
if (state.diagnosticsConsented() && Date.now() - lastDiagnosticsAt >= DIAGNOSTICS_INTERVAL_MS) {
|
|
300
|
+
try {
|
|
301
|
+
body = { diagnostics: await collectDiagnostics() };
|
|
302
|
+
lastDiagnosticsAt = Date.now();
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
// Never let a diagnostics probe failure block the liveness ping.
|
|
306
|
+
log('debug', 'diagnostics collection failed', { err: err?.message });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const r = await deviceFetch('POST', '/remote-devices/heartbeat', body, 10_000, ctx.abort.signal);
|
|
264
310
|
if (r.status === 401) {
|
|
265
311
|
log('warn', 'heartbeat 401 - device revoked, exiting clean');
|
|
266
312
|
ctx.abort.abort('revoked');
|
|
@@ -1131,7 +1177,7 @@ async function spawnSync(cwd, timeoutMs, onSpawn) {
|
|
|
1131
1177
|
return new Promise((resolve, reject) => {
|
|
1132
1178
|
const child = spawnCommand(cmd, ['sync', '--json'], {
|
|
1133
1179
|
cwd,
|
|
1134
|
-
env:
|
|
1180
|
+
env: childEnv(),
|
|
1135
1181
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1136
1182
|
});
|
|
1137
1183
|
// Hand the child to the caller so it can register the sync in `running`,
|
|
@@ -1187,7 +1233,7 @@ export async function spawnGipityClaude(args, cwd, d, queue, meta) {
|
|
|
1187
1233
|
// feed the ephemeral live-typing channel (see stream-delta.ts); whole
|
|
1188
1234
|
// assistant/tool events still arrive unchanged for the persistent path.
|
|
1189
1235
|
const fullArgs = [...args, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
|
|
1190
|
-
const env = {
|
|
1236
|
+
const env = childEnv({ GIPITY_CONVERSATION_GUID: d.conversation_guid });
|
|
1191
1237
|
return new Promise((resolve, reject) => {
|
|
1192
1238
|
const child = spawnCommand(cmd, fullArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1193
1239
|
// `exited` fires when the child fully unwinds (exit event). Callers
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay diagnostics snapshot: non-PII host + version telemetry the daemon
|
|
3
|
+
* reports on its heartbeat (once at startup, then ~daily). Sent as the
|
|
4
|
+
* heartbeat body `{ diagnostics }` and stored on the server as
|
|
5
|
+
* `remote_devices.diagnostics` (JSONB); the web client surfaces it in the
|
|
6
|
+
* Relays table (versions inline, host specs on hover).
|
|
7
|
+
*
|
|
8
|
+
* Design rules:
|
|
9
|
+
* - EVERY probe is best-effort: a failure yields undefined/null for that
|
|
10
|
+
* field, never a thrown error. `collectDiagnostics()` always resolves.
|
|
11
|
+
* - NO personally-identifying data: no paths, no hostname, no username -
|
|
12
|
+
* only counts and non-identifying machine specs.
|
|
13
|
+
* - Cheap in-memory `os.*` reads are gathered inline; the costlier probes
|
|
14
|
+
* (agent `--version` subprocesses, GPU detection) are bounded with a short
|
|
15
|
+
* timeout and only run when the daemon refreshes the snapshot (startup +
|
|
16
|
+
* ~daily), never on the plain 60s liveness ping.
|
|
17
|
+
*
|
|
18
|
+
* Wire shape mirrors packages/shared `RelayDiagnostics` on the platform side.
|
|
19
|
+
*/
|
|
20
|
+
import { cpus, freemem, totalmem, loadavg, release, uptime, homedir } from 'os';
|
|
21
|
+
import { statfsSync, readdirSync } from 'fs';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import { resolveCommand, spawnCommand } from '../platform.js';
|
|
24
|
+
import { cliVersion } from '../client-context.js';
|
|
25
|
+
const PROBE_TIMEOUT_MS = 4000;
|
|
26
|
+
/** Extract a semver-ish token from a `--version` line ("2.0.14 (Claude Code)"
|
|
27
|
+
* → "2.0.14"). Returns undefined when nothing version-like is present.
|
|
28
|
+
* Exported for unit tests. */
|
|
29
|
+
export function parseVersion(out) {
|
|
30
|
+
const m = out.match(/\d+\.\d+(?:\.\d+)?(?:[-.\w]*)?/);
|
|
31
|
+
return m ? m[0] : undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Run a command and capture stdout, ASYNC + non-blocking with a hard timeout.
|
|
35
|
+
* This must not be `spawnSync`: `collectDiagnostics()` runs inside the daemon's
|
|
36
|
+
* heartbeat loop, which shares one event loop with the dispatch + cancellation
|
|
37
|
+
* loops - a synchronous spawn would freeze all of them for the probe's
|
|
38
|
+
* duration. Resolves '' on spawn error, timeout (child SIGKILL'd), or non-zero
|
|
39
|
+
* exit with no output; never rejects.
|
|
40
|
+
*/
|
|
41
|
+
function runProbe(cmd, args) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
let out = '';
|
|
44
|
+
let settled = false;
|
|
45
|
+
const done = (s) => { if (!settled) {
|
|
46
|
+
settled = true;
|
|
47
|
+
resolve(s);
|
|
48
|
+
} };
|
|
49
|
+
let child;
|
|
50
|
+
try {
|
|
51
|
+
child = spawnCommand(resolveCommand(cmd), args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return done('');
|
|
55
|
+
}
|
|
56
|
+
const timer = setTimeout(() => { try {
|
|
57
|
+
child.kill('SIGKILL');
|
|
58
|
+
}
|
|
59
|
+
catch { /* gone */ } done(out); }, PROBE_TIMEOUT_MS);
|
|
60
|
+
child.stdout?.on('data', (d) => { out += d.toString(); });
|
|
61
|
+
child.on('error', () => { clearTimeout(timer); done(''); });
|
|
62
|
+
child.on('close', () => { clearTimeout(timer); done(out); });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** Run `<cmd> --version` (bounded, best-effort). undefined if the command is
|
|
66
|
+
* absent, errors, or prints nothing version-like. */
|
|
67
|
+
async function probeVersion(cmd) {
|
|
68
|
+
return parseVersion(await runProbe(cmd, ['--version']));
|
|
69
|
+
}
|
|
70
|
+
/** Best-effort GPU description. Short-timeout, platform-specific, null on any
|
|
71
|
+
* failure - never blocks the snapshot. */
|
|
72
|
+
async function detectGpu() {
|
|
73
|
+
if (process.platform === 'darwin') {
|
|
74
|
+
const m = (await runProbe('system_profiler', ['SPDisplaysDataType'])).match(/Chipset Model:\s*(.+)/);
|
|
75
|
+
return m ? m[1].trim() : null;
|
|
76
|
+
}
|
|
77
|
+
if (process.platform === 'linux') {
|
|
78
|
+
// nvidia-smi is fast + precise when present; fall back to lspci.
|
|
79
|
+
const nvName = (await runProbe('nvidia-smi', ['--query-gpu=name', '--format=csv,noheader'])).split('\n')[0]?.trim();
|
|
80
|
+
if (nvName)
|
|
81
|
+
return nvName;
|
|
82
|
+
const line = (await runProbe('sh', ['-c', "lspci | grep -iE 'vga|3d|display'"])).split('\n')[0]?.trim();
|
|
83
|
+
if (!line)
|
|
84
|
+
return null;
|
|
85
|
+
// Strip the "00:02.0 VGA compatible controller: " prefix.
|
|
86
|
+
const after = line.split(/:\s/).slice(2).join(': ').trim();
|
|
87
|
+
return after || line;
|
|
88
|
+
}
|
|
89
|
+
if (process.platform === 'win32') {
|
|
90
|
+
const line = (await runProbe('wmic', ['path', 'win32_VideoController', 'get', 'name']))
|
|
91
|
+
.split('\n').map(l => l.trim()).filter(l => l && l !== 'Name')[0];
|
|
92
|
+
return line ?? null;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/** Free/total bytes on the projects volume (best-effort). */
|
|
97
|
+
function diskUsage() {
|
|
98
|
+
try {
|
|
99
|
+
if (typeof statfsSync !== 'function')
|
|
100
|
+
return undefined;
|
|
101
|
+
const st = statfsSync(join(homedir(), 'GipityProjects'), { bigint: false });
|
|
102
|
+
if (!st || !st.bsize)
|
|
103
|
+
return undefined;
|
|
104
|
+
return { total: st.bsize * st.blocks, free: st.bsize * st.bavail };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Projects dir may not exist yet - try the home volume instead.
|
|
108
|
+
try {
|
|
109
|
+
const st = statfsSync(homedir(), { bigint: false });
|
|
110
|
+
return { total: st.bsize * st.blocks, free: st.bsize * st.bavail };
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Count of directories under ~/GipityProjects (no names). */
|
|
118
|
+
function localProjectCount() {
|
|
119
|
+
try {
|
|
120
|
+
return readdirSync(join(homedir(), 'GipityProjects'), { withFileTypes: true })
|
|
121
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.')).length;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Gather the full snapshot. Cheap `os.*` reads plus the bounded subprocess
|
|
129
|
+
* probes. Always resolves (best-effort per field).
|
|
130
|
+
*/
|
|
131
|
+
export async function collectDiagnostics() {
|
|
132
|
+
const cores = (() => { try {
|
|
133
|
+
return cpus();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return [];
|
|
137
|
+
} })();
|
|
138
|
+
// Run the subprocess probes concurrently (each already bounded + best-effort)
|
|
139
|
+
// so the whole snapshot costs one timeout, not the sum of four.
|
|
140
|
+
const [claude, codex, cursor, gpu] = await Promise.all([
|
|
141
|
+
probeVersion('claude'), probeVersion('codex'), probeVersion('cursor'), detectGpu(),
|
|
142
|
+
]);
|
|
143
|
+
const agents = {};
|
|
144
|
+
if (claude)
|
|
145
|
+
agents.claude_code = claude;
|
|
146
|
+
if (codex)
|
|
147
|
+
agents.codex = codex;
|
|
148
|
+
if (cursor)
|
|
149
|
+
agents.cursor = cursor;
|
|
150
|
+
return {
|
|
151
|
+
collected_at: new Date().toISOString(),
|
|
152
|
+
gipity_version: cliVersion(),
|
|
153
|
+
node_version: process.versions.node,
|
|
154
|
+
os: { platform: process.platform, release: safe(() => release()), arch: process.arch },
|
|
155
|
+
cpu: { count: cores.length || undefined, model: cores[0]?.model?.trim() || undefined },
|
|
156
|
+
mem: { total: safe(() => totalmem()), free: safe(() => freemem()) },
|
|
157
|
+
load1: safe(() => Math.round(loadavg()[0] * 100) / 100),
|
|
158
|
+
uptime_s: safe(() => Math.round(uptime())),
|
|
159
|
+
disk: diskUsage(),
|
|
160
|
+
gpu,
|
|
161
|
+
agents: Object.keys(agents).length ? agents : undefined,
|
|
162
|
+
projects_local: localProjectCount(),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function safe(fn) {
|
|
166
|
+
try {
|
|
167
|
+
return fn();
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=diagnostics.js.map
|
package/dist/relay/onboarding.js
CHANGED
|
@@ -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.')}`);
|
package/dist/relay/state.js
CHANGED
|
@@ -38,6 +38,9 @@ export function loadState() {
|
|
|
38
38
|
paused: Boolean(raw.paused),
|
|
39
39
|
relay_enabled: typeof raw.relay_enabled === 'boolean' ? raw.relay_enabled : undefined,
|
|
40
40
|
onboard_shown: Boolean(raw.onboard_shown),
|
|
41
|
+
diagnostics_consent: typeof raw.diagnostics_consent === 'boolean' ? raw.diagnostics_consent : undefined,
|
|
42
|
+
agent_token: typeof raw.agent_token === 'string' ? raw.agent_token : null,
|
|
43
|
+
agent_token_guid: typeof raw.agent_token_guid === 'string' ? raw.agent_token_guid : null,
|
|
41
44
|
};
|
|
42
45
|
}
|
|
43
46
|
catch {
|
|
@@ -73,8 +76,18 @@ export function setDevice(device) {
|
|
|
73
76
|
mutate(s => { s.device = device; });
|
|
74
77
|
}
|
|
75
78
|
export function clearDevice() {
|
|
76
|
-
// Forget the device → also clear pause flag
|
|
77
|
-
|
|
79
|
+
// Forget the device → also clear the pause flag and the agent token
|
|
80
|
+
// (both scoped to the device). Server-side token revocation is the
|
|
81
|
+
// caller's job (best-effort, see revokeRelayAgentToken).
|
|
82
|
+
mutate(s => { s.device = null; s.paused = false; s.agent_token = null; s.agent_token_guid = null; });
|
|
83
|
+
}
|
|
84
|
+
// ─── Agent token (exported to spawned children as GIPITY_TOKEN) ────────
|
|
85
|
+
export function getAgentToken() {
|
|
86
|
+
const s = loadState();
|
|
87
|
+
return s.agent_token ? { token: s.agent_token, guid: s.agent_token_guid ?? null } : null;
|
|
88
|
+
}
|
|
89
|
+
export function setAgentToken(token, guid) {
|
|
90
|
+
mutate(s => { s.agent_token = token; s.agent_token_guid = guid; });
|
|
78
91
|
}
|
|
79
92
|
// ─── Pause ─────────────────────────────────────────────────────────────
|
|
80
93
|
export function isPaused() {
|
|
@@ -101,6 +114,23 @@ export function isRelayEnabled() {
|
|
|
101
114
|
export function setRelayEnabled(enabled) {
|
|
102
115
|
mutate(s => { s.relay_enabled = enabled; });
|
|
103
116
|
}
|
|
117
|
+
// ─── Diagnostics consent (tri-state, default-on) ───────────────────────
|
|
118
|
+
/** Stored preference: `undefined` = never asked; `true`/`false` = explicit. */
|
|
119
|
+
export function getDiagnosticsConsent() {
|
|
120
|
+
return loadState().diagnostics_consent;
|
|
121
|
+
}
|
|
122
|
+
export function setDiagnosticsConsent(consent) {
|
|
123
|
+
mutate(s => { s.diagnostics_consent = consent; });
|
|
124
|
+
}
|
|
125
|
+
/** Effective consent used by the daemon: default-on unless the user explicitly
|
|
126
|
+
* opted out OR a headless opt-out env var (GIPITY_NO_DIAGNOSTICS / DO_NOT_TRACK)
|
|
127
|
+
* is set. Truthy env value ("1"/"true"/anything non-empty) disables. */
|
|
128
|
+
export function diagnosticsConsented() {
|
|
129
|
+
const env = process.env.GIPITY_NO_DIAGNOSTICS ?? process.env.DO_NOT_TRACK;
|
|
130
|
+
if (env && env !== '0' && env.toLowerCase() !== 'false')
|
|
131
|
+
return false;
|
|
132
|
+
return loadState().diagnostics_consent !== false;
|
|
133
|
+
}
|
|
104
134
|
// ─── Daemon PID file (lives at ~/.gipity/relay.pid) ────────────────────
|
|
105
135
|
const RELAY_PID_FILE = join(RELAY_DIR, 'relay.pid');
|
|
106
136
|
export function getDaemonPidPath() {
|
package/dist/setup.js
CHANGED
|
@@ -244,27 +244,39 @@ function versionGte(have, want) {
|
|
|
244
244
|
}
|
|
245
245
|
return true;
|
|
246
246
|
}
|
|
247
|
-
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* (
|
|
253
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
301
|
+
const state = userScopeInstallState();
|
|
302
|
+
if (state.current)
|
|
290
303
|
return;
|
|
291
304
|
if (!claudeOnPath())
|
|
292
305
|
return;
|
|
293
|
-
// Refresh the marketplace clone so
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|