runwork 0.2.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/LICENSE +21 -0
- package/README.md +134 -0
- package/bin/runwork.js +2 -0
- package/bundled-types/agents.d.ts +3 -0
- package/bundled-types/ai.d.ts +2 -0
- package/bundled-types/app-registry.d.ts +74 -0
- package/bundled-types/channels.d.ts +2 -0
- package/bundled-types/components.d.ts +1 -0
- package/bundled-types/core-agent.d.ts +265 -0
- package/bundled-types/core-ai.d.ts +170 -0
- package/bundled-types/core-base-agent.d.ts +89 -0
- package/bundled-types/core-channels.d.ts +42 -0
- package/bundled-types/core-components.d.ts +21 -0
- package/bundled-types/core-endpoints.d.ts +198 -0
- package/bundled-types/core-entities.d.ts +276 -0
- package/bundled-types/core-entity-do.d.ts +119 -0
- package/bundled-types/core-events.d.ts +52 -0
- package/bundled-types/core-file-storage.d.ts +216 -0
- package/bundled-types/core-integration-entities.d.ts +147 -0
- package/bundled-types/core-integrations.d.ts +143 -0
- package/bundled-types/core-routes.d.ts +33 -0
- package/bundled-types/core-scheduler.d.ts +180 -0
- package/bundled-types/core-utils.d.ts +85 -0
- package/bundled-types/core-workflow-cloudflare.d.ts +85 -0
- package/bundled-types/core-workflow-config.d.ts +18 -0
- package/bundled-types/core-workflow-coordinator.d.ts +78 -0
- package/bundled-types/core-workflow-instance.d.ts +82 -0
- package/bundled-types/core-workflow-types.d.ts +251 -0
- package/bundled-types/core-workflows.d.ts +75 -0
- package/bundled-types/core-workspace.d.ts +454 -0
- package/bundled-types/create-app.d.ts +57 -0
- package/bundled-types/endpoints.d.ts +2 -0
- package/bundled-types/entities.d.ts +5 -0
- package/bundled-types/events.d.ts +2 -0
- package/bundled-types/index.d.ts +19 -0
- package/bundled-types/integrations.d.ts +2 -0
- package/bundled-types/routes.d.ts +2 -0
- package/bundled-types/scheduler.d.ts +2 -0
- package/bundled-types/storage.d.ts +2 -0
- package/bundled-types/test-entry.d.ts +21 -0
- package/bundled-types/types.d.ts +1 -0
- package/bundled-types/vite.d.ts +23 -0
- package/bundled-types/workflows.d.ts +14 -0
- package/bundled-types/workspace.d.ts +2 -0
- package/dist/__tests__/types-manager.test.d.ts +1 -0
- package/dist/__tests__/types-manager.test.js +85 -0
- package/dist/api/__tests__/client.test.d.ts +1 -0
- package/dist/api/__tests__/client.test.js +264 -0
- package/dist/api/client.d.ts +72 -0
- package/dist/api/client.js +144 -0
- package/dist/auth/__tests__/store.test.d.ts +1 -0
- package/dist/auth/__tests__/store.test.js +103 -0
- package/dist/auth/browser.d.ts +2 -0
- package/dist/auth/browser.js +7 -0
- package/dist/auth/store.d.ts +5 -0
- package/dist/auth/store.js +33 -0
- package/dist/commands/clone.d.ts +2 -0
- package/dist/commands/clone.js +90 -0
- package/dist/commands/deploy.d.ts +2 -0
- package/dist/commands/deploy.js +43 -0
- package/dist/commands/dev.d.ts +2 -0
- package/dist/commands/dev.js +188 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +87 -0
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +32 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.js +13 -0
- package/dist/commands/logs.d.ts +2 -0
- package/dist/commands/logs.js +166 -0
- package/dist/generated/bundled-types.d.ts +1 -0
- package/dist/generated/bundled-types.js +44 -0
- package/dist/generated/version.d.ts +1 -0
- package/dist/generated/version.js +2 -0
- package/dist/git/__tests__/credentials.test.d.ts +1 -0
- package/dist/git/__tests__/credentials.test.js +128 -0
- package/dist/git/auto-commit.d.ts +3 -0
- package/dist/git/auto-commit.js +209 -0
- package/dist/git/credentials.d.ts +15 -0
- package/dist/git/credentials.js +65 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +31 -0
- package/dist/logs/tailer.d.ts +13 -0
- package/dist/logs/tailer.js +135 -0
- package/dist/template/manifest.d.ts +17 -0
- package/dist/template/manifest.js +102 -0
- package/dist/types-manager.d.ts +1 -0
- package/dist/types-manager.js +35 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.js +1 -0
- package/dist/utils/__tests__/prompt.test.d.ts +1 -0
- package/dist/utils/__tests__/prompt.test.js +103 -0
- package/dist/utils/fs.d.ts +5 -0
- package/dist/utils/fs.js +23 -0
- package/dist/utils/prompt.d.ts +4 -0
- package/dist/utils/prompt.js +34 -0
- package/dist/utils/zip.d.ts +1 -0
- package/dist/utils/zip.js +17 -0
- package/package.json +66 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process');
|
|
3
|
+
vi.mock('../../auth/store.js', () => ({
|
|
4
|
+
getCredentials: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
const mockExecFileSync = (await import('child_process')).execFileSync;
|
|
7
|
+
const { getCredentials: mockGetCredentials } = await import('../../auth/store.js');
|
|
8
|
+
const { configureGitCredentials, removeGitCredentials, handleGitCredentialRequest, } = await import('../credentials.js');
|
|
9
|
+
describe('git/credentials', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.clearAllMocks();
|
|
12
|
+
});
|
|
13
|
+
describe('configureGitCredentials()', () => {
|
|
14
|
+
it('calls git config with correct args', async () => {
|
|
15
|
+
await configureGitCredentials('https://runwork.ai/api/git/ws/app');
|
|
16
|
+
expect(mockExecFileSync).toHaveBeenCalledWith('git', [
|
|
17
|
+
'config',
|
|
18
|
+
'--global',
|
|
19
|
+
'credential.https://runwork.ai.helper',
|
|
20
|
+
'!runwork git-credential-helper',
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
it('extracts origin from full URL', async () => {
|
|
24
|
+
await configureGitCredentials('https://custom.runwork.dev/api/git/ws-1/app-1');
|
|
25
|
+
expect(mockExecFileSync).toHaveBeenCalledWith('git', [
|
|
26
|
+
'config',
|
|
27
|
+
'--global',
|
|
28
|
+
'credential.https://custom.runwork.dev.helper',
|
|
29
|
+
'!runwork git-credential-helper',
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
it('handles URL with port', async () => {
|
|
33
|
+
await configureGitCredentials('https://localhost:8787/api/git/ws/app');
|
|
34
|
+
expect(mockExecFileSync).toHaveBeenCalledWith('git', [
|
|
35
|
+
'config',
|
|
36
|
+
'--global',
|
|
37
|
+
'credential.https://localhost:8787.helper',
|
|
38
|
+
'!runwork git-credential-helper',
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('removeGitCredentials()', () => {
|
|
43
|
+
it('calls git config --unset', async () => {
|
|
44
|
+
await removeGitCredentials('https://runwork.ai');
|
|
45
|
+
expect(mockExecFileSync).toHaveBeenCalledWith('git', [
|
|
46
|
+
'config',
|
|
47
|
+
'--global',
|
|
48
|
+
'--unset',
|
|
49
|
+
'credential.https://runwork.ai.helper',
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
it('ignores errors silently', async () => {
|
|
53
|
+
mockExecFileSync.mockImplementation(() => {
|
|
54
|
+
throw new Error('config key not found');
|
|
55
|
+
});
|
|
56
|
+
// Should not throw
|
|
57
|
+
await expect(removeGitCredentials('https://runwork.ai')).resolves.toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('handleGitCredentialRequest()', () => {
|
|
61
|
+
it('ignores non-get actions (store)', async () => {
|
|
62
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
63
|
+
await handleGitCredentialRequest('store');
|
|
64
|
+
expect(writeSpy).not.toHaveBeenCalled();
|
|
65
|
+
writeSpy.mockRestore();
|
|
66
|
+
});
|
|
67
|
+
it('ignores non-get actions (erase)', async () => {
|
|
68
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
69
|
+
await handleGitCredentialRequest('erase');
|
|
70
|
+
expect(writeSpy).not.toHaveBeenCalled();
|
|
71
|
+
writeSpy.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
it('exits with code 1 when no credentials stored', async () => {
|
|
74
|
+
vi.mocked(mockGetCredentials).mockReturnValue(null);
|
|
75
|
+
// Mock stdin to provide input then end
|
|
76
|
+
const mockStdin = {
|
|
77
|
+
setEncoding: vi.fn(),
|
|
78
|
+
on: vi.fn((event, cb) => {
|
|
79
|
+
if (event === 'data')
|
|
80
|
+
cb('protocol=https\nhost=runwork.ai\n');
|
|
81
|
+
if (event === 'end')
|
|
82
|
+
cb();
|
|
83
|
+
}),
|
|
84
|
+
readableEnded: false,
|
|
85
|
+
};
|
|
86
|
+
const originalStdin = process.stdin;
|
|
87
|
+
Object.defineProperty(process, 'stdin', { value: mockStdin, writable: true });
|
|
88
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
89
|
+
throw new Error('process.exit');
|
|
90
|
+
}));
|
|
91
|
+
await expect(handleGitCredentialRequest('get')).rejects.toThrow('process.exit');
|
|
92
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
93
|
+
exitSpy.mockRestore();
|
|
94
|
+
Object.defineProperty(process, 'stdin', { value: originalStdin, writable: true });
|
|
95
|
+
});
|
|
96
|
+
it('outputs correct credential format when credentials exist', async () => {
|
|
97
|
+
vi.mocked(mockGetCredentials).mockReturnValue({
|
|
98
|
+
apiKey: 'my-api-key',
|
|
99
|
+
email: 'test@example.com',
|
|
100
|
+
baseUrl: 'https://runwork.ai',
|
|
101
|
+
});
|
|
102
|
+
const mockStdin = {
|
|
103
|
+
setEncoding: vi.fn(),
|
|
104
|
+
on: vi.fn((event, cb) => {
|
|
105
|
+
if (event === 'data')
|
|
106
|
+
cb('protocol=https\nhost=runwork.ai\n');
|
|
107
|
+
if (event === 'end')
|
|
108
|
+
cb();
|
|
109
|
+
}),
|
|
110
|
+
readableEnded: false,
|
|
111
|
+
};
|
|
112
|
+
const originalStdin = process.stdin;
|
|
113
|
+
Object.defineProperty(process, 'stdin', { value: mockStdin, writable: true });
|
|
114
|
+
const writes = [];
|
|
115
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((data) => {
|
|
116
|
+
writes.push(data.toString());
|
|
117
|
+
return true;
|
|
118
|
+
});
|
|
119
|
+
await handleGitCredentialRequest('get');
|
|
120
|
+
expect(writes).toContain('protocol=https\n');
|
|
121
|
+
expect(writes).toContain('host=runwork.ai\n');
|
|
122
|
+
expect(writes).toContain('username=runwork\n');
|
|
123
|
+
expect(writes).toContain('password=my-api-key\n');
|
|
124
|
+
writeSpy.mockRestore();
|
|
125
|
+
Object.defineProperty(process, 'stdin', { value: originalStdin, writable: true });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { watch } from 'chokidar';
|
|
4
|
+
import { basename, join, relative } from 'path';
|
|
5
|
+
let watcher = null;
|
|
6
|
+
let fastSyncTimer = null;
|
|
7
|
+
let gitTimer = null;
|
|
8
|
+
let gitPushing = false;
|
|
9
|
+
let gitPendingAfterPush = false;
|
|
10
|
+
// Files awaiting fast sync: path -> 'changed' | 'deleted'
|
|
11
|
+
const pendingFastSync = new Map();
|
|
12
|
+
// Track files the user actually touched during this session (for git)
|
|
13
|
+
const changedFiles = new Set();
|
|
14
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.runwork']);
|
|
15
|
+
const SKIP_FILES = new Set(['.dev.vars']);
|
|
16
|
+
const SKIP_EXTENSIONS = new Set(['.log']);
|
|
17
|
+
// Binary extensions to skip in fast sync (git handles them fine)
|
|
18
|
+
const BINARY_EXTENSIONS = new Set([
|
|
19
|
+
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.avif', '.svg',
|
|
20
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
21
|
+
'.mp3', '.mp4', '.webm', '.ogg', '.wav',
|
|
22
|
+
'.zip', '.tar', '.gz', '.br',
|
|
23
|
+
'.pdf', '.wasm',
|
|
24
|
+
]);
|
|
25
|
+
function isIgnored(filePath) {
|
|
26
|
+
const name = basename(filePath);
|
|
27
|
+
if (SKIP_DIRS.has(name))
|
|
28
|
+
return true;
|
|
29
|
+
if (SKIP_FILES.has(name))
|
|
30
|
+
return true;
|
|
31
|
+
const ext = name.lastIndexOf('.') >= 0 ? name.slice(name.lastIndexOf('.')) : '';
|
|
32
|
+
if (SKIP_EXTENSIONS.has(ext))
|
|
33
|
+
return true;
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
export async function watchAndAutoCommit(directory, client, appId) {
|
|
37
|
+
watcher = watch(directory, {
|
|
38
|
+
ignored: isIgnored,
|
|
39
|
+
persistent: true,
|
|
40
|
+
ignoreInitial: true,
|
|
41
|
+
awaitWriteFinish: {
|
|
42
|
+
stabilityThreshold: 300,
|
|
43
|
+
pollInterval: 100,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const onFileChange = (filePath) => {
|
|
47
|
+
const rel = relative(directory, filePath);
|
|
48
|
+
if (changedFiles.size === 0) {
|
|
49
|
+
console.log(`Changed: ${rel}`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.log(`Changed: ${rel} (+${changedFiles.size} pending)`);
|
|
53
|
+
}
|
|
54
|
+
changedFiles.add(rel);
|
|
55
|
+
pendingFastSync.set(rel, 'changed');
|
|
56
|
+
scheduleFastSync(directory, client, appId);
|
|
57
|
+
scheduleGitCommit();
|
|
58
|
+
};
|
|
59
|
+
const onFileUnlink = (filePath) => {
|
|
60
|
+
const rel = relative(directory, filePath);
|
|
61
|
+
console.log(`Deleted: ${rel}`);
|
|
62
|
+
changedFiles.add(rel);
|
|
63
|
+
pendingFastSync.set(rel, 'deleted');
|
|
64
|
+
scheduleFastSync(directory, client, appId);
|
|
65
|
+
scheduleGitCommit();
|
|
66
|
+
};
|
|
67
|
+
watcher.on('add', onFileChange);
|
|
68
|
+
watcher.on('change', onFileChange);
|
|
69
|
+
watcher.on('unlink', onFileUnlink);
|
|
70
|
+
}
|
|
71
|
+
// ========================================
|
|
72
|
+
// FAST SYNC PATH (200ms debounce)
|
|
73
|
+
// ========================================
|
|
74
|
+
function scheduleFastSync(directory, client, appId) {
|
|
75
|
+
if (fastSyncTimer)
|
|
76
|
+
clearTimeout(fastSyncTimer);
|
|
77
|
+
fastSyncTimer = setTimeout(() => {
|
|
78
|
+
executeFastSync(directory, client, appId);
|
|
79
|
+
}, 200);
|
|
80
|
+
}
|
|
81
|
+
async function executeFastSync(directory, client, appId) {
|
|
82
|
+
if (pendingFastSync.size === 0)
|
|
83
|
+
return;
|
|
84
|
+
// Snapshot and clear
|
|
85
|
+
const snapshot = new Map(pendingFastSync);
|
|
86
|
+
pendingFastSync.clear();
|
|
87
|
+
const files = [];
|
|
88
|
+
const deletedFiles = [];
|
|
89
|
+
for (const [filePath, action] of snapshot) {
|
|
90
|
+
if (action === 'deleted') {
|
|
91
|
+
deletedFiles.push(filePath);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// Skip binary files in fast sync — git path handles them
|
|
95
|
+
const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')) : '';
|
|
96
|
+
if (BINARY_EXTENSIONS.has(ext.toLowerCase()))
|
|
97
|
+
continue;
|
|
98
|
+
try {
|
|
99
|
+
const contents = readFileSync(join(directory, filePath), 'utf-8');
|
|
100
|
+
files.push({ filePath, fileContents: contents });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// File may have been deleted between detection and read
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (files.length === 0 && deletedFiles.length === 0)
|
|
108
|
+
return;
|
|
109
|
+
const total = files.length + deletedFiles.length;
|
|
110
|
+
try {
|
|
111
|
+
await client.syncFiles(appId, files, deletedFiles.length > 0 ? deletedFiles : undefined);
|
|
112
|
+
console.log(` Synced ${total} file(s) to preview.`);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
+
console.warn(` Fast sync failed (git will handle it): ${message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// ========================================
|
|
120
|
+
// GIT PATH (2s debounce)
|
|
121
|
+
// ========================================
|
|
122
|
+
function scheduleGitCommit() {
|
|
123
|
+
if (gitPushing) {
|
|
124
|
+
gitPendingAfterPush = true;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (gitTimer)
|
|
128
|
+
clearTimeout(gitTimer);
|
|
129
|
+
gitTimer = setTimeout(() => {
|
|
130
|
+
commitAndPush();
|
|
131
|
+
}, 2000);
|
|
132
|
+
}
|
|
133
|
+
function commitAndPush() {
|
|
134
|
+
try {
|
|
135
|
+
if (changedFiles.size === 0)
|
|
136
|
+
return;
|
|
137
|
+
gitPushing = true;
|
|
138
|
+
// Stage only files the user actually touched + already-tracked modified files
|
|
139
|
+
execFileSync('git', ['add', '-u'], { stdio: 'pipe' });
|
|
140
|
+
// Also stage specific files the user created/modified this session
|
|
141
|
+
const filesToAdd = Array.from(changedFiles);
|
|
142
|
+
changedFiles.clear();
|
|
143
|
+
if (filesToAdd.length > 0) {
|
|
144
|
+
const BATCH_SIZE = 50;
|
|
145
|
+
for (let i = 0; i < filesToAdd.length; i += BATCH_SIZE) {
|
|
146
|
+
const batch = filesToAdd.slice(i, i + BATCH_SIZE);
|
|
147
|
+
try {
|
|
148
|
+
execFileSync('git', ['add', '--', ...batch], { stdio: 'pipe' });
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Some files may have been deleted between detection and staging
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Check if anything was actually staged
|
|
156
|
+
const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { encoding: 'utf-8' });
|
|
157
|
+
if (!staged.trim())
|
|
158
|
+
return;
|
|
159
|
+
const stagedFiles = staged.trim().split('\n').filter(Boolean);
|
|
160
|
+
// Commit
|
|
161
|
+
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
162
|
+
execFileSync('git', ['commit', '-m', `dev: updated ${timestamp}`], { stdio: 'pipe' });
|
|
163
|
+
// Pull --rebase to integrate any remote changes, then push
|
|
164
|
+
try {
|
|
165
|
+
execFileSync('git', ['pull', '--rebase', 'runwork', 'main'], { stdio: 'pipe' });
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
try {
|
|
169
|
+
execFileSync('git', ['rebase', '--abort'], { stdio: 'pipe' });
|
|
170
|
+
}
|
|
171
|
+
catch { /* no rebase in progress */ }
|
|
172
|
+
console.warn('Auto-sync: rebase failed, skipping push. Run `git pull --rebase runwork main` manually.');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
execFileSync('git', ['rev-parse', '--abbrev-ref', '@{u}'], { stdio: 'pipe' });
|
|
177
|
+
execFileSync('git', ['push', 'runwork', 'main'], { stdio: 'pipe' });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
execFileSync('git', ['push', '-u', 'runwork', 'main'], { stdio: 'pipe' });
|
|
181
|
+
}
|
|
182
|
+
console.log(` Pushed ${stagedFiles.length} file(s) to git.`);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
186
|
+
console.warn(`Auto-sync failed: ${message}`);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
gitPushing = false;
|
|
190
|
+
if (gitPendingAfterPush) {
|
|
191
|
+
gitPendingAfterPush = false;
|
|
192
|
+
scheduleGitCommit();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export async function stopAutoCommit() {
|
|
197
|
+
if (fastSyncTimer) {
|
|
198
|
+
clearTimeout(fastSyncTimer);
|
|
199
|
+
fastSyncTimer = null;
|
|
200
|
+
}
|
|
201
|
+
if (gitTimer) {
|
|
202
|
+
clearTimeout(gitTimer);
|
|
203
|
+
gitTimer = null;
|
|
204
|
+
}
|
|
205
|
+
if (watcher) {
|
|
206
|
+
await watcher.close();
|
|
207
|
+
watcher = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configure git to use the runwork credential helper for our remote.
|
|
3
|
+
* Called by `runwork login` and `runwork init`.
|
|
4
|
+
*/
|
|
5
|
+
export declare function configureGitCredentials(remoteUrl: string): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Remove the git credential helper configuration for our remote.
|
|
8
|
+
*/
|
|
9
|
+
export declare function removeGitCredentials(baseUrl: string): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Git credential helper protocol handler.
|
|
12
|
+
* Git calls: runwork git-credential-helper get
|
|
13
|
+
* We read stdin for host/protocol, write username/password to stdout.
|
|
14
|
+
*/
|
|
15
|
+
export declare function handleGitCredentialRequest(action: string): Promise<void>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { getCredentials } from '../auth/store.js';
|
|
3
|
+
/**
|
|
4
|
+
* Configure git to use the runwork credential helper for our remote.
|
|
5
|
+
* Called by `runwork login` and `runwork init`.
|
|
6
|
+
*/
|
|
7
|
+
export async function configureGitCredentials(remoteUrl) {
|
|
8
|
+
const origin = new URL(remoteUrl).origin;
|
|
9
|
+
execFileSync('git', [
|
|
10
|
+
'config', '--global',
|
|
11
|
+
`credential.${origin}.helper`,
|
|
12
|
+
'!runwork git-credential-helper',
|
|
13
|
+
]);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Remove the git credential helper configuration for our remote.
|
|
17
|
+
*/
|
|
18
|
+
export async function removeGitCredentials(baseUrl) {
|
|
19
|
+
const origin = new URL(baseUrl).origin;
|
|
20
|
+
try {
|
|
21
|
+
execFileSync('git', [
|
|
22
|
+
'config', '--global', '--unset',
|
|
23
|
+
`credential.${origin}.helper`,
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Ignore errors if the config key doesn't exist
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Git credential helper protocol handler.
|
|
32
|
+
* Git calls: runwork git-credential-helper get
|
|
33
|
+
* We read stdin for host/protocol, write username/password to stdout.
|
|
34
|
+
*/
|
|
35
|
+
export async function handleGitCredentialRequest(action) {
|
|
36
|
+
if (action !== 'get') {
|
|
37
|
+
return; // Only 'get' needs handling. 'store' and 'erase' are no-ops.
|
|
38
|
+
}
|
|
39
|
+
const input = await readStdin();
|
|
40
|
+
const lines = input.trim().split('\n');
|
|
41
|
+
const params = {};
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const [key, value] = line.split('=', 2);
|
|
44
|
+
if (key && value)
|
|
45
|
+
params[key] = value;
|
|
46
|
+
}
|
|
47
|
+
const creds = getCredentials();
|
|
48
|
+
if (!creds) {
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write(`protocol=${params.protocol || 'https'}\n`);
|
|
52
|
+
process.stdout.write(`host=${params.host}\n`);
|
|
53
|
+
process.stdout.write(`username=runwork\n`);
|
|
54
|
+
process.stdout.write(`password=${creds.apiKey}\n`);
|
|
55
|
+
}
|
|
56
|
+
function readStdin() {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
let data = '';
|
|
59
|
+
process.stdin.setEncoding('utf8');
|
|
60
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
61
|
+
process.stdin.on('end', () => resolve(data));
|
|
62
|
+
if (process.stdin.readableEnded)
|
|
63
|
+
resolve(data);
|
|
64
|
+
});
|
|
65
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { loginCommand } from './commands/login.js';
|
|
3
|
+
import { initCommand } from './commands/init.js';
|
|
4
|
+
import { cloneCommand } from './commands/clone.js';
|
|
5
|
+
import { devCommand } from './commands/dev.js';
|
|
6
|
+
import { deployCommand } from './commands/deploy.js';
|
|
7
|
+
import { logsCommand } from './commands/logs.js';
|
|
8
|
+
import { logoutCommand } from './commands/logout.js';
|
|
9
|
+
import { handleGitCredentialRequest } from './git/credentials.js';
|
|
10
|
+
import { VERSION } from './generated/version.js';
|
|
11
|
+
const program = new Command();
|
|
12
|
+
program
|
|
13
|
+
.name('runwork')
|
|
14
|
+
.description('Runwork CLI - local development for Runwork apps')
|
|
15
|
+
.version(VERSION);
|
|
16
|
+
program.addCommand(loginCommand);
|
|
17
|
+
program.addCommand(initCommand);
|
|
18
|
+
program.addCommand(cloneCommand);
|
|
19
|
+
program.addCommand(devCommand);
|
|
20
|
+
program.addCommand(deployCommand);
|
|
21
|
+
program.addCommand(logsCommand);
|
|
22
|
+
program.addCommand(logoutCommand);
|
|
23
|
+
const credentialHelper = program
|
|
24
|
+
.command('git-credential-helper', { hidden: true })
|
|
25
|
+
.argument('<action>', 'Credential action (get/store/erase)')
|
|
26
|
+
.description('Git credential helper (called by git, not directly)')
|
|
27
|
+
.action(async (action) => {
|
|
28
|
+
await handleGitCredentialRequest(action);
|
|
29
|
+
});
|
|
30
|
+
credentialHelper.helpOption(false);
|
|
31
|
+
program.parse();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ApiClient } from '../api/client.js';
|
|
2
|
+
interface TailerOptions {
|
|
3
|
+
appId: string;
|
|
4
|
+
client: ApiClient;
|
|
5
|
+
projectDir: string;
|
|
6
|
+
toTerminal: boolean;
|
|
7
|
+
toFile: boolean;
|
|
8
|
+
intervalMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function startLogTailer(options: TailerOptions): {
|
|
11
|
+
stop: () => void;
|
|
12
|
+
};
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
const LOG_FILE = '.runwork/logs.txt';
|
|
4
|
+
function formatTime() {
|
|
5
|
+
const now = new Date();
|
|
6
|
+
return [
|
|
7
|
+
String(now.getHours()).padStart(2, '0'),
|
|
8
|
+
String(now.getMinutes()).padStart(2, '0'),
|
|
9
|
+
String(now.getSeconds()).padStart(2, '0'),
|
|
10
|
+
].join(':');
|
|
11
|
+
}
|
|
12
|
+
function formatLogLine(tag, line) {
|
|
13
|
+
return `[${formatTime()}] [${tag}] ${line}`;
|
|
14
|
+
}
|
|
15
|
+
export function startLogTailer(options) {
|
|
16
|
+
const { appId, client, projectDir, toTerminal, toFile, intervalMs = 5000, } = options;
|
|
17
|
+
const logFilePath = join(projectDir, LOG_FILE);
|
|
18
|
+
// Ensure .runwork/ directory exists and truncate log file with header
|
|
19
|
+
if (toFile) {
|
|
20
|
+
mkdirSync(dirname(logFilePath), { recursive: true });
|
|
21
|
+
writeFileSync(logFilePath, `# Runwork dev logs - started ${new Date().toISOString()}\n\n`, 'utf-8');
|
|
22
|
+
}
|
|
23
|
+
let lastStdoutLength = 0;
|
|
24
|
+
let lastStderrLength = 0;
|
|
25
|
+
let lastEventTimestamp;
|
|
26
|
+
let seenEventIds = new Set();
|
|
27
|
+
let stopped = false;
|
|
28
|
+
let timer;
|
|
29
|
+
function writeLine(line) {
|
|
30
|
+
if (toTerminal) {
|
|
31
|
+
console.log(line);
|
|
32
|
+
}
|
|
33
|
+
if (toFile) {
|
|
34
|
+
try {
|
|
35
|
+
appendFileSync(logFilePath, line + '\n', 'utf-8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Silently ignore file write errors
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function poll() {
|
|
43
|
+
if (stopped)
|
|
44
|
+
return;
|
|
45
|
+
try {
|
|
46
|
+
// Poll preview logs (stdout/stderr)
|
|
47
|
+
const logs = await client.getPreviewLogs(appId);
|
|
48
|
+
// Reset detection: if buffer shrank (sandbox restart), reset cursor
|
|
49
|
+
if (logs.stdout && logs.stdout.length < lastStdoutLength) {
|
|
50
|
+
lastStdoutLength = 0;
|
|
51
|
+
}
|
|
52
|
+
if (logs.stderr && logs.stderr.length < lastStderrLength) {
|
|
53
|
+
lastStderrLength = 0;
|
|
54
|
+
}
|
|
55
|
+
if (logs.stdout && logs.stdout.length > lastStdoutLength) {
|
|
56
|
+
const newContent = logs.stdout.slice(lastStdoutLength);
|
|
57
|
+
const lines = newContent.split('\n').filter(Boolean);
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
writeLine(formatLogLine('RUNTIME', line));
|
|
60
|
+
}
|
|
61
|
+
lastStdoutLength = logs.stdout.length;
|
|
62
|
+
}
|
|
63
|
+
if (logs.stderr && logs.stderr.length > lastStderrLength) {
|
|
64
|
+
const newContent = logs.stderr.slice(lastStderrLength);
|
|
65
|
+
const lines = newContent.split('\n').filter(Boolean);
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
writeLine(formatLogLine('ERROR', line));
|
|
68
|
+
}
|
|
69
|
+
lastStderrLength = logs.stderr.length;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Silently ignore - sandbox may not be ready yet
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
// Poll app events
|
|
77
|
+
const eventOptions = { limit: 20 };
|
|
78
|
+
// We don't use `before` for pagination here; instead we filter by timestamp
|
|
79
|
+
const result = await client.getAppEvents(appId, eventOptions);
|
|
80
|
+
if (result.events && result.events.length > 0) {
|
|
81
|
+
// Events are returned newest-first; process in chronological order
|
|
82
|
+
const sorted = [...result.events].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
83
|
+
for (const event of sorted) {
|
|
84
|
+
// Skip events we've already seen (by ID, or by timestamp for older events)
|
|
85
|
+
if (seenEventIds.has(event.id)) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (lastEventTimestamp && event.timestamp < lastEventTimestamp) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
let detail = event.summary || event.content || event.type;
|
|
92
|
+
// Include structured metadata when available
|
|
93
|
+
if (event.metadata) {
|
|
94
|
+
const meta = event.metadata;
|
|
95
|
+
if (meta.statusCode) {
|
|
96
|
+
detail += ` (status: ${meta.statusCode})`;
|
|
97
|
+
}
|
|
98
|
+
if (meta.error) {
|
|
99
|
+
detail += ` - ${meta.error}`;
|
|
100
|
+
}
|
|
101
|
+
if (meta.path) {
|
|
102
|
+
detail += ` [${meta.path}]`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
writeLine(formatLogLine('EVENT', `${event.type}: ${detail}`));
|
|
106
|
+
seenEventIds.add(event.id);
|
|
107
|
+
}
|
|
108
|
+
// Update cursor to the latest event timestamp and prune old IDs
|
|
109
|
+
const latest = sorted[sorted.length - 1];
|
|
110
|
+
if (latest && (!lastEventTimestamp || latest.timestamp > lastEventTimestamp)) {
|
|
111
|
+
// Clear IDs from previous timestamps since we won't see them again
|
|
112
|
+
seenEventIds = new Set(sorted.filter(e => e.timestamp === latest.timestamp).map(e => e.id));
|
|
113
|
+
lastEventTimestamp = latest.timestamp;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Silently ignore - events endpoint may not be available
|
|
119
|
+
}
|
|
120
|
+
if (!stopped) {
|
|
121
|
+
timer = setTimeout(poll, intervalMs);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Initial poll after a short delay to let sandbox start
|
|
125
|
+
timer = setTimeout(poll, 2000);
|
|
126
|
+
return {
|
|
127
|
+
stop() {
|
|
128
|
+
stopped = true;
|
|
129
|
+
if (timer) {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
timer = undefined;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface TemplateManifest {
|
|
2
|
+
version: 1;
|
|
3
|
+
files: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
export declare function generateManifest(dir: string): Promise<TemplateManifest>;
|
|
6
|
+
export declare function saveManifest(dir: string, manifest: TemplateManifest): Promise<void>;
|
|
7
|
+
export declare function loadManifest(dir: string): Promise<TemplateManifest | null>;
|
|
8
|
+
/**
|
|
9
|
+
* Detect files the user edited outside of `runwork dev`.
|
|
10
|
+
*
|
|
11
|
+
* Two sources of edits:
|
|
12
|
+
* 1. Git-tracked files with uncommitted changes (git diff + git diff --cached)
|
|
13
|
+
* 2. Untracked files that aren't in the template manifest (user-created files)
|
|
14
|
+
*
|
|
15
|
+
* Already-committed files are NOT flagged -- git already knows about them.
|
|
16
|
+
*/
|
|
17
|
+
export declare function detectUserEdits(dir: string, manifest: TemplateManifest): Promise<string[]>;
|