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,85 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('fs');
|
|
3
|
+
const mockFs = await import('fs');
|
|
4
|
+
const { populateTypes } = await import('../types-manager.js');
|
|
5
|
+
describe('types-manager', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.clearAllMocks();
|
|
8
|
+
});
|
|
9
|
+
describe('populateTypes()', () => {
|
|
10
|
+
it('creates .runwork/types directory', async () => {
|
|
11
|
+
// No node_modules, no bundled-types
|
|
12
|
+
vi.mocked(mockFs.existsSync).mockReturnValue(false);
|
|
13
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
14
|
+
await populateTypes('/project');
|
|
15
|
+
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/project/.runwork/types', { recursive: true });
|
|
16
|
+
warnSpy.mockRestore();
|
|
17
|
+
});
|
|
18
|
+
it('copies from node_modules when available (Tier 1)', async () => {
|
|
19
|
+
vi.mocked(mockFs.existsSync).mockImplementation((path) => {
|
|
20
|
+
if (String(path).includes('node_modules/@runworkai/framework/dist')) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
});
|
|
25
|
+
vi.mocked(mockFs.readdirSync).mockReturnValue(['core-utils.d.ts', 'index.d.ts', 'README.md', 'index.js']);
|
|
26
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
27
|
+
await populateTypes('/project');
|
|
28
|
+
expect(mockFs.readdirSync).toHaveBeenCalledWith('/project/node_modules/@runworkai/framework/dist');
|
|
29
|
+
expect(logSpy).toHaveBeenCalledWith('Types populated from node_modules/@runworkai/framework');
|
|
30
|
+
logSpy.mockRestore();
|
|
31
|
+
});
|
|
32
|
+
it('copies only .d.ts files, not other files', async () => {
|
|
33
|
+
vi.mocked(mockFs.existsSync).mockImplementation((path) => {
|
|
34
|
+
if (String(path).includes('node_modules/@runworkai/framework/dist')) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
});
|
|
39
|
+
vi.mocked(mockFs.readdirSync).mockReturnValue(['types.d.ts', 'index.js', 'utils.d.ts', 'readme.md', 'config.json']);
|
|
40
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
41
|
+
await populateTypes('/project');
|
|
42
|
+
// Only .d.ts files should be copied
|
|
43
|
+
expect(mockFs.copyFileSync).toHaveBeenCalledTimes(2);
|
|
44
|
+
expect(mockFs.copyFileSync).toHaveBeenCalledWith('/project/node_modules/@runworkai/framework/dist/types.d.ts', '/project/.runwork/types/types.d.ts');
|
|
45
|
+
expect(mockFs.copyFileSync).toHaveBeenCalledWith('/project/node_modules/@runworkai/framework/dist/utils.d.ts', '/project/.runwork/types/utils.d.ts');
|
|
46
|
+
logSpy.mockRestore();
|
|
47
|
+
});
|
|
48
|
+
it('falls back to bundled types (Tier 3) when node_modules unavailable', async () => {
|
|
49
|
+
vi.mocked(mockFs.existsSync).mockImplementation((path) => {
|
|
50
|
+
if (String(path).includes('bundled-types')) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
});
|
|
55
|
+
vi.mocked(mockFs.readdirSync).mockReturnValue(['framework.d.ts']);
|
|
56
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
57
|
+
await populateTypes('/project');
|
|
58
|
+
// Should have called readdirSync on the bundled-types directory
|
|
59
|
+
expect(mockFs.readdirSync).toHaveBeenCalled();
|
|
60
|
+
expect(mockFs.copyFileSync).toHaveBeenCalledTimes(1);
|
|
61
|
+
expect(logSpy).toHaveBeenCalledWith('Types populated from bundled types');
|
|
62
|
+
logSpy.mockRestore();
|
|
63
|
+
});
|
|
64
|
+
it('warns when no types found at all', async () => {
|
|
65
|
+
vi.mocked(mockFs.existsSync).mockReturnValue(false);
|
|
66
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
67
|
+
await populateTypes('/project');
|
|
68
|
+
expect(warnSpy).toHaveBeenCalledWith('No type definitions found. Install @runworkai/framework or reinstall the CLI.');
|
|
69
|
+
warnSpy.mockRestore();
|
|
70
|
+
});
|
|
71
|
+
it('does not copy non-.d.ts files in bundled types fallback', async () => {
|
|
72
|
+
vi.mocked(mockFs.existsSync).mockImplementation((path) => {
|
|
73
|
+
if (String(path).includes('bundled-types')) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
});
|
|
78
|
+
vi.mocked(mockFs.readdirSync).mockReturnValue(['types.d.ts', 'package.json', 'index.js']);
|
|
79
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
80
|
+
await populateTypes('/project');
|
|
81
|
+
expect(mockFs.copyFileSync).toHaveBeenCalledTimes(1);
|
|
82
|
+
logSpy.mockRestore();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { ApiClient } from '../client.js';
|
|
3
|
+
const mockFetch = vi.fn();
|
|
4
|
+
globalThis.fetch = mockFetch;
|
|
5
|
+
function createJsonResponse(data, status = 200) {
|
|
6
|
+
return {
|
|
7
|
+
ok: status >= 200 && status < 300,
|
|
8
|
+
status,
|
|
9
|
+
json: () => Promise.resolve(data),
|
|
10
|
+
text: () => Promise.resolve(JSON.stringify(data)),
|
|
11
|
+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const defaultCreds = {
|
|
15
|
+
apiKey: 'test-key-abc',
|
|
16
|
+
email: 'user@test.com',
|
|
17
|
+
baseUrl: 'https://custom.runwork.dev',
|
|
18
|
+
};
|
|
19
|
+
describe('ApiClient', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
});
|
|
23
|
+
describe('constructor', () => {
|
|
24
|
+
it('uses default base URL when baseUrl is empty', () => {
|
|
25
|
+
const client = new ApiClient({
|
|
26
|
+
apiKey: 'key',
|
|
27
|
+
email: 'e@e.com',
|
|
28
|
+
baseUrl: '',
|
|
29
|
+
});
|
|
30
|
+
// Verify by calling a method and checking the URL
|
|
31
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: [] }));
|
|
32
|
+
client.listWorkspaces();
|
|
33
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('https://runwork.ai'), expect.anything());
|
|
34
|
+
});
|
|
35
|
+
it('uses custom base URL when provided', () => {
|
|
36
|
+
const client = new ApiClient(defaultCreds);
|
|
37
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: [] }));
|
|
38
|
+
client.listWorkspaces();
|
|
39
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('https://custom.runwork.dev'), expect.anything());
|
|
40
|
+
});
|
|
41
|
+
it('uses provided API key for authorization', async () => {
|
|
42
|
+
const client = new ApiClient(defaultCreds);
|
|
43
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: [] }));
|
|
44
|
+
await client.listWorkspaces();
|
|
45
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
46
|
+
expect(callArgs[1].headers['Authorization']).toBe('Bearer test-key-abc');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('request() - via methods', () => {
|
|
50
|
+
it('adds Authorization header when API key is set', async () => {
|
|
51
|
+
const client = new ApiClient(defaultCreds);
|
|
52
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: [] }));
|
|
53
|
+
await client.listWorkspaces();
|
|
54
|
+
const headers = mockFetch.mock.calls[0][1].headers;
|
|
55
|
+
expect(headers['Authorization']).toBe('Bearer test-key-abc');
|
|
56
|
+
});
|
|
57
|
+
it('omits Authorization header when API key is empty', async () => {
|
|
58
|
+
const client = new ApiClient({
|
|
59
|
+
apiKey: '',
|
|
60
|
+
email: 'e@e.com',
|
|
61
|
+
baseUrl: 'https://runwork.ai',
|
|
62
|
+
});
|
|
63
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: [] }));
|
|
64
|
+
await client.listWorkspaces();
|
|
65
|
+
const headers = mockFetch.mock.calls[0][1].headers;
|
|
66
|
+
expect(headers['Authorization']).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
it('throws on non-ok response with status and body', async () => {
|
|
69
|
+
const client = new ApiClient(defaultCreds);
|
|
70
|
+
mockFetch.mockResolvedValue(createJsonResponse({ error: 'forbidden' }, 403));
|
|
71
|
+
await expect(client.listWorkspaces()).rejects.toThrow('API error 403');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('listWorkspaces()', () => {
|
|
75
|
+
it('calls correct endpoint and unwraps data', async () => {
|
|
76
|
+
const workspaces = [{ id: 'ws-1', name: 'My Workspace' }];
|
|
77
|
+
const client = new ApiClient(defaultCreds);
|
|
78
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: workspaces }));
|
|
79
|
+
const result = await client.listWorkspaces();
|
|
80
|
+
expect(mockFetch).toHaveBeenCalledWith('https://custom.runwork.dev/api/dev/workspaces', expect.anything());
|
|
81
|
+
expect(result).toEqual(workspaces);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('listApps()', () => {
|
|
85
|
+
it('calls correct endpoint without workspace filter', async () => {
|
|
86
|
+
const apps = [{ id: 'app-1', name: 'App' }];
|
|
87
|
+
const client = new ApiClient(defaultCreds);
|
|
88
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: apps }));
|
|
89
|
+
const result = await client.listApps();
|
|
90
|
+
expect(mockFetch).toHaveBeenCalledWith('https://custom.runwork.dev/api/dev/apps', expect.anything());
|
|
91
|
+
expect(result).toEqual(apps);
|
|
92
|
+
});
|
|
93
|
+
it('includes workspaceId query param when provided', async () => {
|
|
94
|
+
const client = new ApiClient(defaultCreds);
|
|
95
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: [] }));
|
|
96
|
+
await client.listApps('ws-123');
|
|
97
|
+
expect(mockFetch).toHaveBeenCalledWith('https://custom.runwork.dev/api/dev/apps?workspaceId=ws-123', expect.anything());
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('getApp()', () => {
|
|
101
|
+
it('calls correct endpoint with appId', async () => {
|
|
102
|
+
const app = { id: 'app-42', name: 'Test App' };
|
|
103
|
+
const client = new ApiClient(defaultCreds);
|
|
104
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: app }));
|
|
105
|
+
const result = await client.getApp('app-42');
|
|
106
|
+
expect(mockFetch).toHaveBeenCalledWith('https://custom.runwork.dev/api/dev/apps/app-42', expect.anything());
|
|
107
|
+
expect(result).toEqual(app);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('initApp()', () => {
|
|
111
|
+
it('sends POST with correct body', async () => {
|
|
112
|
+
const app = { id: 'new-app', name: 'New App' };
|
|
113
|
+
const client = new ApiClient(defaultCreds);
|
|
114
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: app }));
|
|
115
|
+
const result = await client.initApp('ws-1', 'New App');
|
|
116
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
117
|
+
expect(callArgs[1].method).toBe('POST');
|
|
118
|
+
expect(JSON.parse(callArgs[1].body)).toEqual({
|
|
119
|
+
workspaceId: 'ws-1',
|
|
120
|
+
appName: 'New App',
|
|
121
|
+
});
|
|
122
|
+
expect(result).toEqual(app);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('startDevSession()', () => {
|
|
126
|
+
it('sends POST with appId', async () => {
|
|
127
|
+
const session = {
|
|
128
|
+
sessionId: 's-1',
|
|
129
|
+
previewUrl: 'https://preview.test',
|
|
130
|
+
appId: 'app-1',
|
|
131
|
+
};
|
|
132
|
+
const client = new ApiClient(defaultCreds);
|
|
133
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: session }));
|
|
134
|
+
const result = await client.startDevSession('app-1');
|
|
135
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
136
|
+
expect(callArgs[1].method).toBe('POST');
|
|
137
|
+
expect(JSON.parse(callArgs[1].body)).toEqual({ appId: 'app-1' });
|
|
138
|
+
expect(result).toEqual(session);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe('getDevStatus()', () => {
|
|
142
|
+
it('calls correct endpoint', async () => {
|
|
143
|
+
const status = {
|
|
144
|
+
sessionId: 's-1',
|
|
145
|
+
previewUrl: 'https://preview.test',
|
|
146
|
+
appId: 'app-99',
|
|
147
|
+
};
|
|
148
|
+
const client = new ApiClient(defaultCreds);
|
|
149
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: status }));
|
|
150
|
+
const result = await client.getDevStatus('app-99');
|
|
151
|
+
expect(mockFetch).toHaveBeenCalledWith('https://custom.runwork.dev/api/dev/status/app-99', expect.anything());
|
|
152
|
+
expect(result).toEqual(status);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe('triggerDeploy()', () => {
|
|
156
|
+
it('sends POST and returns deploymentUrl', async () => {
|
|
157
|
+
const client = new ApiClient(defaultCreds);
|
|
158
|
+
mockFetch.mockResolvedValue(createJsonResponse({
|
|
159
|
+
data: { deploymentUrl: 'https://deployed.test' },
|
|
160
|
+
}));
|
|
161
|
+
const result = await client.triggerDeploy('app-1');
|
|
162
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
163
|
+
expect(callArgs[0]).toBe('https://custom.runwork.dev/api/dev/deploy/app-1');
|
|
164
|
+
expect(callArgs[1].method).toBe('POST');
|
|
165
|
+
expect(result).toEqual({ deploymentUrl: 'https://deployed.test' });
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('initiateLogin()', () => {
|
|
169
|
+
it('sends POST to auth/login', async () => {
|
|
170
|
+
const loginData = {
|
|
171
|
+
sessionId: 'sess-123',
|
|
172
|
+
loginUrl: 'https://runwork.ai/cli-auth?session=sess-123',
|
|
173
|
+
};
|
|
174
|
+
const client = new ApiClient(defaultCreds);
|
|
175
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: loginData }));
|
|
176
|
+
const result = await client.initiateLogin();
|
|
177
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
178
|
+
expect(callArgs[0]).toBe('https://custom.runwork.dev/api/dev/auth/login');
|
|
179
|
+
expect(callArgs[1].method).toBe('POST');
|
|
180
|
+
expect(result).toEqual(loginData);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
describe('pollLogin()', () => {
|
|
184
|
+
it('returns credentials when available', async () => {
|
|
185
|
+
const creds = {
|
|
186
|
+
apiKey: 'new-key',
|
|
187
|
+
email: 'new@test.com',
|
|
188
|
+
baseUrl: 'https://runwork.ai',
|
|
189
|
+
};
|
|
190
|
+
const client = new ApiClient(defaultCreds);
|
|
191
|
+
mockFetch.mockResolvedValue(createJsonResponse({ data: creds }));
|
|
192
|
+
const result = await client.pollLogin('sess-123');
|
|
193
|
+
expect(mockFetch).toHaveBeenCalledWith('https://custom.runwork.dev/api/dev/auth/poll/sess-123', expect.anything());
|
|
194
|
+
expect(result).toEqual(creds);
|
|
195
|
+
});
|
|
196
|
+
it('returns null on error', async () => {
|
|
197
|
+
const client = new ApiClient(defaultCreds);
|
|
198
|
+
mockFetch.mockResolvedValue(createJsonResponse({ error: 'pending' }, 404));
|
|
199
|
+
const result = await client.pollLogin('sess-123');
|
|
200
|
+
expect(result).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
it('returns null when fetch throws', async () => {
|
|
203
|
+
const client = new ApiClient(defaultCreds);
|
|
204
|
+
mockFetch.mockRejectedValue(new Error('network error'));
|
|
205
|
+
const result = await client.pollLogin('sess-123');
|
|
206
|
+
expect(result).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
describe('downloadSkeleton()', () => {
|
|
210
|
+
it('makes raw fetch call to skeleton endpoint', async () => {
|
|
211
|
+
const client = new ApiClient(defaultCreds);
|
|
212
|
+
const buffer = new ArrayBuffer(16);
|
|
213
|
+
mockFetch.mockResolvedValue({
|
|
214
|
+
ok: true,
|
|
215
|
+
arrayBuffer: () => Promise.resolve(buffer),
|
|
216
|
+
});
|
|
217
|
+
const result = await client.downloadSkeleton();
|
|
218
|
+
expect(mockFetch).toHaveBeenCalledWith('https://custom.runwork.dev/api/dev/skeleton', expect.objectContaining({
|
|
219
|
+
headers: expect.objectContaining({
|
|
220
|
+
Authorization: 'Bearer test-key-abc',
|
|
221
|
+
}),
|
|
222
|
+
}));
|
|
223
|
+
expect(result).toBe(buffer);
|
|
224
|
+
});
|
|
225
|
+
it('throws on non-ok response', async () => {
|
|
226
|
+
const client = new ApiClient(defaultCreds);
|
|
227
|
+
mockFetch.mockResolvedValue({
|
|
228
|
+
ok: false,
|
|
229
|
+
status: 500,
|
|
230
|
+
});
|
|
231
|
+
await expect(client.downloadSkeleton()).rejects.toThrow('Failed to download skeleton: 500');
|
|
232
|
+
});
|
|
233
|
+
it('omits Authorization when apiKey is empty', async () => {
|
|
234
|
+
const client = new ApiClient({
|
|
235
|
+
apiKey: '',
|
|
236
|
+
email: 'e@e.com',
|
|
237
|
+
baseUrl: 'https://runwork.ai',
|
|
238
|
+
});
|
|
239
|
+
mockFetch.mockResolvedValue({
|
|
240
|
+
ok: true,
|
|
241
|
+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
|
242
|
+
});
|
|
243
|
+
await client.downloadSkeleton();
|
|
244
|
+
const headers = mockFetch.mock.calls[0][1].headers;
|
|
245
|
+
expect(headers['Authorization']).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
describe('getGitRemoteUrl()', () => {
|
|
249
|
+
it('returns correct URL format', () => {
|
|
250
|
+
const client = new ApiClient(defaultCreds);
|
|
251
|
+
const url = client.getGitRemoteUrl('ws-1', 'app-1');
|
|
252
|
+
expect(url).toBe('https://custom.runwork.dev/api/git/ws-1/app-1');
|
|
253
|
+
});
|
|
254
|
+
it('uses default base URL when none provided', () => {
|
|
255
|
+
const client = new ApiClient({
|
|
256
|
+
apiKey: 'key',
|
|
257
|
+
email: 'e@e.com',
|
|
258
|
+
baseUrl: '',
|
|
259
|
+
});
|
|
260
|
+
const url = client.getGitRemoteUrl('ws-2', 'app-2');
|
|
261
|
+
expect(url).toBe('https://runwork.ai/api/git/ws-2/app-2');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Credentials, AppInfo, WorkspaceInfo, DevSession } from '../types.js';
|
|
2
|
+
export declare class ApiClient {
|
|
3
|
+
private baseUrl;
|
|
4
|
+
private apiKey;
|
|
5
|
+
constructor(credentials: Credentials);
|
|
6
|
+
private request;
|
|
7
|
+
listWorkspaces(): Promise<WorkspaceInfo[]>;
|
|
8
|
+
listApps(workspaceId?: string): Promise<AppInfo[]>;
|
|
9
|
+
getApp(appId: string): Promise<AppInfo>;
|
|
10
|
+
initApp(workspaceId: string, appName: string): Promise<AppInfo>;
|
|
11
|
+
startDevSession(appId: string): Promise<DevSession>;
|
|
12
|
+
getDevStatus(appId: string): Promise<DevSession>;
|
|
13
|
+
triggerDeploy(appId: string): Promise<{
|
|
14
|
+
deploymentUrl: string;
|
|
15
|
+
}>;
|
|
16
|
+
initiateLogin(): Promise<{
|
|
17
|
+
sessionId: string;
|
|
18
|
+
loginUrl: string;
|
|
19
|
+
}>;
|
|
20
|
+
pollLogin(sessionId: string): Promise<Credentials | null>;
|
|
21
|
+
getAppSkill(appId: string): Promise<{
|
|
22
|
+
skill: string;
|
|
23
|
+
appName: string;
|
|
24
|
+
}>;
|
|
25
|
+
getPreviewLogs(appId: string): Promise<{
|
|
26
|
+
stdout: string;
|
|
27
|
+
stderr: string;
|
|
28
|
+
}>;
|
|
29
|
+
getProductionLogs(appId: string, options?: {
|
|
30
|
+
limit?: number;
|
|
31
|
+
level?: string;
|
|
32
|
+
search?: string;
|
|
33
|
+
startTime?: string;
|
|
34
|
+
endTime?: string;
|
|
35
|
+
}): Promise<{
|
|
36
|
+
logs: Array<Record<string, unknown>>;
|
|
37
|
+
pagination?: {
|
|
38
|
+
total?: number;
|
|
39
|
+
hasMore?: boolean;
|
|
40
|
+
};
|
|
41
|
+
}>;
|
|
42
|
+
getAppEvents(appId: string, options?: {
|
|
43
|
+
limit?: number;
|
|
44
|
+
type?: string;
|
|
45
|
+
source?: string;
|
|
46
|
+
before?: string;
|
|
47
|
+
}): Promise<{
|
|
48
|
+
events: Array<{
|
|
49
|
+
id: string;
|
|
50
|
+
source: string;
|
|
51
|
+
type: string;
|
|
52
|
+
content: string;
|
|
53
|
+
summary?: string;
|
|
54
|
+
metadata?: Record<string, unknown>;
|
|
55
|
+
appId?: string;
|
|
56
|
+
timestamp: string;
|
|
57
|
+
}>;
|
|
58
|
+
totalCount: number;
|
|
59
|
+
hasMore: boolean;
|
|
60
|
+
}>;
|
|
61
|
+
downloadSkeleton(): Promise<ArrayBuffer>;
|
|
62
|
+
syncFiles(appId: string, files: Array<{
|
|
63
|
+
filePath: string;
|
|
64
|
+
fileContents: string;
|
|
65
|
+
}>, deletedFiles?: string[]): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Get git remote URL for an app.
|
|
68
|
+
* Both canonical ({baseUrl}/api/git/{workspaceId}/{appId}) and
|
|
69
|
+
* subdomain ({slug}.runwork.ai/api/git/{appId}) formats are supported.
|
|
70
|
+
*/
|
|
71
|
+
getGitRemoteUrl(workspaceId: string, appId: string): string;
|
|
72
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const DEFAULT_BASE_URL = 'https://runwork.ai';
|
|
2
|
+
export class ApiClient {
|
|
3
|
+
baseUrl;
|
|
4
|
+
apiKey;
|
|
5
|
+
constructor(credentials) {
|
|
6
|
+
this.baseUrl = credentials.baseUrl || DEFAULT_BASE_URL;
|
|
7
|
+
this.apiKey = credentials.apiKey;
|
|
8
|
+
}
|
|
9
|
+
async request(path, options = {}) {
|
|
10
|
+
const url = `${this.baseUrl}${path}`;
|
|
11
|
+
const headers = {
|
|
12
|
+
'Content-Type': 'application/json',
|
|
13
|
+
...Object.fromEntries(Object.entries(options.headers || {})),
|
|
14
|
+
};
|
|
15
|
+
if (this.apiKey) {
|
|
16
|
+
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
|
17
|
+
}
|
|
18
|
+
const response = await fetch(url, { ...options, headers });
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
const body = await response.text();
|
|
21
|
+
throw new Error(`API error ${response.status}: ${body}`);
|
|
22
|
+
}
|
|
23
|
+
return response.json();
|
|
24
|
+
}
|
|
25
|
+
async listWorkspaces() {
|
|
26
|
+
const res = await this.request('/api/dev/workspaces');
|
|
27
|
+
return res.data;
|
|
28
|
+
}
|
|
29
|
+
async listApps(workspaceId) {
|
|
30
|
+
const query = workspaceId ? `?workspaceId=${workspaceId}` : '';
|
|
31
|
+
const res = await this.request(`/api/dev/apps${query}`);
|
|
32
|
+
return res.data;
|
|
33
|
+
}
|
|
34
|
+
async getApp(appId) {
|
|
35
|
+
const res = await this.request(`/api/dev/apps/${appId}`);
|
|
36
|
+
return res.data;
|
|
37
|
+
}
|
|
38
|
+
async initApp(workspaceId, appName) {
|
|
39
|
+
const res = await this.request('/api/dev/init', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
body: JSON.stringify({ workspaceId, appName }),
|
|
42
|
+
});
|
|
43
|
+
const app = res.data;
|
|
44
|
+
// Server returns `appName` for init, normalize to `name`
|
|
45
|
+
if (!app.name && app.appName) {
|
|
46
|
+
app.name = app.appName;
|
|
47
|
+
}
|
|
48
|
+
return app;
|
|
49
|
+
}
|
|
50
|
+
async startDevSession(appId) {
|
|
51
|
+
const res = await this.request('/api/dev/sessions', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
body: JSON.stringify({ appId }),
|
|
54
|
+
});
|
|
55
|
+
return res.data;
|
|
56
|
+
}
|
|
57
|
+
async getDevStatus(appId) {
|
|
58
|
+
const res = await this.request(`/api/dev/status/${appId}`);
|
|
59
|
+
return res.data;
|
|
60
|
+
}
|
|
61
|
+
async triggerDeploy(appId) {
|
|
62
|
+
const res = await this.request(`/api/dev/deploy/${appId}`, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
});
|
|
65
|
+
return res.data;
|
|
66
|
+
}
|
|
67
|
+
async initiateLogin() {
|
|
68
|
+
const res = await this.request('/api/dev/auth/login', { method: 'POST' });
|
|
69
|
+
return res.data;
|
|
70
|
+
}
|
|
71
|
+
async pollLogin(sessionId) {
|
|
72
|
+
try {
|
|
73
|
+
const res = await this.request(`/api/dev/auth/poll/${sessionId}`);
|
|
74
|
+
return res.data;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async getAppSkill(appId) {
|
|
81
|
+
const res = await this.request(`/api/dev/skill/${appId}`);
|
|
82
|
+
return res.data;
|
|
83
|
+
}
|
|
84
|
+
async getPreviewLogs(appId) {
|
|
85
|
+
const res = await this.request(`/api/dev/logs/${appId}/preview`);
|
|
86
|
+
return res.data.logs;
|
|
87
|
+
}
|
|
88
|
+
async getProductionLogs(appId, options) {
|
|
89
|
+
const params = new URLSearchParams();
|
|
90
|
+
if (options?.limit !== undefined)
|
|
91
|
+
params.set('limit', String(options.limit));
|
|
92
|
+
if (options?.level)
|
|
93
|
+
params.set('level', options.level);
|
|
94
|
+
if (options?.search)
|
|
95
|
+
params.set('search', options.search);
|
|
96
|
+
if (options?.startTime)
|
|
97
|
+
params.set('startTime', options.startTime);
|
|
98
|
+
if (options?.endTime)
|
|
99
|
+
params.set('endTime', options.endTime);
|
|
100
|
+
const query = params.toString();
|
|
101
|
+
const res = await this.request(`/api/dev/logs/${appId}/production${query ? `?${query}` : ''}`);
|
|
102
|
+
return res.data;
|
|
103
|
+
}
|
|
104
|
+
async getAppEvents(appId, options) {
|
|
105
|
+
const params = new URLSearchParams();
|
|
106
|
+
if (options?.limit !== undefined)
|
|
107
|
+
params.set('limit', String(options.limit));
|
|
108
|
+
if (options?.type)
|
|
109
|
+
params.set('type', options.type);
|
|
110
|
+
if (options?.source)
|
|
111
|
+
params.set('source', options.source);
|
|
112
|
+
if (options?.before)
|
|
113
|
+
params.set('before', options.before);
|
|
114
|
+
const query = params.toString();
|
|
115
|
+
const res = await this.request(`/api/dev/events/${appId}${query ? `?${query}` : ''}`);
|
|
116
|
+
return res.data;
|
|
117
|
+
}
|
|
118
|
+
async downloadSkeleton() {
|
|
119
|
+
const url = `${this.baseUrl}/api/dev/skeleton`;
|
|
120
|
+
const headers = {};
|
|
121
|
+
if (this.apiKey) {
|
|
122
|
+
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
|
123
|
+
}
|
|
124
|
+
const response = await fetch(url, { headers });
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
throw new Error(`Failed to download skeleton: ${response.status}`);
|
|
127
|
+
}
|
|
128
|
+
return response.arrayBuffer();
|
|
129
|
+
}
|
|
130
|
+
async syncFiles(appId, files, deletedFiles) {
|
|
131
|
+
await this.request(`/api/dev/sync/${appId}`, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
body: JSON.stringify({ files, deletedFiles }),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Get git remote URL for an app.
|
|
138
|
+
* Both canonical ({baseUrl}/api/git/{workspaceId}/{appId}) and
|
|
139
|
+
* subdomain ({slug}.runwork.ai/api/git/{appId}) formats are supported.
|
|
140
|
+
*/
|
|
141
|
+
getGitRemoteUrl(workspaceId, appId) {
|
|
142
|
+
return `${this.baseUrl}/api/git/${workspaceId}/${appId}`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('fs');
|
|
3
|
+
vi.mock('os', () => ({
|
|
4
|
+
homedir: () => '/tmp/test-runwork-home',
|
|
5
|
+
}));
|
|
6
|
+
const mockFs = await import('fs');
|
|
7
|
+
const { getCredentials, saveCredentials, clearCredentials, requireAuth, } = await import('../store.js');
|
|
8
|
+
const EXPECTED_DIR = '/tmp/test-runwork-home/.runwork';
|
|
9
|
+
const EXPECTED_FILE = '/tmp/test-runwork-home/.runwork/.credentials';
|
|
10
|
+
const validCredentials = {
|
|
11
|
+
apiKey: 'test-api-key-123',
|
|
12
|
+
email: 'test@example.com',
|
|
13
|
+
baseUrl: 'https://runwork.ai',
|
|
14
|
+
};
|
|
15
|
+
describe('auth/store', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
});
|
|
19
|
+
describe('getCredentials()', () => {
|
|
20
|
+
it('returns null when credentials file does not exist', () => {
|
|
21
|
+
vi.mocked(mockFs.existsSync).mockReturnValue(false);
|
|
22
|
+
const result = getCredentials();
|
|
23
|
+
expect(result).toBeNull();
|
|
24
|
+
expect(mockFs.existsSync).toHaveBeenCalledWith(EXPECTED_FILE);
|
|
25
|
+
});
|
|
26
|
+
it('returns parsed credentials when file exists with valid JSON', () => {
|
|
27
|
+
vi.mocked(mockFs.existsSync).mockReturnValue(true);
|
|
28
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue(JSON.stringify(validCredentials));
|
|
29
|
+
const result = getCredentials();
|
|
30
|
+
expect(result).toEqual(validCredentials);
|
|
31
|
+
expect(mockFs.readFileSync).toHaveBeenCalledWith(EXPECTED_FILE, 'utf-8');
|
|
32
|
+
});
|
|
33
|
+
it('returns null on malformed JSON', () => {
|
|
34
|
+
vi.mocked(mockFs.existsSync).mockReturnValue(true);
|
|
35
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue('not valid json {{{');
|
|
36
|
+
const result = getCredentials();
|
|
37
|
+
expect(result).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
it('returns null when readFileSync throws', () => {
|
|
40
|
+
vi.mocked(mockFs.existsSync).mockReturnValue(true);
|
|
41
|
+
vi.mocked(mockFs.readFileSync).mockImplementation(() => {
|
|
42
|
+
throw new Error('EACCES: permission denied');
|
|
43
|
+
});
|
|
44
|
+
const result = getCredentials();
|
|
45
|
+
expect(result).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('saveCredentials()', () => {
|
|
49
|
+
it('creates the .runwork directory recursively', () => {
|
|
50
|
+
saveCredentials(validCredentials);
|
|
51
|
+
expect(mockFs.mkdirSync).toHaveBeenCalledWith(EXPECTED_DIR, {
|
|
52
|
+
recursive: true,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
it('writes JSON with 0o600 permissions', () => {
|
|
56
|
+
saveCredentials(validCredentials);
|
|
57
|
+
expect(mockFs.writeFileSync).toHaveBeenCalledWith(EXPECTED_FILE, JSON.stringify(validCredentials, null, 2), { mode: 0o600 });
|
|
58
|
+
});
|
|
59
|
+
it('stores the correct credential data', () => {
|
|
60
|
+
const customCreds = {
|
|
61
|
+
apiKey: 'custom-key',
|
|
62
|
+
email: 'custom@test.com',
|
|
63
|
+
baseUrl: 'https://custom.runwork.dev',
|
|
64
|
+
};
|
|
65
|
+
saveCredentials(customCreds);
|
|
66
|
+
const writtenData = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
|
|
67
|
+
const parsed = JSON.parse(writtenData);
|
|
68
|
+
expect(parsed).toEqual(customCreds);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('clearCredentials()', () => {
|
|
72
|
+
it('deletes the credentials file when it exists', () => {
|
|
73
|
+
vi.mocked(mockFs.existsSync).mockReturnValue(true);
|
|
74
|
+
clearCredentials();
|
|
75
|
+
expect(mockFs.unlinkSync).toHaveBeenCalledWith(EXPECTED_FILE);
|
|
76
|
+
});
|
|
77
|
+
it('does nothing when credentials file does not exist', () => {
|
|
78
|
+
vi.mocked(mockFs.existsSync).mockReturnValue(false);
|
|
79
|
+
clearCredentials();
|
|
80
|
+
expect(mockFs.unlinkSync).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('requireAuth()', () => {
|
|
84
|
+
it('returns credentials when logged in', () => {
|
|
85
|
+
vi.mocked(mockFs.existsSync).mockReturnValue(true);
|
|
86
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue(JSON.stringify(validCredentials));
|
|
87
|
+
const result = requireAuth();
|
|
88
|
+
expect(result).toEqual(validCredentials);
|
|
89
|
+
});
|
|
90
|
+
it('exits process with code 1 when not logged in', () => {
|
|
91
|
+
vi.mocked(mockFs.existsSync).mockReturnValue(false);
|
|
92
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
93
|
+
throw new Error('process.exit called');
|
|
94
|
+
}));
|
|
95
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
96
|
+
expect(() => requireAuth()).toThrow('process.exit called');
|
|
97
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
98
|
+
expect(errorSpy).toHaveBeenCalledWith('Not logged in. Run `runwork login` first.');
|
|
99
|
+
exitSpy.mockRestore();
|
|
100
|
+
errorSpy.mockRestore();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|