gipity 1.0.426 → 1.0.428

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/banner.js CHANGED
@@ -52,12 +52,12 @@ const INFRASTRUCTURE = [
52
52
  'Compute', 'CDN', 'Storage', 'Inference', 'Hosting',
53
53
  'Deploy', 'Uploads', 'Rollback',
54
54
  ];
55
- // ── Egg color palette (shared) - gradient built around #FEA60E ───────
55
+ // ── Egg color palette (shared) - gradient built around #FEA60B ───────
56
56
  // Colors route through fg() so they downgrade (256 / 16 / none) on terminals
57
57
  // without truecolor support instead of rendering as misparsed garbage.
58
58
  const _hi = fg(255, 215, 80); // golden highlight
59
59
  const _lt = fg(254, 190, 45); // light
60
- const _br = fg(254, 166, 14); // base - #FEA60E
60
+ const _br = fg(254, 166, 11); // base - #FEA60B
61
61
  const _md = fg(218, 112, 8); // medium-dark
62
62
  const _sh = fg(170, 68, 4); // shadow
63
63
  // Wobbly egg - asymmetric left/right, organic feel (8 rows)
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Local queue for `gipity bug report` submissions that couldn't be delivered
3
+ * immediately (no network, session expired, server hiccup). The report is
4
+ * the one artifact that must survive the platform being the thing that's
5
+ * broken - a bug report failing to file because the session that just broke
6
+ * is also required to file it would silently lose the report. Queued entries
7
+ * are flushed opportunistically wherever there's a good "we're reconnected"
8
+ * signal: the next successful login, the top of the next `bug report`
9
+ * invocation, and `gipity status` when it finds a valid, unexpired session.
10
+ */
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { homedir } from 'os';
14
+ import { post } from './api.js';
15
+ const QUEUE_DIR = join(process.env.GIPITY_DIR || join(homedir(), '.gipity'), 'bug-queue');
16
+ /** Persist a report that failed to submit so it survives to the next attempt. */
17
+ export function queueBugReport(report) {
18
+ mkdirSync(QUEUE_DIR, { recursive: true, mode: 0o700 });
19
+ const file = join(QUEUE_DIR, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`);
20
+ writeFileSync(file, JSON.stringify(report, null, 2), { mode: 0o600 });
21
+ }
22
+ /** True when a submit failure is worth queuing for later delivery: the
23
+ * network was unreachable, the request timed out, the session/token was
24
+ * rejected, or the server had a transient failure. A validation error
25
+ * (bad category, missing field) would fail identically on retry, so those
26
+ * surface immediately instead of queuing silently forever. */
27
+ export function isRetryableFailure(err) {
28
+ if (err?.name === 'ApiError') {
29
+ return err.statusCode === 401 || err.statusCode === 408 || err.statusCode >= 500;
30
+ }
31
+ // fetch()-level network errors and the "Not authenticated" pre-check both
32
+ // have no statusCode - both mean "can't reach/use the platform right now".
33
+ return typeof err?.statusCode !== 'number';
34
+ }
35
+ /** Best-effort delivery of every queued report. Never throws - called
36
+ * opportunistically and a flush failure must not block the caller's actual
37
+ * command. Returns the count successfully delivered. */
38
+ export async function flushBugQueue() {
39
+ if (!existsSync(QUEUE_DIR))
40
+ return 0;
41
+ let delivered = 0;
42
+ for (const file of readdirSync(QUEUE_DIR)) {
43
+ if (!file.endsWith('.json'))
44
+ continue;
45
+ const path = join(QUEUE_DIR, file);
46
+ let report;
47
+ try {
48
+ report = JSON.parse(readFileSync(path, 'utf-8'));
49
+ }
50
+ catch {
51
+ try {
52
+ unlinkSync(path);
53
+ }
54
+ catch { /* already gone */ }
55
+ continue; // corrupt entry - retrying it can never succeed
56
+ }
57
+ try {
58
+ await post(`/api/${report.projectGuid}/services/bug-report/submit`, {
59
+ category: report.category,
60
+ severity: report.severity,
61
+ summary: report.summary,
62
+ detail: report.detail,
63
+ });
64
+ unlinkSync(path);
65
+ delivered++;
66
+ }
67
+ catch {
68
+ // Still undeliverable - leave it queued for the next attempt. A queued
69
+ // report was only ever retryable at queue time (isRetryableFailure gates
70
+ // queuing); if the server later hard-rejects it permanently (e.g. the
71
+ // project was deleted), it sticks and retries forever. Accepted given
72
+ // bug-report volume is tiny - better than silently dropping it.
73
+ }
74
+ }
75
+ return delivered;
76
+ }
77
+ //# sourceMappingURL=bug-queue.js.map
package/dist/colors.js CHANGED
@@ -145,7 +145,7 @@ export const underline = makeStyle(4, 24);
145
145
  // ── Gipity platform palette ────────────────────────────────────────────
146
146
  // Colors sourced from platform/client/src/css/styles.css and
147
147
  // platform/apps/gipitsm/src/css/tokens.css
148
- export const brand = makeFg(254, 166, 14, bold); // Gipity orange #fea60e (→ bold on 16-color; no orange in palette)
148
+ export const brand = makeFg(254, 166, 11, bold); // Gipity orange #fea60b (→ bold on 16-color; no orange in palette)
149
149
  export const error = makeFg(239, 68, 68); // #ef4444
150
150
  export const warning = makeFg(245, 158, 11); // #f59e0b
151
151
  export const success = makeFg(34, 197, 94); // #22c55e
@@ -156,7 +156,7 @@ export const faint = makeFg(111, 111, 120); // #6f6f78
156
156
  export const accent = makeFg(178, 186, 250); // Light blue-purple
157
157
  export const cyan = makeFg(9, 146, 179); // Teal
158
158
  // ── Background variants ────────────────────────────────────────────────
159
- export const bgBrand = makeBg(242, 101, 34);
159
+ export const bgBrand = makeBg(254, 166, 11);
160
160
  export const bgError = makeBg(239, 68, 68);
161
161
  export const bgSuccess = makeBg(34, 197, 94);
162
162
  // ── Convenience combinators ────────────────────────────────────────────
@@ -3,6 +3,7 @@ import { get, post } from '../api.js';
3
3
  import { resolveProjectContext } from '../config.js';
4
4
  import { bold, muted, success, warning } from '../colors.js';
5
5
  import { run } from '../helpers/index.js';
6
+ import { flushBugQueue, isRetryableFailure, queueBugReport } from '../bug-queue.js';
6
7
  // Kept in sync with @easyclaw/shared BUG_REPORT_CATEGORIES / _SEVERITIES (the CLI
7
8
  // package can't import the server's shared package). The server validates
8
9
  // authoritatively; these are for --help and a friendly client-side check.
@@ -38,12 +39,34 @@ bugCommand
38
39
  throw new Error('--summary must be 7 words or fewer');
39
40
  }
40
41
  const { config } = await resolveProjectContext({ projectOverride: opts.project });
41
- const res = await post(`/api/${config.projectGuid}/services/bug-report/submit`, { category, severity, summary: opts.summary, detail: opts.detail });
42
- if (opts.json) {
43
- console.log(JSON.stringify(res.data));
44
- return;
42
+ // Best-effort: deliver anything stranded by an earlier outage/session-expiry
43
+ // before filing the new one, so a single working connection clears the backlog.
44
+ const delivered = await flushBugQueue().catch(() => 0);
45
+ if (delivered > 0 && !opts.json) {
46
+ console.log(muted(`Delivered ${delivered} previously queued bug report${delivered === 1 ? '' : 's'}.`));
47
+ }
48
+ const payload = { category, severity, summary: opts.summary, detail: opts.detail };
49
+ try {
50
+ const res = await post(`/api/${config.projectGuid}/services/bug-report/submit`, payload);
51
+ if (opts.json) {
52
+ console.log(JSON.stringify(res.data));
53
+ return;
54
+ }
55
+ console.log(success(`✓ Bug report filed (${res.data.report_guid}) — queued for triage.`));
56
+ }
57
+ catch (err) {
58
+ // The report itself is the record of "the platform broke" - don't lose it
59
+ // just because the breakage (dead session, no network) also blocks filing it.
60
+ if (!isRetryableFailure(err))
61
+ throw err;
62
+ queueBugReport({ projectGuid: config.projectGuid, ...payload });
63
+ if (opts.json) {
64
+ console.log(JSON.stringify({ queued: true }));
65
+ return;
66
+ }
67
+ console.log(warning('Bug report saved locally - no connection or session expired.'));
68
+ console.log(muted('It will be delivered automatically next time you log in or file another report.'));
45
69
  }
46
- console.log(success(`✓ Bug report filed (${res.data.report_guid}) — queued for triage.`));
47
70
  }));
48
71
  bugCommand
49
72
  .command('retract <id>')
@@ -71,7 +71,7 @@ import { getProjectsRoot } from '../relay/paths.js';
71
71
  *
72
72
  * Returns a best-effort local fallback if the API call fails (offline,
73
73
  * auth missing, etc.) so the prompt still builds. */
74
- async function fetchProjectStats(projectGuid, cwd) {
74
+ export async function fetchProjectStats(projectGuid, cwd) {
75
75
  if (projectGuid) {
76
76
  try {
77
77
  const res = await get(`/projects/${encodeURIComponent(projectGuid)}/files/stats`);
@@ -133,6 +133,7 @@ export const creditsCommand = new Command('credits')
133
133
  }
134
134
  else {
135
135
  console.log(dim('Need more credits? Run `gipity credits list` to see credit packs, then `gipity credits buy <pack>`.'));
136
+ console.log(dim('Manage or cancel your subscription with `gipity credits manage`.'));
136
137
  }
137
138
  }));
138
139
  // `credits list` compares every plan (with full limits) and the available
@@ -289,6 +290,51 @@ creditsCommand
289
290
  throw err;
290
291
  }
291
292
  }));
293
+ // `credits manage` is the ONE manage/cancel command: it mints a Stripe
294
+ // Customer Portal link (cancel at period end, renew, update card, invoices).
295
+ // Like `buy`, the link is always printed so an agent can relay it; --open
296
+ // also launches a browser. Falls back to the pricing page on error.
297
+ creditsCommand
298
+ .command('manage')
299
+ .alias('cancel')
300
+ .description('Manage or cancel your subscription (opens the Stripe billing portal)')
301
+ .option('--open', 'Open the billing portal in your browser')
302
+ .option('--json', 'Output the portal URL as JSON')
303
+ // optsWithGlobals(): the parent `credits` command also declares --json, which
304
+ // commander binds to the parent - read merged opts so --json/--open are seen here.
305
+ .action((_opts, cmd) => run('Manage', async () => {
306
+ const opts = cmd.optsWithGlobals();
307
+ try {
308
+ const res = await post('/credits/portal', {});
309
+ const url = res.data.portalUrl;
310
+ if (opts.json) {
311
+ console.log(JSON.stringify({ portalUrl: url }));
312
+ return;
313
+ }
314
+ console.log(` ${bold('Billing portal:')} ${success(url)}`);
315
+ console.log('');
316
+ console.log(dim('Open the link to cancel, renew, update your card, or view invoices. Cancelling takes effect at the end of the billing period — you keep your credits.'));
317
+ if (opts.open)
318
+ openInBrowser(url);
319
+ }
320
+ catch (err) {
321
+ // No billing account / payments not configured: point at the pricing page
322
+ // so the user is never dead-ended.
323
+ if (err instanceof ApiError) {
324
+ if (opts.json) {
325
+ console.log(JSON.stringify({ error: err.message, portalUrl: null, fallbackUrl: PRICING_URL }));
326
+ }
327
+ else {
328
+ console.log(muted(`Couldn't open the billing portal (${err.message}).`));
329
+ console.log(`Manage your plan at ${brand(PRICING_URL)}`);
330
+ }
331
+ if (opts.open)
332
+ openInBrowser(PRICING_URL);
333
+ return;
334
+ }
335
+ throw err;
336
+ }
337
+ }));
292
338
  creditsCommand
