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 CHANGED
@@ -211,6 +211,51 @@ export async function download(path, retried = false) {
211
211
  }
212
212
  return Buffer.from(await res.arrayBuffer());
213
213
  }
214
+ /** Download raw bytes plus the response headers - for binary endpoints whose
215
+ * metadata rides headers (e.g. `GET /projects/:guid/export`, where
216
+ * `X-Gip-Skipped` and the Content-Disposition filename matter to the caller).
217
+ * Non-2xx responses are parsed as standard JSON errors. */
218
+ export async function downloadWithHeaders(path, retried = false) {
219
+ const url = `${baseUrl()}${path}`;
220
+ const res = await fetch(url, {
221
+ headers: { ...clientHeaders(), 'Authorization': `Bearer ${await bearerToken()}` },
222
+ });
223
+ if (await shouldRetryAfter401(res.status, retried)) {
224
+ return downloadWithHeaders(path, true);
225
+ }
226
+ if (!res.ok) {
227
+ const json = await res.json().catch(() => ({ error: { code: 'UNKNOWN', message: res.statusText } }));
228
+ const err = json.error || { code: 'UNKNOWN', message: res.statusText };
229
+ throw new ApiError(res.status, err.code, with401Hint(res.status, err.message), json.data);
230
+ }
231
+ return { buffer: Buffer.from(await res.arrayBuffer()), headers: res.headers };
232
+ }
233
+ /** POST raw bytes (e.g. a .gip bundle) and parse the JSON response, returning
234
+ * the HTTP status alongside it so callers can tell a clean 201 from a 207
235
+ * partial success. The timeout scales with the body size (same policy as the
236
+ * presigned PUT path) so a large-but-progressing upload isn't cut off. */
237
+ export async function postBinary(path, body, contentType = 'application/zip', retried = false) {
238
+ const url = `${baseUrl()}${path}`;
239
+ const headers = {
240
+ ...clientHeaders(),
241
+ 'Authorization': `Bearer ${await bearerToken()}`,
242
+ 'Content-Type': contentType,
243
+ };
244
+ const res = await fetchWithTimeout(url, {
245
+ method: 'POST',
246
+ headers,
247
+ body: new Uint8Array(body),
248
+ }, putTimeoutMs(body.length), `POST ${path}`);
249
+ if (await shouldRetryAfter401(res.status, retried)) {
250
+ return postBinary(path, body, contentType, true);
251
+ }
252
+ if (!res.ok) {
253
+ const json = await res.json().catch(() => ({ error: { code: 'UNKNOWN', message: res.statusText } }));
254
+ const err = json.error || { code: 'UNKNOWN', message: res.statusText };
255
+ throw new ApiError(res.status, err.code, with401Hint(res.status, err.message), json.data);
256
+ }
257
+ return { status: res.status, json: await res.json() };
258
+ }
214
259
  /** Download a response as a Node.js Readable stream */
215
260
  export async function downloadStream(path, retried = false) {
216
261
  const { Readable } = await import('stream');
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { resolve, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- function cliVersion() {
4
+ export function cliVersion() {
5
5
  try {
6
6
  const dir = dirname(fileURLToPath(import.meta.url));
7
7
  const pkg = JSON.parse(readFileSync(resolve(dir, '../package.json'), 'utf-8'));
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { get, post, put, del } from '../api.js';
3
- import { resolveProjectContext, saveConfig } from '../config.js';
3
+ import { resolveProjectContext, requireConfig, saveConfig } from '../config.js';
4
4
  import { sync } from '../sync.js';
5
5
  import { error as clrError, muted, success } from '../colors.js';
6
6
  import { run, printList, printResult } from '../helpers/index.js';
@@ -98,13 +98,19 @@ chatCommand
98
98
  });
99
99
  }));
100
100
  chatCommand
101
- .command('rename <guid> <title...>')
102
- .description('Rename a chat')
101
+ .command('rename <title...>')
102
+ .description('Rename a chat (the current chat by default; changes the tab title only)')
103
+ .option('--guid <guid>', 'Chat guid to rename (defaults to the current chat)')
103
104
  .option('--json', 'Output as JSON')
