@faable/faable 4.14.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.
@@ -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
@@ -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',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.14.0",
3
+ "version": "4.15.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",