gipity 1.0.398 → 1.0.399

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.
@@ -7,6 +7,7 @@ import { requireConfig } from '../config.js';
7
7
  import { sync } from '../sync.js';
8
8
  import { success, muted, bold } from '../colors.js';
9
9
  import { run } from '../helpers/index.js';
10
+ import { createProgressReporter, withSpinner } from '../progress.js';
10
11
  const STARTERS = [
11
12
  { key: 'web-vision-cam', hint: 'fullscreen camera app with on-device vision (MediaPipe)' },
12
13
  { key: 'object-spotter', hint: 'camera app that boxes, labels, and counts objects (YOLOX on-device)' },
@@ -198,9 +199,14 @@ export const addCommand = new Command('add')
198
199
  force: opts.force,
199
200
  };
200
201
  }
201
- const res = await post(`/projects/${config.projectGuid}/add`, body);
202
+ // The server runs the whole install pipeline before responding; animate the
203
+ // wait, then clear the spinner so the installed-files list is the result.
204
+ const doAdd = () => post(`/projects/${config.projectGuid}/add`, body);
205
+ const res = opts.json
206
+ ? await doAdd()
207
+ : await withSpinner('Installing…', doAdd, { done: null });
202
208
  // Pull the created/installed files down to local.
203
- const syncResult = await sync({ interactive: false });
209
+ const syncResult = await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
204
210
  const data = res.data;
