Skip to content

Commit 83dd39a

Browse files
flowName and deploy
1 parent 5c62d20 commit 83dd39a

File tree

7 files changed

+410
-17
lines changed

7 files changed

+410
-17
lines changed

packages/cli/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,40 @@ walkeros run serve flow.json --port 8080 --static-dir ./dist
257257
3. Runs in current Node.js process
258258
4. Press Ctrl+C for graceful shutdown
259259

260+
### deploy
261+
262+
Deploy flows to walkerOS cloud.
263+
264+
```bash
265+
walkeros deploy start <flowId> [options]
266+
walkeros deploy status <flowId> [options]
267+
```
268+
269+
**Options:**
270+
271+
- `--project <id>` - Project ID (defaults to WALKEROS_PROJECT_ID)
272+
- `--flow <name>` - Flow name for multi-config flows
273+
- `--no-wait` - Do not wait for deployment to complete (start only)
274+
- `--json` - Output as JSON
275+
- `-v, --verbose` - Verbose output
276+
- `-s, --silent` - Suppress output
277+
278+
**Examples:**
279+
280+
```bash
281+
# Deploy a single-config flow
282+
walkeros deploy start cfg_abc123
283+
284+
# Deploy a specific config from a multi-config flow
285+
walkeros deploy start cfg_abc123 --flow web
286+
287+
# Check deployment status
288+
walkeros deploy status cfg_abc123 --flow server
289+
```
290+
291+
When a flow has multiple configs, the CLI requires `--flow <name>` to specify
292+
which one to deploy. If omitted, the error message lists available names.
293+
260294
## Caching
261295

