runwork 0.2.4 → 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 (52) 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 +103 -85
  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__/auto-commit.test.d.ts +1 -0
  17. package/dist/git/__tests__/auto-commit.test.js +373 -0
  18. package/dist/git/__tests__/manifest.test.d.ts +1 -0
  19. package/dist/git/__tests__/manifest.test.js +377 -0
  20. package/dist/git/__tests__/sync.test.d.ts +1 -0
  21. package/dist/git/__tests__/sync.test.js +405 -0
  22. package/dist/git/auto-commit.d.ts +6 -1
  23. package/dist/git/auto-commit.js +29 -13
  24. package/dist/git/sync.d.ts +15 -0
  25. package/dist/git/sync.js +157 -0
  26. package/dist/index.js +16 -0
  27. package/dist/logs/__tests__/tailer-format.test.d.ts +1 -0
  28. package/dist/logs/__tests__/tailer-format.test.js +43 -0
  29. package/dist/logs/tailer.d.ts +3 -0
  30. package/dist/logs/tailer.js +47 -10
  31. package/dist/template/manifest.js +24 -4
  32. package/dist/types.d.ts +1 -0
  33. package/dist/ui/__tests__/banner.test.d.ts +1 -0
  34. package/dist/ui/__tests__/banner.test.js +82 -0
  35. package/dist/ui/__tests__/colors.test.d.ts +1 -0
  36. package/dist/ui/__tests__/colors.test.js +22 -0
  37. package/dist/ui/__tests__/keyboard.test.d.ts +1 -0
  38. package/dist/ui/__tests__/keyboard.test.js +30 -0
  39. package/dist/ui/__tests__/status-line.test.d.ts +1 -0
  40. package/dist/ui/__tests__/status-line.test.js +54 -0
  41. package/dist/ui/banner.d.ts +29 -0
  42. package/dist/ui/banner.js +118 -0
  43. package/dist/ui/colors.d.ts +4 -0
  44. package/dist/ui/colors.js +7 -0
  45. package/dist/ui/keyboard.d.ts +12 -0
  46. package/dist/ui/keyboard.js +57 -0
  47. package/dist/ui/status-line.d.ts +6 -0
  48. package/dist/ui/status-line.js +53 -0
  49. package/dist/utils/__tests__/prompt.test.js +23 -99
  50. package/dist/utils/prompt.d.ts +1 -0
  51. package/dist/utils/prompt.js +29 -21
  52. package/package.json +4 -2
