runwork 0.14.0 → 0.15.0
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/__tests__/install-scripts.test.js +33 -0
- package/dist/agents/__tests__/chatgpt-registry.test.d.ts +1 -0
- package/dist/agents/__tests__/chatgpt-registry.test.js +61 -0
- package/dist/agents/__tests__/detection.test.js +3 -0
- package/dist/agents/detection.js +3 -0
- package/dist/agents/intro-skill.js +5 -3
- package/dist/agents/registry-data.d.ts +59 -2
- package/dist/agents/registry-data.js +176 -0
- package/dist/commands/__tests__/clone-args.test.js +8 -1
- package/dist/commands/__tests__/clone-source-result.test.d.ts +1 -0
- package/dist/commands/__tests__/clone-source-result.test.js +45 -0
- package/dist/commands/clone.d.ts +33 -0
- package/dist/commands/clone.js +105 -10
- package/dist/commands/deploy.js +128 -11
- package/dist/commands/dev.js +72 -32
- package/dist/commands/doctor.js +64 -3
- package/dist/commands/info.d.ts +3 -0
- package/dist/commands/info.js +11 -0
- package/dist/commands/sync.js +14 -1
- package/dist/commands/validate.d.ts +2 -0
- package/dist/commands/validate.js +89 -0
- package/dist/deploy/__tests__/deploy-state.test.d.ts +1 -0
- package/dist/deploy/__tests__/deploy-state.test.js +80 -0
- package/dist/deploy/__tests__/deploy-status.test.d.ts +1 -0
- package/dist/deploy/__tests__/deploy-status.test.js +41 -0
- package/dist/deploy/__tests__/detach-args.test.d.ts +1 -0
- package/dist/deploy/__tests__/detach-args.test.js +30 -0
- package/dist/deploy/deploy-state.d.ts +36 -0
- package/dist/deploy/deploy-state.js +63 -0
- package/dist/deploy/deploy-status.d.ts +21 -0
- package/dist/deploy/deploy-status.js +36 -0
- package/dist/deploy/detach.d.ts +32 -0
- package/dist/deploy/detach.js +71 -0
- package/dist/dev/__tests__/session.test.js +39 -0
- package/dist/dev/__tests__/startup-sync.test.d.ts +1 -0
- package/dist/dev/__tests__/startup-sync.test.js +32 -0
- package/dist/dev/session.d.ts +9 -0
- package/dist/dev/session.js +4 -1
- package/dist/dev/startup-sync.d.ts +23 -0
- package/dist/dev/startup-sync.js +14 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/classify-sync-error.test.d.ts +1 -0
- package/dist/git/__tests__/classify-sync-error.test.js +57 -0
- package/dist/git/__tests__/ensure-git-credential-helper.test.d.ts +1 -0
- package/dist/git/__tests__/ensure-git-credential-helper.test.js +133 -0
- package/dist/git/__tests__/ensure-runwork-remote.test.d.ts +1 -0
- package/dist/git/__tests__/ensure-runwork-remote.test.js +109 -0
- package/dist/git/__tests__/repo-config.test.d.ts +1 -0
- package/dist/git/__tests__/repo-config.test.js +26 -0
- package/dist/git/classify-sync-error.d.ts +21 -0
- package/dist/git/classify-sync-error.js +111 -0
- package/dist/git/credentials.d.ts +43 -0
- package/dist/git/credentials.js +67 -0
- package/dist/git/remote.d.ts +37 -0
- package/dist/git/remote.js +75 -0
- package/dist/git/repo-config.d.ts +8 -0
- package/dist/git/repo-config.js +29 -0
- package/dist/health/__tests__/checks.test.js +2 -2
- package/dist/health/__tests__/fix.test.d.ts +1 -0
- package/dist/health/__tests__/fix.test.js +72 -0
- package/dist/health/__tests__/runner-filter.test.d.ts +1 -0
- package/dist/health/__tests__/runner-filter.test.js +61 -0
- package/dist/health/checks.d.ts +7 -0
- package/dist/health/checks.js +50 -36
- package/dist/health/fix.d.ts +22 -0
- package/dist/health/fix.js +62 -0
- package/dist/health/runner.d.ts +7 -1
- package/dist/health/runner.js +40 -27
- package/dist/index.js +2 -0
- package/dist/tools/types.d.ts +5 -0
- package/dist/utils/__tests__/help-json.test.d.ts +1 -0
- package/dist/utils/__tests__/help-json.test.js +39 -0
- package/dist/utils/__tests__/ignore-matcher.test.js +18 -1
- package/dist/utils/help-json.d.ts +14 -0
- package/dist/utils/help-json.js +4 -1
- package/dist/validate/__tests__/validate.test.d.ts +1 -0
- package/dist/validate/__tests__/validate.test.js +42 -0
- package/dist/validate/freshness.d.ts +28 -0
- package/dist/validate/freshness.js +48 -0
- package/dist/validate/preview.d.ts +21 -0
- package/dist/validate/preview.js +39 -0
- package/package.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runwork deploy --detach` orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Unlike `runwork dev --detach`, a deploy has no "ready" rendezvous to wait
|
|
5
|
+
* for -- it's fire-and-forget-then-poll. So the parent simply spawns a
|
|
6
|
+
* detached child (whose stdout/stderr go to `.runwork/deploy-*.log`), records
|
|
7
|
+
* an `in-progress` status, prints a poll hint, and exits. The child runs the
|
|
8
|
+
* real deploy and finalizes the status file to `succeeded`/`failed`.
|
|
9
|
+
*
|
|
10
|
+
* This exists so agents whose shell tool times out (~45s) can launch a deploy
|
|
11
|
+
* that survives, then poll `runwork deploy --status` (friction log #7).
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as child_process from 'child_process';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
import { looksLikeBunStandaloneArtifact } from '../dev/detach.js';
|
|
17
|
+
/** Hidden marker passed to the spawned child. Not shown in --help. */
|
|
18
|
+
export const DEPLOY_DETACHED_CHILD_FLAG = '--internal-detached-deploy-child';
|
|
19
|
+
/** Whether this invocation is the detached deploy child. */
|
|
20
|
+
export function isInternalDeployChild(argv = process.argv) {
|
|
21
|
+
return argv.includes(DEPLOY_DETACHED_CHILD_FLAG);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Build the args for the spawned child so it re-runs the same deploy in the
|
|
25
|
+
* foreground (the child must NOT re-detach). Drops `--detach`, any stale
|
|
26
|
+
* marker, and a Bun-on-Windows virtual-FS path; re-adds exactly one marker.
|
|
27
|
+
* Pure for testability.
|
|
28
|
+
*/
|
|
29
|
+
export function buildDeployChildArgs(parentArgv) {
|
|
30
|
+
const rest = [];
|
|
31
|
+
for (let i = 1; i < parentArgv.length; i++) {
|
|
32
|
+
if (i === 1 && looksLikeBunStandaloneArtifact(parentArgv[i]))
|
|
33
|
+
continue;
|
|
34
|
+
rest.push(parentArgv[i]);
|
|
35
|
+
}
|
|
36
|
+
const filtered = rest.filter((a) => a !== DEPLOY_DETACHED_CHILD_FLAG && a !== '--detach');
|
|
37
|
+
return [...filtered, DEPLOY_DETACHED_CHILD_FLAG];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Spawn the detached deploy child with stdout/stderr routed to
|
|
41
|
+
* `.runwork/deploy-{stdout,stderr}.log`. Cross-platform: uses
|
|
42
|
+
* `process.execPath` so it never depends on PATH (Bun-standalone-safe).
|
|
43
|
+
*/
|
|
44
|
+
export function spawnDetachedDeploy(childArgs, cwd) {
|
|
45
|
+
const dir = join(cwd, '.runwork');
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
const stdoutFd = fs.openSync(join(dir, 'deploy-stdout.log'), 'w');
|
|
48
|
+
const stderrFd = fs.openSync(join(dir, 'deploy-stderr.log'), 'w');
|
|
49
|
+
try {
|
|
50
|
+
const proc = child_process.spawn(process.execPath, childArgs, {
|
|
51
|
+
cwd,
|
|
52
|
+
env: process.env,
|
|
53
|
+
detached: true,
|
|
54
|
+
stdio: ['ignore', stdoutFd, stderrFd],
|
|
55
|
+
windowsHide: true,
|
|
56
|
+
});
|
|
57
|
+
if (proc.pid !== undefined)
|
|
58
|
+
proc.unref();
|
|
59
|
+
return { pid: proc.pid ?? 0 };
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
try {
|
|
63
|
+
fs.closeSync(stdoutFd);
|
|
64
|
+
}
|
|
65
|
+
catch { /* ignore */ }
|
|
66
|
+
try {
|
|
67
|
+
fs.closeSync(stderrFd);
|
|
68
|
+
}
|
|
69
|
+
catch { /* ignore */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -368,6 +368,45 @@ describe('session lifecycle primitives', () => {
|
|
|
368
368
|
});
|
|
369
369
|
expect(file.noSync).toBe(true);
|
|
370
370
|
});
|
|
371
|
+
it('omits startupSync when not provided, includes it when provided', () => {
|
|
372
|
+
const base = {
|
|
373
|
+
pid: 1, sessionId: 's', appId: 'a', previewUrl: '', cliVersion: '0.0.0',
|
|
374
|
+
mode: 'detached', deps: { bootTime: () => 0 },
|
|
375
|
+
};
|
|
376
|
+
expect(buildSessionFile(base).startupSync).toBeUndefined();
|
|
377
|
+
const withSync = buildSessionFile({
|
|
378
|
+
...base,
|
|
379
|
+
startupSync: { syncStatus: 'sync-failed', syncFailed: true, staleCode: true },
|
|
380
|
+
});
|
|
381
|
+
expect(withSync.startupSync).toEqual({ syncStatus: 'sync-failed', syncFailed: true, staleCode: true });
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
describe('startupSync field back-compat', () => {
|
|
385
|
+
it('round-trips startupSync through write/read', () => {
|
|
386
|
+
const file = buildSessionFile({
|
|
387
|
+
pid: process.pid,
|
|
388
|
+
sessionId: 's',
|
|
389
|
+
appId: 'a',
|
|
390
|
+
previewUrl: 'https://x',
|
|
391
|
+
cliVersion: '0.0.0',
|
|
392
|
+
mode: 'detached',
|
|
393
|
+
startupSync: { syncStatus: 'sync-failed', syncFailed: true, staleCode: true },
|
|
394
|
+
deps: { bootTime: () => 0 },
|
|
395
|
+
});
|
|
396
|
+
writeSessionFile(appDir, file);
|
|
397
|
+
const read = readSessionFile(appDir);
|
|
398
|
+
expect(read?.startupSync).toEqual({ syncStatus: 'sync-failed', syncFailed: true, staleCode: true });
|
|
399
|
+
});
|
|
400
|
+
it('accepts an older session file with no startupSync field', () => {
|
|
401
|
+
const file = buildSessionFile({
|
|
402
|
+
pid: process.pid, sessionId: 's', appId: 'a', previewUrl: 'https://x',
|
|
403
|
+
cliVersion: '0.0.0', mode: 'detached', deps: { bootTime: () => 0 },
|
|
404
|
+
});
|
|
405
|
+
writeSessionFile(appDir, file);
|
|
406
|
+
const read = readSessionFile(appDir);
|
|
407
|
+
expect(read).not.toBeNull();
|
|
408
|
+
expect(read?.startupSync).toBeUndefined();
|
|
409
|
+
});
|
|
371
410
|
});
|
|
372
411
|
describe('noSync field back-compat', () => {
|
|
373
412
|
it('round-trips noSync through write/read', () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildStartupSyncSummary } from '../startup-sync.js';
|
|
3
|
+
describe('dev/startup-sync', () => {
|
|
4
|
+
it('reports no-sync mode as neither failed nor stale', () => {
|
|
5
|
+
expect(buildStartupSyncSummary({ noSync: true })).toEqual({
|
|
6
|
+
syncStatus: 'no-sync',
|
|
7
|
+
syncFailed: false,
|
|
8
|
+
staleCode: false,
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
it('flags a failed startup sync as stale (preview serving old code)', () => {
|
|
12
|
+
expect(buildStartupSyncSummary({ noSync: false, status: 'sync-failed' })).toEqual({
|
|
13
|
+
syncStatus: 'sync-failed',
|
|
14
|
+
syncFailed: true,
|
|
15
|
+
staleCode: true,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
it.each(['synced', 'merged', 'skipped'])('treats a %s startup sync as live (not stale)', (status) => {
|
|
19
|
+
expect(buildStartupSyncSummary({ noSync: false, status })).toEqual({
|
|
20
|
+
syncStatus: status,
|
|
21
|
+
syncFailed: false,
|
|
22
|
+
staleCode: false,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
it('defaults to skipped when no status is provided', () => {
|
|
26
|
+
expect(buildStartupSyncSummary({ noSync: false })).toEqual({
|
|
27
|
+
syncStatus: 'skipped',
|
|
28
|
+
syncFailed: false,
|
|
29
|
+
staleCode: false,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
package/dist/dev/session.d.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See `docs/plans/2026-05-06-runwork-dev-lifecycle-design.md` for the full
|
|
15
15
|
* lifecycle contract.
|
|
16
16
|
*/
|
|
17
|
+
import type { StartupSyncSummary } from './startup-sync.js';
|
|
17
18
|
export declare const SESSION_FILE_SCHEMA_VERSION = 1;
|
|
18
19
|
/**
|
|
19
20
|
* Tolerance (ms) when comparing the stored bootTime to the currently
|
|
@@ -40,6 +41,13 @@ export interface SessionFile {
|
|
|
40
41
|
* treated as `false`.
|
|
41
42
|
*/
|
|
42
43
|
noSync?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Startup sync outcome, so a detached parent can relay stale-code warnings
|
|
46
|
+
* to the caller instead of those signals only living in the child's log
|
|
47
|
+
* (friction log #2). Optional for back-compat with session files that
|
|
48
|
+
* predate the field.
|
|
49
|
+
*/
|
|
50
|
+
startupSync?: StartupSyncSummary;
|
|
43
51
|
}
|
|
44
52
|
export type SessionStaleReason = 'malformed' | 'version-mismatch' | 'app-id-mismatch' | 'boot-time-mismatch' | 'pid-dead';
|
|
45
53
|
export type SessionState = {
|
|
@@ -161,6 +169,7 @@ export declare function buildSessionFile(input: {
|
|
|
161
169
|
cliVersion: string;
|
|
162
170
|
mode: SessionMode;
|
|
163
171
|
noSync?: boolean;
|
|
172
|
+
startupSync?: StartupSyncSummary;
|
|
164
173
|
startedAt?: number;
|
|
165
174
|
deps?: SessionDeps;
|
|
166
175
|
}): SessionFile;
|
package/dist/dev/session.js
CHANGED
|
@@ -113,7 +113,9 @@ function validateSessionFile(raw) {
|
|
|
113
113
|
typeof r.cliVersion === 'string' &&
|
|
114
114
|
(r.mode === 'foreground' || r.mode === 'detached') &&
|
|
115
115
|
// Optional for back-compat: v1 files lack the field. Accept absent or boolean.
|
|
116
|
-
(r.noSync === undefined || typeof r.noSync === 'boolean')
|
|
116
|
+
(r.noSync === undefined || typeof r.noSync === 'boolean') &&
|
|
117
|
+
// Optional: absent on older files; otherwise an object (shape from buildStartupSyncSummary).
|
|
118
|
+
(r.startupSync === undefined || (typeof r.startupSync === 'object' && r.startupSync !== null)));
|
|
117
119
|
}
|
|
118
120
|
/**
|
|
119
121
|
* Read and parse the session file. Returns `null` for a missing file or
|
|
@@ -251,5 +253,6 @@ export function buildSessionFile(input) {
|
|
|
251
253
|
cliVersion: input.cliVersion,
|
|
252
254
|
mode: input.mode,
|
|
253
255
|
noSync: input.noSync ?? false,
|
|
256
|
+
...(input.startupSync ? { startupSync: input.startupSync } : {}),
|
|
254
257
|
};
|
|
255
258
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SyncResult } from '../git/sync.js';
|
|
2
|
+
/**
|
|
3
|
+
* Summary of the startup sync outcome, surfaced on the `session_started`
|
|
4
|
+
* event so agents can tell whether the preview reflects their latest edits.
|
|
5
|
+
* `staleCode` is the key signal: when true, the initial sync failed and the
|
|
6
|
+
* preview is serving the last good state, NOT the current working tree
|
|
7
|
+
* (friction log #2).
|
|
8
|
+
*/
|
|
9
|
+
export interface StartupSyncSummary {
|
|
10
|
+
syncStatus: SyncResult['status'] | 'no-sync';
|
|
11
|
+
syncFailed: boolean;
|
|
12
|
+
staleCode: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Derive the startup sync summary from how dev started:
|
|
16
|
+
* - no-sync mode never mutates the tree, so it's neither failed nor stale.
|
|
17
|
+
* - otherwise it mirrors the syncWithRemote() status; a `sync-failed` status
|
|
18
|
+
* means the preview is stale.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildStartupSyncSummary(input: {
|
|
21
|
+
noSync: boolean;
|
|
22
|
+
status?: SyncResult['status'];
|
|
23
|
+
}): StartupSyncSummary;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the startup sync summary from how dev started:
|
|
3
|
+
* - no-sync mode never mutates the tree, so it's neither failed nor stale.
|
|
4
|
+
* - otherwise it mirrors the syncWithRemote() status; a `sync-failed` status
|
|
5
|
+
* means the preview is stale.
|
|
6
|
+
*/
|
|
7
|
+
export function buildStartupSyncSummary(input) {
|
|
8
|
+
if (input.noSync) {
|
|
9
|
+
return { syncStatus: 'no-sync', syncFailed: false, staleCode: false };
|
|
10
|
+
}
|
|
11
|
+
const status = input.status ?? 'skipped';
|
|
12
|
+
const failed = status === 'sync-failed';
|
|
13
|
+
return { syncStatus: status, syncFailed: failed, staleCode: failed };
|
|
14
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.15.0";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by scripts/embed-types.ts -- do not edit
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.15.0";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { classifySyncError, diagnoseSyncError } from '../classify-sync-error.js';
|
|
3
|
+
describe('git/classify-sync-error', () => {
|
|
4
|
+
describe('classifySyncError()', () => {
|
|
5
|
+
it('detects a missing/misnamed runwork remote', () => {
|
|
6
|
+
expect(classifySyncError("fatal: 'runwork' does not appear to be a git repository")).toBe('no-remote');
|
|
7
|
+
expect(classifySyncError('fatal: No such remote: runwork')).toBe('no-remote');
|
|
8
|
+
});
|
|
9
|
+
it('detects authentication failures', () => {
|
|
10
|
+
expect(classifySyncError('fatal: Authentication failed for https://runwork.ai/...')).toBe('auth');
|
|
11
|
+
expect(classifySyncError('fatal: could not read Username for https://runwork.ai: terminal prompts disabled')).toBe('auth');
|
|
12
|
+
expect(classifySyncError("fatal: unable to access '...': The requested URL returned error: 403")).toBe('auth');
|
|
13
|
+
});
|
|
14
|
+
it('detects network failures', () => {
|
|
15
|
+
expect(classifySyncError("fatal: unable to access '...': Could not resolve host: runwork.ai")).toBe('network');
|
|
16
|
+
expect(classifySyncError('fatal: unable to access: Failed to connect to runwork.ai port 443: Connection timed out')).toBe('network');
|
|
17
|
+
});
|
|
18
|
+
it('detects merge/rebase conflicts and the stash-conflict sentinel', () => {
|
|
19
|
+
expect(classifySyncError('CONFLICT (content): Merge conflict in src/App.tsx')).toBe('conflict');
|
|
20
|
+
expect(classifySyncError('error: Automatic merge failed')).toBe('conflict');
|
|
21
|
+
expect(classifySyncError('stash-conflict')).toBe('conflict');
|
|
22
|
+
});
|
|
23
|
+
it('falls back to unknown for unrecognized errors and empty input', () => {
|
|
24
|
+
expect(classifySyncError('some unexpected git failure')).toBe('unknown');
|
|
25
|
+
expect(classifySyncError(undefined)).toBe('unknown');
|
|
26
|
+
expect(classifySyncError('')).toBe('unknown');
|
|
27
|
+
});
|
|
28
|
+
it('is case-insensitive', () => {
|
|
29
|
+
expect(classifySyncError('AUTHENTICATION FAILED')).toBe('auth');
|
|
30
|
+
expect(classifySyncError('could not resolve HOST')).toBe('network');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe('diagnoseSyncError()', () => {
|
|
34
|
+
it('points a no-remote failure at doctor --fix, not network/session', () => {
|
|
35
|
+
const d = diagnoseSyncError("fatal: 'runwork' does not appear to be a git repository");
|
|
36
|
+
expect(d.reason).toBe('no-remote');
|
|
37
|
+
expect(d.suggestions.join(' ')).toContain('runwork doctor --fix');
|
|
38
|
+
// Must NOT regress to the old misleading "session expired / network" advice.
|
|
39
|
+
expect(d.diagnosis.toLowerCase()).not.toContain('session expired');
|
|
40
|
+
});
|
|
41
|
+
it('points an auth failure at credential-helper remediation', () => {
|
|
42
|
+
const d = diagnoseSyncError('fatal: Authentication failed');
|
|
43
|
+
expect(d.reason).toBe('auth');
|
|
44
|
+
expect(d.suggestions.join(' ')).toContain('runwork doctor --fix');
|
|
45
|
+
});
|
|
46
|
+
it('gives stash-conflict a restore-specific message', () => {
|
|
47
|
+
const d = diagnoseSyncError('stash-conflict');
|
|
48
|
+
expect(d.reason).toBe('conflict');
|
|
49
|
+
expect(d.message).toContain('restoring local changes');
|
|
50
|
+
});
|
|
51
|
+
it('preserves the raw error in the unknown-case message', () => {
|
|
52
|
+
const d = diagnoseSyncError('weird git explosion');
|
|
53
|
+
expect(d.reason).toBe('unknown');
|
|
54
|
+
expect(d.message).toBe('weird git explosion');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process');
|
|
3
|
+
vi.mock('fs', () => ({
|
|
4
|
+
existsSync: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('../../auth/store.js', () => ({
|
|
7
|
+
getCredentials: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
const mockExecFileSync = (await import('child_process')).execFileSync;
|
|
10
|
+
const { existsSync: mockExistsSync } = await import('fs');
|
|
11
|
+
const { lookupCredentialHelper, helperBinaryStatus, ensureGitCredentialHelper, } = await import('../credentials.js');
|
|
12
|
+
/** Build a `git config --get-regexp` stdout block from full config lines. */
|
|
13
|
+
function gitConfigOutput(...lines) {
|
|
14
|
+
return Buffer.from(lines.join('\n') + '\n');
|
|
15
|
+
}
|
|
16
|
+
/** True when a recorded execFileSync call wrote a credential helper config. */
|
|
17
|
+
function isHelperWrite(call) {
|
|
18
|
+
const args = call[1];
|
|
19
|
+
return call[0] === 'git'
|
|
20
|
+
&& Array.isArray(args)
|
|
21
|
+
&& args.includes('--global')
|
|
22
|
+
&& args.some(a => /^credential\..*\.helper$/.test(a))
|
|
23
|
+
&& !args.includes('--get-regexp')
|
|
24
|
+
&& !args.includes('--unset');
|
|
25
|
+
}
|
|
26
|
+
describe('git/credentials lazy-ensure', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
describe('lookupCredentialHelper()', () => {
|
|
31
|
+
it('returns "none" when git has no matching entries (get-regexp exits non-zero)', () => {
|
|
32
|
+
mockExecFileSync.mockImplementation(() => {
|
|
33
|
+
throw new Error('exit 1'); // git config --get-regexp: no match
|
|
34
|
+
});
|
|
35
|
+
expect(lookupCredentialHelper('https://runwork.ai')).toEqual({ status: 'none' });
|
|
36
|
+
});
|
|
37
|
+
it('returns "unscoped" when entries exist but none match the origin', () => {
|
|
38
|
+
mockExecFileSync.mockReturnValue(gitConfigOutput('credential.https://runwork-staging.example.com.helper !runwork git-credential-helper'));
|
|
39
|
+
expect(lookupCredentialHelper('https://runwork.ai')).toEqual({
|
|
40
|
+
status: 'unscoped',
|
|
41
|
+
otherCount: 1,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
it('returns "registered" with the helper value when a scoped entry exists', () => {
|
|
45
|
+
mockExecFileSync.mockReturnValue(gitConfigOutput('credential.https://runwork.ai.helper !"/usr/local/bin/runwork" git-credential-helper'));
|
|
46
|
+
expect(lookupCredentialHelper('https://runwork.ai')).toEqual({
|
|
47
|
+
status: 'registered',
|
|
48
|
+
value: '!"/usr/local/bin/runwork" git-credential-helper',
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it('matches the correct origin among multiple entries', () => {
|
|
52
|
+
mockExecFileSync.mockReturnValue(gitConfigOutput('credential.https://runwork-staging.example.com.helper !runwork git-credential-helper', 'credential.https://runwork.ai.helper !"/usr/local/bin/runwork" git-credential-helper'));
|
|
53
|
+
const result = lookupCredentialHelper('https://runwork.ai');
|
|
54
|
+
expect(result.status).toBe('registered');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('helperBinaryStatus()', () => {
|
|
58
|
+
it('trusts PATH-relative helper values (no on-disk check)', () => {
|
|
59
|
+
expect(helperBinaryStatus('!runwork git-credential-helper')).toEqual({ ok: true, path: null });
|
|
60
|
+
expect(mockExistsSync).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
it('reports ok when an absolute-path binary exists', () => {
|
|
63
|
+
vi.mocked(mockExistsSync).mockReturnValue(true);
|
|
64
|
+
expect(helperBinaryStatus('!"/usr/local/bin/runwork" git-credential-helper')).toEqual({
|
|
65
|
+
ok: true,
|
|
66
|
+
path: '/usr/local/bin/runwork',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
it('reports not-ok with the path when an absolute-path binary is missing', () => {
|
|
70
|
+
vi.mocked(mockExistsSync).mockReturnValue(false);
|
|
71
|
+
expect(helperBinaryStatus('!"/old/path/runwork" git-credential-helper')).toEqual({
|
|
72
|
+
ok: false,
|
|
73
|
+
path: '/old/path/runwork',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
it('handles Windows absolute paths', () => {
|
|
77
|
+
vi.mocked(mockExistsSync).mockReturnValue(true);
|
|
78
|
+
const result = helperBinaryStatus('!"C:/Users/test/.runwork/bin/runwork.exe" git-credential-helper');
|
|
79
|
+
expect(result.ok).toBe(true);
|
|
80
|
+
expect(result.path).toBe('C:/Users/test/.runwork/bin/runwork.exe');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('ensureGitCredentialHelper()', () => {
|
|
84
|
+
it('is a no-op when a valid scoped helper is already registered', async () => {
|
|
85
|
+
mockExecFileSync.mockImplementation((_file, args) => {
|
|
86
|
+
if (args.includes('--get-regexp')) {
|
|
87
|
+
return gitConfigOutput('credential.https://runwork.ai.helper !runwork git-credential-helper');
|
|
88
|
+
}
|
|
89
|
+
return Buffer.from('');
|
|
90
|
+
});
|
|
91
|
+
await ensureGitCredentialHelper('https://runwork.ai/api/git/ws/app');
|
|
92
|
+
// No registration write should have happened.
|
|
93
|
+
expect(mockExecFileSync.mock.calls.some(isHelperWrite)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
it('registers the helper when none is present (the get_cli_setup path)', async () => {
|
|
96
|
+
mockExecFileSync.mockImplementation((_file, args) => {
|
|
97
|
+
if (args.includes('--get-regexp')) {
|
|
98
|
+
throw new Error('exit 1'); // nothing registered yet
|
|
99
|
+
}
|
|
100
|
+
return Buffer.from('');
|
|
101
|
+
});
|
|
102
|
+
await ensureGitCredentialHelper('https://runwork.ai/api/git/ws/app');
|
|
103
|
+
const write = mockExecFileSync.mock.calls.find(isHelperWrite);
|
|
104
|
+
expect(write).toBeDefined();
|
|
105
|
+
expect(write[1]).toContain('credential.https://runwork.ai.helper');
|
|
106
|
+
});
|
|
107
|
+
it('registers when entries exist but none are scoped to this origin', async () => {
|
|
108
|
+
mockExecFileSync.mockImplementation((_file, args) => {
|
|
109
|
+
if (args.includes('--get-regexp')) {
|
|
110
|
+
return gitConfigOutput('credential.https://other.example.com.helper !runwork git-credential-helper');
|
|
111
|
+
}
|
|
112
|
+
return Buffer.from('');
|
|
113
|
+
});
|
|
114
|
+
await ensureGitCredentialHelper('https://runwork.ai');
|
|
115
|
+
expect(mockExecFileSync.mock.calls.some(isHelperWrite)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
it('repairs a stale helper that points at a missing binary', async () => {
|
|
118
|
+
vi.mocked(mockExistsSync).mockReturnValue(false);
|
|
119
|
+
mockExecFileSync.mockImplementation((_file, args) => {
|
|
120
|
+
if (args.includes('--get-regexp')) {
|
|
121
|
+
return gitConfigOutput('credential.https://runwork.ai.helper !"/old/path/runwork" git-credential-helper');
|
|
122
|
+
}
|
|
123
|
+
return Buffer.from('');
|
|
124
|
+
});
|
|
125
|
+
await ensureGitCredentialHelper('https://runwork.ai');
|
|
126
|
+
expect(mockExecFileSync.mock.calls.some(isHelperWrite)).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
it('is a no-op for an unparseable baseUrl', async () => {
|
|
129
|
+
await ensureGitCredentialHelper('not a url');
|
|
130
|
+
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process');
|
|
3
|
+
const mockExecFileSync = (await import('child_process')).execFileSync;
|
|
4
|
+
const { normalizeRemoteUrl, listRemotes, ensureRunworkRemote, } = await import('../remote.js');
|
|
5
|
+
/** Build `git remote -v` stdout from name->url pairs (fetch + push lines). */
|
|
6
|
+
function remoteVerbose(pairs) {
|
|
7
|
+
const lines = [];
|
|
8
|
+
for (const [name, url] of Object.entries(pairs)) {
|
|
9
|
+
lines.push(`${name}\t${url} (fetch)`);
|
|
10
|
+
lines.push(`${name}\t${url} (push)`);
|
|
11
|
+
}
|
|
12
|
+
return Buffer.from(lines.join('\n') + '\n');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Drive ensureRunworkRemote/listRemotes against a fixed `git remote -v`
|
|
16
|
+
* result, recording any set-url/add mutations.
|
|
17
|
+
*/
|
|
18
|
+
function withRemotes(pairs) {
|
|
19
|
+
const setUrl = [];
|
|
20
|
+
const add = [];
|
|
21
|
+
mockExecFileSync.mockImplementation((_file, args) => {
|
|
22
|
+
if (args[0] === 'remote' && args[1] === '-v') {
|
|
23
|
+
return remoteVerbose(pairs);
|
|
24
|
+
}
|
|
25
|
+
if (args[0] === 'remote' && args[1] === 'set-url') {
|
|
26
|
+
setUrl.push(args);
|
|
27
|
+
return Buffer.from('');
|
|
28
|
+
}
|
|
29
|
+
if (args[0] === 'remote' && args[1] === 'add') {
|
|
30
|
+
add.push(args);
|
|
31
|
+
return Buffer.from('');
|
|
32
|
+
}
|
|
33
|
+
return Buffer.from('');
|
|
34
|
+
});
|
|
35
|
+
return { setUrl, add };
|
|
36
|
+
}
|
|
37
|
+
const CANONICAL = 'https://runwork.ai/api/git/ws/app';
|
|
38
|
+
describe('git/remote', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks();
|
|
41
|
+
});
|
|
42
|
+
describe('normalizeRemoteUrl()', () => {
|
|
43
|
+
it('strips a trailing .git', () => {
|
|
44
|
+
expect(normalizeRemoteUrl('https://runwork.ai/api/git/ws/app.git')).toBe(CANONICAL);
|
|
45
|
+
});
|
|
46
|
+
it('strips trailing slashes and lower-cases the host (path preserved)', () => {
|
|
47
|
+
expect(normalizeRemoteUrl('https://RunWork.ai/api/git/ws/app/')).toBe(CANONICAL);
|
|
48
|
+
});
|
|
49
|
+
it('drops userinfo so embedded creds do not break equality', () => {
|
|
50
|
+
expect(normalizeRemoteUrl('https://x-access-token:KEY@runwork.ai/api/git/ws/app')).toBe(CANONICAL);
|
|
51
|
+
});
|
|
52
|
+
it('falls back to a lower-cased trim for scp-style remotes', () => {
|
|
53
|
+
expect(normalizeRemoteUrl('git@GitHub.com:Foo/Bar.git')).toBe('git@github.com:foo/bar');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('listRemotes()', () => {
|
|
57
|
+
it('parses name -> fetch URL, ignoring push duplicates', () => {
|
|
58
|
+
withRemotes({ origin: 'https://example.com/a', runwork: CANONICAL });
|
|
59
|
+
expect(listRemotes('/repo')).toEqual({
|
|
60
|
+
origin: 'https://example.com/a',
|
|
61
|
+
runwork: CANONICAL,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
it('returns {} when git fails (not a repo)', () => {
|
|
65
|
+
mockExecFileSync.mockImplementation(() => { throw new Error('not a git repo'); });
|
|
66
|
+
expect(listRemotes('/repo')).toEqual({});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('ensureRunworkRemote()', () => {
|
|
70
|
+
it('is a no-op when runwork already points at the canonical URL', () => {
|
|
71
|
+
const { setUrl, add } = withRemotes({ runwork: CANONICAL });
|
|
72
|
+
expect(ensureRunworkRemote('/repo', CANONICAL)).toEqual({ action: 'noop' });
|
|
73
|
+
expect(setUrl).toHaveLength(0);
|
|
74
|
+
expect(add).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
it('treats a .git-suffixed existing runwork remote as a match (no rewrite)', () => {
|
|
77
|
+
const { setUrl, add } = withRemotes({ runwork: `${CANONICAL}.git` });
|
|
78
|
+
expect(ensureRunworkRemote('/repo', CANONICAL)).toEqual({ action: 'noop' });
|
|
79
|
+
expect(setUrl).toHaveLength(0);
|
|
80
|
+
expect(add).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
it('repoints an existing runwork remote that points elsewhere', () => {
|
|
83
|
+
const { setUrl, add } = withRemotes({ runwork: 'https://runwork.ai/api/git/OLD/app' });
|
|
84
|
+
expect(ensureRunworkRemote('/repo', CANONICAL)).toEqual({ action: 'updated' });
|
|
85
|
+
expect(setUrl).toHaveLength(1);
|
|
86
|
+
expect(setUrl[0]).toEqual(['remote', 'set-url', 'runwork', CANONICAL]);
|
|
87
|
+
expect(add).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
it('adds the runwork remote when missing (clean repo)', () => {
|
|
90
|
+
const { setUrl, add } = withRemotes({});
|
|
91
|
+
expect(ensureRunworkRemote('/repo', CANONICAL)).toEqual({ action: 'added' });
|
|
92
|
+
expect(add).toHaveLength(1);
|
|
93
|
+
expect(add[0]).toEqual(['remote', 'add', 'runwork', CANONICAL]);
|
|
94
|
+
expect(setUrl).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
it('recognizes a matching origin (manual git clone) via reusedFrom and still adds the runwork alias', () => {
|
|
97
|
+
const { setUrl, add } = withRemotes({ origin: CANONICAL });
|
|
98
|
+
expect(ensureRunworkRemote('/repo', CANONICAL)).toEqual({ action: 'added', reusedFrom: 'origin' });
|
|
99
|
+
expect(add).toHaveLength(1);
|
|
100
|
+
expect(add[0]).toEqual(['remote', 'add', 'runwork', CANONICAL]);
|
|
101
|
+
expect(setUrl).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
it('does not set reusedFrom when no existing remote matches', () => {
|
|
104
|
+
withRemotes({ origin: 'https://github.com/someone/fork' });
|
|
105
|
+
const result = ensureRunworkRemote('/repo', CANONICAL);
|
|
106
|
+
expect(result).toEqual({ action: 'added' });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process');
|
|
3
|
+
const mockExecFileSync = (await import('child_process')).execFileSync;
|
|
4
|
+
const { hardenRepoForRestrictedFs } = await import('../repo-config.js');
|
|
5
|
+
describe('git/repo-config hardenRepoForRestrictedFs', () => {
|
|
6
|
+
beforeEach(() => vi.clearAllMocks());
|
|
7
|
+
it('disables gc.auto and maintenance.auto in the repo (local scope)', () => {
|
|
8
|
+
mockExecFileSync.mockReturnValue(Buffer.from(''));
|
|
9
|
+
hardenRepoForRestrictedFs('/repo');
|
|
10
|
+
const configCalls = mockExecFileSync.mock.calls
|
|
11
|
+
.filter((c) => c[0] === 'git' && c[1][0] === 'config')
|
|
12
|
+
.map((c) => c[1].slice(1));
|
|
13
|
+
expect(configCalls).toContainEqual(['gc.auto', '0']);
|
|
14
|
+
expect(configCalls).toContainEqual(['maintenance.auto', 'false']);
|
|
15
|
+
// Repo scope, not --global.
|
|
16
|
+
expect(configCalls.every((args) => !args.includes('--global'))).toBe(true);
|
|
17
|
+
// Run against the given cwd.
|
|
18
|
+
for (const call of mockExecFileSync.mock.calls) {
|
|
19
|
+
expect(call[2].cwd).toBe('/repo');
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
it('never throws when git config fails (best-effort)', () => {
|
|
23
|
+
mockExecFileSync.mockImplementation(() => { throw new Error('git unavailable'); });
|
|
24
|
+
expect(() => hardenRepoForRestrictedFs('/repo')).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a git sync failure from the raw stderr that `syncWithRemote`
|
|
3
|
+
* captures, so `runwork dev`/`deploy` can show an accurate cause and fix
|
|
4
|
+
* instead of the old catch-all "the sandbox session expired or the network
|
|
5
|
+
* connection was interrupted" (which pointed users away from the real
|
|
6
|
+
* problem, e.g. a missing `runwork` remote -- see friction log #3).
|
|
7
|
+
*/
|
|
8
|
+
export type SyncErrorReason = 'no-remote' | 'auth' | 'network' | 'conflict' | 'unknown';
|
|
9
|
+
export interface SyncErrorDiagnosis {
|
|
10
|
+
reason: SyncErrorReason;
|
|
11
|
+
/** One-line summary suitable for an `error.message` field. */
|
|
12
|
+
message: string;
|
|
13
|
+
/** Plain explanation of the likely cause. */
|
|
14
|
+
diagnosis: string;
|
|
15
|
+
/** Actionable next steps, most-likely-fix first. */
|
|
16
|
+
suggestions: string[];
|
|
17
|
+
}
|
|
18
|
+
/** Map raw git stderr (or the stash-conflict sentinel) to a coarse reason. */
|
|
19
|
+
export declare function classifySyncError(raw: string | undefined): SyncErrorReason;
|
|
20
|
+
/** Build a full, user-facing diagnosis from the raw git stderr. */
|
|
21
|
+
export declare function diagnoseSyncError(raw: string | undefined): SyncErrorDiagnosis;
|