@faable/faable 4.14.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/FaableApi.js +8 -2
- package/dist/commands/deploy/launch.js +12 -0
- package/dist/commands/deploy/reachable.js +52 -0
- package/dist/commands/deploy/waf/add_rule.js +15 -0
- package/dist/commands/deploy/waf/allow.js +58 -0
- package/dist/commands/deploy/waf/format.js +3 -1
- package/dist/commands/deploy/waf/index.js +3 -1
- package/dist/commands/deploy/waf/rm.js +1 -1
- package/dist/commands/deploy/wait.js +10 -1
- package/dist/mcp/tools.js +19 -0
- package/package.json +1 -1
package/dist/api/FaableApi.js
CHANGED
|
@@ -95,7 +95,9 @@ class FaableApi {
|
|
|
95
95
|
? 'This Faable API key is invalid or was revoked. Create a new one in the dashboard (project settings → API Keys).'
|
|
96
96
|
: 'Your Faable session has expired or is invalid. Run `faable login` to sign in again.', { cause: error });
|
|
97
97
|
expired.status = 401;
|
|
98
|
-
expired.code = by_key
|
|
98
|
+
expired.code = by_key
|
|
99
|
+
? 'apikey_invalid'
|
|
100
|
+
: 'session_expired';
|
|
99
101
|
throw expired;
|
|
100
102
|
}
|
|
101
103
|
const serverMessage = res.data?.message || res.statusText || 'Unknown Error';
|
|
@@ -157,7 +159,11 @@ class FaableApi {
|
|
|
157
159
|
async listProjects(params) {
|
|
158
160
|
const { q, user_id, ...page } = params;
|
|
159
161
|
return data(this.client.get(`/project`, {
|
|
160
|
-
params: {
|
|
162
|
+
params: {
|
|
163
|
+
...page_params(page),
|
|
164
|
+
...(q ? { q } : {}),
|
|
165
|
+
...(user_id ? { user_id } : {})
|
|
166
|
+
}
|
|
161
167
|
}));
|
|
162
168
|
}
|
|
163
169
|
async getProject(project_id) {
|
|
@@ -7,6 +7,7 @@ import { log } from '../../log.js';
|
|
|
7
7
|
import { requireProject } from '../../api/project.js';
|
|
8
8
|
import { git_context } from './git_context.js';
|
|
9
9
|
import { show_deploy_help } from './help.js';
|
|
10
|
+
import { reachable_timeout_ms, wait_reachable } from './reachable.js';
|
|
10
11
|
import { propose_release } from './release_version.js';
|
|
11
12
|
import { assert_name_free } from './apps/name_free.js';
|
|
12
13
|
import { deploy_remote } from './remote/index.js';
|
|
@@ -221,6 +222,13 @@ const launch = {
|
|
|
221
222
|
log.debug(`Polling app status failed, retrying...`);
|
|
222
223
|
}
|
|
223
224
|
}
|
|
225
|
+
// Promoted is not yet answering: wait for the edge to route the URL
|
|
226
|
+
// (F9). `undefined` when the check is off (FAABLE_REACHABLE_TIMEOUT_MS=0).
|
|
227
|
+
let reachable;
|
|
228
|
+
if (promoted && reachable_timeout_ms() > 0) {
|
|
229
|
+
log.info(`⏳ Live — waiting for https://${app.url} to answer...`);
|
|
230
|
+
reachable = await wait_reachable(`https://${app.url}`, reachable_timeout_ms());
|
|
231
|
+
}
|
|
224
232
|
if (args.json) {
|
|
225
233
|
const outcome = promoted
|
|
226
234
|
? 'live'
|
|
@@ -239,6 +247,7 @@ const launch = {
|
|
|
239
247
|
deployment_id: deployment.id,
|
|
240
248
|
outcome,
|
|
241
249
|
url: `https://${app.url}`,
|
|
250
|
+
...(reachable !== undefined ? { reachable } : {}),
|
|
242
251
|
...(failure
|
|
243
252
|
? {
|
|
244
253
|
phase: failure.phase,
|
|
@@ -262,6 +271,9 @@ const launch = {
|
|
|
262
271
|
}
|
|
263
272
|
if (promoted) {
|
|
264
273
|
log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
|
|
274
|
+
if (reachable === false) {
|
|
275
|
+
log.warn(`⚠️ The URL still answers "app not found" — the edge is picking up the route. Try again in a minute.`);
|
|
276
|
+
}
|
|
265
277
|
}
|
|
266
278
|
else if (superseded) {
|
|
267
279
|
// Not a failure: the app IS live, just on a sibling deployment (e.g.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// A promoted deployment is not yet a URL that answers: the edge picks up a
|
|
2
|
+
// new app's route a little later, and until then it says `APP_NOT_FOUND`
|
|
3
|
+
// (header `x-faable-error`, cacheable for 120 s). On 2026-10-16 a deploy
|
|
4
|
+
// reported `outcome: live` and the URL gave 404 for about a minute — an agent
|
|
5
|
+
// checking it right away would conclude the deploy was broken
|
|
6
|
+
// (arch/deploy/cli-mcp-fallos-2026-10-16-plan.md, F9).
|
|
7
|
+
//
|
|
8
|
+
// Only the edge's own "no app here" counts as not served: an app is free to
|
|
9
|
+
// answer 404 (or anything) on `/`, and that is its answer, not ours.
|
|
10
|
+
const NOT_FOUND = 'APP_NOT_FOUND';
|
|
11
|
+
// FAABLE_REACHABLE_TIMEOUT_MS: how long to wait for the edge (default 120 s);
|
|
12
|
+
// `0` skips the check — a stub API in tests, or a caller that only wants the
|
|
13
|
+
// deployment's phase.
|
|
14
|
+
const reachable_timeout_ms = (env = process.env) => {
|
|
15
|
+
const raw = env.FAABLE_REACHABLE_TIMEOUT_MS;
|
|
16
|
+
const n = raw === undefined || raw === '' ? 120_000 : Number(raw);
|
|
17
|
+
return Number.isFinite(n) && n >= 0 ? n : 120_000;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Wait until `url` is served by the app rather than the edge's "no app here".
|
|
21
|
+
* True as soon as it is; false when `timeout_ms` runs out (the deploy is
|
|
22
|
+
* still live — the route is just late). Never throws: a failed request is one
|
|
23
|
+
* more "not yet".
|
|
24
|
+
*/
|
|
25
|
+
const wait_reachable = async (url, timeout_ms, deps = {}) => {
|
|
26
|
+
const fetch_ = deps.fetch ?? fetch;
|
|
27
|
+
const sleep = deps.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms)));
|
|
28
|
+
const now = deps.now ?? Date.now;
|
|
29
|
+
const deadline = now() + timeout_ms;
|
|
30
|
+
for (;;) {
|
|
31
|
+
try {
|
|
32
|
+
// The not-found answer is cacheable: bust it, or we would read our own
|
|
33
|
+
// first answer back for two minutes.
|
|
34
|
+
const probe = `${url}${url.includes('?') ? '&' : '?'}_faable_probe=${now()}`;
|
|
35
|
+
const res = await fetch_(probe, {
|
|
36
|
+
method: 'GET',
|
|
37
|
+
redirect: 'manual',
|
|
38
|
+
headers: { 'cache-control': 'no-cache' }
|
|
39
|
+
});
|
|
40
|
+
if (res.headers.get('x-faable-error') !== NOT_FOUND)
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// not reachable yet
|
|
45
|
+
}
|
|
46
|
+
if (now() >= deadline)
|
|
47
|
+
return false;
|
|
48
|
+
await sleep(3_000);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export { reachable_timeout_ms, wait_reachable };
|
|
@@ -62,6 +62,21 @@ const add_rule = async (opts) => {
|
|
|
62
62
|
}
|
|
63
63
|
if (json)
|
|
64
64
|
return waf;
|
|
65
|
+
if (action === 'allow') {
|
|
66
|
+
// The one rule that opens instead of closing: say exactly what it opens.
|
|
67
|
+
log.info(`🔓 ${describe_selector(want)} → reaches your app`);
|
|
68
|
+
log.info(` for ${app_name} (${app_id}), even where Faable blocks it for every app`);
|
|
69
|
+
log.info(` (archives like .zip or .tar.gz, dotfiles, config files).`);
|
|
70
|
+
log.info(``);
|
|
71
|
+
log.info(`Your own block and sink rules still win over it, and certificate checks,`);
|
|
72
|
+
log.info(`/.faable/* and /_next/* are never affected.`);
|
|
73
|
+
log.info(``);
|
|
74
|
+
log.info(`It takes about 20s to reach the edge. Then check it with:`);
|
|
75
|
+
log.info(` curl -s -o /dev/null -w '%{http_code}\\n' https://${app_url}<path>`);
|
|
76
|
+
log.info(``);
|
|
77
|
+
log.info(`Undo: faable deploy waf rm ${undo_args(want)} --action allow -a ${app_id}`);
|
|
78
|
+
return waf;
|
|
79
|
+
}
|
|
65
80
|
const answer = action === 'deny' ? '403' : '404';
|
|
66
81
|
log.info(`🛡️ ${describe_selector(want)} → ${answer} at the edge`);
|
|
67
82
|
log.info(` for ${app_name} (${app_id}).`);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { print_json, json_option } from '../../../lib/listing.js';
|
|
3
|
+
import { resolve_app_id } from '../resolve_app_id.js';
|
|
4
|
+
import { add_rule } from './add_rule.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one WAF rule that opens instead of closing: paths of THIS app that Faable
|
|
8
|
+
* blocks for every app (archives, dotfiles, config files) and that this app
|
|
9
|
+
* really serves — a `.zip` download, say. Path only: a user-agent or query
|
|
10
|
+
* exception could be claimed by anyone sending that header.
|
|
11
|
+
*/
|
|
12
|
+
const waf_allow = {
|
|
13
|
+
command: 'allow <pattern>',
|
|
14
|
+
describe: 'Let a path Faable blocks for every app (e.g. *.zip) reach your app',
|
|
15
|
+
builder: yargs => json_option(yargs)
|
|
16
|
+
.positional('pattern', {
|
|
17
|
+
type: 'string',
|
|
18
|
+
demandOption: true,
|
|
19
|
+
description: 'Anchored path regex, e.g. ^/downloads/.*\\.zip$ (quote it in your shell)'
|
|
20
|
+
})
|
|
21
|
+
.option('app', {
|
|
22
|
+
alias: 'a',
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
25
|
+
})
|
|
26
|
+
.option('description', {
|
|
27
|
+
alias: 'd',
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Why this rule exists (shown in `waf list`)'
|
|
30
|
+
})
|
|
31
|
+
.example("$0 deploy waf allow '^/downloads/.*\\.zip$'", 'Serve the .zip files under /downloads that Faable would otherwise refuse')
|
|
32
|
+
.epilogue('Faable refuses some paths for every app before they reach it — ' +
|
|
33
|
+
'archives like .zip or .tar.gz, dotfiles, config files — because ' +
|
|
34
|
+
'scanners request them by the thousand. If your app really serves ' +
|
|
35
|
+
'one, allow exactly that path. Keep the pattern narrow: your own ' +
|
|
36
|
+
'block and sink rules still win over it, and it can never cover ' +
|
|
37
|
+
'/.well-known/acme-challenge, /.faable or /_next.')
|
|
38
|
+
.showHelpOnFail(false),
|
|
39
|
+
handler: async (args) => {
|
|
40
|
+
const ctx = await requireApi();
|
|
41
|
+
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
42
|
+
const app = await ctx.api.getApp(app_id);
|
|
43
|
+
const waf = await add_rule({
|
|
44
|
+
api: ctx.api,
|
|
45
|
+
app_id,
|
|
46
|
+
app_name: app.name,
|
|
47
|
+
app_url: app.url,
|
|
48
|
+
pattern: args.pattern,
|
|
49
|
+
action: 'allow',
|
|
50
|
+
description: args.description,
|
|
51
|
+
json: args.json
|
|
52
|
+
});
|
|
53
|
+
if (args.json)
|
|
54
|
+
print_json(waf);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export { waf_allow };
|
|
@@ -5,7 +5,9 @@ const action_label = (action) => action === 'sink'
|
|
|
5
5
|
? '403 (blocked at the edge)'
|
|
6
6
|
: action === 'probe'
|
|
7
7
|
? 'answered by Faable, app not woken (uptime monitors)'
|
|
8
|
-
: action
|
|
8
|
+
: action === 'allow'
|
|
9
|
+
? 'allowed (reaches your app where Faable blocks it for every app)'
|
|
10
|
+
: action;
|
|
9
11
|
/**
|
|
10
12
|
* Render the effective WAF of an app.
|
|
11
13
|
*
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { waf_allow } from './allow.js';
|
|
1
2
|
import { waf_block } from './block.js';
|
|
2
3
|
import { waf_list } from './list.js';
|
|
3
4
|
import { waf_rm } from './rm.js';
|
|
@@ -10,8 +11,9 @@ const waf = {
|
|
|
10
11
|
.command(waf_list)
|
|
11
12
|
.command(waf_block)
|
|
12
13
|
.command(waf_sink)
|
|
14
|
+
.command(waf_allow)
|
|
13
15
|
.command(waf_rm)
|
|
14
|
-
.demandCommand(1, 'Specify a waf command: list, block, sink or rm'),
|
|
16
|
+
.demandCommand(1, 'Specify a waf command: list, block, sink, allow or rm'),
|
|
15
17
|
handler: () => {
|
|
16
18
|
// Unreachable: demandCommand(1) either routes to a subcommand or fails
|
|
17
19
|
// through the global .fail() in src/index.ts.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { reachable_timeout_ms, wait_reachable } from './reachable.js';
|
|
2
|
+
|
|
1
3
|
// The phases a deployment does not leave on its own. READY is the good one.
|
|
2
4
|
const TERMINAL_PHASES = new Set([
|
|
3
5
|
'READY',
|
|
@@ -27,8 +29,15 @@ const wait_for_deployment = async (api, deployment_id, opts, deps = {}) => {
|
|
|
27
29
|
done: true,
|
|
28
30
|
ok: phase === 'READY'
|
|
29
31
|
};
|
|
30
|
-
if (phase === 'READY' && opts.app_url)
|
|
32
|
+
if (phase === 'READY' && opts.app_url) {
|
|
31
33
|
result.url = opts.app_url;
|
|
34
|
+
const check = deps.reachable ??
|
|
35
|
+
(reachable_timeout_ms() > 0
|
|
36
|
+
? (url) => wait_reachable(url, reachable_timeout_ms())
|
|
37
|
+
: undefined);
|
|
38
|
+
if (check)
|
|
39
|
+
result.reachable = await check(opts.app_url);
|
|
40
|
+
}
|
|
32
41
|
if (phase !== 'READY' && d?.status?.reason) {
|
|
33
42
|
result.reason = d.status.reason;
|
|
34
43
|
}
|
package/dist/mcp/tools.js
CHANGED
|
@@ -854,6 +854,25 @@ const TOOLS = [
|
|
|
854
854
|
},
|
|
855
855
|
example: { app: 'web', repo: 'acme/web', branch: 'main' }
|
|
856
856
|
},
|
|
857
|
+
{
|
|
858
|
+
name: 'delete_app',
|
|
859
|
+
title: 'Delete an app',
|
|
860
|
+
description: "Delete an app, as the dashboard's Delete app does: its deployments, domains and secrets go with it and its URL stops being served. It cannot be undone — confirm with the user which app (get_app) before calling it. Only on the local server with `faable mcp --writes`.",
|
|
861
|
+
input: { app, project },
|
|
862
|
+
command: ['deploy', 'apps', 'rm'],
|
|
863
|
+
flags: a => ['--yes', ...target(a)],
|
|
864
|
+
write: true,
|
|
865
|
+
// The hosted server promises its users nothing destructive (the consent
|
|
866
|
+
// screen and docs/cli.md say so): deleting stays with whoever runs the
|
|
867
|
+
// MCP on their own machine and passed --writes.
|
|
868
|
+
local_only: true,
|
|
869
|
+
annotations: {
|
|
870
|
+
readOnlyHint: false,
|
|
871
|
+
destructiveHint: true,
|
|
872
|
+
openWorldHint: true
|
|
873
|
+
},
|
|
874
|
+
example: { app: 'web' }
|
|
875
|
+
},
|
|
857
876
|
{
|
|
858
877
|
name: 'deploy_directory',
|
|
859
878
|
title: 'Deploy a local directory',
|