gipity 1.0.396 → 1.0.398

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/adopt-cwd.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * `.gipity.json`, sync, and install hooks/skills/gitignore.
12
12
  */
13
13
  import { readdirSync, statSync } from 'fs';
14
- import { join, resolve, sep } from 'path';
14
+ import { join, resolve, sep, parse as parsePath } from 'path';
15
15
  import { homedir } from 'os';
16
16
  import { get, post, ApiError } from './api.js';
17
17
  import { isSyncIgnored } from './setup.js';
@@ -103,6 +103,15 @@ export function canAdoptCwd(cwd) {
103
103
  '/tmp', '/var', '/etc', '/usr', '/opt',
104
104
  '/mnt', '/mnt/c', '/mnt/d', '/mnt/wsl',
105
105
  ]);
106
+ // Filesystem root (POSIX `/` already covered by `sep`, but on Windows this
107
+ // catches drive roots like `C:\`). Plus the common Windows system dirs.
108
+ if (norm === parsePath(norm).root)
109
+ return false;
110
+ for (const envVar of ['windir', 'SystemRoot', 'ProgramFiles', 'ProgramFiles(x86)']) {
111
+ const v = process.env[envVar];
112
+ if (v && norm === resolve(v))
113
+ return false;
114
+ }
106
115
  if (blocked.has(norm))
107
116
  return false;
108
117
  // Workspace-parent heuristic: a directory at depth ≤1 below $HOME that
package/dist/api.js CHANGED
@@ -2,6 +2,7 @@ import { Readable } from 'stream';
2
2
  import * as tar from 'tar-stream';
3
3
  import { getAuth, refreshTokenIfNeeded } from './auth.js';
4
4
  import { resolveApiBase, requireConfig, saveConfig } from './config.js';
