runwork 0.2.5 → 0.3.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/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/dev.d.ts +4 -0
- package/dist/commands/dev.js +89 -21
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +29 -21
- package/dist/commands/login.js +10 -25
- package/dist/commands/open.d.ts +2 -0
- package/dist/commands/open.js +43 -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 +16 -0
- 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 +3 -0
- package/dist/logs/tailer.js +47 -10
- package/dist/types.d.ts +1 -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__/prompt.test.js +23 -99
- package/dist/utils/prompt.d.ts +1 -0
- package/dist/utils/prompt.js +29 -21
- package/package.json +4 -2
|
@@ -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,6 +8,7 @@ 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';
|
|
11
12
|
import { handleGitCredentialRequest } from './git/credentials.js';
|
|
12
13
|
import { VERSION } from './generated/version.js';
|
|
13
14
|
const program = new Command();
|
|
@@ -24,6 +25,7 @@ program.addCommand(logsCommand);
|
|
|
24
25
|
program.addCommand(upgradeCommand);
|
|
25
26
|
program.addCommand(logoutCommand);
|
|
26
27
|
program.addCommand(integrationsCommand);
|
|
28
|
+
program.addCommand(openCommand);
|
|
27
29
|
const credentialHelper = program
|
|
28
30
|
.command('git-credential-helper', { hidden: true })
|
|
29
31
|
.argument('<action>', 'Credential action (get/store/erase)')
|
|
@@ -32,4 +34,18 @@ const credentialHelper = program
|
|
|
32
34
|
await handleGitCredentialRequest(action);
|
|
33
35
|
});
|
|
34
36
|
credentialHelper.helpOption(false);
|
|
37
|
+
// First-run detection: bare `runwork` with no command and no credentials -> welcome wizard
|
|
38
|
+
const args = process.argv.slice(2);
|
|
39
|
+
const isHelpOrVersion = args.includes('--help') || args.includes('-h') ||
|
|
40
|
+
args.includes('--version') || args.includes('-v') || args.includes('-V');
|
|
41
|
+
const knownCommands = program.commands.map(c => c.name());
|
|
42
|
+
const hasCommand = args.some(arg => !arg.startsWith('-') && knownCommands.includes(arg));
|
|
43
|
+
if (!hasCommand && !isHelpOrVersion && args.length === 0) {
|
|
44
|
+
const { getCredentials } = await import('./auth/store.js');
|
|
45
|
+
if (!getCredentials()) {
|
|
46
|
+
const { runWelcomeWizard } = await import('./commands/welcome.js');
|
|
47
|
+
await runWelcomeWizard();
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
35
51
|
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
|
+
});
|
package/dist/logs/tailer.d.ts
CHANGED
|
@@ -6,7 +6,10 @@ interface TailerOptions {
|
|
|
6
6
|
toTerminal: boolean;
|
|
7
7
|
toFile: boolean;
|
|
8
8
|
intervalMs?: number;
|
|
9
|
+
getFilter?: () => 'all' | 'events' | 'runtime';
|
|
9
10
|
}
|
|
11
|
+
export declare function formatLogLine(tag: string, line: string): string;
|
|
12
|
+
export declare function formatRelativeTime(eventTimestamp?: string): string;
|
|
10
13
|
export declare function startLogTailer(options: TailerOptions): {
|
|
11
14
|
stop: () => void;
|
|
12
15
|
};
|
package/dist/logs/tailer.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
|
+
import { bold, red, yellow, cyan, gray, dim } from '../ui/colors.js';
|
|
4
|
+
import { stripAnsi } from '../ui/colors.js';
|
|
3
5
|
const LOG_FILE = '.runwork/logs.txt';
|
|
4
6
|
function formatTime() {
|
|
5
7
|
const now = new Date();
|
|
@@ -9,8 +11,35 @@ function formatTime() {
|
|
|
9
11
|
String(now.getSeconds()).padStart(2, '0'),
|
|
10
12
|
].join(':');
|
|
11
13
|
}
|
|
12
|
-
function formatLogLine(tag, line) {
|
|
13
|
-
|
|
14
|
+
export function formatLogLine(tag, line) {
|
|
15
|
+
const time = gray(formatTime());
|
|
16
|
+
let tagFormatted;
|
|
17
|
+
switch (tag) {
|
|
18
|
+
case 'ERROR':
|
|
19
|
+
tagFormatted = bold(red('ERROR '));
|
|
20
|
+
break;
|
|
21
|
+
case 'EVENT':
|
|
22
|
+
tagFormatted = yellow('EVENT ');
|
|
23
|
+
break;
|
|
24
|
+
case 'RUNTIME':
|
|
25
|
+
tagFormatted = cyan('RUNTIME');
|
|
26
|
+
break;
|
|
27
|
+
default:
|
|
28
|
+
tagFormatted = dim(tag.padEnd(7));
|
|
29
|
+
}
|
|
30
|
+
return ` ${time} ${tagFormatted} ${line}`;
|
|
31
|
+
}
|
|
32
|
+
export function formatRelativeTime(eventTimestamp) {
|
|
33
|
+
if (!eventTimestamp)
|
|
34
|
+
return formatTime();
|
|
35
|
+
const diff = Date.now() - new Date(eventTimestamp).getTime();
|
|
36
|
+
if (diff < 0)
|
|
37
|
+
return 'now';
|
|
38
|
+
if (diff < 60_000)
|
|
39
|
+
return `${Math.floor(diff / 1000)}s ago`;
|
|
40
|
+
if (diff < 3_600_000)
|
|
41
|
+
return `${Math.floor(diff / 60_000)}m ago`;
|
|
42
|
+
return formatTime();
|
|
14
43
|
}
|
|
15
44
|
export function startLogTailer(options) {
|
|
16
45
|
const { appId, client, projectDir, toTerminal, toFile, intervalMs = 5000, } = options;
|
|
@@ -25,18 +54,25 @@ export function startLogTailer(options) {
|
|
|
25
54
|
let seenEventIds = new Set();
|
|
26
55
|
let stopped = false;
|
|
27
56
|
let timer;
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
console.log(line);
|
|
31
|
-
}
|
|
57
|
+
function writeLineFiltered(line, tag) {
|
|
58
|
+
// Always write to file (stripped of ANSI codes)
|
|
32
59
|
if (toFile) {
|
|
33
60
|
try {
|
|
34
|
-
appendFileSync(logFilePath, line + '\n', 'utf-8');
|
|
61
|
+
appendFileSync(logFilePath, stripAnsi(line) + '\n', 'utf-8');
|
|
35
62
|
}
|
|
36
63
|
catch {
|
|
37
64
|
// Silently ignore file write errors
|
|
38
65
|
}
|
|
39
66
|
}
|
|
67
|
+
// Filter for terminal
|
|
68
|
+
if (!toTerminal)
|
|
69
|
+
return;
|
|
70
|
+
const filter = options.getFilter?.() || 'all';
|
|
71
|
+
if (filter === 'events' && tag !== 'EVENT')
|
|
72
|
+
return;
|
|
73
|
+
if (filter === 'runtime' && tag !== 'RUNTIME' && tag !== 'ERROR')
|
|
74
|
+
return;
|
|
75
|
+
console.log(line);
|
|
40
76
|
}
|
|
41
77
|
async function poll() {
|
|
42
78
|
if (stopped)
|
|
@@ -55,7 +91,7 @@ export function startLogTailer(options) {
|
|
|
55
91
|
const newContent = logs.stdout.slice(lastStdoutLength);
|
|
56
92
|
const lines = newContent.split('\n').filter(Boolean);
|
|
57
93
|
for (const line of lines) {
|
|
58
|
-
|
|
94
|
+
writeLineFiltered(formatLogLine('RUNTIME', line), 'RUNTIME');
|
|
59
95
|
}
|
|
60
96
|
lastStdoutLength = logs.stdout.length;
|
|
61
97
|
}
|
|
@@ -63,7 +99,7 @@ export function startLogTailer(options) {
|
|
|
63
99
|
const newContent = logs.stderr.slice(lastStderrLength);
|
|
64
100
|
const lines = newContent.split('\n').filter(Boolean);
|
|
65
101
|
for (const line of lines) {
|
|
66
|
-
|
|
102
|
+
writeLineFiltered(formatLogLine('ERROR', line), 'ERROR');
|
|
67
103
|
}
|
|
68
104
|
lastStderrLength = logs.stderr.length;
|
|
69
105
|
}
|
|
@@ -98,7 +134,8 @@ export function startLogTailer(options) {
|
|
|
98
134
|
detail += ` [${meta.path}]`;
|
|
99
135
|
}
|
|
100
136
|
}
|
|
101
|
-
|
|
137
|
+
const relTime = gray(formatRelativeTime(event.timestamp));
|
|
138
|
+
writeLineFiltered(` ${relTime} ${yellow('EVENT ')} ${event.type}: ${detail}`, 'EVENT');
|
|
102
139
|
seenEventIds.add(event.id);
|
|
103
140
|
}
|
|
104
141
|
// Cap the set size to prevent unbounded growth (keep last 200 IDs)
|
package/dist/types.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getAgentCommand, SUPPORTED_AGENTS, getDevBanner, getKeyboardHints, getWelcomeBanner, getInfoPanel } from '../banner.js';
|
|
3
|
+
import { stripAnsi } from '../colors.js';
|
|
4
|
+
describe('getAgentCommand', () => {
|
|
5
|
+
it('returns claude command for a directory', () => {
|
|
6
|
+
const cmd = getAgentCommand('claude-code', '/path/to/app');
|
|
7
|
+
expect(cmd).toContain('claude');
|
|
8
|
+
expect(cmd).toContain('/path/to/app');
|
|
9
|
+
});
|
|
10
|
+
it('returns cursor command', () => {
|
|
11
|
+
expect(getAgentCommand('cursor', '/path')).toContain('cursor');
|
|
12
|
+
});
|
|
13
|
+
it('returns codex command', () => {
|
|
14
|
+
expect(getAgentCommand('codex', '/path')).toContain('codex');
|
|
15
|
+
});
|
|
16
|
+
it('returns null for unknown agent', () => {
|
|
17
|
+
expect(getAgentCommand('unknown-agent', '/path')).toBe(null);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe('SUPPORTED_AGENTS', () => {
|
|
21
|
+
it('includes common agents', () => {
|
|
22
|
+
const ids = SUPPORTED_AGENTS.map(a => a.id);
|
|
23
|
+
expect(ids).toContain('claude-code');
|
|
24
|
+
expect(ids).toContain('cursor');
|
|
25
|
+
expect(ids).toContain('codex');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('getDevBanner', () => {
|
|
29
|
+
it('includes preview URL and app name', () => {
|
|
30
|
+
const banner = getDevBanner({ appName: 'test-app', previewUrl: 'https://test.runwork.dev' });
|
|
31
|
+
const plain = stripAnsi(banner);
|
|
32
|
+
expect(plain).toContain('https://test.runwork.dev');
|
|
33
|
+
expect(plain).toContain('test-app');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe('getKeyboardHints', () => {
|
|
37
|
+
it('includes shortcut keys', () => {
|
|
38
|
+
const hints = stripAnsi(getKeyboardHints());
|
|
39
|
+
expect(hints).toContain('o');
|
|
40
|
+
expect(hints).toContain('q');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('getInfoPanel', () => {
|
|
44
|
+
it('includes app info and agent commands', () => {
|
|
45
|
+
const panel = getInfoPanel({
|
|
46
|
+
appName: 'my-app',
|
|
47
|
+
previewUrl: 'https://test.dev',
|
|
48
|
+
workspaceName: 'my-ws',
|
|
49
|
+
directory: '/path/to/app',
|
|
50
|
+
});
|
|
51
|
+
const plain = stripAnsi(panel);
|
|
52
|
+
expect(plain).toContain('my-app');
|
|
53
|
+
expect(plain).toContain('https://test.dev');
|
|
54
|
+
expect(plain).toContain('my-ws');
|
|
55
|
+
expect(plain).toContain('/path/to/app');
|
|
56
|
+
expect(plain).toContain('Claude Code');
|
|
57
|
+
expect(plain).toContain('Cursor');
|
|
58
|
+
});
|
|
59
|
+
it('omits workspace when not provided', () => {
|
|
60
|
+
const panel = getInfoPanel({
|
|
61
|
+
appName: 'my-app',
|
|
62
|
+
previewUrl: 'https://test.dev',
|
|
63
|
+
directory: '/path',
|
|
64
|
+
});
|
|
65
|
+
const plain = stripAnsi(panel);
|
|
66
|
+
expect(plain).not.toContain('Workspace');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('getDevBanner', () => {
|
|
70
|
+
it('includes workspace name when provided', () => {
|
|
71
|
+
const banner = getDevBanner({ appName: 'app', previewUrl: 'https://x', workspaceName: 'ws' });
|
|
72
|
+
const plain = stripAnsi(banner);
|
|
73
|
+
expect(plain).toContain('ws');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('getWelcomeBanner', () => {
|
|
77
|
+
it('includes Runwork name and mentions AI agents', () => {
|
|
78
|
+
const text = stripAnsi(getWelcomeBanner());
|
|
79
|
+
expect(text).toContain('Runwork');
|
|
80
|
+
expect(text).toContain('Claude');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { bold, red, green, yellow, cyan, dim, gray, stripAnsi, pc } from '../colors.js';
|
|
3
|
+
describe('colors', () => {
|
|
4
|
+
it('re-exports picocolors functions that produce strings', () => {
|
|
5
|
+
// Verify they're callable and return strings (not just that they exist)
|
|
6
|
+
expect(bold('test')).toEqual(expect.any(String));
|
|
7
|
+
expect(red('test')).toEqual(expect.any(String));
|
|
8
|
+
expect(green('test')).toEqual(expect.any(String));
|
|
9
|
+
expect(yellow('test')).toEqual(expect.any(String));
|
|
10
|
+
expect(cyan('test')).toEqual(expect.any(String));
|
|
11
|
+
expect(dim('test')).toEqual(expect.any(String));
|
|
12
|
+
expect(gray('test')).toEqual(expect.any(String));
|
|
13
|
+
});
|
|
14
|
+
it('exports the full picocolors object', () => {
|
|
15
|
+
expect(typeof pc.bold).toBe('function');
|
|
16
|
+
});
|
|
17
|
+
it('strips ANSI codes', () => {
|
|
18
|
+
expect(stripAnsi('\x1b[1mhello\x1b[22m')).toBe('hello');
|
|
19
|
+
expect(stripAnsi('no codes')).toBe('no codes');
|
|
20
|
+
expect(stripAnsi('\x1b[31m\x1b[1mfail\x1b[22m\x1b[39m')).toBe('fail');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseKeypress } from '../keyboard.js';
|
|
3
|
+
describe('parseKeypress', () => {
|
|
4
|
+
it('detects "o" key', () => {
|
|
5
|
+
expect(parseKeypress(Buffer.from('o'))).toBe('o');
|
|
6
|
+
});
|
|
7
|
+
it('detects uppercase "O" as lowercase', () => {
|
|
8
|
+
expect(parseKeypress(Buffer.from('O'))).toBe('o');
|
|
9
|
+
});
|
|
10
|
+
it('detects "p" key', () => {
|
|
11
|
+
expect(parseKeypress(Buffer.from('p'))).toBe('p');
|
|
12
|
+
});
|
|
13
|
+
it('detects Ctrl+C as quit', () => {
|
|
14
|
+
expect(parseKeypress(Buffer.from([0x03]))).toBe('quit');
|
|
15
|
+
});
|
|
16
|
+
it('detects "q" as quit', () => {
|
|
17
|
+
expect(parseKeypress(Buffer.from('q'))).toBe('quit');
|
|
18
|
+
});
|
|
19
|
+
it('returns null for unknown keys', () => {
|
|
20
|
+
expect(parseKeypress(Buffer.from('x'))).toBe(null);
|
|
21
|
+
});
|
|
22
|
+
it('detects log filter keys (a, e, r)', () => {
|
|
23
|
+
expect(parseKeypress(Buffer.from('a'))).toBe('a');
|
|
24
|
+
expect(parseKeypress(Buffer.from('e'))).toBe('e');
|
|
25
|
+
expect(parseKeypress(Buffer.from('r'))).toBe('r');
|
|
26
|
+
});
|
|
27
|
+
it('detects "i" for info', () => {
|
|
28
|
+
expect(parseKeypress(Buffer.from('i'))).toBe('i');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createStatusLine } from '../status-line.js';
|
|
3
|
+
function createMockStream() {
|
|
4
|
+
const mockWrite = vi.fn().mockReturnValue(true);
|
|
5
|
+
return {
|
|
6
|
+
write: mockWrite,
|
|
7
|
+
isTTY: true,
|
|
8
|
+
rows: 24,
|
|
9
|
+
columns: 80,
|
|
10
|
+
on: vi.fn(),
|
|
11
|
+
removeListener: vi.fn(),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
describe('createStatusLine', () => {
|
|
15
|
+
it('returns a no-op status line when not a TTY', () => {
|
|
16
|
+
const stream = createMockStream();
|
|
17
|
+
stream.isTTY = false;
|
|
18
|
+
const status = createStatusLine(stream);
|
|
19
|
+
status.update('test');
|
|
20
|
+
expect(stream.write).not.toHaveBeenCalled();
|
|
21
|
+
});
|
|
22
|
+
it('sets up scroll region on creation for TTY', () => {
|
|
23
|
+
const stream = createMockStream();
|
|
24
|
+
createStatusLine(stream);
|
|
25
|
+
// Should have written scroll region escape sequence
|
|
26
|
+
const output = stream.write.mock.calls.map((c) => c[0]).join('');
|
|
27
|
+
// Scroll region: \x1b[1;23r (rows-1 = 23)
|
|
28
|
+
expect(output).toContain('\x1b[1;23r');
|
|
29
|
+
});
|
|
30
|
+
it('writes status text with inverse styling', () => {
|
|
31
|
+
const stream = createMockStream();
|
|
32
|
+
const status = createStatusLine(stream);
|
|
33
|
+
status.update('Watching | 3 files synced');
|
|
34
|
+
const output = stream.write.mock.calls.map((c) => c[0]).join('');
|
|
35
|
+
expect(output).toContain('Watching');
|
|
36
|
+
});
|
|
37
|
+
it('listens for resize events', () => {
|
|
38
|
+
const stream = createMockStream();
|
|
39
|
+
createStatusLine(stream);
|
|
40
|
+
expect(stream.on).toHaveBeenCalledWith('resize', expect.any(Function));
|
|
41
|
+
});
|
|
42
|
+
it('cleans up on destroy', () => {
|
|
43
|
+
const stream = createMockStream();
|
|
44
|
+
const status = createStatusLine(stream);
|
|
45
|
+
status.update('test');
|
|
46
|
+
stream.write.mockClear();
|
|
47
|
+
status.destroy();
|
|
48
|
+
expect(stream.write).toHaveBeenCalled();
|
|
49
|
+
expect(stream.removeListener).toHaveBeenCalledWith('resize', expect.any(Function));
|
|
50
|
+
// Should reset scroll region to full terminal
|
|
51
|
+
const output = stream.write.mock.calls.map((c) => c[0]).join('');
|
|
52
|
+
expect(output).toContain('\x1b[1;24r');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface AgentInfo {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
/** Shell command to open the agent in a directory. {dir} is replaced with the path. */
|
|
5
|
+
command: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const SUPPORTED_AGENTS: AgentInfo[];
|
|
8
|
+
export declare function getAgentCommand(agentId: string, directory: string): string | null;
|
|
9
|
+
export declare function getAgentGuidance(directory: string): string;
|
|
10
|
+
export interface DevBannerOptions {
|
|
11
|
+
appName: string;
|
|
12
|
+
previewUrl: string;
|
|
13
|
+
workspaceName?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function getDevBanner(options: DevBannerOptions): string;
|
|
16
|
+
export declare function getKeyboardHints(): string;
|
|
17
|
+
/**
|
|
18
|
+
* Interactive wizard that asks which AI agent the user wants to use,
|
|
19
|
+
* then shows specific instructions for opening this app in that agent.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runAgentWizard(directory: string): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Returns full info panel (for 'i' key in dev mode) showing
|
|
24
|
+
* workspace, app, preview URL, and agent guidance.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getInfoPanel(options: DevBannerOptions & {
|
|
27
|
+
directory: string;
|
|
28
|
+
}): string;
|
|
29
|
+
export declare function getWelcomeBanner(): string;
|