@faable/faable 4.13.0 → 4.15.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/commands/deploy/domains/check.js +2 -1
- package/dist/commands/deploy/domains/format.js +13 -1
- package/dist/commands/deploy/launch.js +12 -0
- package/dist/commands/deploy/reachable.js +52 -0
- package/dist/commands/deploy/wait.js +10 -1
- package/dist/mcp/tools.js +20 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { requireApi } from '../../../api/context.js';
|
|
|
2
2
|
import { print_json, json_option } from '../../../lib/listing.js';
|
|
3
3
|
import { log } from '../../../log.js';
|
|
4
4
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
5
|
-
import { find_by_fqdn, acme_record, cname_target, dns_badge } from './format.js';
|
|
5
|
+
import { find_by_fqdn, acme_record, cname_target, dns_badge, tls_line } from './format.js';
|
|
6
6
|
|
|
7
7
|
const domains_check = {
|
|
8
8
|
command: 'check <fqdn>',
|
|
@@ -54,6 +54,7 @@ const domains_check = {
|
|
|
54
54
|
? '✅ in place: the certificate is issued without waiting for the CNAME'
|
|
55
55
|
: '(optional; for a domain live elsewhere, so the move has no TLS gap)'}`);
|
|
56
56
|
}
|
|
57
|
+
log.info(` Certificate: ${tls_line(domain)}`);
|
|
57
58
|
if (status?.dns_checked_at) {
|
|
58
59
|
log.info(` Last checked: ${status.dns_checked_at}`);
|
|
59
60
|
}
|
|
@@ -28,6 +28,18 @@ const acme_record = (domain) => ({
|
|
|
28
28
|
target: domain.status?.acme_expected?.[0] ?? `${domain.id}.acme.faable.com`,
|
|
29
29
|
ready: domain.status?.acme_state === 'ok'
|
|
30
30
|
});
|
|
31
|
+
// The certificate line of `domains check`: what the edge serves today.
|
|
32
|
+
const tls_line = (domain) => {
|
|
33
|
+
if (!domain.tls)
|
|
34
|
+
return 'off (tls: false)';
|
|
35
|
+
if (domain.status?.tls_state === 'ok') {
|
|
36
|
+
const until = domain.status.tls_expires_at?.slice(0, 10);
|
|
37
|
+
return `✅ served${until ? `, valid until ${until}` : ''}`;
|
|
38
|
+
}
|
|
39
|
+
return domain.status?.acme_state === 'ok' || domain.status?.dns_state === 'ok'
|
|
40
|
+
? '⏳ being issued — usually a couple of minutes'
|
|
41
|
+
: '⏳ issued once the CNAME (or the pre-validation record) is in place';
|
|
42
|
+
};
|
|
31
43
|
const find_by_fqdn = (domains, fqdn) => domains.find(d => d.fqdn.toLowerCase() === fqdn.toLowerCase());
|
|
32
44
|
|
|
33
|
-
export { acme_record, cname_target, dns_badge, find_by_fqdn };
|
|
45
|
+
export { acme_record, cname_target, dns_badge, find_by_fqdn, tls_line };
|
|
@@ -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 };
|
|
@@ -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
|
@@ -352,7 +352,7 @@ const TOOLS = [
|
|
|
352
352
|
{
|
|
353
353
|
name: 'check_domain',
|
|
354
354
|
title: 'Check a domain',
|
|
355
|
-
description: 'DNS diagnostic of one custom domain: the CNAME it expects, what DNS answers today, and why it is not verified yet. `prevalidation_cname.ready` says whether the optional `_acme-challenge` record is in place, so the certificate is issued without waiting for the CNAME.',
|
|
355
|
+
description: 'DNS diagnostic of one custom domain: the CNAME it expects, what DNS answers today, and why it is not verified yet. `prevalidation_cname.ready` says whether the optional `_acme-challenge` record is in place, so the certificate is issued without waiting for the CNAME; `status.tls_state` is `ok` once the edge serves a valid certificate for the domain (`status.tls_expires_at`).',
|
|
356
356
|
input: {
|
|
357
357
|
app,
|
|
358
358
|
project,
|
|
@@ -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',
|