runwork 0.2.5 → 0.4.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/api/client.d.ts +2 -1
- package/dist/api/client.js +4 -0
- package/dist/auth/login-flow.d.ts +10 -0
- package/dist/auth/login-flow.js +37 -0
- package/dist/commands/clone.d.ts +3 -0
- package/dist/commands/clone.js +32 -24
- package/dist/commands/deploy.js +24 -7
- package/dist/commands/dev.d.ts +5 -0
- package/dist/commands/dev.js +151 -45
- package/dist/commands/info.d.ts +2 -0
- package/dist/commands/info.js +432 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +29 -21
- package/dist/commands/integrations.js +19 -2
- package/dist/commands/login.js +10 -25
- package/dist/commands/logs.js +82 -23
- package/dist/commands/open.d.ts +2 -0
- package/dist/commands/open.js +53 -0
- package/dist/commands/welcome.d.ts +1 -0
- package/dist/commands/welcome.js +83 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/sync.test.js +10 -8
- package/dist/git/auto-commit.d.ts +5 -1
- package/dist/git/auto-commit.js +15 -9
- package/dist/git/sync.js +46 -5
- package/dist/index.js +22 -1
- package/dist/logs/__tests__/tailer-format.test.d.ts +1 -0
- package/dist/logs/__tests__/tailer-format.test.js +43 -0
- package/dist/logs/tailer.d.ts +4 -0
- package/dist/logs/tailer.js +58 -10
- package/dist/types.d.ts +59 -0
- package/dist/ui/__tests__/banner.test.d.ts +1 -0
- package/dist/ui/__tests__/banner.test.js +82 -0
- package/dist/ui/__tests__/colors.test.d.ts +1 -0
- package/dist/ui/__tests__/colors.test.js +22 -0
- package/dist/ui/__tests__/keyboard.test.d.ts +1 -0
- package/dist/ui/__tests__/keyboard.test.js +30 -0
- package/dist/ui/__tests__/status-line.test.d.ts +1 -0
- package/dist/ui/__tests__/status-line.test.js +54 -0
- package/dist/ui/banner.d.ts +29 -0
- package/dist/ui/banner.js +118 -0
- package/dist/ui/colors.d.ts +4 -0
- package/dist/ui/colors.js +7 -0
- package/dist/ui/keyboard.d.ts +12 -0
- package/dist/ui/keyboard.js +57 -0
- package/dist/ui/status-line.d.ts +6 -0
- package/dist/ui/status-line.js +53 -0
- package/dist/utils/__tests__/output.test.d.ts +1 -0
- package/dist/utils/__tests__/output.test.js +38 -0
- package/dist/utils/__tests__/prompt.test.js +23 -99
- package/dist/utils/output.d.ts +17 -0
- package/dist/utils/output.js +27 -0
- package/dist/utils/prompt.d.ts +1 -0
- package/dist/utils/prompt.js +29 -21
- package/package.json +4 -2
package/dist/commands/logs.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Command } 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, jsonLine } from '../utils/output.js';
|
|
5
6
|
function readConfig() {
|
|
6
7
|
if (!existsSync('.runwork.json')) {
|
|
7
8
|
console.error('No .runwork.json found. Run `runwork init` first.');
|
|
@@ -25,7 +26,8 @@ export const logsCommand = new Command('logs')
|
|
|
25
26
|
.option('--level <level>', 'Filter by log level (production only)')
|
|
26
27
|
.option('--search <text>', 'Search log content (production only)')
|
|
27
28
|
.option('--type <type>', 'Filter by event type (events only)')
|
|
28
|
-
.action(async (opts) => {
|
|
29
|
+
.action(async (opts, command) => {
|
|
30
|
+
const useJson = shouldOutputJson(command.optsWithGlobals().json);
|
|
29
31
|
const config = readConfig();
|
|
30
32
|
const creds = requireAuth();
|
|
31
33
|
const client = new ApiClient(creds);
|
|
@@ -50,15 +52,22 @@ export const logsCommand = new Command('logs')
|
|
|
50
52
|
level: opts.level,
|
|
51
53
|
search: opts.search,
|
|
52
54
|
});
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
+
if (useJson) {
|
|
56
|
+
for (const entry of logs) {
|
|
57
|
+
jsonLine(entry);
|
|
58
|
+
}
|
|
55
59
|
}
|
|
56
60
|
else {
|
|
57
|
-
|
|
58
|
-
console.log(
|
|
61
|
+
if (logs.length === 0) {
|
|
62
|
+
console.log('No production logs found.');
|
|
59
63
|
}
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
else {
|
|
65
|
+
for (const entry of logs) {
|
|
66
|
+
console.log(JSON.stringify(entry));
|
|
67
|
+
}
|
|
68
|
+
if (pagination?.hasMore) {
|
|
69
|
+
console.log(`... ${pagination.total ? `${pagination.total} total entries` : 'more entries available'}`);
|
|
70
|
+
}
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
73
|
}
|
|
@@ -81,25 +90,61 @@ export const logsCommand = new Command('logs')
|
|
|
81
90
|
}
|
|
82
91
|
if (stdout && stdout.length > lastStdoutLength) {
|
|
83
92
|
const newContent = stdout.slice(lastStdoutLength);
|
|
84
|
-
|
|
93
|
+
if (useJson) {
|
|
94
|
+
for (const line of newContent.split('\n')) {
|
|
95
|
+
if (line) {
|
|
96
|
+
jsonLine({ type: 'runtime', message: line, timestamp: new Date().toISOString() });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
process.stdout.write(newContent);
|
|
102
|
+
}
|
|
85
103
|
}
|
|
86
104
|
if (stderr && stderr.length > lastStderrLength) {
|
|
87
105
|
const newContent = stderr.slice(lastStderrLength);
|
|
88
|
-
|
|
106
|
+
if (useJson) {
|
|
107
|
+
for (const line of newContent.split('\n')) {
|
|
108
|
+
if (line) {
|
|
109
|
+
jsonLine({ type: 'error', message: line, timestamp: new Date().toISOString() });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
process.stderr.write(newContent);
|
|
115
|
+
}
|
|
89
116
|
}
|
|
90
117
|
lastStdoutLength = stdout ? stdout.length : 0;
|
|
91
118
|
lastStderrLength = stderr ? stderr.length : 0;
|
|
92
119
|
}
|
|
93
120
|
else {
|
|
94
121
|
// First poll or non-follow: print everything
|
|
95
|
-
if (
|
|
96
|
-
|
|
122
|
+
if (useJson) {
|
|
123
|
+
if (stdout) {
|
|
124
|
+
for (const line of stdout.split('\n')) {
|
|
125
|
+
if (line) {
|
|
126
|
+
jsonLine({ type: 'runtime', message: line, timestamp: new Date().toISOString() });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (stderr) {
|
|
131
|
+
for (const line of stderr.split('\n')) {
|
|
132
|
+
if (line) {
|
|
133
|
+
jsonLine({ type: 'error', message: line, timestamp: new Date().toISOString() });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
97
137
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
138
|
+
else {
|
|
139
|
+
if (stdout) {
|
|
140
|
+
console.log(stdout);
|
|
141
|
+
}
|
|
142
|
+
if (stderr) {
|
|
143
|
+
console.error(stderr);
|
|
144
|
+
}
|
|
145
|
+
if (!stdout && !stderr) {
|
|
146
|
+
console.log('No preview logs available.');
|
|
147
|
+
}
|
|
103
148
|
}
|
|
104
149
|
// Initialize cursors for subsequent follow polls
|
|
105
150
|
lastStdoutLength = stdout ? stdout.length : 0;
|
|
@@ -112,7 +157,7 @@ export const logsCommand = new Command('logs')
|
|
|
112
157
|
}
|
|
113
158
|
}
|
|
114
159
|
if (showEvents) {
|
|
115
|
-
if (showRuntime && isFirstPoll) {
|
|
160
|
+
if (!useJson && showRuntime && isFirstPoll) {
|
|
116
161
|
console.log('\n--- Events ---\n');
|
|
117
162
|
}
|
|
118
163
|
try {
|
|
@@ -120,15 +165,29 @@ export const logsCommand = new Command('logs')
|
|
|
120
165
|
limit,
|
|
121
166
|
type: opts.type,
|
|
122
167
|
});
|
|
123
|
-
if (
|
|
124
|
-
|
|
168
|
+
if (useJson) {
|
|
169
|
+
for (const event of events) {
|
|
170
|
+
jsonLine({
|
|
171
|
+
type: 'event',
|
|
172
|
+
eventType: event.type,
|
|
173
|
+
source: event.source,
|
|
174
|
+
detail: event.summary || event.content,
|
|
175
|
+
metadata: event.metadata || {},
|
|
176
|
+
timestamp: event.timestamp,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
125
179
|
}
|
|
126
180
|
else {
|
|
127
|
-
|
|
128
|
-
console.log(
|
|
181
|
+
if (events.length === 0 && isFirstPoll) {
|
|
182
|
+
console.log('No events found.');
|
|
129
183
|
}
|
|
130
|
-
|
|
131
|
-
|
|
184
|
+
else {
|
|
185
|
+
for (const event of events) {
|
|
186
|
+
console.log(formatEvent(event));
|
|
187
|
+
}
|
|
188
|
+
if (hasMore && isFirstPoll) {
|
|
189
|
+
console.log(`... ${totalCount} total events`);
|
|
190
|
+
}
|
|
132
191
|
}
|
|
133
192
|
}
|
|
134
193
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { requireAuth } from '../auth/store.js';
|
|
4
|
+
import { ApiClient } from '../api/client.js';
|
|
5
|
+
import { cyan } from '../ui/colors.js';
|
|
6
|
+
import { shouldOutputJson, jsonOut } from '../utils/output.js';
|
|
7
|
+
export const openCommand = new Command('open')
|
|
8
|
+
.description('Open app preview or dashboard in browser')
|
|
9
|
+
.argument('[target]', 'What to open: preview (default), dashboard', 'preview')
|
|
10
|
+
.action(async (target, _opts, command) => {
|
|
11
|
+
const useJson = shouldOutputJson(command.optsWithGlobals().json);
|
|
12
|
+
if (!existsSync('.runwork.json')) {
|
|
13
|
+
console.error('No .runwork.json found. Run `runwork init` first.');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
const config = JSON.parse(readFileSync('.runwork.json', 'utf-8'));
|
|
17
|
+
const creds = requireAuth();
|
|
18
|
+
const client = new ApiClient(creds);
|
|
19
|
+
const open = await import('open');
|
|
20
|
+
switch (target) {
|
|
21
|
+
case 'preview': {
|
|
22
|
+
let previewUrl;
|
|
23
|
+
try {
|
|
24
|
+
const status = await client.getDevStatus(config.appId);
|
|
25
|
+
previewUrl = status.previewUrl;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
const session = await client.startDevSession(config.appId);
|
|
29
|
+
previewUrl = session.previewUrl;
|
|
30
|
+
}
|
|
31
|
+
if (useJson) {
|
|
32
|
+
jsonOut({ target: 'preview', url: previewUrl });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
console.log(`Opening preview: ${cyan(previewUrl)}`);
|
|
36
|
+
await open.default(previewUrl);
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case 'dashboard': {
|
|
40
|
+
const url = `${creds.baseUrl}/apps/${config.appId}`;
|
|
41
|
+
if (useJson) {
|
|
42
|
+
jsonOut({ target: 'dashboard', url });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
console.log(`Opening dashboard: ${cyan(url)}`);
|
|
46
|
+
await open.default(url);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
default:
|
|
50
|
+
console.error(`Unknown target: ${target}. Use "preview" or "dashboard".`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runWelcomeWizard(): Promise<void>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { getCredentials } from '../auth/store.js';
|
|
2
|
+
import { performLogin } from '../auth/login-flow.js';
|
|
3
|
+
import { ApiClient } from '../api/client.js';
|
|
4
|
+
import { promptSelect, promptInput, promptConfirm } from '../utils/prompt.js';
|
|
5
|
+
import { getWelcomeBanner, runAgentWizard } from '../ui/banner.js';
|
|
6
|
+
import { cyan, dim, green } from '../ui/colors.js';
|
|
7
|
+
export async function runWelcomeWizard() {
|
|
8
|
+
// Step 1: Welcome
|
|
9
|
+
console.log(getWelcomeBanner());
|
|
10
|
+
// Step 2: Login
|
|
11
|
+
let creds = getCredentials();
|
|
12
|
+
if (creds) {
|
|
13
|
+
console.log(green(` Already logged in as ${creds.email}.`));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
console.log('Opening your browser to log in...');
|
|
17
|
+
console.log('');
|
|
18
|
+
creds = await performLogin();
|
|
19
|
+
}
|
|
20
|
+
// Step 3: Init or Clone
|
|
21
|
+
console.log('');
|
|
22
|
+
const action = await promptSelect('What would you like to do?', [
|
|
23
|
+
{ label: 'Create a new app', value: 'init' },
|
|
24
|
+
{ label: 'Clone an existing app', value: 'clone' },
|
|
25
|
+
]);
|
|
26
|
+
const client = new ApiClient(creds);
|
|
27
|
+
let appDir;
|
|
28
|
+
if (action.value === 'init') {
|
|
29
|
+
const appName = await promptInput('App name');
|
|
30
|
+
if (!appName) {
|
|
31
|
+
console.error('App name is required.');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const workspaces = await client.listWorkspaces();
|
|
35
|
+
if (workspaces.length === 0) {
|
|
36
|
+
console.error('No workspaces found. Create one at runwork.ai first.');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
let workspace;
|
|
40
|
+
if (workspaces.length === 1) {
|
|
41
|
+
workspace = workspaces[0];
|
|
42
|
+
console.log(dim(` Using workspace: ${workspace.name}`));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
console.log('');
|
|
46
|
+
console.log('A workspace is where your apps live. Pick one:');
|
|
47
|
+
const choice = await promptSelect('Select workspace:', workspaces.map(w => ({ label: w.name, value: w })));
|
|
48
|
+
workspace = choice.value;
|
|
49
|
+
}
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log(`Creating ${cyan(appName)} in workspace ${cyan(workspace.name)}...`);
|
|
52
|
+
const { execInit } = await import('./init.js');
|
|
53
|
+
appDir = await execInit(client, appName, workspace);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const apps = await client.listApps();
|
|
57
|
+
if (apps.length === 0) {
|
|
58
|
+
console.error('No apps found. Choose "Create a new app" instead.');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const choice = await promptSelect('Select app to clone:', apps.map(a => ({ label: `${a.name} (${a.workspaceName})`, value: a })));
|
|
62
|
+
const { execClone } = await import('./clone.js');
|
|
63
|
+
appDir = await execClone(client, choice.value);
|
|
64
|
+
}
|
|
65
|
+
// Step 4: Agent guidance
|
|
66
|
+
await runAgentWizard(appDir);
|
|
67
|
+
// Step 5: Offer to start dev
|
|
68
|
+
console.log('');
|
|
69
|
+
const startDev = await promptConfirm('Start developing now?');
|
|
70
|
+
if (startDev) {
|
|
71
|
+
console.log('');
|
|
72
|
+
console.log(dim(` Tip: Open another terminal and launch your AI agent in ${cyan(appDir)}`));
|
|
73
|
+
console.log('');
|
|
74
|
+
process.chdir(appDir);
|
|
75
|
+
const { execDev } = await import('./dev.js');
|
|
76
|
+
await execDev();
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const slug = appDir.split('/').pop() || appDir;
|
|
80
|
+
console.log('');
|
|
81
|
+
console.log(`Next: ${cyan(`cd ${slug} && runwork dev`)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.4.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.4.0";
|
|
@@ -165,7 +165,7 @@ describe('syncWithRemote()', () => {
|
|
|
165
165
|
expect(existsSync(join(local, 'local-file.txt'))).toBe(true);
|
|
166
166
|
expect(existsSync(join(local, 'remote-only.txt'))).toBe(true);
|
|
167
167
|
});
|
|
168
|
-
it('
|
|
168
|
+
it('auto-resolves conflicting unrelated histories by accepting remote', () => {
|
|
169
169
|
const { local, remote } = createTestRepo();
|
|
170
170
|
// Local modifies a file
|
|
171
171
|
commitFile(local, 'shared.txt', 'local-line-1\nlocal-line-2\n', 'local init');
|
|
@@ -178,8 +178,10 @@ describe('syncWithRemote()', () => {
|
|
|
178
178
|
execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: other });
|
|
179
179
|
execFileSync('git', ['push', 'origin', 'main'], { cwd: other, stdio: 'pipe' });
|
|
180
180
|
const result = syncWithRemote(local);
|
|
181
|
-
//
|
|
182
|
-
expect(result.status).toBe('
|
|
181
|
+
// Conflicts auto-resolved with -X theirs (remote wins for first sync)
|
|
182
|
+
expect(result.status).toBe('merged');
|
|
183
|
+
// Remote version should win
|
|
184
|
+
expect(readFileSync(join(local, 'shared.txt'), 'utf-8')).toBe('remote-line-1\nremote-line-2\n');
|
|
183
185
|
});
|
|
184
186
|
it('stashes and restores dirty working tree during sync', () => {
|
|
185
187
|
const { local, remote } = createTestRepo();
|
|
@@ -316,7 +318,7 @@ describe('syncWithRemote() edge cases', () => {
|
|
|
316
318
|
// But local repo should be intact
|
|
317
319
|
expect(readFileSync(join(local, 'a.txt'), 'utf-8')).toBe('hello');
|
|
318
320
|
});
|
|
319
|
-
it('leaves repo clean
|
|
321
|
+
it('leaves repo clean after auto-resolving conflicting unrelated histories', () => {
|
|
320
322
|
const { local, remote } = createTestRepo();
|
|
321
323
|
commitFile(local, 'shared.txt', 'local version\n', 'local init');
|
|
322
324
|
// Create conflicting remote history
|
|
@@ -328,14 +330,14 @@ describe('syncWithRemote() edge cases', () => {
|
|
|
328
330
|
execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: other });
|
|
329
331
|
execFileSync('git', ['push', 'origin', 'main'], { cwd: other, stdio: 'pipe' });
|
|
330
332
|
const result = syncWithRemote(local);
|
|
331
|
-
|
|
333
|
+
// Auto-resolved with -X theirs
|
|
334
|
+
expect(result.status).toBe('merged');
|
|
332
335
|
// Repo should NOT be in a rebase or merge state
|
|
333
336
|
expect(existsSync(join(local, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
334
337
|
expect(existsSync(join(local, '.git', 'rebase-merge'))).toBe(false);
|
|
335
338
|
expect(existsSync(join(local, '.git', 'rebase-apply'))).toBe(false);
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
expect(fileContent).toBe('local version\n');
|
|
339
|
+
// Remote version wins
|
|
340
|
+
expect(readFileSync(join(local, 'shared.txt'), 'utf-8')).toBe('remote version\n');
|
|
339
341
|
});
|
|
340
342
|
it('handles merge conflict during rebase on related histories (same file edited both sides)', () => {
|
|
341
343
|
const { local, remote } = createTestRepo();
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { ApiClient } from '../api/client.js';
|
|
2
2
|
export declare function isIgnored(filePath: string): boolean;
|
|
3
|
-
export declare function watchAndAutoCommit(directory: string, client: ApiClient, appId: string
|
|
3
|
+
export declare function watchAndAutoCommit(directory: string, client: ApiClient, appId: string, callbacks?: {
|
|
4
|
+
onFileChange?: (relPath: string, pendingCount: number) => void;
|
|
5
|
+
onFastSync?: (count: number) => void;
|
|
6
|
+
onGitPush?: (count: number) => void;
|
|
7
|
+
}): Promise<void>;
|
|
4
8
|
export declare function stopAutoCommit(): Promise<void>;
|
package/dist/git/auto-commit.js
CHANGED
|
@@ -2,11 +2,13 @@ import { execFileSync } from 'child_process';
|
|
|
2
2
|
import { readFileSync } from 'fs';
|
|
3
3
|
import { watch } from 'chokidar';
|
|
4
4
|
import { basename, join, relative } from 'path';
|
|
5
|
+
import { dim, cyan, yellow } from '../ui/colors.js';
|
|
5
6
|
let watcher = null;
|
|
6
7
|
let fastSyncTimer = null;
|
|
7
8
|
let gitTimer = null;
|
|
8
9
|
let gitPushing = false;
|
|
9
10
|
let gitPendingAfterPush = false;
|
|
11
|
+
let activeCallbacks;
|
|
10
12
|
// Files awaiting fast sync: path -> 'changed' | 'deleted'
|
|
11
13
|
const pendingFastSync = new Map();
|
|
12
14
|
// Track files the user actually touched during this session (for git)
|
|
@@ -33,7 +35,8 @@ export function isIgnored(filePath) {
|
|
|
33
35
|
return true;
|
|
34
36
|
return false;
|
|
35
37
|
}
|
|
36
|
-
export async function watchAndAutoCommit(directory, client, appId) {
|
|
38
|
+
export async function watchAndAutoCommit(directory, client, appId, callbacks) {
|
|
39
|
+
activeCallbacks = callbacks;
|
|
37
40
|
watcher = watch(directory, {
|
|
38
41
|
ignored: isIgnored,
|
|
39
42
|
persistent: true,
|
|
@@ -46,19 +49,20 @@ export async function watchAndAutoCommit(directory, client, appId) {
|
|
|
46
49
|
const onFileChange = (filePath) => {
|
|
47
50
|
const rel = relative(directory, filePath);
|
|
48
51
|
if (changedFiles.size === 0) {
|
|
49
|
-
console.log(`Changed: ${rel}`);
|
|
52
|
+
console.log(` ${dim('Changed:')} ${cyan(rel)}`);
|
|
50
53
|
}
|
|
51
54
|
else {
|
|
52
|
-
console.log(`Changed: ${rel} (+${changedFiles.size} pending)`);
|
|
55
|
+
console.log(` ${dim('Changed:')} ${cyan(rel)} ${dim(`(+${changedFiles.size} pending)`)}`);
|
|
53
56
|
}
|
|
54
57
|
changedFiles.add(rel);
|
|
55
58
|
pendingFastSync.set(rel, 'changed');
|
|
59
|
+
activeCallbacks?.onFileChange?.(rel, changedFiles.size);
|
|
56
60
|
scheduleFastSync(directory, client, appId);
|
|
57
61
|
scheduleGitCommit();
|
|
58
62
|
};
|
|
59
63
|
const onFileUnlink = (filePath) => {
|
|
60
64
|
const rel = relative(directory, filePath);
|
|
61
|
-
console.log(`Deleted: ${rel}`);
|
|
65
|
+
console.log(` ${dim('Deleted:')} ${cyan(rel)}`);
|
|
62
66
|
changedFiles.add(rel);
|
|
63
67
|
pendingFastSync.set(rel, 'deleted');
|
|
64
68
|
scheduleFastSync(directory, client, appId);
|
|
@@ -109,11 +113,12 @@ async function executeFastSync(directory, client, appId) {
|
|
|
109
113
|
const total = files.length + deletedFiles.length;
|
|
110
114
|
try {
|
|
111
115
|
await client.syncFiles(appId, files, deletedFiles.length > 0 ? deletedFiles : undefined);
|
|
112
|
-
console.log(` Synced ${total} file(s) to preview.`);
|
|
116
|
+
console.log(dim(` Synced ${total} file(s) to preview.`));
|
|
117
|
+
activeCallbacks?.onFastSync?.(total);
|
|
113
118
|
}
|
|
114
119
|
catch (error) {
|
|
115
120
|
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
-
console.warn(` Fast sync failed (git will handle it): ${message}`);
|
|
121
|
+
console.warn(yellow(` Fast sync failed (git will handle it): ${message}`));
|
|
117
122
|
}
|
|
118
123
|
}
|
|
119
124
|
// ========================================
|
|
@@ -178,7 +183,7 @@ function commitAndPush() {
|
|
|
178
183
|
execFileSync('git', ['merge', '--abort'], { stdio: 'pipe' });
|
|
179
184
|
}
|
|
180
185
|
catch { /* no merge in progress */ }
|
|
181
|
-
console.warn('Auto-sync: merge failed, skipping push. Run `git pull --rebase runwork main` manually.');
|
|
186
|
+
console.warn(yellow('Auto-sync: merge failed, skipping push. Run `git pull --rebase runwork main` manually.'));
|
|
182
187
|
return;
|
|
183
188
|
}
|
|
184
189
|
}
|
|
@@ -189,11 +194,12 @@ function commitAndPush() {
|
|
|
189
194
|
catch {
|
|
190
195
|
execFileSync('git', ['push', '-u', 'runwork', 'main'], { stdio: 'pipe' });
|
|
191
196
|
}
|
|
192
|
-
console.log(` Pushed ${stagedFiles.length} file(s) to git.`);
|
|
197
|
+
console.log(dim(` Pushed ${stagedFiles.length} file(s) to git.`));
|
|
198
|
+
activeCallbacks?.onGitPush?.(stagedFiles.length);
|
|
193
199
|
}
|
|
194
200
|
catch (error) {
|
|
195
201
|
const message = error instanceof Error ? error.message : String(error);
|
|
196
|
-
console.warn(`Auto-sync failed: ${message}`);
|
|
202
|
+
console.warn(yellow(`Auto-sync failed: ${message}`));
|
|
197
203
|
}
|
|
198
204
|
finally {
|
|
199
205
|
gitPushing = false;
|
package/dist/git/sync.js
CHANGED
|
@@ -49,6 +49,31 @@ function removeConflictingUntrackedFiles(cwd) {
|
|
|
49
49
|
// best effort
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
function extractGitError(err) {
|
|
53
|
+
if (err && typeof err === 'object') {
|
|
54
|
+
const obj = err;
|
|
55
|
+
// execFileSync errors have stderr and stdout as Buffer or string
|
|
56
|
+
const stderr = bufToStr(obj.stderr);
|
|
57
|
+
const stdout = bufToStr(obj.stdout);
|
|
58
|
+
// Prefer stderr (git's error output), fall back to stdout (merge conflict details)
|
|
59
|
+
if (stderr)
|
|
60
|
+
return stderr;
|
|
61
|
+
if (stdout)
|
|
62
|
+
return stdout;
|
|
63
|
+
}
|
|
64
|
+
if (err instanceof Error)
|
|
65
|
+
return err.message;
|
|
66
|
+
return String(err);
|
|
67
|
+
}
|
|
68
|
+
function bufToStr(val) {
|
|
69
|
+
if (!val)
|
|
70
|
+
return '';
|
|
71
|
+
if (typeof val === 'string')
|
|
72
|
+
return val.trim();
|
|
73
|
+
if (Buffer.isBuffer(val))
|
|
74
|
+
return val.toString('utf-8').trim();
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
52
77
|
/**
|
|
53
78
|
* Sync local repository with the runwork remote.
|
|
54
79
|
*
|
|
@@ -65,13 +90,14 @@ export function syncWithRemote(cwd) {
|
|
|
65
90
|
execFileSync('git', ['stash', 'push', '-m', 'runwork-dev-sync'], { cwd, stdio: 'pipe' });
|
|
66
91
|
}
|
|
67
92
|
let status = 'synced';
|
|
93
|
+
let syncError;
|
|
68
94
|
try {
|
|
69
95
|
execFileSync('git', ['fetch', 'runwork', 'main'], { cwd, stdio: 'pipe' });
|
|
70
96
|
removeConflictingUntrackedFiles(cwd);
|
|
71
97
|
try {
|
|
72
98
|
execFileSync('git', ['rebase', 'runwork/main'], { cwd, stdio: 'pipe' });
|
|
73
99
|
}
|
|
74
|
-
catch {
|
|
100
|
+
catch (rebaseErr) {
|
|
75
101
|
try {
|
|
76
102
|
execFileSync('git', ['rebase', '--abort'], { cwd, stdio: 'pipe' });
|
|
77
103
|
}
|
|
@@ -81,17 +107,32 @@ export function syncWithRemote(cwd) {
|
|
|
81
107
|
status = 'merged';
|
|
82
108
|
}
|
|
83
109
|
catch {
|
|
110
|
+
// Merge conflicts (common with unrelated histories / first sync).
|
|
111
|
+
// Abort and retry accepting the remote's version for all conflicts.
|
|
112
|
+
// This is safe because: user edits were committed before sync,
|
|
113
|
+
// and local template files are ephemeral (re-downloaded each session).
|
|
84
114
|
try {
|
|
85
115
|
execFileSync('git', ['merge', '--abort'], { cwd, stdio: 'pipe' });
|
|
86
116
|
}
|
|
87
117
|
catch { /* no merge in progress */ }
|
|
88
|
-
|
|
118
|
+
try {
|
|
119
|
+
execFileSync('git', ['merge', 'runwork/main', '--allow-unrelated-histories', '--no-edit', '-X', 'theirs'], { cwd, stdio: 'pipe' });
|
|
120
|
+
status = 'merged';
|
|
121
|
+
}
|
|
122
|
+
catch (mergeErr) {
|
|
123
|
+
try {
|
|
124
|
+
execFileSync('git', ['merge', '--abort'], { cwd, stdio: 'pipe' });
|
|
125
|
+
}
|
|
126
|
+
catch { /* no merge in progress */ }
|
|
127
|
+
status = 'sync-failed';
|
|
128
|
+
syncError = extractGitError(mergeErr);
|
|
129
|
+
}
|
|
89
130
|
}
|
|
90
131
|
}
|
|
91
132
|
}
|
|
92
|
-
catch {
|
|
93
|
-
// fetch failed — remote may be unreachable or have no commits
|
|
133
|
+
catch (fetchErr) {
|
|
94
134
|
status = 'sync-failed';
|
|
135
|
+
syncError = extractGitError(fetchErr);
|
|
95
136
|
}
|
|
96
137
|
if (dirty) {
|
|
97
138
|
try {
|
|
@@ -102,7 +143,7 @@ export function syncWithRemote(cwd) {
|
|
|
102
143
|
}
|
|
103
144
|
}
|
|
104
145
|
if (status === 'sync-failed') {
|
|
105
|
-
return { status, pushed: false, error:
|
|
146
|
+
return { status, pushed: false, error: syncError };
|
|
106
147
|
}
|
|
107
148
|
let pushed = false;
|
|
108
149
|
try {
|
package/dist/index.js
CHANGED
|
@@ -8,13 +8,18 @@ import { logsCommand } from './commands/logs.js';
|
|
|
8
8
|
import { upgradeCommand } from './commands/upgrade.js';
|
|
9
9
|
import { logoutCommand } from './commands/logout.js';
|
|
10
10
|
import { integrationsCommand } from './commands/integrations.js';
|
|
11
|
+
import { openCommand } from './commands/open.js';
|
|
12
|
+
import { infoCommand } from './commands/info.js';
|
|
11
13
|
import { handleGitCredentialRequest } from './git/credentials.js';
|
|
12
14
|
import { VERSION } from './generated/version.js';
|
|
13
15
|
const program = new Command();
|
|
14
16
|
program
|
|
15
17
|
.name('runwork')
|
|
16
18
|
.description('Runwork CLI - local development for Runwork apps')
|
|
17
|
-
.version(VERSION)
|
|
19
|
+
.version(VERSION)
|
|
20
|
+
.option('--json', 'Output as JSON (auto-enabled when stdout is not a TTY)');
|
|
21
|
+
// Info first -- it's the agent discovery entry point
|
|
22
|
+
program.addCommand(infoCommand);
|
|
18
23
|
program.addCommand(loginCommand);
|
|
19
24
|
program.addCommand(initCommand);
|
|
20
25
|
program.addCommand(cloneCommand);
|
|
@@ -24,6 +29,8 @@ program.addCommand(logsCommand);
|
|
|
24
29
|
program.addCommand(upgradeCommand);
|
|
25
30
|
program.addCommand(logoutCommand);
|
|
26
31
|
program.addCommand(integrationsCommand);
|
|
32
|
+
program.addCommand(openCommand);
|
|
33
|
+
program.addHelpText('after', '\nFor AI agents: run "runwork info --json" to discover app context and available commands.');
|
|
27
34
|
const credentialHelper = program
|
|
28
35
|
.command('git-credential-helper', { hidden: true })
|
|
29
36
|
.argument('<action>', 'Credential action (get/store/erase)')
|
|
@@ -32,4 +39,18 @@ const credentialHelper = program
|
|
|
32
39
|
await handleGitCredentialRequest(action);
|
|
33
40
|
});
|
|
34
41
|
credentialHelper.helpOption(false);
|
|
42
|
+
// First-run detection: bare `runwork` with no command and no credentials -> welcome wizard
|
|
43
|
+
const args = process.argv.slice(2);
|
|
44
|
+
const isHelpOrVersion = args.includes('--help') || args.includes('-h') ||
|
|
45
|
+
args.includes('--version') || args.includes('-v') || args.includes('-V');
|
|
46
|
+
const knownCommands = program.commands.map(c => c.name());
|
|
47
|
+
const hasCommand = args.some(arg => !arg.startsWith('-') && knownCommands.includes(arg));
|
|
48
|
+
if (!hasCommand && !isHelpOrVersion && args.length === 0) {
|
|
49
|
+
const { getCredentials } = await import('./auth/store.js');
|
|
50
|
+
if (!getCredentials()) {
|
|
51
|
+
const { runWelcomeWizard } = await import('./commands/welcome.js');
|
|
52
|
+
await runWelcomeWizard();
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
35
56
|
program.parse();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { formatLogLine, formatRelativeTime } from '../tailer.js';
|
|
3
|
+
import { stripAnsi } from '../../ui/colors.js';
|
|
4
|
+
describe('formatLogLine', () => {
|
|
5
|
+
it('formats RUNTIME lines with timestamp and tag', () => {
|
|
6
|
+
const line = formatLogLine('RUNTIME', 'Server started on port 8787');
|
|
7
|
+
const plain = stripAnsi(line);
|
|
8
|
+
expect(plain).toMatch(/\d{2}:\d{2}:\d{2}/);
|
|
9
|
+
expect(plain).toContain('RUNTIME');
|
|
10
|
+
expect(plain).toContain('Server started on port 8787');
|
|
11
|
+
});
|
|
12
|
+
it('formats ERROR lines', () => {
|
|
13
|
+
const line = formatLogLine('ERROR', 'Something broke');
|
|
14
|
+
const plain = stripAnsi(line);
|
|
15
|
+
expect(plain).toContain('ERROR');
|
|
16
|
+
});
|
|
17
|
+
it('formats EVENT lines', () => {
|
|
18
|
+
const line = formatLogLine('EVENT', 'workflow_completed: done');
|
|
19
|
+
const plain = stripAnsi(line);
|
|
20
|
+
expect(plain).toContain('EVENT');
|
|
21
|
+
});
|
|
22
|
+
it('indents log lines with 2 spaces', () => {
|
|
23
|
+
const line = formatLogLine('RUNTIME', 'test');
|
|
24
|
+
expect(line.startsWith(' ')).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('formatRelativeTime', () => {
|
|
28
|
+
it('returns "now" for future timestamps', () => {
|
|
29
|
+
const future = new Date(Date.now() + 10000).toISOString();
|
|
30
|
+
expect(formatRelativeTime(future)).toBe('now');
|
|
31
|
+
});
|
|
32
|
+
it('returns seconds for recent events', () => {
|
|
33
|
+
const recent = new Date(Date.now() - 5000).toISOString();
|
|
34
|
+
expect(formatRelativeTime(recent)).toMatch(/\ds ago/);
|
|
35
|
+
});
|
|
36
|
+
it('returns minutes for older events', () => {
|
|
37
|
+
const older = new Date(Date.now() - 120000).toISOString();
|
|
38
|
+
expect(formatRelativeTime(older)).toMatch(/\dm ago/);
|
|
39
|
+
});
|
|
40
|
+
it('returns absolute time when no timestamp given', () => {
|
|
41
|
+
expect(formatRelativeTime()).toMatch(/\d{2}:\d{2}:\d{2}/);
|
|
42
|
+
});
|
|
43
|
+
});
|