@faable/faable 4.2.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +47 -6
  2. package/dist/api/FaableApi.js +51 -1
  3. package/dist/api/auth.js +1 -5
  4. package/dist/api/auth_admin.js +6 -9
  5. package/dist/api/base_client.js +4 -1
  6. package/dist/api/client_id.js +22 -0
  7. package/dist/api/context.js +3 -5
  8. package/dist/api/session.js +2 -2
  9. package/dist/commands/auth/actions/rm.js +12 -15
  10. package/dist/commands/auth/clients/rm.js +17 -15
  11. package/dist/commands/auth/users/export.js +6 -12
  12. package/dist/commands/auth/users/import.js +5 -9
  13. package/dist/commands/auth/users/reinstate.js +10 -17
  14. package/dist/commands/auth/users/suspend.js +10 -17
  15. package/dist/commands/deploy/apps/create.js +132 -0
  16. package/dist/commands/deploy/apps/index.js +18 -2
  17. package/dist/commands/deploy/apps/set.js +70 -0
  18. package/dist/commands/deploy/domains/add.js +8 -2
  19. package/dist/commands/deploy/domains/check.js +8 -3
  20. package/dist/commands/deploy/domains/list.js +1 -1
  21. package/dist/commands/deploy/domains/rm.js +14 -17
  22. package/dist/commands/deploy/index.js +5 -0
  23. package/dist/commands/deploy/inspect/cancel.js +9 -3
  24. package/dist/commands/deploy/inspect/deployments.js +1 -1
  25. package/dist/commands/deploy/inspect/format.js +35 -1
  26. package/dist/commands/deploy/inspect/inspect.js +1 -1
  27. package/dist/commands/deploy/inspect/logs.js +47 -6
  28. package/dist/commands/deploy/inspect/open.js +1 -1
  29. package/dist/commands/deploy/inspect/redeploy.js +11 -3
  30. package/dist/commands/deploy/inspect/status.js +9 -3
  31. package/dist/commands/deploy/inspect/traffic.js +84 -0
  32. package/dist/commands/deploy/inspect/trigger.js +10 -4
  33. package/dist/commands/deploy/launch.js +26 -17
  34. package/dist/commands/deploy/project_usage.js +83 -0
  35. package/dist/commands/deploy/resolve_app_id.js +42 -4
  36. package/dist/commands/deploy/secrets/list.js +1 -1
  37. package/dist/commands/deploy/secrets/merge.js +4 -2
  38. package/dist/commands/deploy/secrets/rm.js +14 -17
  39. package/dist/commands/deploy/secrets/set.js +35 -9
  40. package/dist/commands/deploy/waf/add_rule.js +4 -1
  41. package/dist/commands/deploy/waf/block.js +8 -4
  42. package/dist/commands/deploy/waf/list.js +1 -1
  43. package/dist/commands/deploy/waf/rm.js +6 -3
  44. package/dist/commands/deploy/waf/sink.js +8 -4
  45. package/dist/commands/mcp/index.js +28 -0
  46. package/dist/commands/whoami/index.js +4 -6
  47. package/dist/index.js +34 -17
  48. package/dist/lib/errors.js +31 -0
  49. package/dist/lib/interactive.js +45 -0
  50. package/dist/lib/listing.js +5 -1
  51. package/dist/mcp/run_cli.js +90 -0
  52. package/dist/mcp/server.js +113 -0
  53. package/dist/mcp/tools.js +500 -0
  54. package/package.json +4 -2