104
- .action((guid, titleParts, opts) => run('Rename', async () => {
105
+ .action((titleParts, opts) => run('Rename', async () => {
105
106
  const title = titleParts.join(' ');
107
+ const guid = opts.guid || requireConfig().conversationGuid;
108
+ if (!guid) {
109
+ console.error(clrError('No current chat. Start a chat first, or pass --guid <guid>.'));
110
+ process.exit(1);
111
+ }
106
112
  await put(`/conversations/${guid}`, { title });
107
- printResult(success(`Renamed ${guid} → "${title}".`), opts, { guid, title });
113
+ printResult(success(`Renamed chat → "${title}".`), opts, { guid, title });
108
114
  }));
109
115
  chatCommand
110
116
  .command('archive <guid>')
@@ -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
@@ -36,6 +36,31 @@ export const projectCommand = new Command('project')
36
36
  return `${p.slug}${active}${def}`;
37
37
  });
38
38
  }));
39
+ projectCommand
40
+ .command('rename <name...>')
41
+ .description('Rename the current project (display name only; slug and URLs unchanged)')
42
+ .option('--project <target>', 'Project name, slug, or guid to rename (defaults to the current project)')
43
+ .option('--json', 'Output as JSON')
44
+ .action((nameParts, opts) => run('Rename', async () => {
45
+ const name = nameParts.join(' ');
46
+ const config = requireConfig();
47
+ let guid = config.projectGuid;
48
+ if (opts.project) {
49
+ const res = await get('/projects?limit=1000');
50
+ const match = res.data.find(p => p.slug === opts.project || p.name === opts.project || p.short_guid === opts.project);
51
+ if (!match) {
52
+ console.error(clrError(`Project "${opts.project}" not found.`));
53
+ process.exit(1);
54
+ }
55
+ guid = match.short_guid;
56
+ }
57
+ if (!guid) {
58
+ console.error(clrError('No current project. Link one first, or pass --project <name>.'));
59
+ process.exit(1);
60
+ }
61
+ await put(`/projects/${guid}`, { name });
62
+ printResult(success(`Renamed project → "${name}".`), opts, { guid, name });
63
+ }));
39
64
  projectCommand
40
65
  .command('create <name>')
41
66
  .description('Create a project')
@@ -132,20 +157,6 @@ projectCommand
132
157
  await del(`/projects/${match.short_guid}`);
133
158
  printResult(`Deleted "${match.name}".`, opts, { deleted: match.slug });
134
159
  }));
135
- projectCommand
136
- .command('rename <name> <new-name>')
137
- .description('Rename a project')
138
- .option('--json', 'Output as JSON')
139
- .action((name, newName, opts) => run('Rename', async () => {
140
- const res = await get('/projects?limit=1000');
141
- const match = res.data.find(p => p.slug === name || p.name === name || p.short_guid === name);
142
- if (!match) {
143
- console.error(clrError(`Project "${name}" not found.`));
144
- process.exit(1);
145
- }
146
- await put(`/projects/${match.short_guid}`, { name: newName });
147
- printResult(`Renamed "${match.name}" → "${newName}".`, opts, { renamed: match.slug, from: match.name, to: newName });
148
- }));
149
160
  projectCommand
150
161
  .command('info')
151
162
  .description('Show current project')
@@ -9,7 +9,7 @@ import { confirm } from '../utils.js';
9
9
  // Records API is the only records surface that exists server-side; there is no
10
10
  // /projects/<guid>/records mirror.)
11
11
  export const recordsCommand = new Command('records')
12
- .description('Manage records');
12
+ .description('Manage records-kit data (registry-driven CRUD)');
13
13
  recordsCommand
14
14
  .command('list')
15
15
  .description('List record tables')
@@ -225,6 +225,35 @@ relayCommand
225
225
  state.setPaused(false);
226
226
  console.log(success('Resumed.'));
227
227
  });