205
211
  if (opts.json) {
206
212
  console.log(JSON.stringify({ ...data, synced: syncResult.applied }));
@@ -4,6 +4,7 @@ import { resolveProjectContext, 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';
7
+ import { createProgressReporter, withSpinner } from '../progress.js';
7
8
  export const chatCommand = new Command('chat')
8
9
  .description('Send a message to your agent')
9
10
  .argument('<message>', 'Message to send')
@@ -19,7 +20,12 @@ export const chatCommand = new Command('chat')
19
20
  const body = useExisting
20
21
  ? { content: message, projectGuid: config.projectGuid }
21
22
  : { agentGuid: config.agentGuid, content: message, projectGuid: config.projectGuid };
22
- const res = await post(endpoint, body);
23
+ // The agent can think for many seconds; animate the wait, then clear the
24
+ // spinner (done:null) so the reply itself is the result. JSON mode skips it.
25
+ const doChat = () => post(endpoint, body);
26
+ const res = opts.json
27
+ ? await doChat()
28
+ : await withSpinner('Thinking…', doChat, { done: null });
23
29
  // Save conversation guid for continuity. Skipped in one-off mode: the
24
30
  // config was resolved from the server's Home project and there is no
25
31
  // local `.gipity.json` to update - persisting here would create one in
@@ -31,7 +37,10 @@ export const chatCommand = new Command('chat')
31
37
  let syncSummary = '';
32
38
  let syncChanges = [];
33
39
  if (res.data.filesChanged) {
34
- const syncResult = await sync({ interactive: false });
40
+ const syncResult = await sync({
41
+ interactive: false,
42
+ progress: opts.json ? undefined : createProgressReporter(),
43
+ });
35
44
  if (syncResult.applied > 0) {
36
45
  syncSummary = `\nSynced ${syncResult.applied} change${syncResult.applied > 1 ? 's' : ''}:\n${syncResult.summary}`;
37
46
  }
@@ -5,7 +5,7 @@ import { execSync, spawn } from 'child_process';
5
5
  import { homedir } from 'os';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { resolveCommand } from '../platform.js';
8
- import { getAuth, saveAuth } from '../auth.js';
8
+ import { getAuth, saveAuth, sessionExpired } from '../auth.js';
9
9
  import { get, post, publicPost, ApiError, getAccountSlug } from '../api.js';
10
10
  import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
11
11
  import { sync } from '../sync.js';
@@ -291,7 +291,15 @@ export const claudeCommand = new Command('claude')
291
291
  if (!nonInteractive) {
292
292
  printBanner({ version: __clPkg.version, email: auth?.email, cwd: process.cwd() });
293
293
  }
294
- if (auth) {
294
+ if (auth && sessionExpired()) {
295
+ // The cached auth.json exists but its refresh token has lapsed, so the
296
+ // first API call below would 401 anyway. Re-login up front instead of
297
+ // printing "Logged in" and immediately contradicting it with "session
298
+ // expired" once the projects fetch fails.
299
+ console.log(` ${muted('Your session expired. Let\'s sign you back in.')}\n`);
300
+ auth = await interactiveLogin();
301
+ }
302
+ else if (auth) {
295
303
  console.log(` Logged in (${auth.email}).`);
296
304
  }
297
305
  else {
@@ -4,6 +4,7 @@ import { requireConfig } from '../config.js';
4
4
  import { formatSize } from '../utils.js';
5
5
  import { success, error as clrError, warning, muted, bold, brand } from '../colors.js';
6
6
  import { run, syncBeforeAction } from '../helpers/index.js';
7
+ import { withSpinner } from '../progress.js';
7
8
  // ── Status icons ───────────────────────────────────────────────────────
8
9
  function statusIcon(status) {
9
10
  if (status === 'ok')
@@ -32,14 +33,16 @@ export const deployCommand = new Command('deploy')
32
33
  }
33
34
  const config = requireConfig();
34
35
  await syncBeforeAction(opts);
35
- // Call server - pipeline runs entirely server-side
36
- const res = await post(`/projects/${config.projectGuid}/deploy`, {
36
+ const doDeploy = () => post(`/projects/${config.projectGuid}/deploy`, {
37
37
  target,
38
38
  sourceDir: opts.sourceDir,
39
39
  optimize: opts.optimize,
40
40
  force: opts.force,
41
41
  only: opts.only?.split(',').map((s) => s.trim()),
42
42
  });
43
+ const res = opts.json
44
+ ? await doDeploy()
45
+ : await withSpinner(`Deploying to ${target}…`, doDeploy, { done: null });
43
46
  const d = res.data;
44
47
  if (opts.json) {
45
48
  console.log(JSON.stringify(d));
@@ -4,8 +4,9 @@ import { resolveProjectContext, getConfigPath } from '../config.js';
4
4
  import { pushFile } from '../sync.js';
5
5
  import { writeFileSync } from 'fs';
6
6
  import { resolve as resolvePath, dirname, relative, isAbsolute } from 'path';
7
- import { error as clrError, success, muted, info } from '../colors.js';
7
+ import { error as clrError, success, muted } from '../colors.js';
8
8
  import { printCommandError } from '../helpers/command.js';
9
+ import { withSpinner } from '../progress.js';
9
10
  import { IMAGE_MODELS_DOC, IMAGE_GEMINI_ASPECT_RATIOS, IMAGE_GEMINI_SIZES, VIDEO_MODELS_DOC, TTS_PROVIDER_DESCRIPTIONS } from '../provider-docs.js';
10
11
  /** Download a URL and save to a local file, then push it up to the project so
11
12
  * the cloud (and anything that mirrors it) immediately matches local disk.
@@ -73,7 +74,7 @@ Examples:
73
74
  .action(async (prompt, opts) => {
74
75
  try {
75
76
  const { config } = await resolveProjectContext();
76
- const result = await post(`/projects/${config.projectGuid}/generate/image`, {
77
+ const doGenerate = () => post(`/projects/${config.projectGuid}/generate/image`, {
77
78
  prompt,
78
79
  provider: opts.provider,
79
80
  model: opts.model,
@@ -83,6 +84,9 @@ Examples:
83
84
  image_size: opts.imageSize,
84
85
  seed: Number.isFinite(opts.seed) ? opts.seed : undefined,
85
86
  });
87
+ const result = opts.json
88
+ ? await doGenerate()
89
+ : await withSpinner('Generating image…', doGenerate, { done: null });
86
90
  const ext = result.content_type.includes('png') ? 'png' : 'jpg';
87
91
  const filename = opts.output || `generated.${ext}`;
88
92
  const savedPath = await downloadFile(result.url, filename);
@@ -131,14 +135,16 @@ Examples:
131
135
  .action(async (prompt, opts) => {
132
136
  try {
133
137
  const { config } = await resolveProjectContext();
134
- if (!opts.json)
135
- console.log(info('Generating video (this may take 30-120 seconds)...')); // keep --json stdout pure JSON
136
- const result = await post(`/projects/${config.projectGuid}/generate/video`, {
138
+ const doGenerate = () => post(`/projects/${config.projectGuid}/generate/video`, {
137
139
  prompt,
138
140
  model: opts.model,
139
141
  aspect_ratio: opts.aspect,
140
142
  resolution: opts.resolution,
141
143
  });
144
+ // Veo runs 30-120s; the bouncing bar + timer keeps the wait honest.
145
+ const result = opts.json
146
+ ? await doGenerate()
147
+ : await withSpinner('Generating video…', doGenerate, { done: null });
142
148
  const filename = opts.output || 'generated.mp4';
143
149
  const savedPath = await downloadFile(result.url, filename);
144
150
  if (opts.json) {
@@ -191,13 +197,16 @@ Examples:
191
197
  process.exit(1);
192
198
  }
193
199
  }
194
- const result = await post(`/projects/${config.projectGuid}/generate/speech`, {
200
+ const doGenerate = () => post(`/projects/${config.projectGuid}/generate/speech`, {
195
201
  text,
196
202
  provider: opts.provider,
197
203
  voice: opts.voice,
198
204
  language: opts.language,
199
205
  speakers,
200
206
  });
207
+ const result = opts.json
208
+ ? await doGenerate()
209
+ : await withSpinner('Generating speech…', doGenerate, { done: null });
201
210
  const filename = opts.output || 'speech.mp3';
202
211
  const savedPath = await downloadFile(result.url, filename);
203
212
  if (opts.json) {
@@ -240,14 +249,15 @@ Examples:
240
249
  .action(async (prompt, opts) => {
241
250
  try {
242
251
  const { config } = await resolveProjectContext();
243
- if (!opts.json)
244
- console.log(info('Generating music...')); // keep --json stdout pure JSON
245
- const result = await post(`/projects/${config.projectGuid}/generate/music`, {
252
+ const doGenerate = () => post(`/projects/${config.projectGuid}/generate/music`, {
246
253
  prompt,
247
254
  duration_seconds: opts.duration,
248
255
  model: opts.model,
249
256
  instrumental: !opts.vocals,
250
257
  });
258
+ const result = opts.json
259
+ ? await doGenerate()
260
+ : await withSpinner('Generating music…', doGenerate, { done: null });
251
261
  const filename = opts.output || 'music.mp3';
252
262
  const savedPath = await downloadFile(result.url, filename);
253
263
  if (opts.json) {
@@ -6,6 +6,7 @@ import { getProjectRoot } from '../config.js';
6
6
  import { brand, bold, muted, success } from '../colors.js';
7
7
  import { formatSize } from '../utils.js';
8
8
  import { run } from '../helpers/index.js';
9
+ import { withSpinner } from '../progress.js';
9
10
  const DEVICE_PRESETS = {
10
11
  default: { width: 1280, height: 720 },
11
12
  desktop: { width: 1920, height: 1080 },
@@ -143,7 +144,13 @@ export const pageScreenshotCommand = new Command('screenshot')
143
144
  ...(opts.fakeMedia ? { fakeMedia: true } : {}),
144
145
  ...(opts.action ? { action: opts.action } : {}),
145
146
  };
146
- const entries = await postForTarEntries('/tools/browser/screenshot', body);
147
+ // Load + render across viewports runs server-side and can take many
148
+ // seconds; animate the wait, then clear so the saved-files summary is the
149
+ // result. JSON mode skips the spinner (shares stdout).
150
+ const doShoot = () => postForTarEntries('/tools/browser/screenshot', body);
151
+ const entries = opts.json
152
+ ? await doShoot()
153
+ : await withSpinner('Capturing…', doShoot, { done: null });
147
154
  const metaEntry = entries.find((e) => e.name === 'meta.json');
148
155
  if (!metaEntry)
149
156
  throw new Error('Server response missing meta.json');
@@ -5,6 +5,7 @@ import { sync } from '../sync.js';
5
5
  import { success, muted } from '../colors.js';
6
6
  import { run } from '../helpers/index.js';
7
7
  import { confirm } from '../utils.js';
8
+ import { createProgressReporter, withSpinner } from '../progress.js';
8
9
  export const removeCommand = new Command('remove')
9
10
  .description('Remove an installed kit from the project (inverse of `gipity add <kit>`).')
10
11
  .argument('<kit>', 'Kit key/directory under src/packages/ to remove')
@@ -18,10 +19,13 @@ export const removeCommand = new Command('remove')
18
19
  return;
19
20
  }
20
21
  }
21
- const res = await post(`/projects/${config.projectGuid}/remove`, { name: kit });
22
+ const doRemove = () => post(`/projects/${config.projectGuid}/remove`, { name: kit });
23
+ const res = opts.json
24
+ ? await doRemove()
25
+ : await withSpinner('Removing…', doRemove, { done: null });
22
26
  // Force the pull so the kit's deletions land locally without tripping the
23
27
  // bulk-deletion guard - the removal is an explicit, user-invoked action.
24
- const syncResult = await sync({ interactive: false, force: true });
28
+ const syncResult = await sync({ interactive: false, force: true, progress: opts.json ? undefined : createProgressReporter() });
25
29
  const data = res.data;
26
30
  if (opts.json) {
27
31
  console.log(JSON.stringify({ ...data, synced: syncResult.applied }));
@@ -6,6 +6,7 @@ import { resolveProjectContext, getConfigPath } from '../config.js';
6
6
  import { sync } from '../sync.js';
7
7
  import { error as clrError, dim } from '../colors.js';
8
8
  import { run } from '../helpers/index.js';
9
+ import { createProgressReporter, withSpinner } from '../progress.js';
9
10
  const LANG_MAP = {
10
11
  js: 'javascript',
11
12
  javascript: 'javascript',
@@ -159,15 +160,20 @@ GCC/Rust).
159
160
  // Bidirectional + CAS, so it's a cheap manifest check when nothing changed.
160
161
  // Symmetric with the post-run pull below. Skip in one-off mode (no project).
161
162
  if (getConfigPath()) {
162
- await sync({ interactive: false });
163
+ await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
163
164
  }
164
- const res = await post(`/projects/${config.projectGuid}/sandbox/execute`, {
165
+ // The run blocks until the sandbox finishes (up to the timeout); animate the
166
+ // wait, then clear the spinner so stdout/stderr is the result. JSON skips it.
167
+ const doRun = () => post(`/projects/${config.projectGuid}/sandbox/execute`, {
165
168
  code: source,
166
169
  language,
167
170
  timeout: isNaN(timeout) ? 30 : timeout,
168
171
  input_files: opts.input,
169
172
  cwd,
170
173
  });
174
+ const res = opts.json
175
+ ? await doRun()
176
+ : await withSpinner('Running in sandbox…', doRun, { done: null });
171
177
  // Pull sandbox-written outputs down to the local cwd automatically. The
172
178
  // server has already mirrored them into the project (VFS) and handed back
173
179
  // the exact list, so honoring it here means files land locally without a
@@ -175,7 +181,7 @@ GCC/Rust).
175
181
  // `filesChanged` flag. Skip in one-off mode (no local project to sync into).
176
182
  const pulledLocal = !!(res.data.outputFiles?.length && getConfigPath());
177
183
  if (pulledLocal) {
178
- await sync({ interactive: false });
184
+ await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
179
185
  }
180
186
  if (opts.json) {
181
187
  console.log(JSON.stringify({ ...res.data, filesSynced: pulledLocal }));
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { sync } from '../sync.js';
5
5
  import { muted, error as clrError } from '../colors.js';
6
+ import { createProgressReporter } from '../progress.js';
6
7
  /**
7
8
  * Sync local files with the server before an action (deploy, test, scaffold).
8
9
  * Respects --no-sync and --json flags. Non-interactive: bulk-deletion guard
@@ -14,7 +15,14 @@ import { muted, error as clrError } from '../colors.js';
14
15
  export async function syncBeforeAction(opts) {
15
16
  if (opts.sync === false)
16
17
  return;
17
- const result = await sync({ interactive: false, force: opts.force });
18
+ // Pass a progress reporter so a large pre-action upload shows the transfer
19
+ // bar instead of a silent pause (the reporter is a no-op on non-TTY / when
20
+ // piped, so JSON and headless output stay clean).
21
+ const result = await sync({
22
+ interactive: false,
23
+ force: opts.force,
24
+ progress: opts.json ? undefined : createProgressReporter(),
25
+ });
18
26
  if (result.applied > 0 && !opts.json) {
19
27
  console.log(muted(`Synced ${result.applied} change${result.applied > 1 ? 's' : ''}`));
20
28
  }
package/dist/index.js CHANGED
@@ -57,6 +57,24 @@ import { bold, dim, brand, muted, success } from './colors.js';
57
57
  import { normalizeAliases } from './flag-aliases.js';
58
58
  const __dirname = dirname(fileURLToPath(import.meta.url));
59
59
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
60
+ // Local builds stamp dist/build-info.json (git SHA + dirty flag) via the npm
61
+ // `postbuild` hook, so `-v` can show whether the linked binary is your latest
62
+ // code. It is intentionally absent from published installs: the npm `files`
63
+ // allowlist ships only dist/**/*.js, so a released CLI prints a clean
64
+ // `v1.0.398` with no dev marker. package.json `version` stays the source of
65
+ // truth for the published release; this marker never touches it.
66
+ function versionLabel() {
67
+ const base = `v${pkg.version}`;
68
+ try {
69
+ const info = JSON.parse(readFileSync(resolve(__dirname, 'build-info.json'), 'utf-8'));
70
+ if (info?.sha)
71
+ return `${base} (dev ${info.sha}${info.dirty ? ', modified' : ''})`;
72
+ }
73
+ catch {
74
+ // No build-info.json (published install or pre-build) → clean version.
75
+ }
76
+ return base;
77
+ }
60
78
  // Custom -v/--version output: include auth status so agents know whether
61
79
  // the next CLI call will succeed. Intercepted before Commander parses,
62
80
  // because Commander's built-in `.version()` only prints a string and exits.
@@ -79,7 +97,7 @@ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-
79
97
  authLine = `${success('Logged in')} as ${auth.email}`;
80
98
  }
81
99
  console.log('');
82
- console.log(`${brand(bold('Gipity'))} ${muted(`v${pkg.version}`)}`);
100
+ console.log(`${brand(bold('Gipity'))} ${muted(versionLabel())}`);
83
101
  console.log(authLine);
84
102
  console.log('');
85
103
  process.exit(0);
@@ -150,7 +168,7 @@ program.configureHelp({
150
168
  const padOpt = (s) => s.padEnd(optColWidth);
151
169
  const lines = [];
152
170
  lines.push('');
153
- lines.push(`${brand(bold('Gipity CLI'))} ${muted(`v${pkg.version}`)}`);
171
+ lines.push(`${brand(bold('Gipity CLI'))} ${muted(versionLabel())}`);
154
172
  lines.push(dim(GIPITY_TAGLINE));
155
173
  lines.push(dim('Hosting, databases, deploys, workflows - one place. Pair with Claude Code or use standalone.'));
156
174
  lines.push(dim('Works with Claude Code, Codex, Aider, or any AI coding tool - no MCP server needed.'));
package/dist/progress.js CHANGED
@@ -1,33 +1,57 @@
1
1
  // ── Gipity CLI Progress Reporter ────────────────────────────────────────
2
2
  // One central place for long-running terminal feedback, so commands don't
3
- // each reinvent status lines. Two channels:
3
+ // each reinvent status lines. Three channels, one shared visual vocabulary:
4
4
  //
5
5
  // phase(msg) - a discrete step with no measurable size
6
6
  // (scanning, hashing). Prints one committed line.
7
- // transfer(label, n, tot) - a determinate byte transfer. Renders a single
8
- // in-place bar that updates as bytes move.
7
+ // transfer(label, n, tot) - a DETERMINATE byte transfer. Renders a single
8
+ // in-place bar that fills as bytes move.
9
+ // spinner(label) - an INDETERMINATE wait (a server call where we
10
+ // can't measure bytes: deploy, generate, chat).
11
+ // Renders the SAME-width track as transfer(), but
12
+ // with a short block that bounces left↔right plus
13
+ // an elapsed timer, so a long wait never reads as
14
+ // frozen. Settles to a committed ✓/✗ line.
9
15
  //
10
16
  // On a non-TTY (piped output, hook-driven sync, headless -p) the reporter is a
11
17
  // silent no-op - no `\r` spam in logs. Colors come from ./colors so the bar
12
18
  // matches the rest of the CLI (orange fill + percentage).
13
- import { brand, brandBold, muted, dim } from './colors.js';
19
+ import { brand, brandBold, muted, dim, success, error as clrError } from './colors.js';
14
20
  import { formatSize } from './utils.js';
15
21
  const CLEAR_TO_EOL = '\x1b[K';
16
22
  const BAR_WIDTH = 18;
17
23
  const RENDER_THROTTLE_MS = 60;
24
+ // Indeterminate bounce: a short block sliding across the same BAR_WIDTH track
25
+ // the determinate bar fills. Frame cadence is slow enough to read, fast enough
26
+ // to feel alive.
27
+ const SPIN_BLOCK = 5;
28
+ const SPIN_FRAME_MS = 90;
29
+ /** Compact elapsed: "8s", "1m 04s". Keeps the timer narrow and glanceable. */
30
+ function formatElapsed(ms) {
31
+ const s = Math.floor(ms / 1000);
32
+ if (s < 60)
33
+ return `${s}s`;
34
+ const m = Math.floor(s / 60);
35
+ return `${m}m ${String(s % 60).padStart(2, '0')}s`;
36
+ }
18
37
  class TerminalProgress {
19
- /** True while an in-place transfer line is on screen and not yet committed. */
38
+ /** True while an in-place transfer/spinner line is on screen and not committed. */
20
39
  liveOpen = false;
21
40
  lastRenderAt = 0;
22
41
  /** The label of the current transfer session; a change starts a fresh one. */
23
42
  barLabel = null;
24
43
  /** True once the current session hit 100% - late/overshoot ticks are dropped. */
25
44
  barSettled = false;
45
+ /** Active indeterminate spinner timer, if any. */
46
+ spinTimer = null;
26
47
  phase(message) {
48
+ this.stopSpinTimer();
27
49
  this.commitLive();
28
50
  process.stdout.write(` ${muted(message)}\n`);
29
51
  }
30
52
  transfer(label, doneBytes, totalBytes) {
53
+ // A determinate transfer takes over the live line from any spinner.
54
+ this.stopSpinTimer();
31
55
  // A new label begins a fresh transfer session (e.g. downloads → uploads on
32
56
  // the same reporter). Within a session, once we've drawn the 100% frame we
33
57
  // drop any further ticks - download byte totals are estimated, so the wire
@@ -46,32 +70,115 @@ class TerminalProgress {
46
70
  return;
47
71
  this.lastRenderAt = now;
48
72
  this.liveOpen = true;
49
- process.stdout.write('\r' + this.frame(label, doneBytes, totalBytes) + CLEAR_TO_EOL);
73
+ process.stdout.write('\r' + this.barFrame(label, doneBytes, totalBytes) + CLEAR_TO_EOL);
50
74
  if (finished) {
51
75
  this.commitLive();
52
76
  this.barSettled = true;
53
77
  }
54
78
  }
79
+ spinner(label) {
80
+ this.stopSpinTimer();
81
+ this.commitLive();
82
+ const startedAt = Date.now();
83
+ let tick = 0;
84
+ const draw = () => {
85
+ this.liveOpen = true;
86
+ process.stdout.write('\r' + this.spinFrame(label, tick++, Date.now() - startedAt) + CLEAR_TO_EOL);
87
+ };
88
+ draw();
89
+ this.spinTimer = setInterval(draw, SPIN_FRAME_MS);
90
+ // Don't let the animation keep the event loop (and process) alive on its own.
91
+ this.spinTimer.unref?.();
92
+ const settle = (icon, message) => {
93
+ this.stopSpinTimer();
94
+ if (this.liveOpen)
95
+ process.stdout.write('\r');
96
+ if (icon) {
97
+ // Replace the spinner line in place with a committed ✓/✗ result line.
98
+ const elapsed = muted(formatElapsed(Date.now() - startedAt));
99
+ process.stdout.write(` ${icon} ${muted(message ?? label)} ${elapsed}${CLEAR_TO_EOL}\n`);
100
+ }
101
+ else if (this.liveOpen) {
102
+ // Silent stop: clear the spinner row but DON'T advance - leave the
103
+ // cursor at column 0 so the command's own result overwrites the row
104
+ // instead of leaving a blank line behind it.
105
+ process.stdout.write(CLEAR_TO_EOL);
106
+ }
107
+ this.liveOpen = false;
108
+ };
109
+ return {
110
+ succeed: (m) => settle(success('✓'), m),
111
+ fail: (m) => settle(clrError('✗'), m),
112
+ stop: () => settle(null),
113
+ };
114
+ }
55
115
  finish() {
116
+ this.stopSpinTimer();
56
117
  this.commitLive();
57
118
  }
119
+ stopSpinTimer() {
120
+ if (this.spinTimer) {
121
+ clearInterval(this.spinTimer);
122
+ this.spinTimer = null;
123
+ }
124
+ }
58
125
  commitLive() {
59
126
  if (!this.liveOpen)
60
127
  return;
61
128
  process.stdout.write('\n');
62
129
  this.liveOpen = false;
63
130
  }
64
- frame(label, done, total) {
131
+ barFrame(label, done, total) {
65
132
  const pct = total > 0 ? Math.min(100, Math.floor((done / total) * 100)) : 100;
66
133
  const filled = Math.round((pct / 100) * BAR_WIDTH);
67
134
  const bar = brand('█'.repeat(filled)) + dim('░'.repeat(BAR_WIDTH - filled));
68
135
  const sizes = muted(`${formatSize(done)} / ${formatSize(total)}`);
69
136
  return ` ${muted(label)} ${bar} ${brandBold(`${pct}%`)} ${sizes}`;
70
137
  }
138
+ spinFrame(label, tick, elapsedMs) {
139
+ // Ping-pong the block's left edge between 0 and (BAR_WIDTH - SPIN_BLOCK).
140
+ const span = BAR_WIDTH - SPIN_BLOCK;
141
+ const cycle = span * 2;
142
+ const phase = tick % cycle;
143
+ const pos = phase <= span ? phase : cycle - phase;
144
+ const bar = dim('░'.repeat(pos)) +
145
+ brand('█'.repeat(SPIN_BLOCK)) +
146
+ dim('░'.repeat(BAR_WIDTH - pos - SPIN_BLOCK));
147
+ return ` ${muted(label)} ${bar} ${muted(formatElapsed(elapsedMs))}`;
148
+ }
71
149
  }
72
- const NOOP = { phase() { }, transfer() { }, finish() { } };
150
+ const NOOP_SPINNER = { succeed() { }, fail() { }, stop() { } };
151
+ const NOOP = {
152
+ phase() { }, transfer() { }, finish() { },
153
+ spinner: () => NOOP_SPINNER,
154
+ };
73
155
  /** A reporter that draws on a TTY and stays silent otherwise. */
74
156
  export function createProgressReporter() {
75
157
  return process.stdout.isTTY ? new TerminalProgress() : NOOP;
76
158
  }
159
+ /**
160
+ * Run an indeterminate async operation behind the standard bouncing-block
161
+ * spinner: animate `label` while it's in flight, then settle. On success the
162
+ * line becomes a ✓ (`done`, or `label` if omitted) — or, when `done` is null,
163
+ * the spinner just clears silently (use this when the command prints its own
164
+ * result, e.g. a chat reply). On throw it becomes a ✗ and re-throws so the
165
+ * caller's own error handling still runs. On a non-TTY this is a silent
166
+ * pass-through. The single wrapper every command uses for a server call whose
167
+ * size/duration we can't measure (deploy, generate, chat, sandbox, …).
168
+ */
169
+ export async function withSpinner(label, fn, opts = {}) {
170
+ const sp = (opts.reporter ?? createProgressReporter()).spinner(label);
171
+ try {
172
+ const result = await fn();
173
+ if (opts.done === null)
174
+ sp.stop();
175
+ else
176
+ sp.succeed(opts.done);
177
+ return result;
178
+ }
179
+ catch (e) {
180
+ sp.fail();
181
+ throw e;
182
+ }
183
+ }
77
184
  //# sourceMappingURL=progress.js.map
@@ -632,6 +632,12 @@ async function handleDispatch(d) {
632
632
  // prompt is correct (same authority as running `claude -p` in a local
633
633
  // terminal yourself).
634
634
  const args = ['claude', '-p', d.message, '--permission-mode', 'bypassPermissions'];
635
+ // Per-chat model: the user picked it with `/model` in the web CLI. `gipity
636
+ // claude` forwards --model straight through to the `claude` binary, which
637
+ // honors it on both a fresh session and a --resume. null => agent default.
638
+ if (d.model) {
639
+ args.push('--model', d.model);
640
+ }
635
641
  if (d.kind === 'resume' && d.remote_session_id) {
636
642
  args.push('--resume', d.remote_session_id);
637
643
  }
package/dist/upload.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createReadStream } from 'fs';
2
+ import { Transform } from 'stream';
2
3
  import { extname } from 'path';
3
4
  import { createHash } from 'crypto';
4
5
  import { post, putToPresignedUrl, ApiError } from './api.js';
@@ -170,11 +171,35 @@ export async function uploadOneFile(projectGuid, localPath, virtualPath, opts =
170
171
  export async function transferToS3(localPath, size, mime, data, opts = {}) {
171
172
  // Single-part (covers fresh + resumed PUT - single PUT is idempotent on the staging key).
172
173
  if (data.method === 'PUT') {
174
+ // Report bytes as they flow to S3 so the progress bar moves during the
175
+ // transfer instead of sitting at 0% until the whole file lands (a large
176
+ // file - e.g. a 674 MB video - otherwise looks hung for minutes). A
177
+ // pass-through Transform counts each chunk as fetch pulls it; we don't
178
+ // attach a 'data' listener, which would drain the stream before fetch
179
+ // reads it.
180
+ let reported = 0;
173
181
  const etag = await withRetry('PUT', async () => {
182
+ // A retried attempt re-streams from the top, so back out whatever the
183
+ // failed attempt already counted to keep the shared total accurate.
184
+ if (reported) {
185
+ opts.onBytes?.(-reported);
186
+ reported = 0;
187
+ }
174
188
  const stream = createReadStream(localPath);
175
- return putToPresignedUrl(data.url, stream, size, data.headers?.['Content-Type'] ?? mime);
189
+ let body = stream;
190
+ if (opts.onBytes) {
191
+ const counter = new Transform({
192
+ transform(chunk, _enc, cb) { reported += chunk.length; opts.onBytes(chunk.length); cb(null, chunk); },
193
+ });
194
+ stream.on('error', err => counter.destroy(err));
195
+ body = stream.pipe(counter);
196
+ }
197
+ return putToPresignedUrl(data.url, body, size, data.headers?.['Content-Type'] ?? mime);
176
198
  });
177
- opts.onBytes?.(size);
199
+ // True up to exactly `size`: a dedup/no-op PUT streams nothing, and chunk
200
+ // sums can fall a hair short of the stat size on the final read.
201
+ if (reported !== size)
202
+ opts.onBytes?.(size - reported);
178
203
  return { etag };
179
204
  }
180
205
  // Multipart - start with any parts that already landed (resume case).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.398",
3
+ "version": "1.0.399",
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",
@@ -10,6 +10,7 @@
10
10
  "type": "module",
11
11
  "scripts": {
12
12
  "build": "tsc && chmod +x dist/index.js dist/gipcc.js dist/gipccd.js dist/updater/shim.js dist/updater/check.js",
13
+ "postbuild": "node scripts/gen-build-info.mjs",
13
14
  "dev": "tsc --watch",
14
15
  "test": "npm run test:smoke",
15
16
  "test:smoke": "tsc && node --test dist/__tests__/utils.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__/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__/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-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-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",