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.
- 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 +103 -85
- 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__/auto-commit.test.d.ts +1 -0
- package/dist/git/__tests__/auto-commit.test.js +373 -0
- package/dist/git/__tests__/manifest.test.d.ts +1 -0
- package/dist/git/__tests__/manifest.test.js +377 -0
- package/dist/git/__tests__/sync.test.d.ts +1 -0
- package/dist/git/__tests__/sync.test.js +405 -0
- package/dist/git/auto-commit.d.ts +6 -1
- package/dist/git/auto-commit.js +29 -13
- package/dist/git/sync.d.ts +15 -0
- package/dist/git/sync.js +157 -0
- 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/template/manifest.js +24 -4
- 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
package/dist/git/sync.js
ADDED
|
@@ -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
|
+
});
|
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)
|
|
@@ -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
|
-
|
|
69
|
-
const
|
|
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
|
@@ -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;
|