gipity 1.0.397 → 1.0.399
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adopt-cwd.js +10 -1
- package/dist/api.js +5 -3
- package/dist/client-context.js +63 -0
- package/dist/commands/add.js +8 -2
- package/dist/commands/chat.js +11 -2
- package/dist/commands/claude.js +47 -34
- package/dist/commands/credits.js +53 -14
- package/dist/commands/deploy.js +5 -2
- package/dist/commands/generate.js +24 -13
- package/dist/commands/page-screenshot.js +8 -1
- package/dist/commands/remove.js +6 -2
- package/dist/commands/sandbox.js +9 -3
- package/dist/commands/sync.js +8 -2
- package/dist/helpers/command.js +16 -1
- package/dist/helpers/sync.js +9 -1
- package/dist/index.js +20 -2
- package/dist/platform.js +26 -0
- package/dist/progress.js +115 -8
- package/dist/relay/daemon.js +15 -2
- package/dist/relay/onboarding.js +5 -2
- package/dist/setup.js +11 -3
- package/dist/sync.js +112 -9
- package/dist/updater/bootstrap.js +6 -2
- package/dist/updater/check.js +4 -1
- package/dist/upload.js +27 -2
- package/package.json +5 -3
package/dist/commands/sandbox.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveProjectContext, getConfigPath } from '../config.js';
|
|
|
6
6
|
import { sync } from '../sync.js';
|
|
7
7
|
import { error as clrError, dim } from '../colors.js';
|
|
8
8
|
import { run } from '../helpers/index.js';
|
|
9
|
+
import { createProgressReporter, withSpinner } from '../progress.js';
|
|
9
10
|
const LANG_MAP = {
|
|
10
11
|
js: 'javascript',
|
|
11
12
|
javascript: 'javascript',
|
|
@@ -159,15 +160,20 @@ GCC/Rust).
|
|
|
159
160
|
// Bidirectional + CAS, so it's a cheap manifest check when nothing changed.
|
|
160
161
|
// Symmetric with the post-run pull below. Skip in one-off mode (no project).
|
|
161
162
|
if (getConfigPath()) {
|
|
162
|
-
await sync({ interactive: false });
|
|
163
|
+
await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
|
|
163
164
|
}
|
|
164
|
-
|
|
165
|
+
// The run blocks until the sandbox finishes (up to the timeout); animate the
|
|
166
|
+
// wait, then clear the spinner so stdout/stderr is the result. JSON skips it.
|
|
167
|
+
const doRun = () => post(`/projects/${config.projectGuid}/sandbox/execute`, {
|
|
165
168
|
code: source,
|
|
166
169
|
language,
|
|
167
170
|
timeout: isNaN(timeout) ? 30 : timeout,
|
|
168
171
|
input_files: opts.input,
|
|
169
172
|
cwd,
|
|
170
173
|
});
|
|
174
|
+
const res = opts.json
|
|
175
|
+
? await doRun()
|
|
176
|
+
: await withSpinner('Running in sandbox…', doRun, { done: null });
|
|
171
177
|
// Pull sandbox-written outputs down to the local cwd automatically. The
|
|
172
178
|
// server has already mirrored them into the project (VFS) and handed back
|
|
173
179
|
// the exact list, so honoring it here means files land locally without a
|
|
@@ -175,7 +181,7 @@ GCC/Rust).
|
|
|
175
181
|
// `filesChanged` flag. Skip in one-off mode (no local project to sync into).
|
|
176
182
|
const pulledLocal = !!(res.data.outputFiles?.length && getConfigPath());
|
|
177
183
|
if (pulledLocal) {
|
|
178
|
-
await sync({ interactive: false });
|
|
184
|
+
await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
|
|
179
185
|
}
|
|
180
186
|
if (opts.json) {
|
|
181
187
|
console.log(JSON.stringify({ ...res.data, filesSynced: pulledLocal }));
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
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/helpers/sync.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { sync } from '../sync.js';
|
|
5
5
|
import { muted, error as clrError } from '../colors.js';
|
|
6
|
+
import { createProgressReporter } from '../progress.js';
|
|
6
7
|
/**
|
|
7
8
|
* Sync local files with the server before an action (deploy, test, scaffold).
|
|
8
9
|
* Respects --no-sync and --json flags. Non-interactive: bulk-deletion guard
|
|
@@ -14,7 +15,14 @@ import { muted, error as clrError } from '../colors.js';
|
|
|
14
15
|
export async function syncBeforeAction(opts) {
|
|
15
16
|
if (opts.sync === false)
|
|
16
17
|
return;
|
|
17
|
-
|
|
18
|
+
// Pass a progress reporter so a large pre-action upload shows the transfer
|
|
19
|
+
// bar instead of a silent pause (the reporter is a no-op on non-TTY / when
|
|
20
|
+
// piped, so JSON and headless output stay clean).
|
|
21
|
+
const result = await sync({
|
|
22
|
+
interactive: false,
|
|
23
|
+
force: opts.force,
|
|
24
|
+
progress: opts.json ? undefined : createProgressReporter(),
|
|
25
|
+
});
|
|
18
26
|
if (result.applied > 0 && !opts.json) {
|
|
19
27
|
console.log(muted(`Synced ${result.applied} change${result.applied > 1 ? 's' : ''}`));
|
|
20
28
|
}
|
package/dist/index.js
CHANGED
|
@@ -57,6 +57,24 @@ import { bold, dim, brand, muted, success } from './colors.js';
|
|
|
57
57
|
import { normalizeAliases } from './flag-aliases.js';
|
|
58
58
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
59
59
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
|
60
|
+
// Local builds stamp dist/build-info.json (git SHA + dirty flag) via the npm
|
|
61
|
+
// `postbuild` hook, so `-v` can show whether the linked binary is your latest
|
|
62
|
+
// code. It is intentionally absent from published installs: the npm `files`
|
|
63
|
+
// allowlist ships only dist/**/*.js, so a released CLI prints a clean
|
|
64
|
+
// `v1.0.398` with no dev marker. package.json `version` stays the source of
|
|
65
|
+
// truth for the published release; this marker never touches it.
|
|
66
|
+
function versionLabel() {
|
|
67
|
+
const base = `v${pkg.version}`;
|
|
68
|
+
try {
|
|
69
|
+
const info = JSON.parse(readFileSync(resolve(__dirname, 'build-info.json'), 'utf-8'));
|
|
70
|
+
if (info?.sha)
|
|
71
|
+
return `${base} (dev ${info.sha}${info.dirty ? ', modified' : ''})`;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// No build-info.json (published install or pre-build) → clean version.
|
|
75
|
+
}
|
|
76
|
+
return base;
|
|
77
|
+
}
|
|
60
78
|
// Custom -v/--version output: include auth status so agents know whether
|
|
61
79
|
// the next CLI call will succeed. Intercepted before Commander parses,
|
|
62
80
|
// because Commander's built-in `.version()` only prints a string and exits.
|
|
@@ -79,7 +97,7 @@ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-
|
|
|
79
97
|
authLine = `${success('Logged in')} as ${auth.email}`;
|
|
80
98
|
}
|
|
81
99
|
console.log('');
|
|
82
|
-
console.log(`${brand(bold('Gipity'))} ${muted(
|
|
100
|
+
console.log(`${brand(bold('Gipity'))} ${muted(versionLabel())}`);
|
|
83
101
|
console.log(authLine);
|
|
84
102
|
console.log('');
|
|
85
103
|
process.exit(0);
|
|
@@ -150,7 +168,7 @@ program.configureHelp({
|
|
|
150
168
|
const padOpt = (s) => s.padEnd(optColWidth);
|
|
151
169
|
const lines = [];
|
|
152
170
|
lines.push('');
|
|
153
|
-
lines.push(`${brand(bold('Gipity CLI'))} ${muted(
|
|
171
|
+
lines.push(`${brand(bold('Gipity CLI'))} ${muted(versionLabel())}`);
|
|
154
172
|
lines.push(dim(GIPITY_TAGLINE));
|
|
155
173
|
lines.push(dim('Hosting, databases, deploys, workflows - one place. Pair with Claude Code or use standalone.'));
|
|
156
174
|
lines.push(dim('Works with Claude Code, Codex, Aider, or any AI coding tool - no MCP server needed.'));
|
package/dist/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/progress.js
CHANGED
|
@@ -1,33 +1,57 @@
|
|
|
1
1
|
// ── Gipity CLI Progress Reporter ────────────────────────────────────────
|
|
2
2
|
// One central place for long-running terminal feedback, so commands don't
|
|
3
|
-
// each reinvent status lines.
|
|
3
|
+
// each reinvent status lines. Three channels, one shared visual vocabulary:
|
|
4
4
|
//
|
|
5
5
|
// phase(msg) - a discrete step with no measurable size
|
|
6
6
|
// (scanning, hashing). Prints one committed line.
|
|
7
|
-
// transfer(label, n, tot) - a
|
|
8
|
-
// in-place bar that
|
|
7
|
+
// transfer(label, n, tot) - a DETERMINATE byte transfer. Renders a single
|
|
8
|
+
// in-place bar that fills as bytes move.
|
|
9
|
+
// spinner(label) - an INDETERMINATE wait (a server call where we
|
|
10
|
+
// can't measure bytes: deploy, generate, chat).
|
|
11
|
+
// Renders the SAME-width track as transfer(), but
|
|
12
|
+
// with a short block that bounces left↔right plus
|
|
13
|
+
// an elapsed timer, so a long wait never reads as
|
|
14
|
+
// frozen. Settles to a committed ✓/✗ line.
|
|
9
15
|
//
|
|
10
16
|
// On a non-TTY (piped output, hook-driven sync, headless -p) the reporter is a
|
|
11
17
|
// silent no-op - no `\r` spam in logs. Colors come from ./colors so the bar
|
|
12
18
|
// matches the rest of the CLI (orange fill + percentage).
|
|
13
|
-
import { brand, brandBold, muted, dim } from './colors.js';
|
|
19
|
+
import { brand, brandBold, muted, dim, success, error as clrError } from './colors.js';
|
|
14
20
|
import { formatSize } from './utils.js';
|
|
15
21
|
const CLEAR_TO_EOL = '\x1b[K';
|
|
16
22
|
const BAR_WIDTH = 18;
|
|
17
23
|
const RENDER_THROTTLE_MS = 60;
|
|
24
|
+
// Indeterminate bounce: a short block sliding across the same BAR_WIDTH track
|
|
25
|
+
// the determinate bar fills. Frame cadence is slow enough to read, fast enough
|
|
26
|
+
// to feel alive.
|
|
27
|
+
const SPIN_BLOCK = 5;
|
|
28
|
+
const SPIN_FRAME_MS = 90;
|
|
29
|
+
/** Compact elapsed: "8s", "1m 04s". Keeps the timer narrow and glanceable. */
|
|
30
|
+
function formatElapsed(ms) {
|
|
31
|
+
const s = Math.floor(ms / 1000);
|
|
32
|
+
if (s < 60)
|
|
33
|
+
return `${s}s`;
|
|
34
|
+
const m = Math.floor(s / 60);
|
|
35
|
+
return `${m}m ${String(s % 60).padStart(2, '0')}s`;
|
|
36
|
+
}
|
|
18
37
|
class TerminalProgress {
|
|
19
|
-
/** True while an in-place transfer line is on screen and not
|
|
38
|
+
/** True while an in-place transfer/spinner line is on screen and not committed. */
|
|
20
39
|
liveOpen = false;
|
|
21
40
|
lastRenderAt = 0;
|
|
22
41
|
/** The label of the current transfer session; a change starts a fresh one. */
|
|
23
42
|
barLabel = null;
|
|
24
43
|
/** True once the current session hit 100% - late/overshoot ticks are dropped. */
|
|
25
44
|
barSettled = false;
|
|
45
|
+
/** Active indeterminate spinner timer, if any. */
|
|
46
|
+
spinTimer = null;
|
|
26
47
|
phase(message) {
|
|
48
|
+
this.stopSpinTimer();
|
|
27
49
|
this.commitLive();
|
|
28
50
|
process.stdout.write(` ${muted(message)}\n`);
|
|
29
51
|
}
|
|
30
52
|
transfer(label, doneBytes, totalBytes) {
|
|
53
|
+
// A determinate transfer takes over the live line from any spinner.
|
|
54
|
+
this.stopSpinTimer();
|
|
31
55
|
// A new label begins a fresh transfer session (e.g. downloads → uploads on
|
|
32
56
|
// the same reporter). Within a session, once we've drawn the 100% frame we
|
|
33
57
|
// drop any further ticks - download byte totals are estimated, so the wire
|
|
@@ -46,32 +70,115 @@ class TerminalProgress {
|
|
|
46
70
|
return;
|
|
47
71
|
this.lastRenderAt = now;
|
|
48
72
|
this.liveOpen = true;
|
|
49
|
-
process.stdout.write('\r' + this.
|
|
73
|
+
process.stdout.write('\r' + this.barFrame(label, doneBytes, totalBytes) + CLEAR_TO_EOL);
|
|
50
74
|
if (finished) {
|
|
51
75
|
this.commitLive();
|
|
52
76
|
this.barSettled = true;
|
|
53
77
|
}
|
|
54
78
|
}
|
|
79
|
+
spinner(label) {
|
|
80
|
+
this.stopSpinTimer();
|
|
81
|
+
this.commitLive();
|
|
82
|
+
const startedAt = Date.now();
|
|
83
|
+
let tick = 0;
|
|
84
|
+
const draw = () => {
|
|
85
|
+
this.liveOpen = true;
|
|
86
|
+
process.stdout.write('\r' + this.spinFrame(label, tick++, Date.now() - startedAt) + CLEAR_TO_EOL);
|
|
87
|
+
};
|
|
88
|
+
draw();
|
|
89
|
+
this.spinTimer = setInterval(draw, SPIN_FRAME_MS);
|
|
90
|
+
// Don't let the animation keep the event loop (and process) alive on its own.
|
|
91
|
+
this.spinTimer.unref?.();
|
|
92
|
+
const settle = (icon, message) => {
|
|
93
|
+
this.stopSpinTimer();
|
|
94
|
+
if (this.liveOpen)
|
|
95
|
+
process.stdout.write('\r');
|
|
96
|
+
if (icon) {
|
|
97
|
+
// Replace the spinner line in place with a committed ✓/✗ result line.
|
|
98
|
+
const elapsed = muted(formatElapsed(Date.now() - startedAt));
|
|
99
|
+
process.stdout.write(` ${icon} ${muted(message ?? label)} ${elapsed}${CLEAR_TO_EOL}\n`);
|
|
100
|
+
}
|
|
101
|
+
else if (this.liveOpen) {
|
|
102
|
+
// Silent stop: clear the spinner row but DON'T advance - leave the
|
|
103
|
+
// cursor at column 0 so the command's own result overwrites the row
|
|
104
|
+
// instead of leaving a blank line behind it.
|
|
105
|
+
process.stdout.write(CLEAR_TO_EOL);
|
|
106
|
+
}
|
|
107
|
+
this.liveOpen = false;
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
succeed: (m) => settle(success('✓'), m),
|
|
111
|
+
fail: (m) => settle(clrError('✗'), m),
|
|
112
|
+
stop: () => settle(null),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
55
115
|
finish() {
|
|
116
|
+
this.stopSpinTimer();
|
|
56
117
|
this.commitLive();
|
|
57
118
|
}
|
|
119
|
+
stopSpinTimer() {
|
|
120
|
+
if (this.spinTimer) {
|
|
121
|
+
clearInterval(this.spinTimer);
|
|
122
|
+
this.spinTimer = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
58
125
|
commitLive() {
|
|
59
126
|
if (!this.liveOpen)
|
|
60
127
|
return;
|
|
61
128
|
process.stdout.write('\n');
|
|
62
129
|
this.liveOpen = false;
|
|
63
130
|
}
|
|
64
|
-
|
|
131
|
+
barFrame(label, done, total) {
|
|
65
132
|
const pct = total > 0 ? Math.min(100, Math.floor((done / total) * 100)) : 100;
|
|
66
133
|
const filled = Math.round((pct / 100) * BAR_WIDTH);
|
|
67
134
|
const bar = brand('█'.repeat(filled)) + dim('░'.repeat(BAR_WIDTH - filled));
|
|
68
135
|
const sizes = muted(`${formatSize(done)} / ${formatSize(total)}`);
|
|
69
136
|
return ` ${muted(label)} ${bar} ${brandBold(`${pct}%`)} ${sizes}`;
|
|
70
137
|
}
|
|
138
|
+
spinFrame(label, tick, elapsedMs) {
|
|
139
|
+
// Ping-pong the block's left edge between 0 and (BAR_WIDTH - SPIN_BLOCK).
|
|
140
|
+
const span = BAR_WIDTH - SPIN_BLOCK;
|
|
141
|
+
const cycle = span * 2;
|
|
142
|
+
const phase = tick % cycle;
|
|
143
|
+
const pos = phase <= span ? phase : cycle - phase;
|
|
144
|
+
const bar = dim('░'.repeat(pos)) +
|
|
145
|
+
brand('█'.repeat(SPIN_BLOCK)) +
|
|
146
|
+
dim('░'.repeat(BAR_WIDTH - pos - SPIN_BLOCK));
|
|
147
|
+
return ` ${muted(label)} ${bar} ${muted(formatElapsed(elapsedMs))}`;
|
|
148
|
+
}
|
|
71
149
|
}
|
|
72
|
-
const
|
|
150
|
+
const NOOP_SPINNER = { succeed() { }, fail() { }, stop() { } };
|
|
151
|
+
const NOOP = {
|
|
152
|
+
phase() { }, transfer() { }, finish() { },
|
|
153
|
+
spinner: () => NOOP_SPINNER,
|
|
154
|
+
};
|
|
73
155
|
/** A reporter that draws on a TTY and stays silent otherwise. */
|
|
74
156
|
export function createProgressReporter() {
|
|
75
157
|
return process.stdout.isTTY ? new TerminalProgress() : NOOP;
|
|
76
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Run an indeterminate async operation behind the standard bouncing-block
|
|
161
|
+
* spinner: animate `label` while it's in flight, then settle. On success the
|
|
162
|
+
* line becomes a ✓ (`done`, or `label` if omitted) — or, when `done` is null,
|
|
163
|
+
* the spinner just clears silently (use this when the command prints its own
|
|
164
|
+
* result, e.g. a chat reply). On throw it becomes a ✗ and re-throws so the
|
|
165
|
+
* caller's own error handling still runs. On a non-TTY this is a silent
|
|
166
|
+
* pass-through. The single wrapper every command uses for a server call whose
|
|
167
|
+
* size/duration we can't measure (deploy, generate, chat, sandbox, …).
|
|
168
|
+
*/
|
|
169
|
+
export async function withSpinner(label, fn, opts = {}) {
|
|
170
|
+
const sp = (opts.reporter ?? createProgressReporter()).spinner(label);
|
|
171
|
+
try {
|
|
172
|
+
const result = await fn();
|
|
173
|
+
if (opts.done === null)
|
|
174
|
+
sp.stop();
|
|
175
|
+
else
|
|
176
|
+
sp.succeed(opts.done);
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
sp.fail();
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
77
184
|
//# sourceMappingURL=progress.js.map
|
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';
|
|
@@ -631,6 +632,12 @@ async function handleDispatch(d) {
|
|
|
631
632
|
// prompt is correct (same authority as running `claude -p` in a local
|
|
632
633
|
// terminal yourself).
|
|
633
634
|
const args = ['claude', '-p', d.message, '--permission-mode', 'bypassPermissions'];
|
|
635
|
+
// Per-chat model: the user picked it with `/model` in the web CLI. `gipity
|
|
636
|
+
// claude` forwards --model straight through to the `claude` binary, which
|
|
637
|
+
// honors it on both a fresh session and a --resume. null => agent default.
|
|
638
|
+
if (d.model) {
|
|
639
|
+
args.push('--model', d.model);
|
|
640
|
+
}
|
|
634
641
|
if (d.kind === 'resume' && d.remote_session_id) {
|
|
635
642
|
args.push('--resume', d.remote_session_id);
|
|
636
643
|
}
|
|
@@ -855,7 +862,10 @@ export async function killRunningForConv(convGuid) {
|
|
|
855
862
|
* resolution (the daemon itself doesn't chdir into projects).
|
|
856
863
|
* Non-blocking on failure - caller catches and logs. */
|
|
857
864
|
async function spawnSync(cwd, timeoutMs) {
|
|
858
|
-
|
|
865
|
+
// resolveCommand: on Windows the bare `gipity` is a .cmd shim that spawn
|
|
866
|
+
// can't launch without an explicit path. An explicit env override is used
|
|
867
|
+
// verbatim (it may be a full path); only the default name is resolved.
|
|
868
|
+
const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || resolveCommand('gipity');
|
|
859
869
|
return new Promise((resolve, reject) => {
|
|
860
870
|
const child = spawn(cmd, ['sync', '--json'], {
|
|
861
871
|
cwd,
|
|
@@ -901,7 +911,10 @@ async function spawnSync(cwd, timeoutMs) {
|
|
|
901
911
|
});
|
|
902
912
|
}
|
|
903
913
|
export async function spawnGipityClaude(args, cwd, d) {
|
|
904
|
-
|
|
914
|
+
// resolveCommand: on Windows the bare `gipity` is a .cmd shim that spawn
|
|
915
|
+
// can't launch without an explicit path. An explicit env override is used
|
|
916
|
+
// verbatim (it may be a full path); only the default name is resolved.
|
|
917
|
+
const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || resolveCommand('gipity');
|
|
905
918
|
// Inject stream-json flags here rather than at the call site so every
|
|
906
919
|
// relay spawn path gets the same protocol. `--verbose` is required by
|
|
907
920
|
// 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
|
}
|
|
@@ -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
|
-
|
|
594
|
-
|
|
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
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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();
|