@@ -0,0 +1,500 @@
1
+ import { z } from 'zod';
2
+
3
+ const project = z
4
+ .string()
5
+ .optional()
6
+ .describe('Project id, name or slug. Defaults to the active project (`faable project use`).');
7
+ const app = z
8
+ .string()
9
+ .describe('App id (app_…), name or slug. Names are looked up in the project.');
10
+ const cursor = z
11
+ .string()
12
+ .optional()
13
+ .describe('next_cursor from the previous page, to continue a listing');
14
+ const scope = (a) => (a.project ? ['--project', a.project] : []);
15
+ const target = (a) => ['--app', a.app, ...scope(a)];
16
+ const opt = (flag, value) => value === undefined || value === null || value === ''
17
+ ? []
18
+ : [flag, String(value)];
19
+ const READ = { readOnlyHint: true, openWorldHint: true };
20
+ // A value as a double-quoted .env value, escaping exactly what parse_env
21
+ // (src/commands/deploy/secrets/parse_env.ts) unescapes — no more: JSON's
22
+ // \b, \f or \uXXXX would arrive as literal text.
23
+ const env_quote = (value) => '"' +
24
+ value.replace(/[\\"\n\r\t]/g, c => c === '\n' ? '\\n' : c === '\r' ? '\\r' : c === '\t' ? '\\t' : '\\' + c) +
25
+ '"';
26
+ // A listing page, with each item projected.
27
+ const page = (pick) => (data) => ({
28
+ data: (data?.data ?? []).map(pick),
29
+ has_more: !!data?.has_more,
30
+ next_cursor: data?.next_cursor ?? null
31
+ });
32
+ const first_line = (text) => (text ? text.split('\n')[0] : undefined);
33
+ const deployment_row = (d) => ({
34
+ id: d.id,
35
+ phase: d.status?.phase,
36
+ reason: first_line(d.status?.reason),
37
+ commit: d.github_commit?.slice(0, 7),
38
+ message: first_line(d.github_commit_message),
39
+ ref: d.github_ref,
40
+ trigger: d.trigger,
41
+ release: d.release,
42
+ created_at: d.createdAt
43
+ });
44
+ const app_row = (a) => ({
45
+ id: a.id,
46
+ name: a.name,
47
+ slug: a.slug,
48
+ url: a.url ? `https://${a.url}` : undefined,
49
+ phase: a.status?.phase,
50
+ live_deployment: a.status?.deployment ?? null,
51
+ repository: a.repository || null,
52
+ branch: a.github_branch,
53
+ deploy_mode: a.deploy_trigger
54
+ });
55
+ const TOOLS = [
56
+ {
57
+ name: 'whoami',
58
+ title: 'Who is logged in',
59
+ description: 'The Faable account this server acts as, the active project and its Auth tenant. Use it first when unsure whether the user is logged in or which project is active.',
60
+ input: {},
61
+ command: ['whoami'],
62
+ flags: () => [],
63
+ annotations: READ,
64
+ example: {}
65
+ },
66
+ {
67
+ name: 'list_projects',
68
+ title: 'List projects',
69
+ description: "The user's Faable projects. Apps, domains and Auth tenants live in a project; pass its id as `project` to the other tools.",
70
+ input: {
71
+ query: z
72
+ .string()
73
+ .optional()
74
+ .describe('Search by name, slug or description'),
75
+ cursor
76
+ },
77
+ command: ['project', 'list'],
78
+ flags: a => [
79
+ '--limit',
80
+ '50',
81
+ ...opt('-q', a.query),
82
+ ...opt('--starting-after', a.cursor)
83
+ ],
84
+ shape: page((p) => ({ id: p.id, name: p.name, slug: p.slug })),
85
+ annotations: READ,
86
+ example: { query: 'web', cursor: 'abc' }
87
+ },
88
+ {
89
+ name: 'list_apps',
90
+ title: 'List apps',
91
+ description: 'The apps of a project, with their live phase, URL and linked repository. Start here to find the app the user means.',
92
+ input: { project, cursor },
93
+ command: ['deploy', 'apps', 'list'],
94
+ flags: a => [
95
+ '--limit',
96
+ '100',
97
+ ...scope(a),
98
+ ...opt('--starting-after', a.cursor)
99
+ ],
100
+ shape: page(app_row),
101
+ annotations: READ,
102
+ example: { project: 'p', cursor: 'abc' }
103
+ },
104
+ {
105
+ name: 'get_app',
106
+ title: 'Get an app',
107
+ description: "One app: phase, URL, repository, deploy branch and mode, detected stack, and its newest deployment (which may have failed while an older one keeps serving). Use it to answer 'is my app up?' or 'what is deployed?'.",
108
+ input: { app, project },
109
+ command: ['deploy', 'apps', 'get'],
110
+ flags: target,
111
+ untrusted: true,
112
+ shape: (a) => ({
113
+ ...app_row(a),
114
+ root_dir: a.root_dir ?? null,
115
+ detected: a.detected,
116
+ latest_deployment: a.latest_deployment
117
+ ? deployment_row(a.latest_deployment)
118
+ : null
119
+ }),
120
+ annotations: READ,
121
+ example: { app: 'web', project: 'p' }
122
+ },
123
+ {
124
+ name: 'list_deployments',
125
+ title: 'List deployments',
126
+ description: "Recent deployments of an app, newest first, with phase, commit and the first line of any failure reason. Use it to find the deployment behind 'my last deploy failed'.",
127
+ input: {
128
+ app,
129
+ project,
130
+ limit: z
131
+ .number()
132
+ .int()
133
+ .min(1)
134
+ .max(50)
135
+ .optional()
136
+ .describe('How many (default 10)'),
137
+ cursor
138
+ },
139
+ command: ['deploy', 'deployments'],
140
+ flags: a => [
141
+ ...target(a),
142
+ '--limit',
143
+ String(a.limit ?? 10),
144
+ ...opt('--starting-after', a.cursor)
145
+ ],
146
+ untrusted: true,
147
+ shape: page(deployment_row),
148
+ annotations: READ,
149
+ example: { app: 'web', limit: 5, cursor: 'abc' }
150
+ },
151
+ {
152
+ name: 'get_deployment',
153
+ title: 'Get a deployment',
154
+ description: 'Everything recorded about one deployment: phase, commit, detected stack, the runnable artifact and the FULL failure reason. Without `deployment`, the newest one of the app.',
155
+ input: {
156
+ app,
157
+ project,
158
+ deployment: z.string().optional().describe('Deployment id (deployment_…)')
159
+ },
160
+ command: ['deploy', 'inspect'],
161
+ flags: a => [...(a.deployment ? [a.deployment] : []), ...target(a)],
162
+ untrusted: true,
163
+ shape: (d) => ({
164
+ ...deployment_row(d),
165
+ reason: d.status?.reason,
166
+ app_id: d.app_id,
167
+ detected: d.detected,
168
+ redeploy_of: d.redeploy_of,
169
+ build_ms: d.build_ms,
170
+ artifact: d.artifact?.artifact
171
+ ? {
172
+ profile: d.artifact.artifact.profile,
173
+ runtime: d.artifact.artifact.runtime,
174
+ start_command: d.artifact.artifact.start_command,
175
+ size: d.artifact.artifact.size
176
+ }
177
+ : null
178
+ }),
179
+ annotations: READ,
180
+ example: { app: 'web', deployment: 'deployment_1' }
181
+ },
182
+ {
183
+ name: 'get_build_logs',
184
+ title: 'Read build logs',
185
+ description: 'The build output of a deployment — the first thing to read when a deploy failed and the user asks why. The cause is usually at the end. Without `deployment`, the newest one.',
186
+ input: {
187
+ app,
188
+ project,
189
+ deployment: z
190
+ .string()
191
+ .optional()
192
+ .describe('Deployment id (deployment_…)'),
193
+ tail: z
194
+ .number()
195
+ .int()
196
+ .min(1)
197
+ .max(2000)
198
+ .optional()
199
+ .describe('Last N lines (default 200)')
200
+ },
201
+ command: ['deploy', 'logs'],
202
+ flags: a => [
203
+ '--build',
204
+ ...opt('--deployment', a.deployment),
205
+ '--tail',
206
+ String(a.tail ?? 200),
207
+ ...target(a)
208
+ ],
209
+ untrusted: true,
210
+ annotations: READ,
211
+ example: { app: 'web', deployment: 'deployment_1', tail: 100 }
212
+ },
213
+ {
214
+ name: 'get_runtime_logs',
215
+ title: 'Read runtime logs',
216
+ description: 'What the running app printed (last 24 hours, up to 200 lines). Use it when the app is up but misbehaving, or crashes after starting. A deployment retired more than a day ago has none — read its build logs instead.',
217
+ input: {
218
+ app,
219
+ project,
220
+ deployment: z.string().optional().describe('Only this deployment'),
221
+ tail: z.number().int().min(1).max(200).optional().describe('Last N lines')
222
+ },
223
+ command: ['deploy', 'logs'],
224
+ flags: a => [
225
+ ...opt('--deployment', a.deployment),
226
+ ...opt('--tail', a.tail),
227
+ ...target(a)
228
+ ],
229
+ untrusted: true,
230
+ shape: (d) => d?.data ?? [],
231
+ annotations: READ,
232
+ example: { app: 'web', deployment: 'deployment_1', tail: 50 }
233
+ },
234
+ {
235
+ name: 'get_app_traffic',
236
+ title: 'Read app traffic',
237
+ description: 'Requests the app served: status codes, busiest paths and failing paths. Trails reality by up to 15 minutes.',
238
+ input: {
239
+ app,
240
+ project,
241
+ since: z
242
+ .string()
243
+ .regex(/^\d+[mhd]$/)
244
+ .optional()
245
+ .describe('How far back: 30m, 24h, 7d (default 24h)'),
246
+ deployment: z
247
+ .string()
248
+ .optional()
249
+ .describe('Only what this deployment served')
250
+ },
251
+ command: ['deploy', 'traffic'],
252
+ flags: a => [
253
+ ...opt('--since', a.since),
254
+ ...opt('--deployment', a.deployment),
255
+ ...target(a)
256
+ ],
257
+ untrusted: true,
258
+ shape: (t) => ({
259
+ range: t.range,
260
+ status_codes: t.status_codes,
261
+ top_endpoints: t.top_endpoints?.slice(0, 15),
262
+ error_endpoints: t.error_endpoints?.slice(0, 15),
263
+ degraded: t.degraded
264
+ }),
265
+ annotations: READ,
266
+ example: { app: 'web', since: '7d', deployment: 'deployment_1' }
267
+ },
268
+ {
269
+ name: 'get_usage',
270
+ title: 'Read project usage',
271
+ description: "This billing period's usage of a project: plan, apps, domains, deployments and egress.",
272
+ input: { project },
273
+ command: ['deploy', 'usage'],
274
+ flags: scope,
275
+ annotations: READ,
276
+ example: { project: 'p' }
277
+ },
278
+ {
279
+ name: 'get_quota',
280
+ title: 'Read deploy quota',
281
+ description: "Today's deploy allowance of a project and the builds held waiting for it. Use it when a deploy did not start: a held build is waiting, not failing.",
282
+ input: { project },
283
+ command: ['deploy', 'quota'],
284
+ flags: scope,
285
+ annotations: READ,
286
+ example: { project: 'p' }
287
+ },
288
+ {
289
+ name: 'list_domains',
290
+ title: 'List custom domains',
291
+ description: 'Custom domains of an app and whether their DNS is verified.',
292
+ input: { app, project },
293
+ command: ['deploy', 'domains', 'list'],
294
+ flags: target,
295
+ shape: page((d) => ({
296
+ id: d.id,
297
+ fqdn: d.fqdn,
298
+ verified: d.verified,
299
+ tls: d.tls,
300
+ dns_message: d.status?.dns_message
301
+ })),
302
+ annotations: READ,
303
+ example: { app: 'web' }
304
+ },
305
+ {
306
+ name: 'check_domain',
307
+ title: 'Check a domain',
308
+ description: 'DNS diagnostic of one custom domain: the CNAME it expects, what DNS answers today, and why it is not verified yet.',
309
+ input: {
310
+ app,
311
+ project,
312
+ fqdn: z.string().describe('The domain, e.g. www.example.com')
313
+ },
314
+ command: ['deploy', 'domains', 'check'],
315
+ flags: a => [a.fqdn, ...target(a)],
316
+ shape: (d) => ({
317
+ fqdn: d.fqdn,
318
+ verified: d.verified,
319
+ expected_cname: d.expected_cname,
320
+ observed: d.status?.dns_observed ?? [],
321
+ diagnostic: d.status?.dns_message,
322
+ checked_at: d.status?.dns_checked_at
323
+ }),
324
+ annotations: READ,
325
+ example: { app: 'web', fqdn: 'www.example.com' }
326
+ },
327
+ {
328
+ name: 'list_secrets',
329
+ title: 'List secret names',
330
+ description: 'The environment variables set on an app. Names only — values are masked and this tool can never reveal them. Use it to check whether a variable the app needs is set.',
331
+ input: { app, project },
332
+ command: ['deploy', 'secrets', 'list'],
333
+ // Never --show.
334
+ flags: target,
335
+ shape: page((s) => ({
336
+ name: s.name,
337
+ inherited_from_project: s.related_model === 'profile'
338
+ })),
339
+ annotations: READ,
340
+ example: { app: 'web' }
341
+ },
342
+ {
343
+ name: 'deploy_app',
344
+ title: 'Deploy an app',
345
+ description: "Build and deploy the latest commit of the app's deploy branch on Faable's servers — the same as a git push. Nothing is uploaded from this machine. Returns the deployment id; follow it with get_deployment and get_build_logs. Only for apps with push-to-deploy.",
346
+ input: { app, project },
347
+ command: ['deploy', 'trigger'],
348
+ flags: target,
349
+ annotations: {
350
+ readOnlyHint: false,
351
+ destructiveHint: false,
352
+ openWorldHint: true
353
+ },
354
+ example: { app: 'web' }
355
+ },
356
+ // ── Reversible writes: only with `faable mcp --writes` ────────────────────
357
+ {
358
+ name: 'create_app',
359
+ title: 'Create an app from a repository',
360
+ description: "Create an app from a GitHub repository in a project, link it and start its first deploy (the dashboard's Create & connect). Needs the Faable GitHub App installed on the repository; if the link fails, the new app is removed and the error says what to fix.",
361
+ input: {
362
+ repo: z.string().describe('GitHub repository, owner/repo'),
363
+ name: z
364
+ .string()
365
+ .optional()
366
+ .describe('App name (defaults to the repository name)'),
367
+ branch: z
368
+ .string()
369
+ .optional()
370
+ .describe('Branch to deploy (defaults to the repository default)'),
371
+ deploy: z
372
+ .boolean()
373
+ .optional()
374
+ .describe('Start the first deploy (default true)'),
375
+ project
376
+ },
377
+ command: ['deploy', 'apps', 'create'],
378
+ flags: a => [
379
+ '--repo',
380
+ a.repo,
381
+ ...opt('--name', a.name),
382
+ ...opt('--branch', a.branch),
383
+ ...(a.deploy === false ? ['--no-deploy'] : []),
384
+ ...scope(a)
385
+ ],
386
+ write: true,
387
+ annotations: {
388
+ readOnlyHint: false,
389
+ destructiveHint: false,
390
+ openWorldHint: true
391
+ },
392
+ example: { repo: 'acme/web', name: 'web', branch: 'main', deploy: false }
393
+ },
394
+ {
395
+ name: 'redeploy',
396
+ title: 'Retry a failed deployment',
397
+ description: 'Rebuild a failed deployment from the source it recorded. Without `deployment`, the newest failed one. Refused for code older than what production serves.',
398
+ input: { app, project, deployment: z.string().optional() },
399
+ command: ['deploy', 'redeploy'],
400
+ flags: a => [...(a.deployment ? [a.deployment] : []), ...target(a)],
401
+ write: true,
402
+ annotations: {
403
+ readOnlyHint: false,
404
+ destructiveHint: false,
405
+ openWorldHint: true
406
+ },
407
+ example: { app: 'web', deployment: 'deployment_1' }
408
+ },
409
+ {
410
+ name: 'cancel_deployment',
411
+ title: 'Cancel a build',
412
+ description: 'Stop a deployment that is still queued or building. Production keeps serving what it served. Without `deployment`, the one in flight.',
413
+ input: { app, project, deployment: z.string().optional() },
414
+ command: ['deploy', 'cancel'],
415
+ flags: a => [...(a.deployment ? [a.deployment] : []), ...target(a)],
416
+ write: true,
417
+ annotations: {
418
+ readOnlyHint: false,
419
+ destructiveHint: false,
420
+ idempotentHint: true,
421
+ openWorldHint: true
422
+ },
423
+ example: { app: 'web', deployment: 'deployment_1' }
424
+ },
425
+ {
426
+ name: 'set_secrets',
427
+ title: 'Set environment variables',
428
+ description: 'Add or update environment variables of an app. The app restarts to apply them when a value changed. Returns the names added, updated and unchanged — never the values.',
429
+ input: {
430
+ app,
431
+ project,
432
+ variables: z
433
+ .record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/), z.string())
434
+ .describe('NAME → value')
435
+ },
436
+ command: ['deploy', 'secrets', 'set'],
437
+ // The values go on stdin, as a .env document — never in argv.
438
+ flags: a => ['-f', '-', ...target(a)],
439
+ stdin: a => Object.entries(a.variables)
440
+ .map(([k, v]) => `${k}=${env_quote(v)}`)
441
+ .join('\n') + '\n',
442
+ write: true,
443
+ annotations: {
444
+ readOnlyHint: false,
445
+ destructiveHint: false,
446
+ idempotentHint: true,
447
+ openWorldHint: true
448
+ },
449
+ example: { app: 'web', variables: { API_URL: 'https://x' } }
450
+ },
451
+ {
452
+ name: 'add_domain',
453
+ title: 'Add a custom domain',
454
+ description: 'Attach a custom domain to an app. Returns the CNAME record the user must create at their DNS provider; Faable verifies it and issues the certificate on its own.',
455
+ input: { app, project, fqdn: z.string().describe('e.g. www.example.com') },
456
+ command: ['deploy', 'domains', 'add'],
457
+ flags: a => [a.fqdn, ...target(a)],
458
+ write: true,
459
+ annotations: {
460
+ readOnlyHint: false,
461
+ destructiveHint: false,
462
+ openWorldHint: true
463
+ },
464
+ example: { app: 'web', fqdn: 'www.example.com' }
465
+ },
466
+ {
467
+ name: 'configure_repo',
468
+ title: 'Configure how an app deploys',
469
+ description: 'Change the deploy branch, the monorepo root directory ("" clears it) or the deploy mode (push: every push; ci: once CI tags a release; workflow: the repo\'s own GitHub workflow). Applies to the next deploy.',
470
+ input: {
471
+ app,
472
+ project,
473
+ branch: z.string().optional(),
474
+ root_dir: z.string().optional(),
475
+ mode: z.enum(['push', 'ci', 'workflow']).optional()
476
+ },
477
+ command: ['deploy', 'apps', 'set'],
478
+ flags: a => [
479
+ ...opt('--branch', a.branch),
480
+ ...(a.root_dir !== undefined ? ['--root-dir', a.root_dir] : []),
481
+ ...opt('--mode', a.mode),
482
+ ...target(a)
483
+ ],
484
+ write: true,
485
+ shape: app_row,
486
+ annotations: {
487
+ readOnlyHint: false,
488
+ destructiveHint: false,
489
+ openWorldHint: true
490
+ },
491
+ example: {
492
+ app: 'web',
493
+ branch: 'release',
494
+ root_dir: 'apps/web',
495
+ mode: 'push'
496
+ }
497
+ }
498
+ ];
499
+
500
+ export { TOOLS, env_quote };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",
@@ -28,6 +28,7 @@
28
28
  "dependencies": {
29
29
  "@actions/core": "^3.0.0",
30
30
  "@faable/auth-sdk": "^2.7.29",
31
+ "@modelcontextprotocol/sdk": "^1.31.0",
31
32
  "axios": "^1.18.1",
32
33
  "fs-extra": "^11.3.2",
33
34
  "open": "^11.0.0",
@@ -37,7 +38,8 @@
37
38
  "promisify-child-process": "^4.1.2",
38
39
  "prompts": "^2.4.2",
39
40
  "tslib": "^2.8.1",
40
- "yargs": "^18.0.0"
41
+ "yargs": "^18.0.0",
42
+ "zod": "^4.6.5"
41
43
  },
42
44
  "devDependencies": {
43
45
  "@eslint/js": "^10.0.1",