5
+ import { clientHeaders } from './client-context.js';
5
6
  export class ApiError extends Error {
6
7
  statusCode;
7
8
  code;
@@ -32,6 +33,7 @@ async function bearerToken() {
32
33
  }
33
34
  async function getHeaders() {
34
35
  return {
36
+ ...clientHeaders(),
35
37
  'Authorization': `Bearer ${await bearerToken()}`,
36
38
  'Content-Type': 'application/json',
37
39
  };
@@ -137,7 +139,7 @@ export async function sendMessage(message) {
137
139
  export async function download(path) {
138
140
  const url = `${baseUrl()}${path}`;
139
141
  const res = await fetch(url, {
140
- headers: { 'Authorization': `Bearer ${await bearerToken()}` },
142
+ headers: { ...clientHeaders(), 'Authorization': `Bearer ${await bearerToken()}` },
141
143
  });
142
144
  if (!res.ok) {
143
145
  throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
@@ -149,7 +151,7 @@ export async function downloadStream(path) {
149
151
  const { Readable } = await import('stream');
150
152
  const url = `${baseUrl()}${path}`;
151
153
  const res = await fetch(url, {
152
- headers: { 'Authorization': `Bearer ${await bearerToken()}` },
154
+ headers: { ...clientHeaders(), 'Authorization': `Bearer ${await bearerToken()}` },
153
155
  });
154
156
  if (!res.ok) {
155
157
  throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
@@ -199,7 +201,7 @@ export async function publicPost(path, body) {
199
201
  const url = `${baseUrl()}${path}`;
200
202
  const res = await fetch(url, {
201
203
  method: 'POST',
202
- headers: { 'Content-Type': 'application/json' },
204
+ headers: { ...clientHeaders(), 'Content-Type': 'application/json' },
203
205
  body: JSON.stringify(body),
204
206
  });
205
207
  if (!res.ok) {
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from 'fs';
2
+ import { resolve, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ function cliVersion() {
5
+ try {
6
+ const dir = dirname(fileURLToPath(import.meta.url));
7
+ const pkg = JSON.parse(readFileSync(resolve(dir, '../package.json'), 'utf-8'));
8
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
9
+ }
10
+ catch {
11
+ return 'unknown';
12
+ }
13
+ }
14
+ function hasEnvPrefix(prefix) {
15
+ return Object.keys(process.env).some((k) => k.startsWith(prefix));
16
+ }
17
+ function isCi() {
18
+ const ci = (process.env.CI ?? '').toLowerCase();
19
+ return ci === '1' || ci === 'true' || !!process.env.GITHUB_ACTIONS;
20
+ }
21
+ /** Identify the coding harness from its env footprint. Order = specificity. Exported for tests. */
22
+ export function detectHarness() {
23
+ // Claude Code is the most reliable: CLAUDECODE=1 plus a versioned exec path.
24
+ if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_ENTRYPOINT) {
25
+ const version = process.env.CLAUDE_CODE_EXECPATH?.match(/versions\/([^/\\]+)/)?.[1];
26
+ return { harness: 'claude-code', harnessVersion: version, harnessSession: process.env.CLAUDE_CODE_SESSION_ID };
27
+ }
28
+ if (hasEnvPrefix('CODEX_'))
29
+ return { harness: 'codex' };
30
+ if (process.env.CURSOR_TRACE_ID || hasEnvPrefix('CURSOR_') || (process.env.TERM_PROGRAM ?? '').toLowerCase().includes('cursor')) {
31
+ return { harness: 'cursor' };
32
+ }
33
+ if (hasEnvPrefix('AIDER_'))
34
+ return { harness: 'aider' };
35
+ if (process.env.GEMINI_CLI)
36
+ return { harness: 'gemini' };
37
+ if (isCi())
38
+ return { harness: 'ci' };
39
+ return { harness: 'manual' };
40
+ }
41
+ let cached;
42
+ export function clientContext() {
43
+ if (cached)
44
+ return cached;
45
+ cached = {
46
+ cli: cliVersion(),
47
+ ...detectHarness(),
48
+ node: process.versions.node,
49
+ os: process.platform,
50
+ arch: process.arch,
51
+ ci: isCi() || undefined,
52
+ };
53
+ return cached;
54
+ }
55
+ /** Headers to merge into every API request so the server can attribute the call. */
56
+ export function clientHeaders() {
57
+ const ctx = clientContext();
58
+ return {
59
+ 'User-Agent': `gipity-cli/${ctx.cli} (${ctx.os}; ${ctx.arch}; harness=${ctx.harness})`,
60
+ 'X-Gipity-Client': JSON.stringify(ctx),
61
+ };
62
+ }
63
+ //# sourceMappingURL=client-context.js.map
@@ -4,19 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdir
4
4
  import { execSync, spawn } from 'child_process';
5
5
  import { homedir } from 'os';
6
6
  import { fileURLToPath } from 'url';
7
- /** On Windows, spawn without shell:true needs an explicit extension (.exe or .cmd) */
8
- function resolveCommand(cmd) {
9
- if (process.platform !== 'win32')
10
- return cmd;
11
- try {
12
- const lines = execSync(`where ${cmd}`, { encoding: 'utf-8' }).split(/\r?\n/).map(l => l.trim()).filter(Boolean);
13
- // Prefer .exe (native) over .cmd (npm shim)
14
- return lines.find(l => l.endsWith('.exe')) || lines.find(l => l.endsWith('.cmd')) || cmd;
15
- }
16
- catch {
17
- return `${cmd}.cmd`;
18
- }
19
- }
7
+ import { resolveCommand } from '../platform.js';
20
8
  import { getAuth, saveAuth } from '../auth.js';
21
9
  import { get, post, publicPost, ApiError, getAccountSlug } from '../api.js';
22
10
  import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
@@ -26,12 +14,35 @@ import { buildProjectContextBlock as buildProjectContextBlockText, buildNewProje
26
14
  import * as relayState from '../relay/state.js';
27
15
  import { maybeOfferRelayOn, ensureDaemonRunning } from '../relay/onboarding.js';
28
16
  import { prompt, pickOne, decodeJwtExp, confirm } from '../utils.js';
29
- import { brand, bold, info, success, error as clrError, muted } from '../colors.js';
17
+ import { brand, bold, info, success, warning, error as clrError, muted } from '../colors.js';
30
18
  import { createProgressReporter } from '../progress.js';
31
19
  import { printBanner } from '../banner.js';
32
20
  import { scanForAdoption, isLikelyEmpty, canAdoptCwd, formatCwdLabel, formatBytes, adoptCurrentDir, ADOPT_THRESHOLDS, } from '../adopt-cwd.js';
33
21
  const __clDir = dirname(fileURLToPath(import.meta.url));
34
22
  const __clPkg = JSON.parse(readFileSync(resolve(__clDir, '../../package.json'), 'utf-8'));
23
+ /** Report a sync run to the user. Beyond the applied-changes line, this SURFACES
24
+ * sync.errors - a download that came back incomplete (truncated bulk tar, a file
25
+ * that couldn't be refetched) lands here, so we never print "ready" over a
26
+ * half-synced project as if nothing went wrong. Deletions are disarmed on an
27
+ * incomplete pull, so we can tell the user plainly that nothing was deleted. */
28
+ function reportSyncResult(result) {
29
+ if (result.aborted) {
30
+ console.log(` ${warning('Merge cancelled — this folder was NOT synced with the project.')}`);
31
+ console.log(` ${muted('For a clean copy, quit and open the project in an empty folder. To merge anyway, run `gipity sync`.')}`);
32
+ return;
33
+ }
34
+ if (result.applied > 0) {
35
+ console.log(` Synced ${result.applied} change${result.applied > 1 ? 's' : ''} with Gipity.`);
36
+ }
37
+ if (result.errors.length) {
38
+ console.log(` ${warning(`Sync finished with ${result.errors.length} problem${result.errors.length === 1 ? '' : 's'} — your local copy may be incomplete:`)}`);
39
+ for (const e of result.errors.slice(0, 8))
40
+ console.log(` - ${e}`);
41
+ if (result.errors.length > 8)
42
+ console.log(` …and ${result.errors.length - 8} more.`);
43
+ console.log(` ${muted('Nothing was deleted. Re-run `gipity sync` to finish the pull.')}`);
44
+ }
45
+ }
35
46
  import { getProjectsRoot } from '../relay/paths.js';
36
47
  /** Ask the server for recursive VFS counts. Server owns the file metadata
37
48
  * (counts, bytes, paths), so a single aggregate query beats walking the
@@ -395,13 +406,11 @@ export const claudeCommand = new Command('claude')
395
406
  });
396
407
  console.log(`\n Using ${projectDir}`);
397
408
  try {
398
- const result = await sync({ interactive: !nonInteractive, progress: syncProgress });
399
- if (result.applied > 0) {
400
- console.log(` Synced ${result.applied} change${result.applied > 1 ? 's' : ''} with Gipity.`);
401
- }
409
+ const result = await sync({ interactive: !nonInteractive, confirmMerge: !nonInteractive, progress: syncProgress });
410
+ reportSyncResult(result);
402
411
  }
403
- catch {
404
- console.log(' Could not sync files (will retry on next prompt).');
412
+ catch (err) {
413
+ console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
405
414
  }
406
415
  setupClaudeHooks();
407
416
  setupClaudeMd();
@@ -445,13 +454,11 @@ export const claudeCommand = new Command('claude')
445
454
  }
446
455
  if (doSync) {
447
456
  try {
448
- const result = await sync({ interactive: !nonInteractive, progress: syncProgress });
449
- if (result.applied > 0) {
450
- console.log(` Synced ${result.applied} change${result.applied > 1 ? 's' : ''} with Gipity.`);
451
- }
457
+ const result = await sync({ interactive: !nonInteractive, confirmMerge: !nonInteractive, progress: syncProgress });
458
+ reportSyncResult(result);
452
459
  }
453
- catch {
454
- console.log(' Could not sync files (will retry on next prompt).');
460
+ catch (err) {
461
+ console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
455
462
  }
456
463
  }
457
464
  // Interactive: no seeded prompt - CLAUDE.md carries the context now.
@@ -570,13 +577,11 @@ export const claudeCommand = new Command('claude')
570
577
  console.log(`\n Using ${projectDir}`);
571
578
  // Unified sync - push and pull resolved via three-way merge (non-fatal)
572
579
  try {
573
- const result = await sync({ interactive: !nonInteractive, progress: syncProgress });
574
- if (result.applied > 0) {
575
- console.log(` Synced ${result.applied} change${result.applied > 1 ? 's' : ''} with Gipity.`);
576
- }
580
+ const result = await sync({ interactive: !nonInteractive, confirmMerge: !nonInteractive, progress: syncProgress });
581
+ reportSyncResult(result);
577
582
  }
578
- catch {
579
- console.log(' Could not sync files (will retry on next prompt).');
583
+ catch (err) {
584
+ console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
580
585
  }
581
586
  }
582
587
  // Interactive launch: no seeded first message (new or existing). The
@@ -1,15 +1,18 @@
1
1
  import { Command } from 'commander';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { get } from '../api.js';
4
- import { brand, muted } from '../colors.js';
4
+ import { brand, muted, success, error as clrError } from '../colors.js';
5
5
  import { run, printList } from '../helpers/index.js';
6
6
  const PRICING_URL = 'https://prompt.gipity.ai/pricing';
7
7
  function openInBrowser(url) {
8
- const cmd = process.platform === 'darwin' ? 'open' :
9
- process.platform === 'win32' ? 'start' :
10
- 'xdg-open';
8
+ // Windows `start` is a cmd.exe builtin, not an executable - spawn can't
9
+ // launch it directly, so go through `cmd /c start`. The empty "" is the
10
+ // window-title arg; without it a quoted URL would be swallowed as the title.
11
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] :
12
+ process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] :
13
+ ['xdg-open', [url]];
11
14
  try {
12
- spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref();
15
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
13
16
  }
14
17
  catch {
15
18
  // Spawn failed (no xdg-open on minimal Linux, etc.) - caller prints the URL.
@@ -19,17 +22,23 @@ export const creditsCommand = new Command('credits')
19
22
  .description('View credits')
20
23
  .option('--json', 'Output as JSON')
21
24
  .action((opts) => run('Credits', async () => {
22
- const res = await get('/credits/balance');
23
25
  if (opts.json) {
26
+ const res = await get('/credits/balance');
24
27
  console.log(JSON.stringify(res.data));
28
+ return;
25
29
  }
26
- else {
27
- console.log(`Credits: ${brand(res.data.available.toLocaleString())}`);
28
- if (res.data.balances.length > 0) {
29
- for (const b of res.data.balances) {
30
- const exp = new Date(b.expiresAt).toLocaleDateString();
31
- console.log(`${b.source}: ${b.creditsRemaining.toLocaleString()} ${muted(`expires ${exp}`)}`);
32
- }
30
+ const [balRes, subRes] = await Promise.all([
31
+ get('/credits/balance'),
32
+ get('/credits/subscription'),
33
+ ]);
34
+ const sub = subRes.data;
35
+ const tierLabel = sub.tier === 'pro' ? 'Gipity Pro' : 'Free';
36
+ console.log(`Plan: ${brand(tierLabel)} ${muted(`(${sub.status})`)}`);
37
+ console.log(`Credits: ${brand(balRes.data.available.toLocaleString())}`);
38
+ if (balRes.data.balances.length > 0) {
39
+ for (const b of balRes.data.balances) {
40
+ const exp = new Date(b.expiresAt).toLocaleDateString();
41
+ console.log(`${b.source}: ${b.creditsRemaining.toLocaleString()} ${muted(`expires ${exp}`)}`);
33
42
  }
34
43
  }
35
44
  }));
@@ -41,12 +50,42 @@ creditsCommand
41
50
  openInBrowser(PRICING_URL);
42
51
  console.log(muted("If your browser didn't open automatically, copy the URL above."));
43
52
  }));
53
+ creditsCommand
54
+ .command('status')
55
+ .description('Show billing/Stripe configuration health (admin only)')
56
+ .option('--json', 'Output as JSON')
57
+ // optsWithGlobals(): the parent `credits` command also declares --json, which
58
+ // commander binds to the parent - so read merged opts to see the flag here.
59
+ .action((_opts, cmd) => run('Billing status', async () => {
60
+ const opts = cmd.optsWithGlobals();
61
+ const res = await get('/admin/billing-status');
62
+ const s = res.data;
63
+ if (opts.json) {
64
+ console.log(JSON.stringify(s));
65
+ return;
66
+ }
67
+ const mark = (ok) => (ok ? success('✓') : clrError('✗'));
68
+ console.log(`Stripe mode: ${brand(s.mode)}`);
69
+ console.log(`Secret key: ${mark(s.secretKeyConfigured)}`);
70
+ console.log(`Webhook secret: ${mark(s.webhookSecretConfigured)} ${muted(`endpoint ${s.webhookPath}`)}`);
71
+ console.log(`Credit packs: ${mark(s.packsConfigured)}${s.packsError ? ` ${clrError(s.packsError)}` : ''}`);
72
+ for (const p of s.packs) {
73
+ console.log(` ${p.name} ${muted(p.priceId)}${p.hidden ? muted(' [hidden]') : ''}`);
74
+ }
75
+ console.log(`Test pack ($1): ${mark(s.testPackConfigured)} ${muted('set STRIPE_PACK_TEST_PRICE_ID to enable a cheap purchase smoke test')}`);
76
+ console.log('Subscription plans:');
77
+ for (const pl of s.subscriptionPlans) {
78
+ console.log(` ${pl.name} (${pl.tier}) ${mark(pl.priceIdConfigured)}`);
79
+ }
80
+ }));
44
81
  creditsCommand
45
82
  .command('usage')
46
83
  .description('Show recent usage')
47
84
  .option('--limit <n>', 'Number of entries', '20')
48
85
  .option('--json', 'Output as JSON')
49
- .action((opts) => run('Usage', async () => {
86
+ // optsWithGlobals(): parent `credits` also declares --json (see status above).
87
+ .action((_opts, cmd) => run('Usage', async () => {
88
+ const opts = cmd.optsWithGlobals();
50
89
  const limit = parseInt(opts.limit, 10) || 20;
51
90
  const res = await get(`/credits/usage?limit=${limit}`);
52
91
  printList(res.data, opts, 'No usage history.', u => {
@@ -5,6 +5,7 @@ import { pushFile } from '../sync.js';
5
5
  import { writeFileSync } from 'fs';
6
6
  import { resolve as resolvePath, dirname, relative, isAbsolute } from 'path';
7
7
  import { error as clrError, success, muted, info } from '../colors.js';
8
+ import { printCommandError } from '../helpers/command.js';
8
9
  import { IMAGE_MODELS_DOC, IMAGE_GEMINI_ASPECT_RATIOS, IMAGE_GEMINI_SIZES, VIDEO_MODELS_DOC, TTS_PROVIDER_DESCRIPTIONS } from '../provider-docs.js';
9
10
  /** Download a URL and save to a local file, then push it up to the project so
10
11
  * the cloud (and anything that mirrors it) immediately matches local disk.
@@ -98,7 +99,7 @@ Examples:
98
99
  }
99
100
  }
100
101
  catch (err) {
101
- console.error(clrError(`Image generation failed: ${err.message}`));
102
+ printCommandError('Image generation', err);
102
103
  process.exit(1);
103
104
  }
104
105
  });
@@ -150,7 +151,7 @@ Examples:
150
151
  }
151
152
  }
152
153
  catch (err) {
153
- console.error(clrError(`Video generation failed: ${err.message}`));
154
+ printCommandError('Video generation', err);
154
155
  process.exit(1);
155
156
  }
156
157
  });
@@ -209,7 +210,7 @@ Examples:
209
210
  }
210
211
  }
211
212
  catch (err) {
212
- console.error(clrError(`Speech generation failed: ${err.message}`));
213
+ printCommandError('Speech generation', err);
213
214
  process.exit(1);
214
215
  }
215
216
  });
@@ -259,7 +260,7 @@ Examples:
259
260
  }
260
261
  }
261
262
  catch (err) {
262
- console.error(clrError(`Music generation failed: ${err.message}`));
263
+ printCommandError('Music generation', err);
263
264
  process.exit(1);
264
265
  }
265
266
  });
@@ -1,17 +1,22 @@
1
1
  import { Command } from 'commander';
2
2
  import { sync } from '../sync.js';
3
3
  import { createProgressReporter } from '../progress.js';
4
- import { error as clrError } from '../colors.js';
4
+ import { error as clrError, muted } from '../colors.js';
5
5
  export const syncCommand = new Command('sync')
6
6
  .description('Sync files (a .gipityignore at the project root excludes paths, gitignore-style)')
7
7
  .option('--plan', 'Print the plan without applying any changes')
8
8
  .option('--force', 'Bypass the bulk-deletion guard')
9
+ .option('--prune', 'Remove files that exist on Gipity but not locally (applies the bulk deletes the guard defers)')
9
10
  .option('--json', 'Output as JSON')
10
11
  .action(async (opts) => {
11
12
  try {
12
13
  // JSON mode stays machine-clean; otherwise show live progress on a TTY.
13
14
  const progress = opts.json ? undefined : createProgressReporter();
14
- const result = await sync({ plan: opts.plan, force: opts.force, progress });
15
+ // confirmMerge arms the uncertain-merge guard: a first-time sync into a
16
+ // folder that has its own files prompts on a TTY, proceeds with --yes, and
17
+ // fail-safe ABORTS when non-interactive without --yes - so a script never
18
+ // merges an unexpected folder into the project silently.
19
+ const result = await sync({ plan: opts.plan, force: opts.force, prune: opts.prune, confirmMerge: true, progress });
15
20
  if (opts.json) {
16
21
  console.log(JSON.stringify(result));
17
22
  }
@@ -20,13 +25,21 @@ export const syncCommand = new Command('sync')
20
25
  if (!opts.plan && result.applied > 0) {
21
26
  console.log(`\n${result.applied} action${result.applied > 1 ? 's' : ''} applied.`);
22
27
  }
23
- if (result.skipped > 0) {
24
- console.error(clrError(`${result.skipped} action${result.skipped > 1 ? 's' : ''} skipped by guard.`));
28
+ // The guard defers bulk server-side deletes (files on Gipity but not
29
+ // local). Surface that here as a helpful hint - not a red error - with
30
+ // the one command that resolves it. Only `gipity sync` shows this;
31
+ // deploy/test/sandbox stay silent (their internal sync defers quietly).
32
+ if (result.deferredDeletes > 0) {
33
+ console.log(muted(`\n${result.deferredDeletes} file${result.deferredDeletes > 1 ? 's' : ''} on Gipity ` +
34
+ `${result.deferredDeletes > 1 ? 'are' : 'is'} not present locally and ${result.deferredDeletes > 1 ? 'were' : 'was'} left untouched. ` +
35
+ `Run 'gipity sync --prune' to remove ${result.deferredDeletes > 1 ? 'them' : 'it'}.`));
25
36
  }
26
37
  for (const e of result.errors)
27
38
  console.error(clrError(e));
28
39
  }
29
- if (result.errors.length > 0)
40
+ // A refused merge or any sync error is a non-zero exit so scripts/CI can
41
+ // detect that nothing was applied.
42
+ if (result.aborted || result.errors.length > 0)
30
43
  process.exit(1);
31
44
  }
32
45
  catch (err) {
@@ -10,9 +10,24 @@ import { error as clrError } from '../colors.js';
10
10
  * Usage:
11
11
  * .action((name, opts) => run('Create', async () => { ... }))
12
12
  */
13
+ /**
14
+ * Print a command error. Out-of-credits is a recoverable, user-actionable
15
+ * state, so flag it plainly (and keep the server's message, which carries the
16
+ * buy link) - that way an agent reading this output, e.g. Claude Code, can tell
17
+ * the user they're out of Gipity credits and where to top up rather than
18
+ * treating it as a hard failure. Does not exit; the caller decides.
19
+ */
20
+ export function printCommandError(label, err) {
21
+ if (err?.code === 'INSUFFICIENT_CREDITS') {
22
+ console.error(clrError(`${label} failed - out of Gipity credits.`));
23
+ console.error(err.message);
24
+ return;
25
+ }
26
+ console.error(clrError(`${label} failed: ${err.message}`));
27
+ }
13
28
  export function run(label, action) {
14
29
  action().catch((err) => {
15
- console.error(clrError(`${label} failed: ${err.message}`));
30
+ printCommandError(label, err);
16
31
  process.exit(1);
17
32
  });
18
33
  }
package/dist/knowledge.js CHANGED
@@ -45,7 +45,7 @@ Gipity is the cloud platform your project runs on - hosting, databases, deployme
45
45
 
46
46
  Prefer the cheapest option that works - CLI and sandbox are instant and free, app services are runtime HTTP calls, \`gipity chat\` burns LLM tokens:
47
47
 
48
- 1. CLI commands (fast, no agent overhead). The \`gipity\` CLI covers add, deploy, db, fn, logs, browser, sync, memory, skill, and more. All commands support \`--json\`.
48
+ 1. CLI commands (fast, no agent overhead). The \`gipity\` CLI covers add, deploy, db, fn, logs, browser, sync, memory, skill, email, and more. All commands support \`--json\`. You can send email yourself - \`gipity email send\` goes out as the agent from \`gipity@gipity.ai\` with no setup or API keys (\`gipity skill read email\`); don't build a \`mailto:\` workaround or reach for an SMTP library.
49
49
  2. Cloud sandbox via \`gipity sandbox run\` - Docker container with pre-installed tools for media (ffmpeg, ImageMagick, sox), documents (pandoc, LibreOffice), and data (pandas, matplotlib, sqlite3). Run \`gipity skill read sandbox-tools\` for the full toolkit. No network from inside the sandbox - fetch what you need before sending it in.
50
50
  3. App services - runtime HTTP endpoints your deployed app calls directly at \`https://a.gipity.ai/api/<PROJECT_GUID>/services/*\`. Available: LLM, TTS, image, sound, music, transcribe, video, file upload, realtime, location. Load the matching skill (\`app-llm\`, \`app-tts\`, etc.) before writing service code - they have the schemas, auth pattern, and common-mistake guards. For one-off generation during development, prefer \`gipity generate <image|video|speech|music>\` or \`gipity chat\`. \`gipity generate\` saves to a generic file in the current directory by default (e.g. \`./generated.png\`) - pass \`-o <path>\` to write it straight into your source tree so it deploys (e.g. \`gipity generate image "hero banner" -o src/assets/images/hero.png\`) instead of generating at cwd and moving it.
51
51
  4. Delegate to Gip (\`gipity chat "<task>"\`) - only when the work genuinely needs agent reasoning or a tool not in the CLI, sandbox, or app services. Required for: Twitter/X search, Gmail, calendar, push notifications, video understanding, audio source isolation, cross-model second opinions, multi-step orchestration. Don't use \`gipity chat\` for anything the sandbox can do - it's slower and burns tokens.
@@ -54,7 +54,7 @@ You are the developer. Write files in this directory - the Gipity Claude Code pl
54
54
 
55
55
  ## Use first-party services before reaching outside
56
56
 
57
- Gipity ships first-party services for what apps usually pull from third parties - auth, location/geocoding, LLM, image/audio/video generation, transcription, file uploads, realtime. Before calling an external API or adding an npm package for one of these, check \`gipity skill list\` for a match. First-party services need no API keys, cost less, and keep data in-house. Reach outside only when the catalog has no equivalent - and say so when you do.
57
+ Gipity ships first-party services for what apps usually pull from third parties - auth, location/geocoding, LLM, image/audio/video generation, transcription, file uploads, realtime, and email (send as the agent via \`gipity email send\`, or from a deployed app via a workflow \`notify\` step - no SMTP/SendGrid/Nodemailer). Before calling an external API or adding an npm package for one of these, check \`gipity skill list\` for a match. First-party services need no API keys, cost less, and keep data in-house. Reach outside only when the catalog has no equivalent - and say so when you do.
58
58
 
59
59
  ## Don't guess Gipity facts - look them up
60
60
 
@@ -98,7 +98,7 @@ mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <s
98
98
 
99
99
  ## CLI quick reference
100
100
 
101
- 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 skill read <name>\`.
101
+ 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 email send --to <addr> --subject <s> --body <b>\` (sends as \`gipity@gipity.ai\`; omit \`--to\` to self-send), \`gipity skill read <name>\`.
102
102
  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").
103
103
  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.
104
104
  Run \`gipity --help\` for the full list. Use \`--help\` on any command for details.
@@ -149,6 +149,7 @@ Kit skills (reusable building blocks - \`gipity add <kit>\`):
149
149
  - \`chatbot\` - the chatbot kit: persona + scope guardrails + static knowledge, bubble widget or headless engine
150
150
 
151
151
  Other key skills:
152
+ - \`email\` - sending email as the agent from gipity@gipity.ai (no setup/keys) — plus Gmail-thread replies, HTML formatting, images
152
153
  - \`sandbox-tools\` - cloud sandbox capabilities and pre-installed tools
153
154
  - \`tts\` - agent-side speech tools (different from the \`app-tts\` HTTP service)`;
154
155
  export const DEFINITION_OF_DONE = `## Definition of done (build tasks)
@@ -0,0 +1,26 @@
1
+ import { execSync } from 'child_process';
2
+ /**
3
+ * Resolve a command to something Node's spawn/spawnSync can launch directly.
4
+ *
5
+ * On Windows, npm-installed CLIs (npm, npx, gipity, claude) are `.cmd` batch
6
+ * shims, not `.exe`s. Node's spawn without `shell: true` cannot launch a bare
7
+ * `npm` - it fails with ENOENT and a null exit status. Resolving the real path
8
+ * (preferring `.exe`, falling back to `.cmd`) lets us spawn without `shell: true`
9
+ * (which would otherwise require escaping arguments).
10
+ */
11
+ export function resolveCommand(cmd) {
12
+ if (process.platform !== 'win32')
13
+ return cmd;
14
+ try {
15
+ const lines = execSync(`where ${cmd}`, { encoding: 'utf-8' })
16
+ .split(/\r?\n/)
17
+ .map(l => l.trim())
18
+ .filter(Boolean);
19
+ // Prefer .exe (native) over .cmd (npm shim)
20
+ return lines.find(l => l.endsWith('.exe')) || lines.find(l => l.endsWith('.cmd')) || cmd;
21
+ }
22
+ catch {
23
+ return `${cmd}.cmd`;
24
+ }
25
+ }
26
+ //# sourceMappingURL=platform.js.map
@@ -21,6 +21,7 @@
21
21
  * See docs/feature-backlog/gipity-relay-phases.md (Phase A Step 7).
22
22
  */
23
23
  import { spawn } from 'child_process';
24
+ import { resolveCommand } from '../platform.js';
24
25
  import { appendFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, closeSync, openSync, unlinkSync } from 'fs';
25
26
  import { stat, readFile } from 'fs/promises';
26
27
  import { createInterface } from 'readline';
@@ -855,7 +856,10 @@ export async function killRunningForConv(convGuid) {
855
856
  * resolution (the daemon itself doesn't chdir into projects).
856
857
  * Non-blocking on failure - caller catches and logs. */
857
858
  async function spawnSync(cwd, timeoutMs) {
858
- const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || 'gipity';
859
+ // resolveCommand: on Windows the bare `gipity` is a .cmd shim that spawn
860
+ // can't launch without an explicit path. An explicit env override is used
861
+ // verbatim (it may be a full path); only the default name is resolved.
862
+ const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || resolveCommand('gipity');
859
863
  return new Promise((resolve, reject) => {
860
864
  const child = spawn(cmd, ['sync', '--json'], {
861
865
  cwd,
@@ -901,7 +905,10 @@ async function spawnSync(cwd, timeoutMs) {
901
905
  });
902
906
  }
903
907
  export async function spawnGipityClaude(args, cwd, d) {
904
- const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || 'gipity';
908
+ // resolveCommand: on Windows the bare `gipity` is a .cmd shim that spawn
909
+ // can't launch without an explicit path. An explicit env override is used
910
+ // verbatim (it may be a full path); only the default name is resolved.
911
+ const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || resolveCommand('gipity');
905
912
  // Inject stream-json flags here rather than at the call site so every
906
913
  // relay spawn path gets the same protocol. `--verbose` is required by
907
914
  // Claude Code when combining `-p` with `--output-format stream-json`.
@@ -30,10 +30,13 @@ export function ensureDaemonRunning() {
30
30
  if (state.isDaemonRunning())
31
31
  return;
32
32
  try {
33
- const child = spawn(resolveCliPath(), ['relay', 'run'], {
33
+ // Launch via the current Node binary + the CLI entry script. The previous
34
+ // `shell: true` on Windows ran the .js path through the shell, where the
35
+ // file association (Windows Script Host, not Node) would mis-handle it.
36
+ // process.execPath is cross-platform and needs no shell.
37
+ const child = spawn(process.execPath, [resolveCliPath(), 'relay', 'run'], {
34
38
  detached: true,
35
39
  stdio: 'ignore',
36
- shell: process.platform === 'win32',
37
40
  });
38
41
  child.unref();
39
42
  }
package/dist/setup.js CHANGED
@@ -5,6 +5,7 @@ import { resolve, join, dirname } from 'path';
5
5
  import { homedir } from 'os';
6
6
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
7
7
  import { spawnSync } from 'child_process';
8
+ import { resolveCommand } from './platform.js';
8
9
  import { SKILLS_CONTENT, BUILD_VS_NON_BUILD_RULE, DEFINITION_OF_DONE } from './knowledge.js';
9
10
  export { SKILLS_CONTENT };
10
11
  /** Canonical list of workstation artifacts that are NOT part of the project.
@@ -268,11 +269,15 @@ export function ensureGipityPluginInstalled() {
268
269
  // Refresh the marketplace clone so `install` resolves the current version,
269
270
  // then (re)install at user scope - idempotent, and upgrades an older or
270
271
  // project-scoped install to the current one at user scope.
271
- spawnSync('claude', ['plugin', 'marketplace', 'update', GIPITY_MARKETPLACE_NAME], {
272
+ // resolveCommand: on Windows `claude` is a .cmd shim that spawn can't launch
273
+ // without an explicit path, so resolve it (otherwise the install silently
274
+ // ENOENTs and the plugin's hooks never land at user scope).
275
+ const claudeCmd = resolveCommand('claude');
276
+ spawnSync(claudeCmd, ['plugin', 'marketplace', 'update', GIPITY_MARKETPLACE_NAME], {
272
277
  stdio: 'ignore',
273
278
  timeout: 120_000,
274
279
  });
275
- spawnSync('claude', ['plugin', 'install', GIPITY_PLUGIN_ID, '--scope', 'user'], {
280
+ spawnSync(claudeCmd, ['plugin', 'install', GIPITY_PLUGIN_ID, '--scope', 'user'], {
276
281
  stdio: 'ignore',
277
282
  timeout: 120_000,
278
283
  });
@@ -478,7 +483,10 @@ export function setupGitignore() {
478
483
  const entries = ['.gipity/', '.gipity.json'];
479
484
  if (existsSync(gitignorePath)) {
480
485
  let content = readFileSync(gitignorePath, 'utf-8');
481
- const lines = content.split('\n');
486
+ // Split on \r?\n so a CRLF .gitignore (the Windows default) doesn't leave a
487
+ // trailing \r on each entry - otherwise `lines.includes('.gipity/')` never
488
+ // matches '.gipity/\r' and every run re-appends the entries as duplicates.
489
+ const lines = content.split(/\r?\n/);
482
490
  const toAdd = entries.filter(e => !lines.includes(e));
483
491
  if (toAdd.length > 0) {
484
492
  content = content.trimEnd() + '\n' + toAdd.join('\n') + '\n';
package/dist/sync.js CHANGED
@@ -235,6 +235,11 @@ async function downloadAll(projectGuid, onBytes) {
235
235
  });
236
236
  extract.on('finish', () => resolve(files));
237
237
  extract.on('error', reject);
238
+ // pipe() does NOT forward source-stream errors to the destination, so a
239
+ // truncated/aborted HTTP body would otherwise surface as a clean tar
240
+ // 'finish' with a partial file set. Reject explicitly so a short download is
241
+ // an error the caller can recover from, never a silent partial.
242
+ stream.on('error', reject);
238
243
  stream.pipe(extract);
239
244
  });
240
245
  }
@@ -481,16 +486,20 @@ async function bulkDeleteGuard(p, knownFiles, opts) {
481
486
  const totalDeletes = p.deletesLocal + p.deletesRemote;
482
487
  if (totalDeletes === 0)
483
488
  return true;
484
- if (opts.force)
489
+ if (opts.force || opts.prune)
485
490
  return true;
486
491
  const denom = Math.max(knownFiles, 1);
487
492
  const fraction = totalDeletes / denom;
488
493
  if (totalDeletes < BULK_DELETE_COUNT || fraction < BULK_DELETE_FRACTION)
489
494
  return true;
490
495
  if (!opts.interactive || getAutoConfirm()) {
491
- console.error(`Refusing to delete ${totalDeletes} file${totalDeletes > 1 ? 's' : ''} ` +
492
- `(${Math.round(fraction * 100)}% of tree) non-interactively. ` +
493
- `Re-run with --force or interactively to confirm.`);
496
+ // Non-interactive (deploy/test/sandbox auto-mirror): there's no human to
497
+ // confirm, so defer the bulk deletes SILENTLY and carry them forward. This
498
+ // is the safe default - uploads/downloads still apply, nothing is deleted -
499
+ // and it avoids spamming a "Refusing to delete N files" error on every
500
+ // command when a project legitimately has server-only files (e.g. runtime
501
+ // uploads, or sandbox outputs not kept locally). The deferred count rides
502
+ // out in SyncResult.deferredDeletes; `gipity sync --prune` applies them.
494
503
  return false;
495
504
  }
496
505
  const answer = await prompt(`\nPlan deletes ${totalDeletes} files (${Math.round(fraction * 100)}% of the tree). Type "delete" to confirm: `);
@@ -572,8 +581,63 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
572
581
  return {
573
582
  plan: planned, applied: 0, skipped: 0, errors: [],
574
583
  summary: formatPlan(planned),
584
+ deferredDeletes: 0,
575
585
  };
576
586
  }
587
+ // Uncertain-merge guard (armed via confirmMerge): syncing INTO a populated
588
+ // directory we've never synced for this project (empty baseline + local files
589
+ // that would upload or collide). Local-only files get pushed UP into the
590
+ // project and same-path differences fork into conflict copies - a two-way
591
+ // merge that may be unintended. Nothing here can delete.
592
+ //
593
+ // Three ways to resolve it, so both outcomes are scriptable:
594
+ // --yes / autoConfirm → proceed (merge confirmed)
595
+ // interactive TTY → prompt the user
596
+ // non-interactive → fail safe: ABORT rather than merge blindly
597
+ if (opts.confirmMerge &&
598
+ remote.size > 0 && // project already has files - a real merge target
599
+ Object.keys(baseline.files).length === 0 && // we've never synced this folder for it
600
+ (planned.uploads > 0 || planned.conflicts > 0) // and local content would push up or collide
601
+ ) {
602
+ const f = (n) => `${n} file${n === 1 ? '' : 's'}`;
603
+ const shape = [` Server: ${f(remote.size)} · Local: ${f(local.size)}`];
604
+ if (planned.downloads > 0)
605
+ shape.push(` ↓ download ${f(planned.downloads)} from the project into this folder`);
606
+ if (planned.uploads > 0)
607
+ shape.push(` ↑ upload ${f(planned.uploads)} from this folder INTO the project (they become part of it)`);
608
+ if (planned.conflicts > 0)
609
+ shape.push(` ! ${f(planned.conflicts)} differ on both sides — both kept (your copy is renamed)`);
610
+ const abort = () => ({
611
+ plan: planned, applied: 0, skipped: planned.actions.length, errors: [],
612
+ summary: [
613
+ `This folder has files that haven't been synced with this project yet — merge not confirmed.`,
614
+ ...shape,
615
+ `Re-run with --yes to merge, or sync into an empty folder.`,
616
+ ].join('\n'),
617
+ deferredDeletes: 0, aborted: true,
618
+ });
619
+ if (getAutoConfirm()) {
620
+ // --yes: proceed with the merge; the caller reports the applied counts.
621
+ }
622
+ else if (interactive) {
623
+ const answer = await prompt([
624
+ '',
625
+ ` This folder has files that haven't been synced with this project yet.`,
626
+ ` Syncing here MERGES the two — nothing is deleted:`,
627
+ '',
628
+ ...shape,
629
+ '',
630
+ ` Continue? [y/N]: `,
631
+ ].join('\n'));
632
+ if (!/^(y|yes|continue)$/i.test(answer.trim()))
633
+ return abort();
634
+ }
635
+ else {
636
+ // Non-interactive and not confirmed: don't silently merge a folder we
637
+ // weren't told to. `--yes` opts in.
638
+ return abort();
639
+ }
640
+ }
577
641
  // Bulk-delete guard over the *planned* deletes.
578
642
  const knownFiles = local.size + remote.size;
579
643
  const deletesOk = await bulkDeleteGuard(planned, knownFiles, { ...opts, interactive });
@@ -585,8 +649,14 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
585
649
  // ── Pre-fetch remote bytes once for all downloads (conflict-originating
586
650
  // remote versions are fetched on demand after 409). ──
587
651
  const downloadedBytes = new Map();
588
- const needsBulkDownload = plannedToApply.some(a => a.kind === 'download' || a.kind === 'conflict');
589
- if (needsBulkDownload) {
652
+ // Set when the download phase could not retrieve every byte the plan needs.
653
+ // A delete is only safe against a complete, authoritative view, so an
654
+ // incomplete download disarms the deletes pass below - this is what breaks
655
+ // the "truncated pull → files missing locally → next sync deletes them"
656
+ // amplification loop.
657
+ let downloadIncomplete = false;
658
+ const wantedDownloads = plannedToApply.filter(a => a.kind === 'download' || a.kind === 'conflict');
659
+ if (wantedDownloads.length) {
590
660
  // The tree endpoint streams the *whole* remote tree as one tar (the caller
591
661
  // then picks out only the paths it planned to apply), so the bytes that
592
662
  // actually move = the sum of every remote file's size. That's the honest
@@ -604,22 +674,46 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
604
674
  : undefined;
605
675
  try {
606
676
  const all = await downloadAll(config.projectGuid, onBytes);
607
- for (const a of plannedToApply) {
608
- if (a.kind === 'download' || a.kind === 'conflict') {
609
- const buf = all.get(a.path);
610
- if (buf)
611
- downloadedBytes.set(a.path, buf);
612
- }
677
+ for (const a of wantedDownloads) {
678
+ const buf = all.get(a.path);
679
+ if (buf)
680
+ downloadedBytes.set(a.path, buf);
613
681
  }
614
682
  }
615
683
  catch (err) {
616
- errors.push(`Download batch failed: ${err.message}`);
684
+ // The bulk tar can truncate mid-stream on a large project (transport or
685
+ // proxy timeout) and either reject here or "finish" with a partial set.
686
+ // Either way we fall through to the single-file recovery below rather than
687
+ // proceeding on a half-empty tree - a partial download must never be
688
+ // mistaken for a complete one.
689
+ errors.push(`Bulk download incomplete (${err.message}); recovering files individually…`);
617
690
  }
618
691
  finally {
619
692
  // Settle the bar even if the extracted-byte tally fell short of the
620
693
  // estimate (the live line stays open until something hits 100% or finish()).
621
694
  p?.finish();
622
695
  }
696
+ // Recover whatever the bulk tar dropped over the reliable single-file
697
+ // endpoint. This is what lets a project whose tar keeps truncating still
698
+ // sync to completion - and what recovers a checkout left half-downloaded by
699
+ // an earlier truncated pull.
700
+ const missing = wantedDownloads.filter(a => !downloadedBytes.has(a.path));
701
+ if (missing.length) {
702
+ p?.phase(`Recovering ${missing.length} file${missing.length === 1 ? '' : 's'} the bulk download dropped…`);
703
+ for (const a of missing) {
704
+ let buf = null;
705
+ for (let attempt = 0; attempt < 3 && !buf; attempt++) {
706
+ buf = await fetchOne(config.projectGuid, a.path);
707
+ }
708
+ if (buf)
709
+ downloadedBytes.set(a.path, buf);
710
+ }
711
+ }
712
+ // Anything still missing is a hard failure: the plan needs bytes we could
713
+ // not retrieve. Mark the download incomplete so the deletes pass is skipped;
714
+ // the per-path "Download missing" errors below carry the detail.
715
+ if (wantedDownloads.some(a => !downloadedBytes.has(a.path)))
716
+ downloadIncomplete = true;
623
717
  }
624
718
  // ── Writes pass: uploads, downloads, conflicts (rename + download + upload copy) ──
625
719
  // We serialize conflicts; uploads run with bounded concurrency.
@@ -906,7 +1000,17 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
906
1000
  }
907
1001
  }
908
1002
  // ── Deletes pass ──
1003
+ // A delete is only safe against a complete, authoritative view. If the
1004
+ // download phase couldn't retrieve everything it planned, the local tree is
1005
+ // not a trustworthy deletion signal - this is exactly how a truncated pull
1006
+ // turns "files we failed to fetch" into "delete those files." Skip ALL deletes
1007
+ // this run and let a clean sync replan them once the pull succeeds.
1008
+ let deletesSkippedIncomplete = 0;
909
1009
  for (const a of plannedToApply) {
1010
+ if (downloadIncomplete && (a.kind === 'delete-local' || a.kind === 'delete-remote')) {
1011
+ deletesSkippedIncomplete++;
1012
+ continue;
1013
+ }
910
1014
  if (a.kind === 'delete-local') {
911
1015
  try {
912
1016
  unlinkSync(resolveInRoot(root, a.path));
@@ -972,6 +1076,10 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
972
1076
  }
973
1077
  }
974
1078
  }
1079
+ if (deletesSkippedIncomplete > 0) {
1080
+ errors.push(`Skipped ${deletesSkippedIncomplete} deletion${deletesSkippedIncomplete === 1 ? '' : 's'} because the download was incomplete - ` +
1081
+ `nothing was deleted. Re-run \`gipity sync\` once the pull finishes to apply any real deletions.`);
1082
+ }
975
1083
  // Clean up empty local directories after delete-local actions.
976
1084
  cleanupEmptyDirs(root, config.ignore);
977
1085
  baseline.lastFullSync = new Date().toISOString();
@@ -983,6 +1091,7 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
983
1091
  skipped: skippedByGuard,
984
1092
  errors,
985
1093
  summary: formatPlan(planned),
1094
+ deferredDeletes: skippedByGuard,
986
1095
  };
987
1096
  }
988
1097
  function cleanupEmptyDirs(root, ignorePatterns) {
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'fs';
2
2
  import { spawnSync } from 'child_process';
3
3
  import { join } from 'path';
4
4
  import { LOCAL_DIR, LOCAL_ENTRY, LOCAL_PKG_DIR, writeState, readState } from './state.js';
5
+ import { resolveCommand } from '../platform.js';
5
6
  export function isBootstrapped() {
6
7
  return existsSync(LOCAL_ENTRY);
7
8
  }
@@ -25,7 +26,7 @@ export function bootstrap(version, quiet = false) {
25
26
  // --ignore-scripts: don't run install lifecycle hooks (gipity ships
26
27
  // precompiled, deps need no build), so a compromised package can't execute
27
28
  // code during this self-managed install.
28
- const res = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts', `gipity@${version}`], {
29
+ const res = spawnSync(resolveCommand('npm'), ['install', '--no-audit', '--no-fund', '--ignore-scripts', `gipity@${version}`], {
29
30
  cwd: LOCAL_DIR,
30
31
  stdio: ['ignore', 'pipe', 'pipe'],
31
32
  encoding: 'utf-8',
@@ -38,7 +39,10 @@ export function bootstrap(version, quiet = false) {
38
39
  process.stderr.write(`gipity v${version} is not yet published to npm - using the currently installed build.\n`);
39
40
  }
40
41
  else {
41
- const firstLine = stderr.split('\n').map(l => l.trim()).find(l => l.length > 0) || `npm exit ${res.status}`;
42
+ // res.error (e.g. ENOENT when npm can't be launched) carries the real
43
+ // cause; res.status is null in that case, so prefer the error message.
44
+ const firstLine = stderr.split('\n').map(l => l.trim()).find(l => l.length > 0)
45
+ || (res.error ? res.error.message : `npm exit ${res.status}`);
42
46
  const reason = firstLine.length > 160 ? firstLine.slice(0, 157) + '...' : firstLine;
43
47
  process.stderr.write(`gipity: could not set up local install (${reason}). Using the currently installed build.\n`);
44
48
  }
@@ -4,6 +4,7 @@
4
4
  import { spawnSync } from 'child_process';
5
5
  import { appendFileSync, existsSync } from 'fs';
6
6
  import { LOCAL_DIR, LOCAL_ENTRY, UPDATE_LOG, readState, writeState, updatesDisabled } from './state.js';
7
+ import { resolveCommand } from '../platform.js';
7
8
  const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
8
9
  function log(line) {
9
10
  try {
@@ -37,10 +38,12 @@ function installVersion(version) {
37
38
  // --ignore-scripts: this runs unattended in the background, so don't let a
38
39
  // compromised package's install lifecycle hooks execute. gipity ships
39
40
  // precompiled (dist/) and its deps need no build step, so nothing is lost.
40
- const res = spawnSync('npm', ['install', '--silent', '--no-audit', '--no-fund', '--ignore-scripts', `gipity@${version}`], {
41
+ const res = spawnSync(resolveCommand('npm'), ['install', '--silent', '--no-audit', '--no-fund', '--ignore-scripts', `gipity@${version}`], {
41
42
  cwd: LOCAL_DIR,
42
43
  stdio: 'ignore',
43
44
  });
45
+ if (res.error)
46
+ log(`npm spawn failed: ${res.error.message}`);
44
47
  return res.status === 0 && existsSync(LOCAL_ENTRY);
45
48
  }
46
49
  export async function runCheck(opts = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.396",
3
+ "version": "1.0.398",
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",
@@ -12,8 +12,9 @@
12
12
  "build": "tsc && chmod +x dist/index.js dist/gipcc.js dist/gipccd.js dist/updater/shim.js dist/updater/check.js",
13
13
  "dev": "tsc --watch",
14
14
  "test": "npm run test:smoke",
15
- "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__/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",
16
- "test:e2e": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-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",
15
+ "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",
16
+ "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-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",
17
+ "test:e2e:sync": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sync-live.test.js",
17
18
  "test:e2e:sandbox": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sandbox-live.test.js"
18
19
  },
19
20
  "dependencies": {