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.
Files changed (45) hide show
  1. package/dist/auth/login-flow.d.ts +10 -0
  2. package/dist/auth/login-flow.js +37 -0
  3. package/dist/commands/clone.d.ts +3 -0
  4. package/dist/commands/clone.js +32 -24
  5. package/dist/commands/dev.d.ts +4 -0
  6. package/dist/commands/dev.js +89 -21
  7. package/dist/commands/init.d.ts +3 -0
  8. package/dist/commands/init.js +29 -21
  9. package/dist/commands/login.js +10 -25
  10. package/dist/commands/open.d.ts +2 -0
  11. package/dist/commands/open.js +43 -0
  12. package/dist/commands/welcome.d.ts +1 -0
  13. package/dist/commands/welcome.js +83 -0
  14. package/dist/generated/version.d.ts +1 -1
  15. package/dist/generated/version.js +1 -1
  16. package/dist/git/__tests__/sync.test.js +10 -8
  17. package/dist/git/auto-commit.d.ts +5 -1
  18. package/dist/git/auto-commit.js +15 -9
  19. package/dist/git/sync.js +46 -5
  20. package/dist/index.js +16 -0
  21. package/dist/logs/__tests__/tailer-format.test.d.ts +1 -0
  22. package/dist/logs/__tests__/tailer-format.test.js +43 -0
  23. package/dist/logs/tailer.d.ts +3 -0
  24. package/dist/logs/tailer.js +47 -10
  25. package/dist/types.d.ts +1 -0
  26. package/dist/ui/__tests__/banner.test.d.ts +1 -0
  27. package/dist/ui/__tests__/banner.test.js +82 -0
  28. package/dist/ui/__tests__/colors.test.d.ts +1 -0
  29. package/dist/ui/__tests__/colors.test.js +22 -0
  30. package/dist/ui/__tests__/keyboard.test.d.ts +1 -0
  31. package/dist/ui/__tests__/keyboard.test.js +30 -0
  32. package/dist/ui/__tests__/status-line.test.d.ts +1 -0
  33. package/dist/ui/__tests__/status-line.test.js +54 -0
  34. package/dist/ui/banner.d.ts +29 -0
  35. package/dist/ui/banner.js +118 -0
  36. package/dist/ui/colors.d.ts +4 -0
  37. package/dist/ui/colors.js +7 -0
  38. package/dist/ui/keyboard.d.ts +12 -0
  39. package/dist/ui/keyboard.js +57 -0
  40. package/dist/ui/status-line.d.ts +6 -0
  41. package/dist/ui/status-line.js +53 -0
  42. package/dist/utils/__tests__/prompt.test.js +23 -99
  43. package/dist/utils/prompt.d.ts +1 -0
  44. package/dist/utils/prompt.js +29 -21
  45. 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): Promise<void>;
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>;
@@ -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
- status = 'sync-failed';
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: 'Pull failed (remote may not have commits yet)' };
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
+ });
@@ -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
  };
@@ -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
- return `[${formatTime()}] [${tag}] ${line}`;
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 writeLine(line) {
29
- if (toTerminal) {
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
- writeLine(formatLogLine('RUNTIME', line));
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
- writeLine(formatLogLine('ERROR', line));
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
- writeLine(formatLogLine('EVENT', `${event.type}: ${detail}`));
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
@@ -1,5 +1,6 @@
1
1
  export interface RunworkConfig {
2
2
  workspaceId: string;
3
+ workspaceName?: string;
3
4
  appId: string;
4
5
  appName: string;
5
6
  }
@@ -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;