@@ -0,0 +1,157 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { unlinkSync } from 'fs';
3
+ import { join } from 'path';
4
+ export function hasCommits(cwd) {
5
+ try {
6
+ execFileSync('git', ['rev-parse', 'HEAD'], { cwd, stdio: 'pipe' });
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ export function hasTrackedChanges(cwd) {
14
+ try {
15
+ const output = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' });
16
+ return output.trim().split('\n').some(line => line.length > 0 && !line.startsWith('??'));
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ /**
23
+ * Remove untracked local files that exist on the remote.
24
+ * Skeleton files are ephemeral (re-downloaded each session) so the
25
+ * server's versions take precedence. User-edited files are already
26
+ * tracked/committed at this point and won't be affected.
27
+ */
28
+ function removeConflictingUntrackedFiles(cwd) {
29
+ try {
30
+ const remoteFiles = execFileSync('git', ['ls-tree', '-r', '--name-only', 'runwork/main'], {
31
+ cwd,
32
+ encoding: 'utf-8',
33
+ }).trim().split('\n');
34
+ const untrackedOutput = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
35
+ cwd,
36
+ encoding: 'utf-8',
37
+ }).trim();
38
+ const untracked = new Set(untrackedOutput.split('\n').filter(Boolean));
39
+ for (const file of remoteFiles) {
40
+ if (untracked.has(file)) {
41
+ try {
42
+ unlinkSync(join(cwd, file));
43
+ }
44
+ catch { /* already gone */ }
45
+ }
46
+ }
47
+ }
48
+ catch {
49
+ // best effort
50
+ }
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
+ }
77
+ /**
78
+ * Sync local repository with the runwork remote.
79
+ *
80
+ * Strategy: fetch, then attempt rebase. If rebase fails (e.g. diverged
81
+ * histories after a template update), fall back to merge with
82
+ * --allow-unrelated-histories. Stash/pop around dirty working trees.
83
+ */
84
+ export function syncWithRemote(cwd) {
85
+ if (!hasCommits(cwd)) {
86
+ return { status: 'skipped', pushed: false };
87
+ }
88
+ const dirty = hasTrackedChanges(cwd);
89
+ if (dirty) {
90
+ execFileSync('git', ['stash', 'push', '-m', 'runwork-dev-sync'], { cwd, stdio: 'pipe' });
91
+ }
92
+ let status = 'synced';
93
+ let syncError;
94
+ try {
95
+ execFileSync('git', ['fetch', 'runwork', 'main'], { cwd, stdio: 'pipe' });
96
+ removeConflictingUntrackedFiles(cwd);
97
+ try {
98
+ execFileSync('git', ['rebase', 'runwork/main'], { cwd, stdio: 'pipe' });
99
+ }
100
+ catch (rebaseErr) {
101
+ try {
102
+ execFileSync('git', ['rebase', '--abort'], { cwd, stdio: 'pipe' });
103
+ }
104
+ catch { /* no rebase in progress */ }
105
+ try {
106
+ execFileSync('git', ['merge', 'runwork/main', '--allow-unrelated-histories', '--no-edit'], { cwd, stdio: 'pipe' });
107
+ status = 'merged';
108
+ }
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).
114
+ try {
115
+ execFileSync('git', ['merge', '--abort'], { cwd, stdio: 'pipe' });
116
+ }
117
+ catch { /* no merge in progress */ }
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
+ }
130
+ }
131
+ }
132
+ }
133
+ catch (fetchErr) {
134
+ status = 'sync-failed';
135
+ syncError = extractGitError(fetchErr);
136
+ }
137
+ if (dirty) {
138
+ try {
139
+ execFileSync('git', ['stash', 'pop'], { cwd, stdio: 'pipe' });
140
+ }
141
+ catch {
142
+ return { status, pushed: false, error: 'stash-conflict' };
143
+ }
144
+ }
145
+ if (status === 'sync-failed') {
146
+ return { status, pushed: false, error: syncError };
147
+ }
148
+ let pushed = false;
149
+ try {
150
+ execFileSync('git', ['push', 'runwork', 'main'], { cwd, stdio: 'pipe' });
151
+ pushed = true;
152
+ }
153
+ catch {
154
+ // push failed — continue with current state
155
+ }
156
+ return { status, pushed };
157
+ }
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)
@@ -63,10 +63,13 @@ export async function loadManifest(dir) {
63
63
  */
64
64
  export async function detectUserEdits(dir, manifest) {
65
65
  const edits = [];
66
- // 1. Uncommitted changes to tracked files
66
+ // 1. Uncommitted changes to tracked files (skip if no commits yet)
67
+ // Use -c core.quotePath=false to get real filenames instead of octal-escaped quoted strings
68
+ // for non-ASCII characters (e.g. "caf\303\251.txt" → café.txt)
67
69
  try {
68
- const modified = execFileSync('git', ['diff', '--name-only', 'HEAD'], { cwd: dir, encoding: 'utf-8' }).trim();
69
- const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: dir, encoding: 'utf-8' }).trim();
70
+ execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, stdio: 'pipe' });
71
+ const modified = execFileSync('git', ['-c', 'core.quotePath=false', 'diff', '--name-only', 'HEAD'], { cwd: dir, encoding: 'utf-8' }).trim();
72
+ const staged = execFileSync('git', ['-c', 'core.quotePath=false', 'diff', '--cached', '--name-only'], { cwd: dir, encoding: 'utf-8' }).trim();
70
73
  for (const file of [...modified.split('\n'), ...staged.split('\n')]) {
71
74
  if (file && !edits.includes(file))
72
75
  edits.push(file);
@@ -77,7 +80,7 @@ export async function detectUserEdits(dir, manifest) {
77
80
  }
78
81
  // 2. Untracked files that aren't template files
79
82
  try {
80
- const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: dir, encoding: 'utf-8' }).trim();
83
+ const untracked = execFileSync('git', ['-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard'], { cwd: dir, encoding: 'utf-8' }).trim();
81
84
  for (const relPath of untracked.split('\n')) {
82
85
  if (!relPath)
83
86
  continue;
@@ -98,5 +101,22 @@ export async function detectUserEdits(dir, manifest) {
98
101
  catch {
99
102
  // No git repo
100
103
  }
104
+ // 3. Scan manifest files on disk directly — catches gitignored files that
105
+ // git ls-files --exclude-standard would miss. If a template file was added
106
+ // to .gitignore by the user and then modified, only this check detects it.
107
+ for (const [relPath, expectedHash] of Object.entries(manifest.files)) {
108
+ if (edits.includes(relPath))
109
+ continue;
110
+ const filePath = join(dir, relPath);
111
+ try {
112
+ const content = readFileSync(filePath);
113
+ if (sha256(content) !== expectedHash) {
114
+ edits.push(relPath);
115
+ }
116
+ }
117
+ catch {
118
+ // File doesn't exist on disk — not an edit to detect
119
+ }
120
+ }
101
121
  return edits;
102
122
  }
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;