gipity 1.0.397 → 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
  });
@@ -12,7 +12,11 @@ export const syncCommand = new Command('sync')
12
12
  try {
13
13
  // JSON mode stays machine-clean; otherwise show live progress on a TTY.
14
14
  const progress = opts.json ? undefined : createProgressReporter();
15
- const result = await sync({ plan: opts.plan, force: opts.force, prune: opts.prune, 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 });
16
20
  if (opts.json) {
17
21
  console.log(JSON.stringify(result));
18
22
  }
@@ -33,7 +37,9 @@ export const syncCommand = new Command('sync')
33
37
  for (const e of result.errors)
34
38
  console.error(clrError(e));
35
39
  }
36
- 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)
37
43
  process.exit(1);
38
44
  }
39
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
  }
@@ -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
  }
@@ -579,6 +584,60 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
579
584
  deferredDeletes: 0,
580
585
  };
581
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
+ }
582
641
  // Bulk-delete guard over the *planned* deletes.
583
642
  const knownFiles = local.size + remote.size;
584
643
  const deletesOk = await bulkDeleteGuard(planned, knownFiles, { ...opts, interactive });
@@ -590,8 +649,14 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
590
649
  // ── Pre-fetch remote bytes once for all downloads (conflict-originating
591
650
  // remote versions are fetched on demand after 409). ──
592
651
  const downloadedBytes = new Map();
593
- const needsBulkDownload = plannedToApply.some(a => a.kind === 'download' || a.kind === 'conflict');
594
- 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) {
595
660
  // The tree endpoint streams the *whole* remote tree as one tar (the caller
596
661
  // then picks out only the paths it planned to apply), so the bytes that
597
662
  // actually move = the sum of every remote file's size. That's the honest
@@ -609,22 +674,46 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
609
674
  : undefined;
610
675
  try {
611
676
  const all = await downloadAll(config.projectGuid, onBytes);
612
- for (const a of plannedToApply) {
613
- if (a.kind === 'download' || a.kind === 'conflict') {
614
- const buf = all.get(a.path);
615
- if (buf)
616
- downloadedBytes.set(a.path, buf);
617
- }
677
+ for (const a of wantedDownloads) {
678
+ const buf = all.get(a.path);
679
+ if (buf)
680
+ downloadedBytes.set(a.path, buf);
618
681
  }
619
682
  }
620
683
  catch (err) {
621
- 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…`);
622
690
  }
623
691
  finally {
624
692
  // Settle the bar even if the extracted-byte tally fell short of the
625
693
  // estimate (the live line stays open until something hits 100% or finish()).
626
694
  p?.finish();
627
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;
628
717
  }
629
718
  // ── Writes pass: uploads, downloads, conflicts (rename + download + upload copy) ──
630
719
  // We serialize conflicts; uploads run with bounded concurrency.
@@ -911,7 +1000,17 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
911
1000
  }
912
1001
  }
913
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;
914
1009
  for (const a of plannedToApply) {
1010
+ if (downloadIncomplete && (a.kind === 'delete-local' || a.kind === 'delete-remote')) {
1011
+ deletesSkippedIncomplete++;
1012
+ continue;
1013
+ }
915
1014
  if (a.kind === 'delete-local') {
916
1015
  try {
917
1016
  unlinkSync(resolveInRoot(root, a.path));
@@ -977,6 +1076,10 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
977
1076
  }
978
1077
  }
979
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
+ }
980
1083
  // Clean up empty local directories after delete-local actions.
981
1084
  cleanupEmptyDirs(root, config.ignore);
982
1085
  baseline.lastFullSync = new Date().toISOString();
@@ -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.397",
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": {