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 +10 -1
- package/dist/api.js +5 -3
- package/dist/client-context.js +63 -0
- package/dist/commands/claude.js +37 -32
- package/dist/commands/credits.js +53 -14
- package/dist/commands/generate.js +5 -4
- package/dist/commands/sync.js +18 -5
- package/dist/helpers/command.js +16 -1
- package/dist/knowledge.js +4 -3
- package/dist/platform.js +26 -0
- package/dist/relay/daemon.js +9 -2
- package/dist/relay/onboarding.js +5 -2
- package/dist/setup.js +11 -3
- package/dist/sync.js +122 -13
- package/dist/updater/bootstrap.js +6 -2
- package/dist/updater/check.js +4 -1
- package/package.json +4 -3
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
|
package/dist/commands/claude.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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
|
package/dist/commands/credits.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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,
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
+
printCommandError('Music generation', err);
|
|
263
264
|
process.exit(1);
|
|
264
265
|
}
|
|
265
266
|
});
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/helpers/command.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/platform.js
ADDED
|
@@ -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
|
package/dist/relay/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`.
|
package/dist/relay/onboarding.js
CHANGED
|
@@ -30,10 +30,13 @@ export function ensureDaemonRunning() {
|
|
|
30
30
|
if (state.isDaemonRunning())
|
|
31
31
|
return;
|
|
32
32
|
try {
|
|
33
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
589
|
-
|
|
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
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/updater/check.js
CHANGED
|
@@ -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.
|
|
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": {
|