293
339
  .command('usage')
294
340
  .description('Show recent usage')
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { existsSync, readFileSync, statSync } from 'fs';
3
3
  import { join } from 'path';
4
+ import { homedir } from 'os';
4
5
  import { LOCAL_PKG_DIR, LOCAL_ENTRY, STATE_FILE, SETTINGS_FILE, UPDATE_LOG, readState, readSettings, updatesDisabled } from '../updater/state.js';
5
6
  import { bold, dim, success, warning, error as clrError, muted } from '../colors.js';
6
7
  import { getAuth, sessionExpired } from '../auth.js';
@@ -9,6 +10,36 @@ import * as relayState from '../relay/state.js';
9
10
  import { planFor, UnsupportedPlatformError } from '../relay/installers.js';
10
11
  import { resolveCliPath } from '../relay/setup.js';
11
12
  const NODE_MIN_MAJOR = 18;
13
+ /**
14
+ * If the `node` running this CLI lives under a version-manager install tree
15
+ * (nvm/fnm/asdf/volta), return the manager's name; otherwise null.
16
+ *
17
+ * Why it matters: `npm install -g gipity` drops the `gipity` binary in the
18
+ * *active node version's* bin dir. With a lazily-activated manager (nvm's
19
+ * default setup), that dir isn't on a fresh shell's PATH until the manager
20
+ * initializes, so a new terminal reports `gipity: command not found` and users
21
+ * think the install failed. (Plugin hooks used to break the same way; the
22
+ * plugin's sh launcher now resolves node itself, so this is only about the
23
+ * `gipity` binary's own discoverability.) `gipity doctor` surfaces it with a
24
+ * remediation instead of leaving it a mystery.
25
+ */
26
+ export function versionManagerNode(execPath, env = process.env, home = homedir()) {
27
+ const norm = (s) => s.replace(/\\/g, '/').replace(/\/+$/, '') + '/';
28
+ const p = norm(execPath);
29
+ const h = norm(home);
30
+ const roots = [
31
+ ['nvm', env.NVM_DIR ? norm(env.NVM_DIR) : `${h}.nvm/`],
32
+ ['fnm', env.FNM_DIR ? norm(env.FNM_DIR) : `${h}.fnm/`],
33
+ ['fnm', `${h}.local/share/fnm/`],
34
+ ['asdf', env.ASDF_DIR ? norm(env.ASDF_DIR) : `${h}.asdf/`],
35
+ ['volta', env.VOLTA_HOME ? norm(env.VOLTA_HOME) : `${h}.volta/`],
36
+ ];
37
+ for (const [name, root] of roots) {
38
+ if (p.startsWith(root))
39
+ return name;
40
+ }
41
+ return null;
42
+ }
12
43
  function localVersion() {
13
44
  const pkgPath = join(LOCAL_PKG_DIR, 'package.json');
14
45
  if (!existsSync(pkgPath))
@@ -68,7 +99,11 @@ export function gatherEnv(opts = {}) {
68
99
  const auth = getAuth();
69
100
  const expired = auth ? sessionExpired() : false;
70
101
  const device = relayState.getDevice();
71
- const node = { ok: nodeMajor >= NODE_MIN_MAJOR, version: process.versions.node };
102
+ const node = {
103
+ ok: nodeMajor >= NODE_MIN_MAJOR,
104
+ version: process.versions.node,
105
+ version_manager: versionManagerNode(process.execPath),
106
+ };
72
107
  const gipity = {
73
108
  installed: true, // we're running it
74
109
  version: shimVersion(),
@@ -118,7 +153,11 @@ export const doctorCommand = new Command('doctor')
118
153
  console.log(bold('Gipity - doctor'));
119
154
  console.log('');
120
155
  console.log(bold('Environment'));
121
- console.log(`${muted('node ')} ${env.node.version} ${env.node.ok ? success('✓') : clrError(`(need ${NODE_MIN_MAJOR}+)`)}`);
156
+ console.log(`${muted('node ')} ${env.node.version} ${env.node.ok ? success('✓') : clrError(`(need ${NODE_MIN_MAJOR}+)`)}${env.node.version_manager ? muted(` (via ${env.node.version_manager})`) : ''}`);
157
+ if (env.node.version_manager) {
158
+ console.log(`${muted(' ')} ${warning(`node is managed by ${env.node.version_manager}`)} - if a fresh terminal reports \`gipity: command not found\`, its bin dir isn't on your`);
159
+ console.log(`${muted(' ')} ${dim(`login PATH yet. Ensure ${env.node.version_manager} auto-activates in your shell rc, set it as the default version, or symlink node + gipity onto your base PATH.`)}`);
160
+ }
122
161
  console.log(`${muted('gipity login ')} ${env.gipity.logged_in ? success(`logged in as ${env.gipity.email}`) : (env.gipity.session_expired ? warning(`session expired (${env.gipity.email})`) : warning('not logged in'))}`);
123
162
  console.log(`${muted('claude code ')} installed ${yn(env.claude.installed)} · authenticated ${yn(env.claude.authenticated)}`);
124
163
  const autostartLabel = env.relay.autostart === null ? muted('n/a') : yn(env.relay.autostart);
@@ -1,14 +1,24 @@
1
1
  import { Command } from 'commander';
2
- import { post } from '../api.js';
2
+ import { get, post } from '../api.js';
3
3
  import { resolveProjectContext } from '../config.js';
4
- import { error as clrError, success } from '../colors.js';
4
+ import { error as clrError, success, bold, muted, warning } from '../colors.js';
5
+ import { run } from '../helpers/index.js';
5
6
  /** Commander collector: build an array from repeated flags (--to a --to b). */
6
7
  function collect(value, prev) {
7
8
  return [...prev, value];
8
9
  }
10
+ // `email` is a command GROUP. Agent email is `email send`; the deployed app's
11
+ // email() service is exercised via `email test` / `email log`. (Keeping agent
12
+ // send under `send` rather than a bare-`email` default action avoids a
13
+ // parent/subcommand `--subject` option collision — and matches the `email`
14
+ // skill doc, which documents the CLI verb as `gipity email send`.)
9
15
  export const emailCommand = new Command('email')
10
- .description('Send email')
11
- .addHelpText('after', '\nSends from gipity@gipity.ai. Omit --to to self-send.')
16
+ .description("Send email as the agent (send), or test/inspect a deployed app's email() sends (test/log)");
17
+ // --- gipity email send ---
18
+ // Agent email from gipity@gipity.ai (omit --to to self-send).
19
+ emailCommand
20
+ .command('send')
21
+ .description('Send an email as the agent (from gipity@gipity.ai; omit --to to self-send)')
12
22
  .requiredOption('--subject <subject>', 'Email subject')
13
23
  .requiredOption('--body <body>', 'Email body (plain text)')
14
24
  .option('--to <email>', 'Recipient (repeatable; omit for self-send)', collect, [])
@@ -20,10 +30,7 @@ export const emailCommand = new Command('email')
20
30
  .action(async (opts) => {
21
31
  try {
22
32
  await resolveProjectContext();
23
- const payload = {
24
- subject: opts.subject,
25
- body: opts.body,
26
- };
33
+ const payload = { subject: opts.subject, body: opts.body };
27
34
  if (opts.to.length)
28
35
  payload.to = opts.to;
29
36
  if (opts.cc.length)
@@ -50,4 +57,66 @@ export const emailCommand = new Command('email')
50
57
  process.exit(1);
51
58
  }
52
59
  });
60
+ // --- gipity email test <to> ---
61
+ // Exercises the deployed app's email() send path (platform-brokered, owner-billed)
62
+ // so the agent can verify email works after deploy — the parallel of `notify test`.
63
+ emailCommand
64
+ .command('test')
65
+ .description("Send a test email through your app's email() path (owner-billed)")
66
+ .argument('<to>', 'Recipient address')
67
+ .option('--subject <text>', 'Subject', 'Test email from Gipity')
68
+ .option('--text <text>', 'Body text', 'This is a test email sent through your app\'s email() service. ✉️')
69
+ .option('--reply-to <email>', 'Reply-To address')
70
+ .option('--from-name <name>', 'Sender display name (address stays gipity@gipity.ai)')
71
+ .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
72
+ .option('--json', 'Output raw JSON')
73
+ .action((to, opts) => run('Email', async () => {
74
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
75
+ const payload = { to, subject: opts.subject, text: opts.text };
76
+ if (opts.replyTo)
77
+ payload.replyTo = opts.replyTo;
78
+ if (opts.fromName)
79
+ payload.fromName = opts.fromName;
80
+ const res = await post(`/api/${config.projectGuid}/services/email/send`, payload);
81
+ if (opts.json) {
82
+ console.log(JSON.stringify(res.data));
83
+ return;
84
+ }
85
+ const { sent, skipped, results } = res.data;
86
+ if (sent > 0)
87
+ console.log(success(`✓ Sent to ${sent} recipient${sent === 1 ? '' : 's'}.`));
88
+ else
89
+ console.log(warning(`Nothing sent (${skipped} skipped).`));
90
+ for (const r of results)
91
+ console.log(muted(` ${r.to} — ${r.status}`));
92
+ }));
93
+ // --- gipity email log ---
94
+ // Recent email() sends from the credits ledger (operation = email_send).
95
+ emailCommand
96
+ .command('log')
97
+ .description('Recent email() sends from your app')
98
+ .option('--range <range>', 'Time range (24h, 7d, 30d)', '7d')
99
+ .option('--limit <n>', 'Max rows', '50')
100
+ .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
101
+ .option('--json', 'Output raw JSON')
102
+ .action((opts) => run('Email', async () => {
103
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
104
+ const res = await get(`/account/logs/credits?operations=email_send&app_guid=${config.projectGuid}&range=${encodeURIComponent(opts.range)}&limit=${encodeURIComponent(opts.limit)}`);
105
+ if (opts.json) {
106
+ console.log(JSON.stringify(res.data));
107
+ return;
108
+ }
109
+ const { totals, items } = res.data;
110
+ if (!items.length) {
111
+ console.log(muted('No email() sends in this range.'));
112
+ return;
113
+ }
114
+ console.log(bold(`${totals.n} send${totals.n === 1 ? '' : 's'} · ${totals.credits} credit${totals.credits === 1 ? '' : 's'}`));
115
+ for (const r of items) {
116
+ const when = new Date(r.created_at).toISOString().replace('T', ' ').slice(0, 16);
117
+ const to = r.detail?.to ?? '—';
118
+ const subj = r.detail?.subject ? ` · ${r.detail.subject}` : '';
119
+ console.log(` ${muted(when)} ${to}${muted(subj)}`);
120
+ }
121
+ }));
53
122
  //# sourceMappingURL=email.js.map
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { post } from '../api.js';
3
3
  import { resolveProjectContext, getConfigPath } from '../config.js';
4
4
  import { pushFile } from '../sync.js';
5
- import { writeFileSync } from 'fs';
5
+ import { mkdirSync, writeFileSync } from 'fs';
6
6
  import { resolve as resolvePath, dirname, relative, isAbsolute } from 'path';
7
7
  import { error as clrError, success, muted } from '../colors.js';
8
8
  import { printCommandError } from '../helpers/command.js';
@@ -25,11 +25,27 @@ async function downloadFile(url, filename) {
25
25
  if (!res.ok)
26
26
  throw new Error(`Download failed: ${res.status}`);
27
27
  const buffer = Buffer.from(await res.arrayBuffer());
28
+ ensureOutputDir(filename);
28
29
  writeFileSync(filename, buffer);
29
30
  const savedPath = resolvePath(filename);
30
31
  await pushGenerated(savedPath);
31
32
  return savedPath;
32
33
  }
34
+ /** Create the parent directory of an -o path, so `-o src/audio/ding.mp3` works
35
+ * in a tree that has no `src/audio/` yet instead of dying on a raw ENOENT.
36
+ *
37
+ * Every action also calls this *before* its generate request: generation is
38
+ * billed per call, so an unwritable output path has to fail before we spend
39
+ * one, not after the bytes are already paid for and in hand. */
40
+ function ensureOutputDir(output) {
41
+ const dir = dirname(resolvePath(output));
42
+ try {
43
+ mkdirSync(dir, { recursive: true });
44
+ }
45
+ catch (err) {
46
+ throw new Error(`can't create the output directory ${dir} (${err.code || err.message}). Pick a different -o path.`);
47
+ }
48
+ }
33
49
  /** Sync a freshly generated file up to the linked project (no-op when there's
34
50
  * no local project, or the file was written outside the project tree). */
35
51
  async function pushGenerated(savedPath) {
@@ -74,6 +90,8 @@ Examples:
74
90
  .action(async (prompt, opts) => {
75
91
  try {
76
92
  const { config } = await resolveProjectContext();
93
+ if (opts.output)
94
+ ensureOutputDir(opts.output);
77
95
  const doGenerate = () => post(`/projects/${config.projectGuid}/generate/image`, {
78
96
  prompt,
79
97
  provider: opts.provider,
@@ -135,6 +153,8 @@ Examples:
135
153
  .action(async (prompt, opts) => {
136
154
  try {
137
155
  const { config } = await resolveProjectContext();
156
+ if (opts.output)
157
+ ensureOutputDir(opts.output);
138
158
  const doGenerate = () => post(`/projects/${config.projectGuid}/generate/video`, {
139
159
  prompt,
140
160
  model: opts.model,
@@ -187,6 +207,8 @@ Examples:
187
207
  .action(async (text, opts) => {
188
208
  try {
189
209
  const { config } = await resolveProjectContext();
210
+ if (opts.output)
211
+ ensureOutputDir(opts.output);
190
212
  let speakers;
191
213
  if (opts.speakers) {
192
214
  try {
@@ -249,6 +271,8 @@ Examples:
249
271
  .action(async (prompt, opts) => {
250
272
  try {
251
273
  const { config } = await resolveProjectContext();
274
+ if (opts.output)
275
+ ensureOutputDir(opts.output);
252
276
  const doGenerate = () => post(`/projects/${config.projectGuid}/generate/music`, {
253
277
  prompt,
254
278
  duration_seconds: opts.duration,
@@ -299,6 +323,8 @@ Examples:
299
323
  .action(async (text, opts) => {
300
324
  try {
301
325
  const { config } = await resolveProjectContext();
326
+ if (opts.output)
327
+ ensureOutputDir(opts.output);
302
328
  const doGenerate = () => post(`/projects/${config.projectGuid}/generate/sound`, {
303
329
  text,
304
330
  duration_seconds: opts.duration,
@@ -3,6 +3,7 @@ import { saveAuth, getAuth } from '../auth.js';
3
3
  import { publicPost } from '../api.js';
4
4
  import { prompt, decodeJwtExp } from '../utils.js';
5
5
  import { success, error as clrError, muted } from '../colors.js';
6
+ import { flushBugQueue } from '../bug-queue.js';
6
7
  export const loginCommand = new Command('login')
7
8
  .description('Log in or sign up')
8
9
  .option('--email <email>', 'Email address')
@@ -59,5 +60,11 @@ async function verify(email, code) {
59
60
  expiresAt,
60
61
  });
61
62
  console.log(success(`Logged in (${email}).`));
63
+ // A fresh session is the clearest "we're reconnected" signal - clear any bug
64
+ // reports that got stranded while this account's session was expired/offline.
65
+ const delivered = await flushBugQueue().catch(() => 0);
66
+ if (delivered > 0) {
67
+ console.log(muted(`Delivered ${delivered} queued bug report${delivered === 1 ? '' : 's'}.`));
68
+ }
62
69
  }
63
70
  //# sourceMappingURL=login.js.map
@@ -275,6 +275,16 @@ export const pageEvalCommand = new Command('eval')
275
275
  if (d.navigationIncomplete) {
276
276
  console.log(`${warning('⚠ Navigation incomplete:')} ${d.note || 'page did not reach full load'}`);
277
277
  }
278
+ // A slow-painting page makes any wall-clock wait silently wrong: the
279
+ // page's rAF loop is its clock, and engines cap the per-frame delta, so
280
+ // `setTimeout(2000)` may advance only a fraction of a second of app time.
281
+ // Without this line the eval returns a plausible-looking false negative.
282
+ if (d.slowRender) {
283
+ console.log(`${warning('⚠ Slow render:')} page painted at ${d.slowRender.fps} fps. `
284
+ + `Waiting on real time (setTimeout) advances animation/physics time far slower than it looks — `
285
+ + `assertions after a wall-clock wait can report a false negative. `
286
+ + `Step the app's own loop deterministically instead (3D templates: ${bold('core.advance(seconds)')}).`);
287
+ }
278
288
  // Auth state: without this line an agent can't distinguish "signed-in
279
289
  // eval" from "--auth silently no-op'd against the anonymous page".
280
290
  if (d.auth?.requested) {
@@ -6,6 +6,7 @@ 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
8
  import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, setupClaudeHooks, ensureGipityPlugin, ensureGipityPluginInstalled, userScopeInstallState } from '../setup.js';
9
+ import { flushBugQueue } from '../bug-queue.js';
9
10
  /** Hooks ship in the Gipity Claude Code plugin now. "Installed" means three
10
11
  * things must all hold: the user-scope settings register the marketplace and
11
12
  * enable the plugin (declarative), AND Claude Code actually has a user-scope
@@ -37,7 +38,11 @@ function checkGipityPlugin() {
37
38
  const stale = missing.length === 1 && missing[0] === 'install' && install.exists;
38
39
  return { missing, ok: missing.length === 0, stale };
39
40
  }
41
+ // `whoami` is the name agents reach for first when they want the signed-in
42
+ // identity (it's the unix spelling), and this is the command that prints it.
43
+ // Aliasing costs one line and turns a guess into a hit.
40
44
  export const statusCommand = new Command('status')
45
+ .alias('whoami')
41
46
  .description('Show project and login status')
42
47
  .option('--json', 'Output as JSON')
43
48
  .option('--repair-hooks', 'Re-enable the Gipity Claude Code plugin (hooks) if missing or disabled')
@@ -47,6 +52,12 @@ export const statusCommand = new Command('status')
47
52
  const cwd = resolve(process.cwd());
48
53
  void cwd;
49
54
  const hookCheck = config ? checkGipityPlugin() : null;
55
+ // `status` is the command an agent reaches for to confirm "are we back?"
56
+ // after an outage/session-expiry - a valid, unexpired session here is the
57
+ // clearest signal we have that any bug reports stranded by that outage can
58
+ // now go out. Skipped entirely (existsSync short-circuit) when the queue
59
+ // is empty, which is the common case, so this stays a no-op most runs.
60
+ const queueDelivered = (auth && !sessionExpired()) ? await flushBugQueue().catch(() => 0) : 0;
50
61
  if (opts.json) {
51
62
  console.log(JSON.stringify({
52
63
  project: config ? {
@@ -86,6 +97,9 @@ export const statusCommand = new Command('status')
86
97
  else {
87
98
  console.log(`${muted('Auth:')} ${success(auth.email)}`);
88
99
  }
100
+ if (queueDelivered > 0) {
101
+ console.log(`${muted('Bug queue:')} ${success(`delivered ${queueDelivered} queued bug report${queueDelivered === 1 ? '' : 's'}`)}`);
102
+ }
89
103
  if (hookCheck) {
90
104
  if (hookCheck.ok) {
91
105
  console.log(`${muted('Hooks:')} ${success(`Gipity plugin enabled (${GIPITY_PLUGIN_ID})`)}`);