228
+ // ─── gipity relay diagnostics [on|off] ─────────────────────────────────
229
+ relayCommand
230
+ .command('diagnostics [state]')
231
+ .description('View or set sharing of anonymous host/version diagnostics (on|off)')
232
+ .action((arg) => {
233
+ if (arg === undefined) {
234
+ const stored = state.getDiagnosticsConsent();
235
+ const effective = state.diagnosticsConsented();
236
+ const label = stored === undefined ? 'on (default, not yet chosen)' : stored ? 'on' : 'off';
237
+ console.log(`Diagnostics sharing: ${effective ? success(label) : muted(label)}`);
238
+ if (effective !== (stored !== false)) {
239
+ console.log(muted(' (overridden off by GIPITY_NO_DIAGNOSTICS / DO_NOT_TRACK)'));
240
+ }
241
+ console.log(muted(' Non-PII only: CPU/GPU/memory/disk specs, OS + tool versions. No file paths, hostname, or username.'));
242
+ console.log(muted(' Set with `gipity relay diagnostics on` / `gipity relay diagnostics off`.'));
243
+ return;
244
+ }
245
+ const on = /^(on|true|yes|1|enable)$/i.test(arg);
246
+ const off = /^(off|false|no|0|disable)$/i.test(arg);
247
+ if (!on && !off) {
248
+ console.log(clrError("Expected 'on' or 'off'."));
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+ state.setDiagnosticsConsent(on);
253
+ console.log(on
254
+ ? `${success('Diagnostics sharing on.')} ${muted('Reported on the next daily heartbeat.')}`
255
+ : `${success('Diagnostics sharing off.')} ${muted('No host/version data will be sent.')}`);
256
+ });
228
257
  // ─── gipity relay rename <name> ────────────────────────────────────────
229
258
  relayCommand
230
259
  .command('rename <new-name>')
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `gipity save [output]` - export the linked project as a portable .gip bundle.
3
+ *
4
+ * A .gip is a zip of the project's file tree plus a reserved
5
+ * `_gip/manifest.json` metadata entry. It pairs with `gipity load`, which
6
+ * creates a brand-new project from a bundle. Code-only: secrets, custom
7
+ * domains, chats, and database rows are never in a .gip.
8
+ */
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { createHash } from 'crypto';
12
+ import { Command } from 'commander';
13
+ import { downloadWithHeaders } from '../api.js';
14
+ import { requireConfig } from '../config.js';
15
+ import { success, muted, warning } from '../colors.js';
16
+ import { run } from '../helpers/index.js';
17
+ import { withSpinner } from '../progress.js';
18
+ import { formatBytes } from '../adopt-cwd.js';
19
+ /** Count archive entries by reading the zip end-of-central-directory record -
20
+ * cheap (no inflation, no dependency) and good enough for a summary line.
21
+ * Returns null when the record can't be found or the count is zip64-saturated,
22
+ * in which case the line is simply omitted. */
23
+ export function zipEntryCount(buf) {
24
+ const EOCD_SIG = 0x06054b50;
25
+ const MIN_EOCD = 22; // fixed EOCD size with an empty comment
26
+ if (buf.length < MIN_EOCD)
27
+ return null;
28
+ // The EOCD sits at the end, preceded only by an optional comment (<= 64KB).
29
+ const floor = Math.max(0, buf.length - MIN_EOCD - 0xffff);
30
+ for (let i = buf.length - MIN_EOCD; i >= floor; i--) {
31
+ if (buf.readUInt32LE(i) === EOCD_SIG) {
32
+ const n = buf.readUInt16LE(i + 10); // total central-directory records
33
+ return n === 0xffff ? null : n;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+ /** Pull the server's suggested filename out of Content-Disposition
39
+ * (`attachment; filename="<slug>.gip"`). The server slug is fresher than the
40
+ * locally cached one if the project was renamed. */
41
+ function dispositionFilename(headers) {
42
+ const m = /filename="([^"]+)"/.exec(headers.get('content-disposition') ?? '');
43
+ return m ? m[1] : null;
44
+ }
45
+ export const saveCommand = new Command('save')
46
+ .description('Save this app as a portable .gip bundle')
47
+ .argument('[output]', 'Output .gip path, or a directory to write <slug>.gip into (default: ./<slug>.gip)')
48
+ .option('--json', 'Output as JSON')
49
+ .addHelpText('after', '\nRestore anywhere with `gipity load <file>.gip` - it always creates a NEW project.')
50
+ .action((output, opts) => run('Save', async () => {
51
+ const config = requireConfig();
52
+ const doExport = () => downloadWithHeaders(`/projects/${config.projectGuid}/export`);
53
+ const { buffer, headers } = opts.json
54
+ ? await doExport()
55
+ : await withSpinner('Exporting…', doExport, { done: null });
56
+ const filename = dispositionFilename(headers) ?? `${config.projectSlug}.gip`;
57
+ let dest = path.resolve(output ?? filename);
58
+ if (output && fs.existsSync(dest) && fs.statSync(dest).isDirectory()) {
59
+ dest = path.join(dest, filename);
60
+ }
61
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
62
+ fs.writeFileSync(dest, buffer);
63
+ const sha256 = createHash('sha256').update(buffer).digest('hex');
64
+ const skipped = Number(headers.get('x-gip-skipped') ?? 0);
65
+ const entries = zipEntryCount(buffer);
66
+ if (opts.json) {
67
+ console.log(JSON.stringify({
68
+ path: dest,
69
+ bytes: buffer.length,
70
+ sha256,
71
+ ...(entries !== null ? { entries } : {}),
72
+ skipped,
73
+ }));
74
+ return;
75
+ }
76
+ console.log(success(`Saved ${path.basename(dest)}`));
77
+ console.log(` Path: ${dest}`);
78
+ console.log(` Size: ${formatBytes(buffer.length)}`);
79
+ if (entries !== null)
80
+ console.log(` Entries: ${entries} (including the bundle manifest)`);
81
+ console.log(` SHA-256: ${sha256}`);
82
+ if (skipped > 0) {
83
+ console.log('');
84
+ console.warn(warning(`${skipped} file(s) were skipped (unreadable or over 100 MB) - this bundle is partial.`));
85
+ console.warn(warning('Large assets belong in Gipity Storage, not in a code bundle.'));
86
+ }
87
+ console.log('');
88
+ console.log(muted('Restore anywhere with `gipity load ' + path.basename(dest) + '` (creates a new project).'));
89
+ }));
90
+ //# sourceMappingURL=save.js.map
@@ -20,7 +20,7 @@ const SERVICES = [
20
20
  { name: 'location/geocode', method: 'POST', desc: 'Reverse geocode ({ lat, lon })' },
21
21
  ];
22
22
  export const serviceCommand = new Command('service')
23
- .description('Call an app service')
23
+ .description('Call an app service (llm, tts, image, transcribe, ...)')
24
24
  .addHelpText('after', '\nCall a Gipity app service: LLM, image, music, TTS, and more.');
25
25
  serviceCommand
26
26
  .command('list')
@@ -20,7 +20,7 @@ export const setupCommand = new Command('setup')
20
20
  .description('Set up this computer as a relay (no project, no launch)')
21
21
  .action(async () => {
22
22
  try {
23
- console.log('');
23
+ // Leading blank comes from the central output frame (installOutputFrame).
24
24
  console.log(` ${bold('Gipity setup')} ${muted('- get this computer ready as a relay')}`);
25
25
  console.log('');
26
26
  // ── Step 1: Auth ──────────────────────────────────────────────────
@@ -17,7 +17,7 @@ function readInstalledKitReadme(name) {
17
17
  return existsSync(readme) ? readFileSync(readme, 'utf-8') : null;
18
18
  }
19
19
  export const skillCommand = new Command('skill')
20
- .description('Read platform docs');
20
+ .description('Task docs - read the matching skill before building');
21
21
  skillCommand
22
22
  .command('list')
23
23
  .description('List skills')