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
@@ -0,0 +1,118 @@
1
+ import { bold, cyan, dim, green } from './colors.js';
2
+ export const SUPPORTED_AGENTS = [
3
+ { id: 'claude-code', name: 'Claude Code', command: 'cd {dir} && claude' },
4
+ { id: 'cursor', name: 'Cursor', command: 'cursor {dir}' },
5
+ { id: 'codex', name: 'OpenAI Codex', command: 'cd {dir} && codex' },
6
+ { id: 'claude-desktop', name: 'Claude Desktop', command: 'Open Claude Desktop and add {dir} as a project folder' },
7
+ { id: 'antigravity', name: 'Antigravity', command: 'cd {dir} && antigravity' },
8
+ ];
9
+ export function getAgentCommand(agentId, directory) {
10
+ const agent = SUPPORTED_AGENTS.find(a => a.id === agentId);
11
+ if (!agent)
12
+ return null;
13
+ return agent.command.replace(/{dir}/g, directory);
14
+ }
15
+ export function getAgentGuidance(directory) {
16
+ const lines = [
17
+ '',
18
+ bold('Open in your AI agent:'),
19
+ '',
20
+ ];
21
+ for (const agent of SUPPORTED_AGENTS) {
22
+ const cmd = agent.command.replace(/{dir}/g, directory);
23
+ lines.push(` ${agent.name.padEnd(18)} ${cyan(cmd)}`);
24
+ }
25
+ return lines.join('\n');
26
+ }
27
+ export function getDevBanner(options) {
28
+ const lines = [
29
+ '',
30
+ bold('Runwork Dev'),
31
+ '',
32
+ ];
33
+ if (options.workspaceName) {
34
+ lines.push(` Workspace: ${dim(options.workspaceName)}`);
35
+ }
36
+ lines.push(` App: ${cyan(options.appName)}`, ` Preview: ${green(options.previewUrl)}`, '', dim(' Changes sync automatically. Logs appear below.'), '');
37
+ return lines.join('\n');
38
+ }
39
+ export function getKeyboardHints() {
40
+ return dim(' Press: ') +
41
+ dim('[') + 'o' + dim(']pen preview ') +
42
+ dim('[') + 'a' + dim(']ll logs ') +
43
+ dim('[') + 'e' + dim(']vents ') +
44
+ dim('[') + 'r' + dim(']untime ') +
45
+ dim('[') + 'q' + dim(']uit');
46
+ }
47
+ /**
48
+ * Interactive wizard that asks which AI agent the user wants to use,
49
+ * then shows specific instructions for opening this app in that agent.
50
+ */
51
+ export async function runAgentWizard(directory) {
52
+ const { select, isCancel } = await import('@clack/prompts');
53
+ console.log('');
54
+ const result = await select({
55
+ message: 'Which AI agent do you use?',
56
+ options: SUPPORTED_AGENTS.map(a => ({
57
+ value: a.id,
58
+ label: a.name,
59
+ })),
60
+ });
61
+ if (isCancel(result)) {
62
+ return;
63
+ }
64
+ const agent = SUPPORTED_AGENTS.find(a => a.id === result);
65
+ if (!agent)
66
+ return;
67
+ const cmd = agent.command.replace(/{dir}/g, directory);
68
+ console.log('');
69
+ console.log(bold(`Open your app in ${agent.name}:`));
70
+ console.log('');
71
+ console.log(` ${cyan(cmd)}`);
72
+ console.log('');
73
+ if (agent.id === 'claude-desktop') {
74
+ console.log(dim(' Claude Desktop works with project folders. Add your app directory'));
75
+ console.log(dim(' as a project and Claude will have full context of your Runwork app.'));
76
+ }
77
+ else {
78
+ console.log(dim(' Run this in a separate terminal while `runwork dev` is running.'));
79
+ console.log(dim(' Your AI agent will have full access to your app code and types.'));
80
+ }
81
+ console.log('');
82
+ }
83
+ /**
84
+ * Returns full info panel (for 'i' key in dev mode) showing
85
+ * workspace, app, preview URL, and agent guidance.
86
+ */
87
+ export function getInfoPanel(options) {
88
+ const lines = [
89
+ '',
90
+ bold('App Info'),
91
+ '',
92
+ ];
93
+ if (options.workspaceName) {
94
+ lines.push(` Workspace: ${dim(options.workspaceName)}`);
95
+ }
96
+ lines.push(` App: ${cyan(options.appName)}`, ` Preview: ${green(options.previewUrl)}`, ` Directory: ${dim(options.directory)}`);
97
+ lines.push('', bold('Open in your AI agent:'), '');
98
+ for (const agent of SUPPORTED_AGENTS) {
99
+ const cmd = agent.command.replace(/{dir}/g, options.directory);
100
+ lines.push(` ${agent.name.padEnd(18)} ${cyan(cmd)}`);
101
+ }
102
+ lines.push('');
103
+ return lines.join('\n');
104
+ }
105
+ export function getWelcomeBanner() {
106
+ return [
107
+ '',
108
+ bold('Welcome to Runwork!'),
109
+ '',
110
+ ' Runwork lets you build full-stack apps with AI agents like',
111
+ ' Claude Code, Cursor, Codex, and more -- running on your machine,',
112
+ ' deployed to the edge.',
113
+ '',
114
+ ' To get started, you need a Runwork account.',
115
+ ' We will open your browser to sign up or log in.',
116
+ '',
117
+ ].join('\n');
118
+ }
@@ -0,0 +1,4 @@
1
+ import pc from 'picocolors';
2
+ export declare const bold: import("picocolors/types").Formatter, dim: import("picocolors/types").Formatter, red: import("picocolors/types").Formatter, green: import("picocolors/types").Formatter, yellow: import("picocolors/types").Formatter, cyan: import("picocolors/types").Formatter, gray: import("picocolors/types").Formatter, italic: import("picocolors/types").Formatter, underline: import("picocolors/types").Formatter, inverse: import("picocolors/types").Formatter;
3
+ export { pc };
4
+ export declare function stripAnsi(text: string): string;
@@ -0,0 +1,7 @@
1
+ import pc from 'picocolors';
2
+ export const { bold, dim, red, green, yellow, cyan, gray, italic, underline, inverse } = pc;
3
+ export { pc };
4
+ // eslint-disable-next-line no-control-regex
5
+ export function stripAnsi(text) {
6
+ return text.replace(/\x1b\[[0-9;]*m/g, '');
7
+ }
@@ -0,0 +1,12 @@
1
+ export type KeyAction = 'o' | 'p' | 'a' | 'e' | 'r' | 'i' | 'quit' | null;
2
+ /** Parse a raw stdin buffer into a named action. */
3
+ export declare function parseKeypress(data: Buffer): KeyAction;
4
+ export interface KeyboardListener {
5
+ start(handler: (action: KeyAction) => void): void;
6
+ stop(): void;
7
+ }
8
+ /**
9
+ * Listens for raw keypresses on stdin.
10
+ * Only works when stdin is a TTY. Returns a no-op listener otherwise.
11
+ */
12
+ export declare function createKeyboardListener(): KeyboardListener;
@@ -0,0 +1,57 @@
1
+ /** Parse a raw stdin buffer into a named action. */
2
+ export function parseKeypress(data) {
3
+ if (data.length === 1) {
4
+ const byte = data[0];
5
+ if (byte === 0x03)
6
+ return 'quit'; // Ctrl+C
7
+ const char = String.fromCharCode(byte).toLowerCase();
8
+ switch (char) {
9
+ case 'o': return 'o';
10
+ case 'p': return 'p';
11
+ case 'a': return 'a';
12
+ case 'e': return 'e';
13
+ case 'r': return 'r';
14
+ case 'i': return 'i';
15
+ case 'q': return 'quit';
16
+ default: return null;
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ /**
22
+ * Listens for raw keypresses on stdin.
23
+ * Only works when stdin is a TTY. Returns a no-op listener otherwise.
24
+ */
25
+ export function createKeyboardListener() {
26
+ if (!process.stdin.isTTY) {
27
+ return { start() { }, stop() { } };
28
+ }
29
+ let dataHandler = null;
30
+ return {
31
+ start(handler) {
32
+ process.stdin.setRawMode(true);
33
+ process.stdin.resume();
34
+ dataHandler = (data) => {
35
+ const action = parseKeypress(data);
36
+ if (action)
37
+ handler(action);
38
+ };
39
+ process.stdin.on('data', dataHandler);
40
+ },
41
+ stop() {
42
+ if (dataHandler) {
43
+ process.stdin.removeListener('data', dataHandler);
44
+ dataHandler = null;
45
+ }
46
+ try {
47
+ if (process.stdin.isTTY) {
48
+ process.stdin.setRawMode(false);
49
+ process.stdin.pause();
50
+ }
51
+ }
52
+ catch {
53
+ // stdin may already be closed during shutdown
54
+ }
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,6 @@
1
+ export interface StatusLine {
2
+ /** Update with pre-styled text. ANSI colors are preserved in the bar. */
3
+ update(text: string): void;
4
+ destroy(): void;
5
+ }
6
+ export declare function createStatusLine(stream: NodeJS.WriteStream): StatusLine;
@@ -0,0 +1,53 @@
1
+ import { stripAnsi } from './colors.js';
2
+ // ANSI escape helpers for direct use in the bar (not through picocolors,
3
+ // since we need to layer colors on top of the background)
4
+ const BG_DARK = '\x1b[48;5;236m'; // dark gray background
5
+ const RESET = '\x1b[0m';
6
+ export function createStatusLine(stream) {
7
+ if (!stream.isTTY) {
8
+ return { update() { }, destroy() { } };
9
+ }
10
+ let lastStyled = '';
11
+ let lastPlainLen = 0;
12
+ let rows = stream.rows || 24;
13
+ let cols = stream.columns || 80;
14
+ const originalWrite = stream.write.bind(stream);
15
+ function setupScrollRegion() {
16
+ originalWrite(`\x1b[1;${rows - 1}r`);
17
+ originalWrite(`\x1b[${rows - 1};1H`);
18
+ }
19
+ function renderBar() {
20
+ if (!lastStyled)
21
+ return;
22
+ // Pad with spaces to fill the bar width (using plain length for calculation)
23
+ const padding = lastPlainLen < cols
24
+ ? ' '.repeat(cols - lastPlainLen)
25
+ : '';
26
+ // Render: dark background + styled content + padding + reset
27
+ originalWrite(`\x1b7\x1b[${rows};1H\x1b[2K${BG_DARK}${lastStyled}${padding}${RESET}\x1b8`);
28
+ }
29
+ // Handle terminal resize
30
+ function onResize() {
31
+ rows = stream.rows || 24;
32
+ cols = stream.columns || 80;
33
+ setupScrollRegion();
34
+ renderBar();
35
+ }
36
+ // Activate
37
+ setupScrollRegion();
38
+ stream.on('resize', onResize);
39
+ return {
40
+ update(text) {
41
+ lastStyled = ` ${text} `;
42
+ lastPlainLen = stripAnsi(lastStyled).length;
43
+ renderBar();
44
+ },
45
+ destroy() {
46
+ stream.removeListener('resize', onResize);
47
+ // Clear status bar and reset scroll region to full terminal
48
+ originalWrite(`\x1b7\x1b[${rows};1H\x1b[2K\x1b8`);
49
+ originalWrite(`\x1b[1;${rows}r`);
50
+ lastStyled = '';
51
+ },
52
+ };
53
+ }
@@ -1,103 +1,27 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- const mockQuestion = vi.fn();
3
- const mockClose = vi.fn();
4
- vi.mock('readline', () => ({
5
- createInterface: () => ({
6
- question: mockQuestion,
7
- close: mockClose,
8
- }),
9
- }));
10
- const { promptSelect, promptInput } = await import('../prompt.js');
11
- describe('utils/prompt', () => {
12
- beforeEach(() => {
13
- vi.clearAllMocks();
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { promptSelect } from '../prompt.js';
3
+ describe('promptSelect', () => {
4
+ it('throws when no choices available', async () => {
5
+ await expect(promptSelect('Pick:', [])).rejects.toThrow('No choices available');
14
6
  });
15
- describe('promptSelect()', () => {
16
- it('throws on empty choices array', async () => {
17
- await expect(promptSelect('Pick one', [])).rejects.toThrow('No choices available');
18
- });
19
- it('auto-selects when only one choice', async () => {
20
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
21
- const result = await promptSelect('Pick one', [
22
- { label: 'Only Option', value: 42 },
23
- ]);
24
- expect(result).toEqual({ label: 'Only Option', value: 42 });
25
- expect(logSpy).toHaveBeenCalledWith('Pick one: Only Option');
26
- logSpy.mockRestore();
27
- });
28
- it('auto-selects preserves all properties on the choice object', async () => {
29
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
30
- const choice = { label: 'Test', id: 'abc', extra: true };
31
- const result = await promptSelect('Select', [choice]);
32
- expect(result).toBe(choice);
33
- logSpy.mockRestore();
34
- });
35
- it('returns correct choice by index when multiple choices', async () => {
36
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
37
- // promptSelect calls promptInput internally, which uses readline
38
- // Mock the question callback to simulate user selecting "2"
39
- mockQuestion.mockImplementation((_prompt, cb) => {
40
- cb('2');
41
- });
42
- const choices = [
43
- { label: 'First', id: 1 },
44
- { label: 'Second', id: 2 },
45
- { label: 'Third', id: 3 },
46
- ];
47
- const result = await promptSelect('Choose', choices);
48
- expect(result).toEqual({ label: 'Second', id: 2 });
49
- logSpy.mockRestore();
50
- });
51
- it('exits process on invalid selection', async () => {
52
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
53
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
54
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
55
- throw new Error('process.exit');
56
- }));
57
- mockQuestion.mockImplementation((_prompt, cb) => {
58
- cb('99');
59
- });
60
- const choices = [
61
- { label: 'First', id: 1 },
62
- { label: 'Second', id: 2 },
63
- ];
64
- await expect(promptSelect('Choose', choices)).rejects.toThrow('process.exit');
65
- expect(exitSpy).toHaveBeenCalledWith(1);
66
- logSpy.mockRestore();
67
- errorSpy.mockRestore();
68
- exitSpy.mockRestore();
69
- });
7
+ it('returns single choice without prompting', async () => {
8
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
9
+ const choice = { label: 'Only option', value: 'only' };
10
+ const result = await promptSelect('Pick:', [choice]);
11
+ expect(result).toBe(choice);
12
+ consoleSpy.mockRestore();
70
13
  });
71
- describe('promptInput()', () => {
72
- it('returns user input trimmed', async () => {
73
- mockQuestion.mockImplementation((_prompt, cb) => {
74
- cb(' user input ');
75
- });
76
- const result = await promptInput('Enter value');
77
- expect(result).toBe('user input');
78
- expect(mockClose).toHaveBeenCalled();
79
- });
80
- it('returns default value when input is empty', async () => {
81
- mockQuestion.mockImplementation((_prompt, cb) => {
82
- cb('');
83
- });
84
- const result = await promptInput('Enter value', 'default-val');
85
- expect(result).toBe('default-val');
86
- });
87
- it('includes default value hint in prompt', async () => {
88
- mockQuestion.mockImplementation((_prompt, cb) => {
89
- cb('answer');
90
- });
91
- await promptInput('Name', 'default');
92
- const promptText = mockQuestion.mock.calls[0][0];
93
- expect(promptText).toContain('[default]');
94
- });
95
- it('returns empty string when no input and no default', async () => {
96
- mockQuestion.mockImplementation((_prompt, cb) => {
97
- cb(' ');
98
- });
99
- const result = await promptInput('Enter');
100
- expect(result).toBe('');
101
- });
14
+ it('auto-selects preserves all properties on single choice', async () => {
15
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
16
+ const choice = { label: 'Test', id: 'abc', extra: true };
17
+ const result = await promptSelect('Select', [choice]);
18
+ expect(result).toBe(choice);
19
+ consoleSpy.mockRestore();
20
+ });
21
+ it('logs message and choice label when single choice', async () => {
22
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
23
+ await promptSelect('Pick one', [{ label: 'Only Option', value: 42 }]);
24
+ expect(consoleSpy).toHaveBeenCalledWith('Pick one: Only Option');
25
+ consoleSpy.mockRestore();
102
26
  });
103
27
  });
@@ -2,3 +2,4 @@ export declare function promptSelect<T extends {
2
2
  label: string;
3
3
  }>(message: string, choices: T[]): Promise<T>;
4
4
  export declare function promptInput(message: string, defaultValue?: string): Promise<string>;
5
+ export declare function promptConfirm(message: string): Promise<boolean>;
@@ -1,4 +1,4 @@
1
- import { createInterface } from 'readline';
1
+ import { select, text, confirm, isCancel } from '@clack/prompts';
2
2
  export async function promptSelect(message, choices) {
3
3
  if (choices.length === 0) {
4
4
  throw new Error('No choices available');
@@ -7,28 +7,36 @@ export async function promptSelect(message, choices) {
7
7
  console.log(`${message}: ${choices[0].label}`);
8
8
  return choices[0];
9
9
  }
10
- console.log(message);
11
- for (let i = 0; i < choices.length; i++) {
12
- console.log(` ${i + 1}. ${choices[i].label}`);
13
- }
14
- const answer = await promptInput(`Select (1-${choices.length})`, '1');
15
- const index = parseInt(answer, 10) - 1;
16
- if (isNaN(index) || index < 0 || index >= choices.length) {
17
- console.error('Invalid selection.');
18
- process.exit(1);
10
+ const result = await select({
11
+ message,
12
+ options: choices.map((c, i) => ({
13
+ value: i,
14
+ label: c.label,
15
+ })),
16
+ });
17
+ if (isCancel(result)) {
18
+ console.log('Cancelled.');
19
+ process.exit(0);
19
20
  }
20
- return choices[index];
21
+ return choices[result];
21
22
  }
22
23
  export async function promptInput(message, defaultValue) {
23
- const rl = createInterface({
24
- input: process.stdin,
25
- output: process.stdout,
26
- });
27
- const suffix = defaultValue ? ` [${defaultValue}]` : '';
28
- return new Promise((resolve) => {
29
- rl.question(`${message}${suffix}: `, (answer) => {
30
- rl.close();
31
- resolve(answer.trim() || defaultValue || '');
32
- });
24
+ const result = await text({
25
+ message,
26
+ defaultValue,
27
+ placeholder: defaultValue,
33
28
  });
29
+ if (isCancel(result)) {
30
+ console.log('Cancelled.');
31
+ process.exit(0);
32
+ }
33
+ return result.trim() || defaultValue || '';
34
+ }
35
+ export async function promptConfirm(message) {
36
+ const result = await confirm({ message });
37
+ if (isCancel(result)) {
38
+ console.log('Cancelled.');
39
+ process.exit(0);
40
+ }
41
+ return result;
34
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runwork",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for Runwork: develop, preview, and deploy Runwork apps from your local machine.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Runwork <info@runwork.ai> (https://www.runwork.ai)",
@@ -41,10 +41,12 @@
41
41
  "release:full": "bun run release && bun run scripts/release-binaries.ts"
42
42
  },
43
43
  "dependencies": {
44
+ "@clack/prompts": "^1.1.0",
44
45
  "chokidar": "^4.0.0",
45
46
  "commander": "^13.0.0",
46
47
  "fflate": "^0.8.2",
47
- "open": "^10.0.0"
48
+ "open": "^10.0.0",
49
+ "picocolors": "^1.1.1"
48
50
  },
49
51
  "devDependencies": {
50
52
  "typescript": "^5.7.0",