runwork 0.13.4 → 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/bundled-types/core-endpoints.d.ts +16 -4
- 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/claude-desktop-plugin-tree.js +23 -8
- package/dist/agents/claude-desktop.js +6 -3
- package/dist/agents/codex.js +15 -7
- 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/api/client.d.ts +5 -0
- package/dist/api/client.js +8 -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/__tests__/mcp-entries.test.d.ts +1 -0
- package/dist/commands/__tests__/mcp-entries.test.js +48 -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/mcp-entries.d.ts +13 -0
- package/dist/commands/mcp-entries.js +26 -0
- package/dist/commands/sync.js +20 -11
- 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/bundled-types.js +1 -1
- 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/types.d.ts +3 -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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { normalizeCloneArgs } from '../clone.js';
|
|
2
|
+
import { normalizeCloneArgs, RESTRICTED_FS_HELP } from '../clone.js';
|
|
3
3
|
describe('normalizeCloneArgs', () => {
|
|
4
4
|
it('passes through both args unchanged when --app is not provided', () => {
|
|
5
5
|
expect(normalizeCloneArgs('app-id', 'C:/dir', undefined)).toEqual({
|
|
@@ -42,3 +42,10 @@ describe('normalizeCloneArgs', () => {
|
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
44
|
});
|
|
45
|
+
describe('clone --help restricted-FS guidance', () => {
|
|
46
|
+
it('warns agents about restricted "outputs" mounts and the remedy', () => {
|
|
47
|
+
expect(RESTRICTED_FS_HELP).toContain('Operation not permitted');
|
|
48
|
+
expect(RESTRICTED_FS_HELP).toContain('outputs');
|
|
49
|
+
expect(RESTRICTED_FS_HELP).toContain('home directory');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { pullOutcomeFromError, isCloneSourceFailure } from '../clone.js';
|
|
3
|
+
/** Fake an execFileSync error carrying git's stderr. */
|
|
4
|
+
function gitError(stderr) {
|
|
5
|
+
const err = new Error('git failed');
|
|
6
|
+
err.stderr = Buffer.from(stderr);
|
|
7
|
+
return err;
|
|
8
|
+
}
|
|
9
|
+
describe('clone source outcome', () => {
|
|
10
|
+
describe('pullOutcomeFromError()', () => {
|
|
11
|
+
it('classifies an empty remote (brand-new app) as benign', () => {
|
|
12
|
+
const src = pullOutcomeFromError(gitError("fatal: couldn't find remote ref main"));
|
|
13
|
+
expect(src.pulled).toBe(false);
|
|
14
|
+
expect(src.reason).toBe('empty-remote');
|
|
15
|
+
});
|
|
16
|
+
it('classifies an auth failure', () => {
|
|
17
|
+
const src = pullOutcomeFromError(gitError('fatal: Authentication failed for https://runwork.ai'));
|
|
18
|
+
expect(src.reason).toBe('auth');
|
|
19
|
+
});
|
|
20
|
+
it('classifies a network failure', () => {
|
|
21
|
+
const src = pullOutcomeFromError(gitError('fatal: unable to access: Could not resolve host: runwork.ai'));
|
|
22
|
+
expect(src.reason).toBe('network');
|
|
23
|
+
});
|
|
24
|
+
it('classifies a missing remote', () => {
|
|
25
|
+
const src = pullOutcomeFromError(gitError("fatal: 'runwork' does not appear to be a git repository"));
|
|
26
|
+
expect(src.reason).toBe('no-remote');
|
|
27
|
+
});
|
|
28
|
+
it('preserves the raw stderr as detail', () => {
|
|
29
|
+
const src = pullOutcomeFromError(gitError('fatal: Authentication failed'));
|
|
30
|
+
expect(src.detail).toContain('Authentication failed');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe('isCloneSourceFailure()', () => {
|
|
34
|
+
it('is false for a successful pull', () => {
|
|
35
|
+
expect(isCloneSourceFailure({ pulled: true })).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it('is false for an empty remote (expected for new apps)', () => {
|
|
38
|
+
expect(isCloneSourceFailure({ pulled: false, reason: 'empty-remote' })).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
it.each(['auth', 'network', 'no-remote', 'unknown'])('is true for a real %s failure (template-only scaffold)', (reason) => {
|
|
41
|
+
const src = { pulled: false, reason };
|
|
42
|
+
expect(isCloneSourceFailure(src)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildExternalMcpEntries } from '../mcp-entries.js';
|
|
3
|
+
const PLATFORM_KEY = 'rk_ws_live_supersecretplatformkey';
|
|
4
|
+
function server(overrides) {
|
|
5
|
+
return {
|
|
6
|
+
id: 'srv-1',
|
|
7
|
+
name: 'example',
|
|
8
|
+
url: 'https://mcp.example.com',
|
|
9
|
+
transport: 'streamable-http',
|
|
10
|
+
enabled: true,
|
|
11
|
+
addedAt: 0,
|
|
12
|
+
addedBy: 'user-1',
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
describe('buildExternalMcpEntries', () => {
|
|
17
|
+
it('never injects the Runwork platform key onto external entries', () => {
|
|
18
|
+
const entries = buildExternalMcpEntries([
|
|
19
|
+
server({ id: 'a', authType: 'header' }),
|
|
20
|
+
server({ id: 'b', authType: 'oauth' }),
|
|
21
|
+
server({ id: 'c', authType: 'none' }),
|
|
22
|
+
server({ id: 'd' }), // authType unset
|
|
23
|
+
], { a: { Authorization: 'Bearer user-token' } });
|
|
24
|
+
// Regression: the platform key must not appear anywhere in the output.
|
|
25
|
+
expect(JSON.stringify(entries)).not.toContain(PLATFORM_KEY);
|
|
26
|
+
});
|
|
27
|
+
it('injects resolved per-server headers for header-auth servers', () => {
|
|
28
|
+
const entries = buildExternalMcpEntries([server({ id: 'a', authType: 'header' })], { a: { Authorization: 'Bearer tok', 'X-Api-Key': 'key123' } });
|
|
29
|
+
expect(entries[0].headers).toEqual({ Authorization: 'Bearer tok', 'X-Api-Key': 'key123' });
|
|
30
|
+
});
|
|
31
|
+
it('emits no auth header for oauth servers (local agent runs its own flow)', () => {
|
|
32
|
+
const entries = buildExternalMcpEntries([server({ id: 'a', authType: 'oauth' })], { a: { Authorization: 'Bearer should-not-be-used' } });
|
|
33
|
+
expect(entries[0].headers).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
it('emits no auth header for none/unset servers', () => {
|
|
36
|
+
const entries = buildExternalMcpEntries([server({ id: 'a', authType: 'none' }), server({ id: 'b' })], {});
|
|
37
|
+
expect(entries[0].headers).toBeUndefined();
|
|
38
|
+
expect(entries[1].headers).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
it('omits headers when a header-auth server has no resolved credentials', () => {
|
|
41
|
+
const entries = buildExternalMcpEntries([server({ id: 'a', authType: 'header' })], {});
|
|
42
|
+
expect(entries[0].headers).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
it('skips disabled servers', () => {
|
|
45
|
+
const entries = buildExternalMcpEntries([server({ id: 'a', enabled: false })], {});
|
|
46
|
+
expect(entries).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
});
|
package/dist/commands/clone.d.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { ApiClient } from '../api/client.js';
|
|
3
3
|
import type { Credentials, AppInfo } from '../types.js';
|
|
4
|
+
import { type SyncErrorReason } from '../git/classify-sync-error.js';
|
|
5
|
+
/**
|
|
6
|
+
* Outcome of overlaying the app's git-tracked source on top of the template.
|
|
7
|
+
* `pulled: false` with `reason: 'empty-remote'` is the expected, benign state
|
|
8
|
+
* for a brand-new app (nothing pushed yet). Any other `reason` is a real
|
|
9
|
+
* failure that left a template-only scaffold -- the caller must NOT report
|
|
10
|
+
* success, since editing it would edit the wrong code (friction log #1).
|
|
11
|
+
*/
|
|
12
|
+
export interface CloneSource {
|
|
13
|
+
pulled: boolean;
|
|
14
|
+
reason?: 'empty-remote' | SyncErrorReason;
|
|
15
|
+
detail?: string;
|
|
16
|
+
}
|
|
4
17
|
export interface CloneResult {
|
|
5
18
|
appId: string;
|
|
6
19
|
appName: string;
|
|
@@ -8,8 +21,21 @@ export interface CloneResult {
|
|
|
8
21
|
directory: string;
|
|
9
22
|
workspaceId: string;
|
|
10
23
|
workspaceName: string;
|
|
24
|
+
source: CloneSource;
|
|
11
25
|
}
|
|
12
26
|
export declare function execClone(client: ApiClient, app: AppInfo, directory?: string, creds?: Credentials | null): Promise<CloneResult>;
|
|
27
|
+
/**
|
|
28
|
+
* Turn a failed-pull error into a CloneSource: an empty remote (benign,
|
|
29
|
+
* brand-new app) vs a classified real failure (auth/network/no-remote) that
|
|
30
|
+
* leaves a template-only scaffold.
|
|
31
|
+
*/
|
|
32
|
+
export declare function pullOutcomeFromError(err: unknown): CloneSource;
|
|
33
|
+
/**
|
|
34
|
+
* A clone is a real failure -- a template-only scaffold rather than the app's
|
|
35
|
+
* code -- when the source pull didn't happen for any reason other than an
|
|
36
|
+
* empty remote. Callers must not report success in that case.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isCloneSourceFailure(source: CloneSource): boolean;
|
|
13
39
|
/**
|
|
14
40
|
* Reconcile positional args with --app. When --app is provided, the first
|
|
15
41
|
* positional argument is intended as the *directory* -- not another appId
|
|
@@ -24,4 +50,11 @@ export declare function normalizeCloneArgs(appId: string | undefined, directory:
|
|
|
24
50
|
appId: string | undefined;
|
|
25
51
|
directory: string | undefined;
|
|
26
52
|
};
|
|
53
|
+
/**
|
|
54
|
+
* Help epilogue shown under `runwork clone --help`. Agents tend to read help
|
|
55
|
+
* before running, so we surface the restricted-mount caveat (friction log #9)
|
|
56
|
+
* here. Exported so it can be asserted on directly (commander's help-text
|
|
57
|
+
* hooks aren't reflected by `helpInformation()`).
|
|
58
|
+
*/
|
|
59
|
+
export declare const RESTRICTED_FS_HELP = "\nNote for AI agents: clone into a normal working directory. Some sandboxed\n\"outputs\" mounts (e.g. certain agent environments) disallow the unlink\noperations git uses for pack files, which can corrupt the repo with errors\nlike \"unable to unlink '.git/objects/pack/...': Operation not permitted\". If\nyou hit that, clone into your home directory or another standard path instead.";
|
|
27
60
|
export declare const cloneCommand: Command;
|
package/dist/commands/clone.js
CHANGED
|
@@ -13,6 +13,10 @@ import { runAgentWizard } from '../ui/banner.js';
|
|
|
13
13
|
import { shouldOutputJson, jsonOut } from '../utils/output.js';
|
|
14
14
|
import { buildCloneGuide, buildErrorResponse } from '../utils/agent-guidance.js';
|
|
15
15
|
import { ensureGitIdentity } from '../git/identity.js';
|
|
16
|
+
import { ensureGitCredentialHelper } from '../git/credentials.js';
|
|
17
|
+
import { ensureRunworkRemote } from '../git/remote.js';
|
|
18
|
+
import { hardenRepoForRestrictedFs } from '../git/repo-config.js';
|
|
19
|
+
import { classifySyncError } from '../git/classify-sync-error.js';
|
|
16
20
|
import { requireGit } from '../git/preflight.js';
|
|
17
21
|
import { formatError } from '../utils/format-error.js';
|
|
18
22
|
export async function execClone(client, app, directory, creds) {
|
|
@@ -58,16 +62,24 @@ export async function execClone(client, app, directory, creds) {
|
|
|
58
62
|
execFileSync('git', ['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: dir, stdio: 'pipe' });
|
|
59
63
|
}
|
|
60
64
|
catch { /* best-effort: push uses HEAD:main so this is just cleanliness */ }
|
|
65
|
+
// Disable background gc/maintenance so pack rewrites don't fail on
|
|
66
|
+
// restricted filesystems (e.g. some agent "outputs" mounts). Harmless on
|
|
67
|
+
// normal filesystems. See git/repo-config.ts.
|
|
68
|
+
hardenRepoForRestrictedFs(dir);
|
|
61
69
|
}
|
|
62
70
|
// Seed a local git identity so subsequent commits do not fail on machines
|
|
63
71
|
// (commonly fresh Windows installs) without `git config --global user.email`.
|
|
64
72
|
ensureGitIdentity(dir, creds);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
// Make sure git can authenticate to the runwork remote before we fetch.
|
|
74
|
+
// Headless auth paths (the get_cli_setup MCP tool, or a hand-written
|
|
75
|
+
// ~/.runwork/.credentials) register API creds but not the git helper, so
|
|
76
|
+
// without this the fetch below silently fails and we fall back to a
|
|
77
|
+
// template-only scaffold. See git/credentials.ts:ensureGitCredentialHelper.
|
|
78
|
+
if (creds?.baseUrl) {
|
|
79
|
+
await ensureGitCredentialHelper(creds.baseUrl);
|
|
70
80
|
}
|
|
81
|
+
ensureRunworkRemote(dir, remoteUrl);
|
|
82
|
+
let source = { pulled: true };
|
|
71
83
|
try {
|
|
72
84
|
execFileSync('git', ['fetch', 'runwork', 'main'], { cwd: dir, stdio: 'pipe' });
|
|
73
85
|
// Reset to remote HEAD - overlays git-tracked files on top of template
|
|
@@ -76,11 +88,19 @@ export async function execClone(client, app, directory, creds) {
|
|
|
76
88
|
execFileSync('git', ['checkout', '.'], { cwd: dir, stdio: 'pipe' });
|
|
77
89
|
}
|
|
78
90
|
catch (err) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
source = pullOutcomeFromError(err);
|
|
92
|
+
if (source.reason === 'empty-remote') {
|
|
93
|
+
// Brand-new app: the remote has no `main` branch yet. Expected and
|
|
94
|
+
// benign -- the template scaffold IS the starting point.
|
|
95
|
+
console.warn('No source pushed for this app yet. Starting from the template scaffold.');
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
// A real failure (auth, missing remote, network). The scaffold here is
|
|
99
|
+
// NOT the app's code -- surface it loudly so the caller fails instead of
|
|
100
|
+
// silently editing the wrong files.
|
|
101
|
+
console.warn('Could not pull the app source from the runwork remote.');
|
|
102
|
+
console.warn(` ${formatError(err)}`);
|
|
103
|
+
}
|
|
84
104
|
}
|
|
85
105
|
// Write .runwork.json config
|
|
86
106
|
const config = {
|
|
@@ -107,8 +127,45 @@ export async function execClone(client, app, directory, creds) {
|
|
|
107
127
|
directory: resolve(dir),
|
|
108
128
|
workspaceId: app.workspaceId,
|
|
109
129
|
workspaceName: app.workspaceName,
|
|
130
|
+
source,
|
|
110
131
|
};
|
|
111
132
|
}
|
|
133
|
+
/** Pull the raw git stderr off an execFileSync error for classification. */
|
|
134
|
+
function extractStderr(err) {
|
|
135
|
+
const e = err;
|
|
136
|
+
if (e?.stderr)
|
|
137
|
+
return e.stderr.toString().trim();
|
|
138
|
+
return formatError(err);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether a failed `git fetch runwork main` means the remote simply has no
|
|
142
|
+
* `main` branch yet (a brand-new app), as opposed to an auth/network/remote
|
|
143
|
+
* failure. Git reports this as "couldn't find remote ref main".
|
|
144
|
+
*/
|
|
145
|
+
function isEmptyRemote(stderr) {
|
|
146
|
+
const s = stderr.toLowerCase();
|
|
147
|
+
return s.includes("couldn't find remote ref") || s.includes('could not find remote ref');
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Turn a failed-pull error into a CloneSource: an empty remote (benign,
|
|
151
|
+
* brand-new app) vs a classified real failure (auth/network/no-remote) that
|
|
152
|
+
* leaves a template-only scaffold.
|
|
153
|
+
*/
|
|
154
|
+
export function pullOutcomeFromError(err) {
|
|
155
|
+
const detail = extractStderr(err);
|
|
156
|
+
if (isEmptyRemote(detail)) {
|
|
157
|
+
return { pulled: false, reason: 'empty-remote', detail };
|
|
158
|
+
}
|
|
159
|
+
return { pulled: false, reason: classifySyncError(detail), detail };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* A clone is a real failure -- a template-only scaffold rather than the app's
|
|
163
|
+
* code -- when the source pull didn't happen for any reason other than an
|
|
164
|
+
* empty remote. Callers must not report success in that case.
|
|
165
|
+
*/
|
|
166
|
+
export function isCloneSourceFailure(source) {
|
|
167
|
+
return !source.pulled && source.reason !== 'empty-remote';
|
|
168
|
+
}
|
|
112
169
|
/**
|
|
113
170
|
* Reconcile positional args with --app. When --app is provided, the first
|
|
114
171
|
* positional argument is intended as the *directory* -- not another appId
|
|
@@ -123,11 +180,24 @@ export function normalizeCloneArgs(appId, directory, options) {
|
|
|
123
180
|
}
|
|
124
181
|
return { appId, directory };
|
|
125
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Help epilogue shown under `runwork clone --help`. Agents tend to read help
|
|
185
|
+
* before running, so we surface the restricted-mount caveat (friction log #9)
|
|
186
|
+
* here. Exported so it can be asserted on directly (commander's help-text
|
|
187
|
+
* hooks aren't reflected by `helpInformation()`).
|
|
188
|
+
*/
|
|
189
|
+
export const RESTRICTED_FS_HELP = `
|
|
190
|
+
Note for AI agents: clone into a normal working directory. Some sandboxed
|
|
191
|
+
"outputs" mounts (e.g. certain agent environments) disallow the unlink
|
|
192
|
+
operations git uses for pack files, which can corrupt the repo with errors
|
|
193
|
+
like "unable to unlink '.git/objects/pack/...': Operation not permitted". If
|
|
194
|
+
you hit that, clone into your home directory or another standard path instead.`;
|
|
126
195
|
export const cloneCommand = new Command('clone')
|
|
127
196
|
.description('Clone a Runwork app to local development')
|
|
128
197
|
.argument('[appId]', 'App ID to clone (interactive if omitted)')
|
|
129
198
|
.argument('[directory]', 'Target directory')
|
|
130
199
|
.option('--app <name-or-id>', 'App name or ID (skips interactive selection)')
|
|
200
|
+
.addHelpText('after', RESTRICTED_FS_HELP)
|
|
131
201
|
.action(async (rawAppId, rawDirectory, options) => {
|
|
132
202
|
requireGit('clone');
|
|
133
203
|
const { appId, directory } = normalizeCloneArgs(rawAppId, rawDirectory, options);
|
|
@@ -156,20 +226,45 @@ export const cloneCommand = new Command('clone')
|
|
|
156
226
|
console.log(`Cloning "${app.name}"...`);
|
|
157
227
|
}
|
|
158
228
|
const cloneResult = await execClone(client, app, directory, creds);
|
|
229
|
+
// A real pull failure (auth/network/missing-remote) left a template-only
|
|
230
|
+
// scaffold that is NOT the app's deployed code. Report it as a failure so
|
|
231
|
+
// an agent doesn't start editing the wrong files (friction log #1).
|
|
232
|
+
const pullFailed = isCloneSourceFailure(cloneResult.source);
|
|
159
233
|
if (useJson) {
|
|
234
|
+
if (pullFailed) {
|
|
235
|
+
jsonOut(buildErrorResponse('clone', 'Cloned the scaffold but could not pull the app source', `git could not fetch the app's source (${cloneResult.source.reason}), so the directory holds only the template scaffold, not the deployed code. ${cloneResult.source.detail ?? ''}`.trim(), [
|
|
236
|
+
'Run `runwork doctor --fix` to repair git auth and the runwork remote',
|
|
237
|
+
`Then re-clone: runwork clone --app ${cloneResult.appName}`,
|
|
238
|
+
]));
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
160
241
|
const response = {
|
|
161
242
|
success: true,
|
|
162
243
|
command: 'clone',
|
|
163
244
|
result: cloneResult,
|
|
245
|
+
warning: cloneResult.source.reason === 'empty-remote'
|
|
246
|
+
? 'This app has no source pushed yet. The directory is the starting template scaffold.'
|
|
247
|
+
: undefined,
|
|
164
248
|
guide: buildCloneGuide(cloneResult.appName, cloneResult.slug),
|
|
165
249
|
};
|
|
166
250
|
jsonOut(response);
|
|
167
251
|
return;
|
|
168
252
|
}
|
|
253
|
+
if (pullFailed) {
|
|
254
|
+
console.error(`\nCloned the scaffold for "${cloneResult.appName}", but could NOT pull the app source (${cloneResult.source.reason}).`);
|
|
255
|
+
console.error('The directory holds only the template scaffold, not the deployed code.');
|
|
256
|
+
console.error('Fix git access, then re-clone:');
|
|
257
|
+
console.error(' runwork doctor --fix');
|
|
258
|
+
console.error(` runwork clone --app ${cloneResult.appName}`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
169
261
|
// No trailing separator: cloneResult.directory is already an absolute
|
|
170
262
|
// path. Appending '/' here mixed with Windows '\' separators produced
|
|
171
263
|
// confusing output like `C:\Users\...\app/` on Windows.
|
|
172
264
|
console.log(`\nApp "${cloneResult.appName}" cloned to ${cloneResult.directory}`);
|
|
265
|
+
if (cloneResult.source.reason === 'empty-remote') {
|
|
266
|
+
console.log('Note: no source pushed for this app yet. Starting from the template scaffold.');
|
|
267
|
+
}
|
|
173
268
|
console.log(`Remote: ${client.getGitRemoteUrl(cloneResult.workspaceId, cloneResult.appId)}`);
|
|
174
269
|
await runAgentWizard(cloneResult.directory);
|
|
175
270
|
console.log(`Next: cd ${cloneResult.slug} && runwork dev`);
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
1
|
+
import { Command, Option } from 'commander';
|
|
2
2
|
import { readFileSync, existsSync } from 'fs';
|
|
3
3
|
import { requireAuth } from '../auth/store.js';
|
|
4
4
|
import { ApiClient } from '../api/client.js';
|
|
5
|
-
import { shouldOutputJson, jsonOut } from '../utils/output.js';
|
|
5
|
+
import { shouldOutputJson, jsonOut, jsonLine } from '../utils/output.js';
|
|
6
|
+
import { dim, green, yellow } from '../ui/colors.js';
|
|
7
|
+
import { getHeadSha, writeDeployState, getDeploySummary } from '../deploy/deploy-state.js';
|
|
8
|
+
import { writeDeployStatus, readDeployStatus, deployLogPath } from '../deploy/deploy-status.js';
|
|
9
|
+
import { isInternalDeployChild, buildDeployChildArgs, spawnDetachedDeploy, DEPLOY_DETACHED_CHILD_FLAG } from '../deploy/detach.js';
|
|
6
10
|
import { buildDeployGuide, buildErrorResponse } from '../utils/agent-guidance.js';
|
|
7
11
|
import { requireGit } from '../git/preflight.js';
|
|
8
12
|
import { ensureGitIdentity } from '../git/identity.js';
|
|
13
|
+
import { ensureGitCredentialHelper } from '../git/credentials.js';
|
|
14
|
+
import { ensureRunworkRemote } from '../git/remote.js';
|
|
15
|
+
import { diagnoseSyncError } from '../git/classify-sync-error.js';
|
|
9
16
|
import { commitWorkingTree } from '../git/auto-commit.js';
|
|
10
17
|
import { syncWithRemote, hasCommits } from '../git/sync.js';
|
|
11
18
|
import { snapshotCriticalFiles, restoreMissingCriticalFiles, commitAndPushRestoredFiles } from '../git/critical-files.js';
|
|
@@ -14,8 +21,14 @@ import { promptConfirm } from '../utils/prompt.js';
|
|
|
14
21
|
export const deployCommand = new Command('deploy')
|
|
15
22
|
.description('Deploy the current app to production')
|
|
16
23
|
.option('-y, --yes', 'Skip the confirmation prompt when the working tree differs from the preview')
|
|
24
|
+
.option('--detach', 'Run the deploy in the background and return immediately. Logs go to .runwork/deploy-{stdout,stderr}.log; poll `runwork deploy --status`.')
|
|
25
|
+
.option('--status', 'Show the status (in-progress/succeeded/failed) of the last deploy started from this machine, plus the deployed-vs-local commit.')
|
|
26
|
+
// Hidden marker the `--detach` parent passes to its background child. Not
|
|
27
|
+
// for end users -- registered so commander accepts it on the child.
|
|
28
|
+
.addOption(new Option(DEPLOY_DETACHED_CHILD_FLAG).hideHelp())
|
|
17
29
|
.action(async (opts, command) => {
|
|
18
30
|
const useJson = shouldOutputJson(command.optsWithGlobals().json);
|
|
31
|
+
const cwd = process.cwd();
|
|
19
32
|
if (!existsSync('.runwork.json')) {
|
|
20
33
|
if (useJson) {
|
|
21
34
|
jsonOut(buildErrorResponse('deploy', 'No .runwork.json found', 'This directory is not a Runwork app.', ['Run runwork init to create an app first', 'Or cd into an existing app directory']));
|
|
@@ -24,11 +37,50 @@ export const deployCommand = new Command('deploy')
|
|
|
24
37
|
console.error('No .runwork.json found. Run `runwork init` first.');
|
|
25
38
|
process.exit(1);
|
|
26
39
|
}
|
|
40
|
+
// --status: a local read; no auth/git required.
|
|
41
|
+
if (opts.status) {
|
|
42
|
+
printDeployStatus(cwd, useJson);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
27
45
|
requireGit('deploy');
|
|
28
46
|
const config = JSON.parse(readFileSync('.runwork.json', 'utf-8'));
|
|
47
|
+
const isChild = isInternalDeployChild(process.argv);
|
|
48
|
+
// --detach parent: spawn a background child running the real deploy,
|
|
49
|
+
// record an in-progress status, print a poll hint, and exit immediately so
|
|
50
|
+
// the launching shell doesn't time out (friction log #7).
|
|
51
|
+
if (opts.detach && !isChild) {
|
|
52
|
+
const startedAt = new Date().toISOString();
|
|
53
|
+
const handle = spawnDetachedDeploy(buildDeployChildArgs(process.argv), cwd);
|
|
54
|
+
writeDeployStatus(cwd, { state: 'in-progress', startedAt, pid: handle.pid });
|
|
55
|
+
if (useJson) {
|
|
56
|
+
jsonLine({ event: 'deploy_started', detached: true, pid: handle.pid, logPath: deployLogPath(cwd), startedAt, hint: 'Poll `runwork deploy --status` for completion.' });
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.log(`Deploy started in the background (pid ${handle.pid}).`);
|
|
60
|
+
console.log(`Logs: ${deployLogPath(cwd)}`);
|
|
61
|
+
console.log('Check: runwork deploy --status');
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// The detached child finalizes the status file on exit. If it crashes or
|
|
66
|
+
// hits any `process.exit(1)` failure path before the success block marks
|
|
67
|
+
// it done, record `failed` so `--status` doesn't report a stuck deploy.
|
|
68
|
+
// Register this BEFORE requireAuth(): a child whose credentials expired
|
|
69
|
+
// between spawn and run would otherwise exit in requireAuth() with the
|
|
70
|
+
// status stuck at `in-progress`. (requireGit/config above ran identically
|
|
71
|
+
// in the parent before it spawned this child, so they can't fail here.)
|
|
72
|
+
let deployFinalized = false;
|
|
73
|
+
const childStartedAt = readDeployStatus(cwd)?.startedAt ?? new Date().toISOString();
|
|
74
|
+
if (isChild) {
|
|
75
|
+
jsonLine({ event: 'deploy_started', startedAt: childStartedAt });
|
|
76
|
+
process.on('exit', (code) => {
|
|
77
|
+
if (!deployFinalized && code !== 0) {
|
|
78
|
+
writeDeployStatus(cwd, { state: 'failed', startedAt: childStartedAt, finishedAt: new Date().toISOString(), error: `deploy process exited with code ${code}` });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
29
82
|
const creds = requireAuth();
|
|
30
83
|
const client = new ApiClient(creds);
|
|
31
|
-
const cwd = process.cwd();
|
|
32
84
|
// Deploy guard: the preview reflects the last *pushed* commit. If the
|
|
33
85
|
// working tree is dirty or ahead of `runwork/main` and no auto-syncing
|
|
34
86
|
// dev session is keeping the preview in lockstep, `runwork deploy` will
|
|
@@ -42,8 +94,9 @@ export const deployCommand = new Command('deploy')
|
|
|
42
94
|
// Agents must never be blocked: the warning rides along in the
|
|
43
95
|
// final success response below so we proceed without prompting.
|
|
44
96
|
}
|
|
45
|
-
else if (opts.yes || !process.stdin.isTTY) {
|
|
46
|
-
// Non-interactive (no TTY) or explicit --yes:
|
|
97
|
+
else if (opts.yes || isChild || !process.stdin.isTTY) {
|
|
98
|
+
// Non-interactive (no TTY), the detached child, or explicit --yes:
|
|
99
|
+
// warn and proceed without prompting.
|
|
47
100
|
console.warn(deployGuardWarning);
|
|
48
101
|
}
|
|
49
102
|
else {
|
|
@@ -61,6 +114,16 @@ export const deployCommand = new Command('deploy')
|
|
|
61
114
|
// `ensureGitIdentity` seeds a local identity so the commit cannot fail
|
|
62
115
|
// on a fresh machine where git user.email/.name were never configured.
|
|
63
116
|
ensureGitIdentity(cwd, creds);
|
|
117
|
+
// Ensure git can authenticate to the runwork remote before sync/push.
|
|
118
|
+
// Headless auth (get_cli_setup / hand-written credentials) registers API
|
|
119
|
+
// creds but not the git helper. See
|
|
120
|
+
// git/credentials.ts:ensureGitCredentialHelper.
|
|
121
|
+
if (creds?.baseUrl) {
|
|
122
|
+
await ensureGitCredentialHelper(creds.baseUrl);
|
|
123
|
+
}
|
|
124
|
+
// Ensure the `runwork` remote exists and points at the canonical URL, so a
|
|
125
|
+
// manually `git clone`d repo (remote named `origin`) still syncs/pushes.
|
|
126
|
+
ensureRunworkRemote(cwd, client.getGitRemoteUrl(config.workspaceId, config.appId));
|
|
64
127
|
if (!useJson)
|
|
65
128
|
console.log('Syncing...');
|
|
66
129
|
try {
|
|
@@ -94,15 +157,14 @@ export const deployCommand = new Command('deploy')
|
|
|
94
157
|
}
|
|
95
158
|
}
|
|
96
159
|
if (syncResult.status === 'sync-failed') {
|
|
97
|
-
const
|
|
98
|
-
? syncResult.error
|
|
99
|
-
: 'Local history could not be reconciled with the remote.';
|
|
160
|
+
const diag = diagnoseSyncError(syncResult.error);
|
|
100
161
|
if (useJson) {
|
|
101
|
-
jsonOut(buildErrorResponse('deploy', 'Sync failed before deployment',
|
|
162
|
+
jsonOut(buildErrorResponse('deploy', 'Sync failed before deployment', diag.diagnosis, [...diag.suggestions, 'Then retry runwork deploy']));
|
|
102
163
|
process.exit(1);
|
|
103
164
|
}
|
|
104
|
-
console.error(`Sync failed: ${
|
|
105
|
-
|
|
165
|
+
console.error(`Sync failed: ${diag.message}. ${diag.diagnosis}`);
|
|
166
|
+
for (const s of diag.suggestions)
|
|
167
|
+
console.error(` - ${s}`);
|
|
106
168
|
process.exit(1);
|
|
107
169
|
}
|
|
108
170
|
if (!syncResult.pushed) {
|
|
@@ -143,6 +205,21 @@ export const deployCommand = new Command('deploy')
|
|
|
143
205
|
console.error('Run `runwork info` to check state, or `runwork logs --production` to inspect errors.');
|
|
144
206
|
process.exit(1);
|
|
145
207
|
}
|
|
208
|
+
// Record what we just deployed (local HEAD == the pushed/deployed commit)
|
|
209
|
+
// so `runwork info` / `deploy --status` can cheaply compare it to HEAD
|
|
210
|
+
// later without scraping the production bundle.
|
|
211
|
+
const deployedSha = getHeadSha(cwd);
|
|
212
|
+
const finishedAt = new Date().toISOString();
|
|
213
|
+
if (deployedSha) {
|
|
214
|
+
writeDeployState(cwd, { sha: deployedSha, deployedAt: finishedAt, url: deploymentUrl });
|
|
215
|
+
}
|
|
216
|
+
// Mark the deploy finished so the child's exit hook doesn't overwrite this
|
|
217
|
+
// with a `failed` status, and so `runwork deploy --status` reports success.
|
|
218
|
+
deployFinalized = true;
|
|
219
|
+
writeDeployStatus(cwd, { state: 'succeeded', startedAt: childStartedAt, finishedAt, url: deploymentUrl, sha: deployedSha ?? undefined });
|
|
220
|
+
if (isChild) {
|
|
221
|
+
jsonLine({ event: 'deploy_succeeded', url: deploymentUrl, deployedSha, finishedAt });
|
|
222
|
+
}
|
|
146
223
|
if (useJson) {
|
|
147
224
|
const response = {
|
|
148
225
|
success: true,
|
|
@@ -151,6 +228,7 @@ export const deployCommand = new Command('deploy')
|
|
|
151
228
|
deployed,
|
|
152
229
|
url: deploymentUrl,
|
|
153
230
|
appName: config.appName,
|
|
231
|
+
deployedSha,
|
|
154
232
|
},
|
|
155
233
|
guide: buildDeployGuide(),
|
|
156
234
|
...(deployGuardWarning ? { warning: deployGuardWarning } : {}),
|
|
@@ -159,4 +237,43 @@ export const deployCommand = new Command('deploy')
|
|
|
159
237
|
return;
|
|
160
238
|
}
|
|
161
239
|
console.log(`Deployed: ${deploymentUrl}`);
|
|
240
|
+
if (deployedSha)
|
|
241
|
+
console.log(dim(`Commit: ${deployedSha.slice(0, 7)}`));
|
|
162
242
|
});
|
|
243
|
+
/**
|
|
244
|
+
* Print the status of the last deploy started from this machine
|
|
245
|
+
* (`runwork deploy --status`), reconciled with the deployed-vs-local commit
|
|
246
|
+
* comparison from deploy-state.
|
|
247
|
+
*/
|
|
248
|
+
function printDeployStatus(cwd, useJson) {
|
|
249
|
+
const status = readDeployStatus(cwd);
|
|
250
|
+
const summary = getDeploySummary(cwd);
|
|
251
|
+
if (useJson) {
|
|
252
|
+
jsonOut({ success: true, command: 'deploy', result: { status, deploy: summary } });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!status) {
|
|
256
|
+
console.log('No deploy has been started from this machine yet.');
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const label = status.state === 'succeeded' ? green('succeeded')
|
|
260
|
+
: status.state === 'failed' ? yellow('failed')
|
|
261
|
+
: 'in-progress';
|
|
262
|
+
console.log(`Last deploy: ${label}`);
|
|
263
|
+
console.log(dim(` started: ${status.startedAt}`));
|
|
264
|
+
if (status.finishedAt)
|
|
265
|
+
console.log(dim(` finished: ${status.finishedAt}`));
|
|
266
|
+
if (status.url)
|
|
267
|
+
console.log(dim(` url: ${status.url}`));
|
|
268
|
+
if (status.error)
|
|
269
|
+
console.log(yellow(` error: ${status.error}`));
|
|
270
|
+
if (status.state === 'in-progress')
|
|
271
|
+
console.log(dim(` logs: ${deployLogPath(cwd)}`));
|
|
272
|
+
}
|
|
273
|
+
if (summary.deployedShortSha) {
|
|
274
|
+
const sync = summary.inSync === true ? green('in sync')
|
|
275
|
+
: summary.inSync === false ? yellow('local has undeployed commits')
|
|
276
|
+
: dim('unknown');
|
|
277
|
+
console.log(`Commit: deployed ${summary.deployedShortSha}${summary.localShortSha ? `, local ${summary.localShortSha}` : ''} ${sync}`);
|
|
278
|
+
}
|
|
279
|
+
}
|