262296
The CLI implements intelligent caching for faster builds:
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { createApiClient } from '../../core/api-client.js';
2+
import {
3+
authenticatedFetch,
4+
requireProjectId,
5+
resolveBaseUrl,
6+
} from '../../core/auth.js';
7+
import { getFlow } from '../../commands/flows/index.js';
8+
import { deploy, getDeployment } from '../../commands/deploy/index.js';
9+
import { bundleRemote } from '../../commands/bundle/index.js';
10+
11+
jest.mock('../../core/api-client.js');
12+
jest.mock('../../core/auth.js', () => ({
13+
...jest.requireActual('../../core/auth.js'),
14+
requireProjectId: jest.fn().mockReturnValue('proj_default'),
15+
resolveBaseUrl: jest.fn().mockReturnValue('https://app.walkeros.io'),
16+
authenticatedFetch: jest.fn(),
17+
}));
18+
jest.mock('../../commands/flows/index.js', () => ({
19+
getFlow: jest.fn(),
20+
}));
21+
22+
const mockPost = jest.fn();
23+
const mockGet = jest.fn();
24+
25+
(createApiClient as jest.Mock).mockReturnValue({
26+
GET: mockGet,
27+
POST: mockPost,
28+
PATCH: jest.fn(),
29+
DELETE: jest.fn(),
30+
});
31+
32+
const mockGetFlow = jest.mocked(getFlow);
33+
const mockAuthFetch = jest.mocked(authenticatedFetch);
34+
35+
const multiFlowContent = {
36+
content: {
37+
flows: {
38+
web: { web: {} },
39+
server: { server: {} },
40+
},
41+
},
42+
};
43+
44+
describe('deploy', () => {
45+
afterEach(() => jest.clearAllMocks());
46+
47+
describe('deploy() without flowName', () => {
48+
it('calls legacy POST route', async () => {
49+
mockPost.mockResolvedValue({
50+
data: { status: 'bundling', deploymentId: 'dep_1' },
51+
});
52+
await deploy({ flowId: 'cfg_1', wait: false });
53+
expect(mockPost).toHaveBeenCalledWith(
54+
'/api/projects/{projectId}/flows/{flowId}/deploy',
55+
{ params: { path: { projectId: 'proj_default', flowId: 'cfg_1' } } },
56+
);
57+
});
58+
59+
it('handles AMBIGUOUS_CONFIG with helpful error', async () => {
60+
mockPost.mockResolvedValue({
61+
error: {
62+
error: { message: 'Ambiguous config', code: 'AMBIGUOUS_CONFIG' },
63+
},
64+
});
65+
mockGetFlow.mockResolvedValue(multiFlowContent as any);
66+
67+
await expect(deploy({ flowId: 'cfg_1' })).rejects.toThrow(
68+
/Use --flow <name> to specify one/,
69+
);
70+
});
71+
});
72+
73+
describe('deploy() with flowName', () => {
74+
it('resolves configId and calls per-config route', async () => {
75+
mockGetFlow.mockResolvedValue(multiFlowContent as any);
76+
mockAuthFetch.mockResolvedValue(
77+
new Response(
78+
JSON.stringify({ status: 'bundling', deploymentId: 'dep_1' }),
79+
{
80+
status: 200,
81+
},
82+
),
83+
);
84+
85+
await deploy({ flowId: 'cfg_1', flowName: 'web', wait: false });
86+
87+
expect(mockGetFlow).toHaveBeenCalledWith({
88+
flowId: 'cfg_1',
89+
projectId: 'proj_default',
90+
});
91+
expect(mockAuthFetch).toHaveBeenCalledWith(
92+
'https://app.walkeros.io/api/projects/proj_default/flows/cfg_1/configs/web/deploy',
93+
{ method: 'POST' },
94+
);
95+
});
96+
97+
it('throws with available names when flowName not found', async () => {
98+
mockGetFlow.mockResolvedValue(multiFlowContent as any);
99+
100+
await expect(
101+
deploy({ flowId: 'cfg_1', flowName: 'nonexistent' }),
102+
).rejects.toThrow(/Flow "nonexistent" not found. Available: web, server/);
103+
});
104+
105+
it('polls advance endpoint when wait=true', async () => {
106+
jest.useFakeTimers();
107+
mockGetFlow.mockResolvedValue(multiFlowContent as any);
108+
109+
// First call: trigger deploy
110+
mockAuthFetch.mockResolvedValueOnce(
111+
new Response(
112+
JSON.stringify({ status: 'bundling', deploymentId: 'dep_1' }),
113+
{ status: 200 },
114+
),
115+
);
116+
// Second call: advance -> published
117+
mockAuthFetch.mockResolvedValueOnce(
118+
new Response(
119+
JSON.stringify({
120+
status: 'published',
121+
publicUrl: 'https://cdn.example.com/walker.js',
122+
}),
123+
{ status: 200 },
124+
),
125+
);
126+
127+
const promise = deploy({
128+
flowId: 'cfg_1',
129+
flowName: 'web',
130+
wait: true,
131+
});
132+
133+
// Advance past the 3s poll delay
134+
await jest.advanceTimersByTimeAsync(3000);
135+
136+
const result = await promise;
137+
138+
expect(result).toMatchObject({ status: 'published' });
139+
expect(mockAuthFetch).toHaveBeenCalledTimes(2);
140+
expect(mockAuthFetch).toHaveBeenLastCalledWith(
141+
expect.stringContaining('/configs/web/deployments/dep_1/advance'),
142+
{ method: 'POST' },
143+
);
144+
jest.useRealTimers();
145+
});
146+
});
147+
148+
describe('getDeployment()', () => {
149+
it('without flowName calls typed GET route', async () => {
150+
mockGet.mockResolvedValue({
151+
data: { id: 'dep_1', type: 'web', status: 'published' },
152+
});
153+
await getDeployment({ flowId: 'cfg_1' });
154+
expect(mockGet).toHaveBeenCalledWith(
155+
'/api/projects/{projectId}/flows/{flowId}/deploy',
156+
{ params: { path: { projectId: 'proj_default', flowId: 'cfg_1' } } },
157+
);
158+
});
159+
160+
it('with flowName calls per-config GET route', async () => {
161+
mockGetFlow.mockResolvedValue(multiFlowContent as any);
162+
mockAuthFetch.mockResolvedValue(
163+
new Response(
164+
JSON.stringify({ id: 'dep_1', type: 'web', status: 'published' }),
165+
{ status: 200 },
166+
),
167+
);
168+
169+
await getDeployment({ flowId: 'cfg_1', flowName: 'web' });
170+
171+
expect(mockAuthFetch).toHaveBeenCalledWith(
172+
'https://app.walkeros.io/api/projects/proj_default/flows/cfg_1/configs/web/deploy',
173+
);
174+
});
175+
});
176+
177+
describe('bundleRemote()', () => {
178+
it('without flowName sends only flow in body', async () => {
179+
const content = { version: 1, flows: { default: {} } };
180+
mockPost.mockResolvedValue({
181+
data: 'console.log("bundle")',
182+
response: { headers: new Headers() },
183+
});
184+
await bundleRemote({ content });
185+
expect(mockPost).toHaveBeenCalledWith('/api/bundle', {
186+
body: { flow: content },
187+
parseAs: 'text',
188+
});
189+
});
190+
191+
it('with flowName includes flowName in body', async () => {
192+
const content = { version: 1, flows: { web: {}, server: {} } };
193+
mockPost.mockResolvedValue({
194+
data: 'console.log("bundle")',
195+
response: { headers: new Headers() },
196+
});
197+
await bundleRemote({ content, flowName: 'web' });
198+
expect(mockPost).toHaveBeenCalledWith('/api/bundle', {
199+
body: { flow: content, flowName: 'web' },
200+
parseAs: 'text',
201+
});
202+
});
203+
});
204+
});

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ deployCmd
357357
.command('start <flowId>')
358358
.description('Deploy a flow (auto-detects web or server)')
359359
.option('--project <id>', 'project ID (defaults to WALKEROS_PROJECT_ID)')
360+
.option('--flow <name>', 'flow name for multi-config flows')
360361
.option('--no-wait', 'do not wait for deployment to complete')
361362
.option('-o, --output <path>', 'output file path')
362363
.option('--json', 'output as JSON')
@@ -370,6 +371,7 @@ deployCmd
370371
.command('status <flowId>')
371372
.description('Get the latest deployment status for a flow')
372373
.option('--project <id>', 'project ID (defaults to WALKEROS_PROJECT_ID)')
374+
.option('--flow <name>', 'flow name for multi-config flows')
373375
.option('-o, --output <path>', 'output file path')
374376
.option('--json', 'output as JSON')
375377
.option('-v, --verbose', 'verbose output')

packages/cli/src/commands/bundle/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,10 +381,13 @@ EXPOSE 8080
381381
*/
382382
export async function bundleRemote(options: {
383383
content: Record<string, unknown>;
384+
flowName?: string;
384385
}) {
385386
const client = createApiClient();
387+
const body: Record<string, unknown> = { flow: options.content };
388+
if (options.flowName) body.flowName = options.flowName;
386389
const { data, error, response } = await client.POST('/api/bundle', {
387-
body: { flow: options.content } as unknown as Record<string, never>,
390+
body: body as unknown as Record<string, never>,
388391
parseAs: 'text',
389392
});
390393
if (error)

0 commit comments

Comments
 (0)