runwork 0.13.3 → 0.13.4
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-workflow-instance.d.ts +6 -0
- package/bundled-types/workflows.d.ts +1 -0
- package/dist/commands/deploy.js +101 -22
- package/dist/commands/dev.d.ts +1 -0
- package/dist/commands/dev.js +145 -155
- package/dist/commands/logs.js +10 -3
- package/dist/dev/__tests__/session.test.js +60 -0
- package/dist/dev/session.d.ts +8 -0
- package/dist/dev/session.js +4 -1
- package/dist/generated/bundled-types.js +2 -2
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/auto-commit.test.js +59 -1
- package/dist/git/__tests__/deploy-guard.test.d.ts +1 -0
- package/dist/git/__tests__/deploy-guard.test.js +61 -0
- package/dist/git/auto-commit.d.ts +22 -0
- package/dist/git/auto-commit.js +72 -11
- package/dist/git/critical-files.d.ts +20 -0
- package/dist/git/critical-files.js +68 -0
- package/dist/git/deploy-guard.d.ts +53 -0
- package/dist/git/deploy-guard.js +78 -0
- package/dist/utils/agent-guidance.d.ts +2 -0
- package/dist/utils/ignore-matcher.d.ts +9 -0
- package/dist/utils/ignore-matcher.js +16 -0
- package/package.json +1 -1
|
@@ -9,6 +9,12 @@ import { DurableObject } from 'cloudflare:workers';
|
|
|
9
9
|
import type { NativeWorkflowInstance, NativeWorkflowConfig } from './core-workflow-types';
|
|
10
10
|
import type { StepOptions, WaitEventOptions } from './core-workflows';
|
|
11
11
|
import { type Env } from './core-utils';
|
|
12
|
+
export declare class WorkflowPausedError extends Error {
|
|
13
|
+
reason: 'sleep' | 'waitEvent';
|
|
14
|
+
resumeAt?: number | undefined;
|
|
15
|
+
constructor(reason: 'sleep' | 'waitEvent', resumeAt?: number | undefined);
|
|
16
|
+
}
|
|
17
|
+
export declare function isWorkflowControlSignal(error: unknown): boolean;
|
|
12
18
|
/**
|
|
13
19
|
* WorkflowInstance Durable Object
|
|
14
20
|
*
|
|
@@ -12,3 +12,4 @@ export { DEFAULT_WORKFLOW_CONFIG } from './core-workflow-types';
|
|
|
12
12
|
export { WORKFLOW_INFRA_MODE } from './core-workflow-config';
|
|
13
13
|
export { WorkflowInstanceDO } from './core-workflow-instance';
|
|
14
14
|
export { WorkflowCoordinator } from './core-workflow-coordinator';
|
|
15
|
+
export { WorkflowPausedError, isWorkflowControlSignal } from './core-workflow-instance';
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { execFileSync } from '../utils/subprocess.js';
|
|
3
2
|
import { readFileSync, existsSync } from 'fs';
|
|
4
3
|
import { requireAuth } from '../auth/store.js';
|
|
5
4
|
import { ApiClient } from '../api/client.js';
|
|
6
5
|
import { shouldOutputJson, jsonOut } from '../utils/output.js';
|
|
7
6
|
import { buildDeployGuide, buildErrorResponse } from '../utils/agent-guidance.js';
|
|
7
|
+
import { requireGit } from '../git/preflight.js';
|
|
8
|
+
import { ensureGitIdentity } from '../git/identity.js';
|
|
9
|
+
import { commitWorkingTree } from '../git/auto-commit.js';
|
|
10
|
+
import { syncWithRemote, hasCommits } from '../git/sync.js';
|
|
11
|
+
import { snapshotCriticalFiles, restoreMissingCriticalFiles, commitAndPushRestoredFiles } from '../git/critical-files.js';
|
|
12
|
+
import { evaluateDeployGuard } from '../git/deploy-guard.js';
|
|
13
|
+
import { promptConfirm } from '../utils/prompt.js';
|
|
8
14
|
export const deployCommand = new Command('deploy')
|
|
9
15
|
.description('Deploy the current app to production')
|
|
10
|
-
.
|
|
16
|
+
.option('-y, --yes', 'Skip the confirmation prompt when the working tree differs from the preview')
|
|
17
|
+
.action(async (opts, command) => {
|
|
11
18
|
const useJson = shouldOutputJson(command.optsWithGlobals().json);
|
|
12
19
|
if (!existsSync('.runwork.json')) {
|
|
13
20
|
if (useJson) {
|
|
@@ -17,36 +24,93 @@ export const deployCommand = new Command('deploy')
|
|
|
17
24
|
console.error('No .runwork.json found. Run `runwork init` first.');
|
|
18
25
|
process.exit(1);
|
|
19
26
|
}
|
|
27
|
+
requireGit('deploy');
|
|
20
28
|
const config = JSON.parse(readFileSync('.runwork.json', 'utf-8'));
|
|
21
29
|
const creds = requireAuth();
|
|
22
30
|
const client = new ApiClient(creds);
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
const cwd = process.cwd();
|
|
32
|
+
// Deploy guard: the preview reflects the last *pushed* commit. If the
|
|
33
|
+
// working tree is dirty or ahead of `runwork/main` and no auto-syncing
|
|
34
|
+
// dev session is keeping the preview in lockstep, `runwork deploy` will
|
|
35
|
+
// commit and ship code the user never saw in the preview. Warn first.
|
|
36
|
+
// Runs BEFORE the commit/sync/deploy steps below, which are unchanged.
|
|
37
|
+
const guard = evaluateDeployGuard(cwd, config.appId);
|
|
38
|
+
let deployGuardWarning;
|
|
39
|
+
if (guard.warn) {
|
|
40
|
+
deployGuardWarning = 'You have uncommitted or unpushed changes. `runwork deploy` will commit and deploy them, but they are not reflected in the preview.';
|
|
41
|
+
if (useJson) {
|
|
42
|
+
// Agents must never be blocked: the warning rides along in the
|
|
43
|
+
// final success response below so we proceed without prompting.
|
|
44
|
+
}
|
|
45
|
+
else if (opts.yes || !process.stdin.isTTY) {
|
|
46
|
+
// Non-interactive (no TTY) or explicit --yes: warn and proceed.
|
|
47
|
+
console.warn(deployGuardWarning);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
console.warn(deployGuardWarning);
|
|
51
|
+
const proceed = await promptConfirm('Deploy these changes anyway?');
|
|
52
|
+
if (!proceed) {
|
|
53
|
+
console.log('Deploy cancelled.');
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Deploy must work even when no `runwork dev` session is (or ever was)
|
|
59
|
+
// running. Reuse the exact sync path dev uses: commit any pending
|
|
60
|
+
// working-tree changes, then fetch -> rebase (fallback merge) -> push.
|
|
61
|
+
// `ensureGitIdentity` seeds a local identity so the commit cannot fail
|
|
62
|
+
// on a fresh machine where git user.email/.name were never configured.
|
|
63
|
+
ensureGitIdentity(cwd, creds);
|
|
64
|
+
if (!useJson)
|
|
65
|
+
console.log('Syncing...');
|
|
25
66
|
try {
|
|
26
|
-
|
|
27
|
-
repoHasCommits = true;
|
|
67
|
+
commitWorkingTree(cwd, `deploy: ${new Date().toISOString().replace('T', ' ').slice(0, 19)}`);
|
|
28
68
|
}
|
|
29
69
|
catch {
|
|
30
|
-
// No
|
|
70
|
+
// No commit created (clean tree) or git not initialized yet. The
|
|
71
|
+
// sync/push step below surfaces the actionable failure.
|
|
72
|
+
}
|
|
73
|
+
if (!hasCommits(cwd)) {
|
|
74
|
+
if (useJson) {
|
|
75
|
+
jsonOut(buildErrorResponse('deploy', 'Nothing to deploy', 'No commits exist and the working tree has no changes to commit, so there is nothing to sync or deploy.', ['Make changes to your app first', 'Run runwork dev to develop and verify changes', 'Then run runwork deploy']));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
console.error('Nothing to deploy. Make changes first, then run `runwork deploy`.');
|
|
79
|
+
process.exit(1);
|
|
31
80
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
81
|
+
// Protect critical files across the sync exactly as `runwork dev` does:
|
|
82
|
+
// syncWithRemote may merge with `-X theirs` (remote wins) to reconcile
|
|
83
|
+
// diverged histories, which can delete `.runwork.json`/`blueprint.json`/
|
|
84
|
+
// `.gitignore` if the remote tip lacks them. Snapshot before, restore after.
|
|
85
|
+
const criticalSnapshot = snapshotCriticalFiles(cwd);
|
|
86
|
+
const syncResult = syncWithRemote(cwd);
|
|
87
|
+
const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
|
|
88
|
+
if (restoredCriticalFiles.length > 0) {
|
|
89
|
+
const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
|
|
90
|
+
if (!useJson) {
|
|
91
|
+
console.warn(`Sync removed critical file(s); restored: ${restoredCriticalFiles.join(', ')}`);
|
|
92
|
+
if (!pushed)
|
|
93
|
+
console.warn('Restoration committed locally but not pushed; will retry on next sync.');
|
|
37
94
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
95
|
+
}
|
|
96
|
+
if (syncResult.status === 'sync-failed') {
|
|
97
|
+
const reason = syncResult.error && syncResult.error !== 'stash-conflict'
|
|
98
|
+
? syncResult.error
|
|
99
|
+
: 'Local history could not be reconciled with the remote.';
|
|
100
|
+
if (useJson) {
|
|
101
|
+
jsonOut(buildErrorResponse('deploy', 'Sync failed before deployment', reason, ['Run git fetch runwork && git rebase runwork/main and resolve any conflicts', 'Or run runwork dev to sync interactively', 'Then retry runwork deploy']));
|
|
102
|
+
process.exit(1);
|
|
41
103
|
}
|
|
42
|
-
|
|
104
|
+
console.error(`Sync failed: ${reason}`);
|
|
105
|
+
console.error('Resolve conflicts (git status), or run `runwork dev`, then retry `runwork deploy`.');
|
|
106
|
+
process.exit(1);
|
|
43
107
|
}
|
|
44
|
-
|
|
108
|
+
if (!syncResult.pushed) {
|
|
45
109
|
if (useJson) {
|
|
46
|
-
jsonOut(buildErrorResponse('deploy', '
|
|
110
|
+
jsonOut(buildErrorResponse('deploy', 'Failed to push before deployment', 'Local commits were synced but could not be pushed to the remote.', ['Check your network connection', 'Run runwork dev to retry syncing', 'Then retry runwork deploy']));
|
|
47
111
|
process.exit(1);
|
|
48
112
|
}
|
|
49
|
-
console.error('
|
|
113
|
+
console.error('Push failed. Check your connection and retry `runwork deploy`.');
|
|
50
114
|
process.exit(1);
|
|
51
115
|
}
|
|
52
116
|
// Trigger deploy
|
|
@@ -65,19 +129,34 @@ export const deployCommand = new Command('deploy')
|
|
|
65
129
|
console.error(`Deploy failed: ${message}`);
|
|
66
130
|
process.exit(1);
|
|
67
131
|
}
|
|
132
|
+
// The server returns a deployment URL only when a deploy actually
|
|
133
|
+
// happened. An empty/missing URL means the deploy was a no-op or did
|
|
134
|
+
// not produce a live deployment, so we must not report success.
|
|
135
|
+
const deploymentUrl = result.deploymentUrl?.trim() ?? '';
|
|
136
|
+
const deployed = deploymentUrl.length > 0;
|
|
137
|
+
if (!deployed) {
|
|
138
|
+
if (useJson) {
|
|
139
|
+
jsonOut(buildErrorResponse('deploy', 'Deployment did not produce a URL', 'The deploy API returned no deployment URL. The deploy may have been a no-op (no changes) or failed server-side.', ['Confirm your changes were committed and pushed (the sync step above succeeded)', 'Run runwork info to check the deployed state', 'Run runwork logs --production to inspect server-side errors', 'Retry runwork deploy']));
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
console.error('Deploy did not return a URL. The deploy may have been a no-op or failed server-side.');
|
|
143
|
+
console.error('Run `runwork info` to check state, or `runwork logs --production` to inspect errors.');
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
68
146
|
if (useJson) {
|
|
69
147
|
const response = {
|
|
70
148
|
success: true,
|
|
71
149
|
command: 'deploy',
|
|
72
150
|
result: {
|
|
73
|
-
deployed
|
|
74
|
-
url:
|
|
151
|
+
deployed,
|
|
152
|
+
url: deploymentUrl,
|
|
75
153
|
appName: config.appName,
|
|
76
154
|
},
|
|
77
155
|
guide: buildDeployGuide(),
|
|
156
|
+
...(deployGuardWarning ? { warning: deployGuardWarning } : {}),
|
|
78
157
|
};
|
|
79
158
|
jsonOut(response);
|
|
80
159
|
return;
|
|
81
160
|
}
|
|
82
|
-
console.log(`Deployed: ${
|
|
161
|
+
console.log(`Deployed: ${deploymentUrl}`);
|
|
83
162
|
});
|
package/dist/commands/dev.d.ts
CHANGED
package/dist/commands/dev.js
CHANGED
|
@@ -6,6 +6,7 @@ import { requireAuth } from '../auth/store.js';
|
|
|
6
6
|
import { ApiClient } from '../api/client.js';
|
|
7
7
|
import { watchAndAutoCommit, stopAutoCommit } from '../git/auto-commit.js';
|
|
8
8
|
import { syncWithRemote } from '../git/sync.js';
|
|
9
|
+
import { snapshotCriticalFiles, restoreMissingCriticalFiles, commitAndPushRestoredFiles } from '../git/critical-files.js';
|
|
9
10
|
import { ensureGitIdentity } from '../git/identity.js';
|
|
10
11
|
import { requireGit } from '../git/preflight.js';
|
|
11
12
|
import { startLogTailer } from '../logs/tailer.js';
|
|
@@ -43,69 +44,14 @@ function readConfig() {
|
|
|
43
44
|
}
|
|
44
45
|
return JSON.parse(readFileSync('.runwork.json', 'utf-8'));
|
|
45
46
|
}
|
|
46
|
-
// Files the CLI cannot function without and which the user did not author.
|
|
47
|
-
// `.runwork.json` is the local app identity; `blueprint.json` is the app's
|
|
48
|
-
// canonical feature definition; `.gitignore` keeps caches out of git. If a
|
|
49
|
-
// sync from the remote silently removes any of these (which has happened
|
|
50
|
-
// when a server-side agent commits with a stale index — see
|
|
51
|
-
// worker/agents/git/git.ts), restoring from a pre-sync snapshot keeps the
|
|
52
|
-
// project usable and unblocks `runwork deploy`.
|
|
53
|
-
const CRITICAL_FILES = ['.runwork.json', 'blueprint.json', '.gitignore'];
|
|
54
|
-
function snapshotCriticalFiles(cwd) {
|
|
55
|
-
const snapshots = [];
|
|
56
|
-
for (const rel of CRITICAL_FILES) {
|
|
57
|
-
const abs = join(cwd, rel);
|
|
58
|
-
if (!existsSync(abs))
|
|
59
|
-
continue;
|
|
60
|
-
try {
|
|
61
|
-
snapshots.push({ path: rel, contents: readFileSync(abs, 'utf-8') });
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
// Best-effort: skip unreadable files.
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return snapshots;
|
|
68
|
-
}
|
|
69
|
-
function restoreMissingCriticalFiles(cwd, snapshots) {
|
|
70
|
-
const restored = [];
|
|
71
|
-
for (const snap of snapshots) {
|
|
72
|
-
const abs = join(cwd, snap.path);
|
|
73
|
-
if (existsSync(abs))
|
|
74
|
-
continue;
|
|
75
|
-
try {
|
|
76
|
-
writeFileSync(abs, snap.contents, 'utf-8');
|
|
77
|
-
restored.push(snap.path);
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
// Best-effort: skip files we cannot write back.
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return restored;
|
|
84
|
-
}
|
|
85
|
-
function commitAndPushRestoredFiles(cwd, files) {
|
|
86
|
-
if (files.length === 0)
|
|
87
|
-
return false;
|
|
88
|
-
try {
|
|
89
|
-
execFileSync('git', ['add', '--', ...files], { cwd, stdio: 'pipe' });
|
|
90
|
-
execFileSync('git', ['commit', '-m', 'chore: restore critical files removed by sync'], { cwd, stdio: 'pipe' });
|
|
91
|
-
}
|
|
92
|
-
catch {
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
try {
|
|
96
|
-
// `HEAD:main` so the push works regardless of local branch name.
|
|
97
|
-
execFileSync('git', ['push', 'runwork', 'HEAD:main'], { cwd, stdio: 'pipe' });
|
|
98
|
-
return true;
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
// Push failures are non-fatal: the local copy is restored, and the
|
|
102
|
-
// restoration commit will be pushed with the next auto-sync cycle.
|
|
103
|
-
return false;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
47
|
export async function execDev(options) {
|
|
107
48
|
const useJson = options?.json ?? false;
|
|
108
49
|
const mode = options?.mode ?? 'foreground';
|
|
50
|
+
// No-sync mode: hold the preview open but never auto-touch the user's
|
|
51
|
+
// git or working tree. No file watcher, no auto-commit, and no
|
|
52
|
+
// working-tree-mutating startup (template auto-update / syncWithRemote).
|
|
53
|
+
// The user drives git manually; `git push` updates the preview.
|
|
54
|
+
const noSync = options?.noSync ?? false;
|
|
109
55
|
requireGit('dev');
|
|
110
56
|
const config = readConfig();
|
|
111
57
|
const creds = requireAuth();
|
|
@@ -199,30 +145,34 @@ export async function execDev(options) {
|
|
|
199
145
|
}
|
|
200
146
|
}
|
|
201
147
|
}
|
|
202
|
-
// Download fresh template to ensure latest version
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
removeNestedGitDirs(cwd);
|
|
213
|
-
const newManifest = await generateManifest(cwd);
|
|
214
|
-
await saveManifest(cwd, newManifest);
|
|
148
|
+
// Download fresh template to ensure latest version. Skipped entirely in
|
|
149
|
+
// no-sync mode: this block mutates the working tree (`git checkout -- .`),
|
|
150
|
+
// which would fight the user's manual git workflow.
|
|
151
|
+
if (!noSync) {
|
|
152
|
+
if (useJson) {
|
|
153
|
+
jsonLine({ event: 'startup', phase: 'template_update', timestamp: ts() });
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
console.log(dim('Updating template...'));
|
|
157
|
+
}
|
|
215
158
|
try {
|
|
216
|
-
|
|
159
|
+
const zipData = await client.downloadSkeleton();
|
|
160
|
+
extractZip(zipData, cwd);
|
|
161
|
+
removeNestedGitDirs(cwd);
|
|
162
|
+
const newManifest = await generateManifest(cwd);
|
|
163
|
+
await saveManifest(cwd, newManifest);
|
|
164
|
+
try {
|
|
165
|
+
execFileSync('git', ['checkout', '--', '.'], { cwd, stdio: 'pipe' });
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// May fail if no commits yet
|
|
169
|
+
}
|
|
217
170
|
}
|
|
218
171
|
catch {
|
|
219
|
-
|
|
172
|
+
if (!useJson)
|
|
173
|
+
console.warn(yellow('Template update failed. Continuing with current files.'));
|
|
220
174
|
}
|
|
221
175
|
}
|
|
222
|
-
catch {
|
|
223
|
-
if (!useJson)
|
|
224
|
-
console.warn(yellow('Template update failed. Continuing with current files.'));
|
|
225
|
-
}
|
|
226
176
|
// Populate type definitions
|
|
227
177
|
await populateTypes(cwd);
|
|
228
178
|
if (useJson)
|
|
@@ -239,89 +189,108 @@ export async function execDev(options) {
|
|
|
239
189
|
await populateSkill(cwd, client, config.appId);
|
|
240
190
|
if (useJson)
|
|
241
191
|
jsonLine({ event: 'startup', phase: 'skill_fetched', timestamp: ts() });
|
|
242
|
-
// Sync
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
192
|
+
// Sync. In no-sync mode we never mutate the working tree: instead of
|
|
193
|
+
// syncWithRemote (rebase/merge/commit/push) we do a best-effort,
|
|
194
|
+
// read-only `git fetch` so remote-tracking refs stay current and
|
|
195
|
+
// `runwork info` can still report ahead/behind accurately.
|
|
196
|
+
if (noSync) {
|
|
197
|
+
try {
|
|
198
|
+
execFileSync('git', ['fetch', 'runwork'], { cwd, stdio: 'pipe' });
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Offline, or the `runwork` remote does not exist yet. Non-fatal.
|
|
202
|
+
}
|
|
250
203
|
if (useJson) {
|
|
251
|
-
jsonLine({
|
|
252
|
-
event: 'sync_restored_critical_files',
|
|
253
|
-
files: restoredCriticalFiles,
|
|
254
|
-
pushed,
|
|
255
|
-
timestamp: ts(),
|
|
256
|
-
});
|
|
204
|
+
jsonLine({ event: 'startup', phase: 'fetch', timestamp: ts() });
|
|
257
205
|
}
|
|
258
206
|
else {
|
|
259
|
-
console.
|
|
260
|
-
if (!pushed) {
|
|
261
|
-
console.warn(dim(' Restoration committed locally but not pushed; will retry on next auto-sync.'));
|
|
262
|
-
}
|
|
207
|
+
console.log(dim('Fetched remote refs (no-sync mode).'));
|
|
263
208
|
}
|
|
264
209
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
210
|
+
else {
|
|
211
|
+
if (!useJson)
|
|
212
|
+
console.log(dim('Syncing...'));
|
|
213
|
+
const criticalSnapshot = snapshotCriticalFiles(cwd);
|
|
214
|
+
const syncResult = syncWithRemote(cwd);
|
|
215
|
+
const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
|
|
216
|
+
if (restoredCriticalFiles.length > 0) {
|
|
217
|
+
const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
|
|
218
|
+
if (useJson) {
|
|
219
|
+
jsonLine({
|
|
220
|
+
event: 'sync_restored_critical_files',
|
|
221
|
+
files: restoredCriticalFiles,
|
|
222
|
+
pushed,
|
|
223
|
+
timestamp: ts(),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
console.warn(yellow(` Sync removed critical file(s); restored: ${restoredCriticalFiles.join(', ')}`));
|
|
228
|
+
if (!pushed) {
|
|
229
|
+
console.warn(dim(' Restoration committed locally but not pushed; will retry on next auto-sync.'));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (useJson) {
|
|
234
|
+
jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
|
|
235
|
+
if (syncResult.status === 'sync-failed') {
|
|
236
|
+
if (syncResult.error === 'stash-conflict') {
|
|
237
|
+
jsonLine({
|
|
238
|
+
event: 'error',
|
|
239
|
+
phase: 'sync',
|
|
240
|
+
timestamp: ts(),
|
|
241
|
+
error: {
|
|
242
|
+
message: 'Merge conflicts detected after stash pop',
|
|
243
|
+
diagnosis: 'Local changes could not be automatically merged with the remote state. The stash pop resulted in conflicts that require manual resolution.',
|
|
244
|
+
suggestions: [
|
|
245
|
+
'Resolve the merge conflicts in the affected files',
|
|
246
|
+
'Run git add on resolved files, then git commit',
|
|
247
|
+
'Run runwork dev again after resolving conflicts',
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
269
253
|
jsonLine({
|
|
270
254
|
event: 'error',
|
|
271
255
|
phase: 'sync',
|
|
272
256
|
timestamp: ts(),
|
|
273
257
|
error: {
|
|
274
|
-
message:
|
|
275
|
-
diagnosis: '
|
|
258
|
+
message: syncResult.error ?? 'File sync failed',
|
|
259
|
+
diagnosis: 'Could not sync files to the sandbox. This usually means the sandbox session expired or the network connection was interrupted.',
|
|
276
260
|
suggestions: [
|
|
277
|
-
'
|
|
278
|
-
'
|
|
279
|
-
'Run runwork
|
|
261
|
+
'Run runwork dev again to start a fresh session',
|
|
262
|
+
'Check your network connection',
|
|
263
|
+
'Run runwork doctor to diagnose environment issues',
|
|
280
264
|
],
|
|
281
265
|
},
|
|
282
266
|
});
|
|
283
|
-
process.exit(1);
|
|
284
267
|
}
|
|
285
|
-
jsonLine({
|
|
286
|
-
event: 'error',
|
|
287
|
-
phase: 'sync',
|
|
288
|
-
timestamp: ts(),
|
|
289
|
-
error: {
|
|
290
|
-
message: syncResult.error ?? 'File sync failed',
|
|
291
|
-
diagnosis: 'Could not sync files to the sandbox. This usually means the sandbox session expired or the network connection was interrupted.',
|
|
292
|
-
suggestions: [
|
|
293
|
-
'Run runwork dev again to start a fresh session',
|
|
294
|
-
'Check your network connection',
|
|
295
|
-
'Run runwork doctor to diagnose environment issues',
|
|
296
|
-
],
|
|
297
|
-
},
|
|
298
|
-
});
|
|
299
268
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
269
|
+
else {
|
|
270
|
+
switch (syncResult.status) {
|
|
271
|
+
case 'skipped':
|
|
272
|
+
console.log(dim(' No commits yet. Skipping sync.'));
|
|
273
|
+
break;
|
|
274
|
+
case 'synced':
|
|
275
|
+
console.log(green(' Synced with Runwork.'));
|
|
276
|
+
break;
|
|
277
|
+
case 'merged':
|
|
278
|
+
console.log(yellow(' Merged with Runwork (histories diverged).'));
|
|
279
|
+
break;
|
|
280
|
+
case 'sync-failed':
|
|
281
|
+
if (syncResult.error === 'stash-conflict') {
|
|
282
|
+
console.error('Merge conflicts detected after stash pop. Resolve conflicts and restart.');
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
console.warn(yellow(`Sync failed. Continuing...`));
|
|
286
|
+
if (syncResult.error) {
|
|
287
|
+
console.warn(dim(` ${syncResult.error}`));
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
if (!syncResult.pushed && syncResult.status !== 'skipped' && syncResult.status !== 'sync-failed') {
|
|
292
|
+
console.warn(yellow('Push failed. Continuing with current state...'));
|
|
293
|
+
}
|
|
325
294
|
}
|
|
326
295
|
}
|
|
327
296
|
// The session POST returns a snapshot of the preview URL at boot time.
|
|
@@ -341,6 +310,7 @@ export async function execDev(options) {
|
|
|
341
310
|
previewUrl: currentPreviewUrl,
|
|
342
311
|
cliVersion: VERSION,
|
|
343
312
|
mode,
|
|
313
|
+
noSync,
|
|
344
314
|
}));
|
|
345
315
|
// Emit session_started (JSON) or show banner (human)
|
|
346
316
|
if (useJson) {
|
|
@@ -457,15 +427,32 @@ export async function execDev(options) {
|
|
|
457
427
|
}
|
|
458
428
|
});
|
|
459
429
|
}
|
|
460
|
-
// Start file watcher
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
430
|
+
// Start file watcher. In no-sync mode we never watch the working tree:
|
|
431
|
+
// no fast-sync to the sandbox, no git auto-commit. The user drives git
|
|
432
|
+
// manually and `git push` is what updates the preview. We print a loud
|
|
433
|
+
// notice so it is unmistakable that edits are not flowing automatically.
|
|
434
|
+
if (noSync) {
|
|
435
|
+
if (useJson) {
|
|
436
|
+
jsonLine({ event: 'startup', phase: 'no_sync_mode', timestamp: ts() });
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
console.log('');
|
|
440
|
+
console.log(yellow(' Auto-sync is OFF (--no-sync).'));
|
|
441
|
+
console.log(dim(' The preview reflects pushed commits. Run `git push` to update it;'));
|
|
442
|
+
console.log(dim(' uncommitted or unpushed changes are not shown.'));
|
|
443
|
+
console.log('');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
await watchAndAutoCommit(process.cwd(), client, config.appId, {
|
|
448
|
+
onFastSync: useJson
|
|
449
|
+
? (count) => { jsonLine({ event: 'files_synced', count, target: 'preview', timestamp: ts() }); }
|
|
450
|
+
: (count) => { syncedCount += count; updateStatus(); },
|
|
451
|
+
onGitPush: useJson
|
|
452
|
+
? (count) => { jsonLine({ event: 'files_pushed', count, target: 'git', timestamp: ts() }); }
|
|
453
|
+
: (count) => { syncedCount += count; updateStatus(); },
|
|
454
|
+
});
|
|
455
|
+
}
|
|
469
456
|
// Start log tailing (unless --no-logs)
|
|
470
457
|
if (options?.logs !== false) {
|
|
471
458
|
logTailer = startLogTailer({
|
|
@@ -487,12 +474,14 @@ export const devCommand = new Command('dev')
|
|
|
487
474
|
.option('--logs-only-file', 'Write logs to file only, not terminal')
|
|
488
475
|
.option('--detach', 'Start the dev session in the background and exit. Logs go to .runwork/dev-{stdout,stderr}.log')
|
|
489
476
|
.option('--restart', 'Stop any existing dev session first, then start a fresh one')
|
|
477
|
+
.option('--no-sync', 'Hold the preview open without auto-syncing files or git; you push manually to update the preview')
|
|
490
478
|
// Hidden internal flag passed by `runwork dev --detach` when it spawns
|
|
491
479
|
// its detached child. Not for end users -- registered here only so
|
|
492
480
|
// commander does not throw "unknown option" when the child receives it.
|
|
493
481
|
.addOption(new Option(INTERNAL_DETACHED_CHILD_FLAG).hideHelp())
|
|
494
482
|
.action(async (options, command) => {
|
|
495
483
|
const globalJson = shouldOutputJson(command.optsWithGlobals().json);
|
|
484
|
+
const noSync = options.sync === false;
|
|
496
485
|
// The internal child marker is parsed manually because we want it
|
|
497
486
|
// hidden from --help and from commander's option list. Its presence
|
|
498
487
|
// means: "this process is the detached child of a `--detach` parent;
|
|
@@ -510,6 +499,7 @@ export const devCommand = new Command('dev')
|
|
|
510
499
|
json: globalJson,
|
|
511
500
|
mode: isChild ? 'detached' : 'foreground',
|
|
512
501
|
restart: options.restart,
|
|
502
|
+
noSync,
|
|
513
503
|
});
|
|
514
504
|
});
|
|
515
505
|
/**
|
package/dist/commands/logs.js
CHANGED
|
@@ -21,13 +21,20 @@ export const logsCommand = new Command('logs')
|
|
|
21
21
|
.option('--production', 'Show production runtime logs')
|
|
22
22
|
.option('--events', 'Show app activity/events')
|
|
23
23
|
.option('--all', 'Show both runtime logs and events')
|
|
24
|
-
.option('--follow', 'Poll for new logs every 3s')
|
|
24
|
+
.option('--follow', 'Poll for new logs every 3s (TTY only; ignored when piped or with --json)')
|
|
25
|
+
.option('--once', 'Fetch once and exit, even if --follow is set (forced in non-TTY/agent contexts)')
|
|
25
26
|
.option('--limit <n>', 'Number of log entries to show', '25')
|
|
26
27
|
.option('--level <level>', 'Filter by log level (production only)')
|
|
27
28
|
.option('--search <text>', 'Search log content (production only)')
|
|
28
29
|
.option('--type <type>', 'Filter by event type (events only)')
|
|
29
30
|
.action(async (opts, command) => {
|
|
30
31
|
const useJson = shouldOutputJson(command.optsWithGlobals().json);
|
|
32
|
+
// Follow mode blocks forever (until SIGINT/SIGTERM), which deadlocks
|
|
33
|
+
// non-interactive callers: piped shells, CI, and AI agents never send
|
|
34
|
+
// those signals and hang waiting for the command to exit. Only follow
|
|
35
|
+
// when explicitly requested AND stdout is an interactive TTY AND we are
|
|
36
|
+
// not emitting JSON. `--once` forces single-shot even on a TTY.
|
|
37
|
+
const shouldFollow = Boolean(opts.follow) && !opts.once && !useJson && Boolean(process.stdout.isTTY);
|
|
31
38
|
const config = readConfig();
|
|
32
39
|
const creds = requireAuth();
|
|
33
40
|
const client = new ApiClient(creds);
|
|
@@ -79,7 +86,7 @@ export const logsCommand = new Command('logs')
|
|
|
79
86
|
// Preview logs (default)
|
|
80
87
|
try {
|
|
81
88
|
const { stdout, stderr } = await client.getPreviewLogs(appId);
|
|
82
|
-
if (
|
|
89
|
+
if (shouldFollow && !isFirstPoll) {
|
|
83
90
|
// Incremental mode: only print new content since last poll
|
|
84
91
|
// Reset detection: if buffer was truncated (sandbox restart), reset cursor
|
|
85
92
|
if (stdout && stdout.length < lastStdoutLength) {
|
|
@@ -198,7 +205,7 @@ export const logsCommand = new Command('logs')
|
|
|
198
205
|
isFirstPoll = false;
|
|
199
206
|
};
|
|
200
207
|
await fetchAndPrint();
|
|
201
|
-
if (
|
|
208
|
+
if (shouldFollow) {
|
|
202
209
|
let stopped = false;
|
|
203
210
|
let timer;
|
|
204
211
|
const scheduleNext = () => {
|