@faable/faable 4.18.1 → 4.18.3

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.
@@ -5,6 +5,16 @@ import { create_base_client } from './base_client.js';
5
5
  // method (a reset mid-flight means the request was not processed).
6
6
  const RESET_CODES = new Set(['ECONNRESET', 'EPIPE']);
7
7
  const RETRY_DELAY_MS = 300;
8
+ // The api's 401 codes that mean the credential itself is dead (api
9
+ // `lib/api_error.ts`). No code at all is an old api or the auth layer: treated
10
+ // as dead too, like before.
11
+ const CREDENTIAL_ERRORS = new Set([
12
+ 'unauthorized',
13
+ 'invalid_token',
14
+ 'invalid_apikey',
15
+ 'apikey_expired'
16
+ ]);
17
+ const is_credential_error = (code) => !code || CREDENTIAL_ERRORS.has(code);
8
18
  const is_connection_reset = (e) => e.isAxiosError &&
9
19
  !e.response &&
10
20
  (RESET_CODES.has(e.code ?? '') || e.message.includes('socket hang up'));
@@ -86,8 +96,14 @@ class FaableApi {
86
96
  const url = e.config?.url || '';
87
97
  if (res) {
88
98
  // Uniform handling for an expired/invalid session across every
89
- // command, regardless of which endpoint returned the 401.
90
- if (res.status === 401) {
99
+ // command, regardless of which endpoint returned the 401. Only
100
+ // for a dead credential: the api also answers 401 to a valid one
101
+ // that may not do this (`team_or_scope_required`,
102
+ // `secrets_access_denied`…), and telling that caller to log in
103
+ // again sent them in circles (backlog §374).
104
+ if (res.status === 401 &&
105
+ is_credential_error(res.data?.error_code ??
106
+ res.data?.code)) {
91
107
  // With an API key there is no session to renew: the key itself
92
108
  // is wrong or was revoked (the hosted MCP server runs on keys).
93
109
  const by_key = !!process.env.FAABLE_API_KEY && !process.env.FAABLE_TOKEN;
@@ -236,8 +252,14 @@ class FaableApi {
236
252
  async uploadDeploymentLogs(deployment_id, body) {
237
253
  return data(this.client.post(`/deployment/${deployment_id}/logs`, body, { timeout: 60_000, maxBodyLength: Infinity, maxContentLength: Infinity }));
238
254
  }
239
- async getAppSecrets(app_id) {
240
- return firstPage(data(this.client.get(`/secret/${app_id}`)));
255
+ // With the app's project as `x-faable-project`: a user token carries no
256
+ // project, and the api refuses secrets to a context without one
257
+ // (`secrets_access_denied`) — `secrets list` failed for every `faable login`
258
+ // user (backlog §374).
259
+ async getAppSecrets(app_id, team) {
260
+ return firstPage(data(this.client.get(`/secret/${app_id}`, {
261
+ headers: projectHeader(team)
262
+ })));
241
263
  }
242
264
  // Replace the app's whole secret set (the endpoint deletes and recreates).
243
265
  // This is the only mutation path that triggers an immediate restart of the
@@ -393,4 +415,4 @@ class FaableApi {
393
415
  }
394
416
  }
395
417
 
396
- export { FaableApi, projectHeader };
418
+ export { FaableApi, is_credential_error, projectHeader };
@@ -1,5 +1,6 @@
1
1
  import { requireApi } from '../../../api/context.js';
2
- import { fetch_page, print_json, print, more_hint, list_options } from '../../../lib/listing.js';
2
+ import { requireProject } from '../../../api/project.js';
3
+ import { print_json, print, more_hint, list_options, fetch_page } from '../../../lib/listing.js';
3
4
  import { log } from '../../../log.js';
4
5
  import { phase_badge } from './format.js';
5
6
 
@@ -10,8 +11,21 @@ const apps_list = {
10
11
  handler: async (args) => {
11
12
  const ctx = await requireApi();
12
13
  // Scoped to the active project when there is one (--project,
13
- // FAABLE_PROJECT, `faable project use`); every app you can see otherwise.
14
- const page = await fetch_page(p => ctx.api.listApps(p), args);
14
+ // FAABLE_PROJECT, `faable project use`); every app you can see otherwise —
15
+ // which the api only allows a token with a global scope. Anyone else gets
16
+ // `team_or_scope_required`: then, like `whoami`, the only project they
17
+ // belong to, or an error that says how to pick one (backlog §374).
18
+ const list = () => fetch_page(p => ctx.api.listApps(p), args);
19
+ let page;
20
+ try {
21
+ page = await list();
22
+ }
23
+ catch (e) {
24
+ if (ctx.api.project || e?.code !== 'team_or_scope_required')
25
+ throw e;
26
+ ctx.api.project = (await requireProject(ctx.api)).id;
27
+ page = await list();
28
+ }
15
29
  if (args.json)
16
30
  return print_json(page);
17
31
  if (page.data.length === 0) {
@@ -61,12 +61,16 @@ const logs = {
61
61
  const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
62
62
  const app = await ctx.api.getApp(app_id);
63
63
  if (args.build) {
64
- // Build output lives on the deployment. Default to the newest one —
65
- // exactly what you want after a red `faable deploy`.
64
+ // Build output lives on the deployment. Default to the newest one that
65
+ // was not cancelled — exactly what you want after a red `faable deploy`.
66
+ // A cancelled redeploy usually never built: picking it answered 404 to
67
+ // "why did my build fail?" (backlog §374). Only cancelled ones: the
68
+ // newest anyway.
66
69
  let deployment_id = args.deployment;
67
70
  if (!deployment_id) {
68
71
  const deployments = await ctx.api.listDeployments(app_id, app.team);
69
- deployment_id = deployments[0]?.id;
72
+ deployment_id = (deployments.find(d => d.status?.phase !== 'CANCELED') ??
73
+ deployments[0])?.id;
70
74
  if (!deployment_id) {
71
75
  if (args.json) {
72
76
  return print_json({
@@ -5,14 +5,34 @@ const POLL_INTERVAL_MS = 4000;
5
5
  // 30min, abandoned janitor 60min) resolve the deployment first in practice.
6
6
  const FOLLOW_TIMEOUT_MS = 45 * 60 * 1000;
7
7
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
8
+ /** The clone the platform created to retry this failed build, if any. */
9
+ const retry_of = (deployment) => {
10
+ const retry = deployment?.runtime_fallback_retry;
11
+ if (!retry?.deployment_id)
12
+ return null;
13
+ // The api stores the clone as bare hex (a `.lean()` ObjectId).
14
+ const id = retry.deployment_id.startsWith("deployment_")
15
+ ? retry.deployment_id
16
+ : `deployment_${retry.deployment_id}`;
17
+ return { id, to: retry.to };
18
+ };
8
19
  /**
9
20
  * Follow a remote build until the image handoff: poll the phase and tail the
10
21
  * build logs the builder uploads every ~10s (replace semantics → print the
11
22
  * suffix beyond what we already showed). Returns on INITIALIZING/READY
12
23
  * (image landed — the regular promotion poll takes over); throws on
13
24
  * BUILD_ERROR/ERROR with the server-provided reason.
25
+ *
26
+ * One exception to "BUILD_ERROR throws": when the platform already retried
27
+ * the build on the previous Python (`runtime_fallback_retry`, api §12), the
28
+ * failure is not the outcome — the clone's is. Follow the clone instead and
29
+ * return ITS id, so the promotion poll and `--json` speak about the deployment
30
+ * that will go live. Until 2026-10-18 the CLI exited red for a deploy that
31
+ * was about to succeed (backlog §379).
14
32
  */
15
- const follow_remote_build = async (api, deployment_id,
33
+ const follow_remote_build = async (api,
34
+ // `let`: a platform retry switches the follow to its clone.
35
+ initial_deployment_id,
16
36
  // Where the build log goes. stdout for a person; stderr when stdout carries
17
37
  // a JSON document (--json, or a program driving the CLI): one build line in
18
38
  // front of it and the caller can't parse the outcome — the MCP reported a
@@ -20,6 +40,8 @@ const follow_remote_build = async (api, deployment_id,
20
40
  out = process.stdout) => {
21
41
  let printed = 0;
22
42
  let last_phase = "";
43
+ let followed_retry = false;
44
+ let deployment_id = initial_deployment_id;
23
45
  const started = Date.now();
24
46
  const tail_logs = async () => {
25
47
  try {
@@ -49,6 +71,15 @@ out = process.stdout) => {
49
71
  }
50
72
  await tail_logs();
51
73
  if (phase === "BUILD_ERROR" || phase === "ERROR") {
74
+ const retry = retry_of(deployment);
75
+ if (retry && !followed_retry) {
76
+ followed_retry = true;
77
+ log.info(`↩️ The default Python failed with your dependencies — the platform is retrying on Python ${retry.to} · ${retry.id}`);
78
+ deployment_id = retry.id;
79
+ printed = 0;
80
+ last_phase = "";
81
+ continue;
82
+ }
52
83
  const reason = deployment?.status?.reason;
53
84
  throw new Error(`Remote build failed${reason ? `: ${reason}` : ""}`.trim());
54
85
  }
@@ -56,7 +87,7 @@ out = process.stdout) => {
56
87
  // already live (READY) — the promotion poll takes over from here.
57
88
  if (phase === "INITIALIZING" || phase === "READY") {
58
89
  await tail_logs();
59
- return;
90
+ return { id: deployment_id };
60
91
  }
61
92
  await wait(POLL_INTERVAL_MS);
62
93
  }
@@ -34,7 +34,12 @@ const deploy_remote = async (props) => {
34
34
  });
35
35
  props.on_deployment?.(deployment.id);
36
36
  log.info(`Preparing to build in faable cloud · ${deployment.id}`);
37
- await follow_remote_build(api, deployment.id, props.log_stream);
37
+ const landed = await follow_remote_build(api, deployment.id, props.log_stream);
38
+ // The platform retried the build on the previous Python (§12): what goes
39
+ // live is the clone, so the promotion poll must wait for IT.
40
+ if (landed.id !== deployment.id) {
41
+ return await api.getDeployment(landed.id);
42
+ }
38
43
  return deployment;
39
44
  };
40
45
 
@@ -25,7 +25,8 @@ const secrets_list = {
25
25
  handler: async (args) => {
26
26
  const ctx = await requireApi();
27
27
  const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
28
- const secrets = await ctx.api.getAppSecrets(app_id);
28
+ const app = await ctx.api.getApp(app_id);
29
+ const secrets = await ctx.api.getAppSecrets(app_id, app.team);
29
30
  // The whole set in one response (the api doesn't page secrets). Values
30
31
  // stay masked unless --show, in JSON as on screen.
31
32
  if (args.json) {
@@ -34,7 +34,7 @@ const secrets_rm = {
34
34
  // remaining set first — this also rejects unknown names and secrets
35
35
  // inherited from the team profile.
36
36
  const app = await ctx.api.getApp(app_id);
37
- const secrets = await ctx.api.getAppSecrets(app_id);
37
+ const secrets = await ctx.api.getAppSecrets(app_id, app.team);
38
38
  const remaining = remove_app_secret(secrets, args.name);
39
39
  const go = await confirm({
40
40
  message: `Remove secret "${args.name}" from ${app_id}?`,
@@ -111,7 +111,7 @@ const secrets_set = {
111
111
  // getApp also validates access to the app and provides the team the
112
112
  // batch endpoint requires as request context.
113
113
  const app = await ctx.api.getApp(app_id);
114
- const existing = await ctx.api.getAppSecrets(app_id);
114
+ const existing = await ctx.api.getAppSecrets(app_id, app.team);
115
115
  const merged = merge_app_secrets(existing, parsed);
116
116
  await ctx.api.createSecretsBatch(app.id, app.team, merged);
117
117
  const current = new Map(existing
package/dist/mcp/tools.js CHANGED
@@ -233,7 +233,7 @@ const TOOLS = [
233
233
  {
234
234
  name: 'get_build_logs',
235
235
  title: 'Read build logs',
236
- 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.',
236
+ 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 that was not cancelled.',
237
237
  input: {
238
238
  app,
239
239
  project,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.18.1",
3
+ "version": "4.18.3",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",