@faable/faable 1.38.4 → 1.39.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.
@@ -3,11 +3,11 @@ import { create_base_client } from './base_client.js';
3
3
  // Socket-level failures where no response ever arrived: the connection died
4
4
  // before the server produced anything, so a single retry is safe for any
5
5
  // method (a reset mid-flight means the request was not processed).
6
- const RESET_CODES = new Set(["ECONNRESET", "EPIPE"]);
6
+ const RESET_CODES = new Set(['ECONNRESET', 'EPIPE']);
7
7
  const RETRY_DELAY_MS = 300;
8
8
  const is_connection_reset = (e) => e.isAxiosError &&
9
9
  !e.response &&
10
- (RESET_CODES.has(e.code ?? "") || e.message.includes("socket hang up"));
10
+ (RESET_CODES.has(e.code ?? '') || e.message.includes('socket hang up'));
11
11
  const firstPage = async (res) => {
12
12
  const items = (await res).results;
13
13
  return items;
@@ -58,28 +58,26 @@ class FaableApi {
58
58
  const config = e.config;
59
59
  if (config && !config._retried && is_connection_reset(e)) {
60
60
  config._retried = true;
61
- await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
61
+ await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
62
62
  return client.request(config);
63
63
  }
64
64
  throw error;
65
65
  });
66
- this.client.interceptors.response.use((response) => response, (error) => {
66
+ this.client.interceptors.response.use(response => response, error => {
67
67
  const e = error;
68
68
  if (e.isAxiosError) {
69
69
  const res = e.response;
70
- const url = e.config?.url || "";
70
+ const url = e.config?.url || '';
71
71
  if (res) {
72
72
  // Uniform handling for an expired/invalid session across every
73
73
  // command, regardless of which endpoint returned the 401.
74
74
  if (res.status === 401) {
75
- const expired = new Error("Your Faable session has expired or is invalid. Run `faable login` to sign in again.", { cause: error });
75
+ const expired = new Error('Your Faable session has expired or is invalid. Run `faable login` to sign in again.', { cause: error });
76
76
  expired.status = 401;
77
77
  throw expired;
78
78
  }
79
- const serverMessage = res.data?.message || res.statusText || "Unknown Error";
79
+ const serverMessage = res.data?.message || res.statusText || 'Unknown Error';
80
80
  const wrapped = new Error(`FaableApi ${url} ${res.status}: ${serverMessage}`, { cause: error });
81
- // Surface the structured error contract (e.g. the repository-link
82
- // flow returns { code, action }) so callers can branch on it.
83
81
  wrapped.status = res.status;
84
82
  if (res.data?.code)
85
83
  wrapped.code = res.data.code;
@@ -98,8 +96,8 @@ class FaableApi {
98
96
  return new FaableApi(config);
99
97
  }
100
98
  async list() {
101
- return allPages((next) => data(this.client.get(`/app`, {
102
- params: { pageSize: 200, ...(next ? { next } : {}) },
99
+ return allPages(next => data(this.client.get(`/app`, {
100
+ params: { pageSize: 200, ...(next ? { next } : {}) }
103
101
  })));
104
102
  }
105
103
  async getBySlug(slug) {
@@ -121,7 +119,7 @@ class FaableApi {
121
119
  async uploadMissing(app_id, files) {
122
120
  return data(this.client.post(`/upload/missing`, {
123
121
  app_id,
124
- files: files.map(({ path, sha, size }) => ({ path, sha, size })),
122
+ files: files.map(({ path, sha, size }) => ({ path, sha, size }))
125
123
  }, { timeout: 60_000 }));
126
124
  }
127
125
  // Remote builds: read the build output the builder attaches to the
@@ -139,7 +137,7 @@ class FaableApi {
139
137
  // deployment and materialize it.
140
138
  async completeDeployment(deployment_id, image) {
141
139
  return data(this.client.post(`/deployment/${deployment_id}`, {
142
- image,
140
+ image
143
141
  }));
144
142
  }
145
143
  // Fetch a deployment with its runtime status, so the deploy command can
@@ -169,7 +167,7 @@ class FaableApi {
169
167
  // context, which a CLI user token does not carry — pass the app's team
170
168
  // (from getApp) so it travels as the `x-faable-team` header.
171
169
  async createSecretsBatch(context_id, team, secrets) {
172
- return data(this.client.post(`/secret/createbatch`, { context_id, secrets }, { headers: { "x-faable-team": team } }));
170
+ return data(this.client.post(`/secret/createbatch`, { context_id, secrets }, { headers: { 'x-faable-team': team } }));
173
171
  }
174
172
  async updateApp(app_id, params) {
175
173
  return data(this.client.post(`/app/${app_id}`, params));
@@ -179,11 +177,11 @@ class FaableApi {
179
177
  }
180
178
  // Organizations/accounts where the Faable GitHub App is installed.
181
179
  async listGithubInstallations() {
182
- return data(this.client.get(`/github/installations`)).then((res) => res.installations);
180
+ return data(this.client.get(`/github/installations`)).then(res => res.installations);
183
181
  }
184
182
  // Top repositories for a single installation (org), optionally filtered.
185
183
  async listGithubRepos(installation_id, params = {}) {
186
- return data(this.client.get(`/github/installations/${installation_id}/repositories`, { params })).then((res) => res.repositories);
184
+ return data(this.client.get(`/github/installations/${installation_id}/repositories`, { params })).then(res => res.repositories);
187
185
  }
188
186
  async getMe() {
189
187
  return data(this.client.get(`/auth/me`));
@@ -198,7 +196,7 @@ class FaableApi {
198
196
  async listDeployments(app_id, team) {
199
197
  return firstPage(data(this.client.get(`/deployment`, {
200
198
  params: { app_id },
201
- headers: { "x-faable-team": team },
199
+ headers: { 'x-faable-team': team }
202
200
  })));
203
201
  }
204
202
  // Build and deploy the current head of the deploy branch server-side —
@@ -210,7 +208,14 @@ class FaableApi {
210
208
  // git ref). The API enforces the guards: failed phase only, never older
211
209
  // than what production serves.
212
210
  async redeployDeployment(deployment_id, team) {
213
- return data(this.client.post(`/deployment/${deployment_id}/redeploy`, undefined, { headers: { "x-faable-team": team } }));
211
+ return data(this.client.post(`/deployment/${deployment_id}/redeploy`, undefined, { headers: { 'x-faable-team': team } }));
212
+ }
213
+ // Give up on a build still in the pre-handoff window (QUEUED/BUILDING).
214
+ // A route of its own rather than a `{phase: 'CANCELED'}` status write:
215
+ // phase transitions belong to the controller, and the api only lets a user
216
+ // token touch the phases the CLI owns.
217
+ async cancelDeployment(deployment_id, team) {
218
+ return data(this.client.post(`/deployment/${deployment_id}/cancel`, undefined, { headers: { 'x-faable-team': team } }));
214
219
  }
215
220
  // Domains are team-scoped rows; a CLI user token carries no default team,
216
221
  // so every call pins the app's team via `x-faable-team` (same pattern as
@@ -218,22 +223,22 @@ class FaableApi {
218
223
  async listDomains(app_id, team) {
219
224
  return firstPage(data(this.client.get(`/domain`, {
220
225
  params: { app_id },
221
- headers: { "x-faable-team": team },
226
+ headers: { 'x-faable-team': team }
222
227
  })));
223
228
  }
224
229
  async createDomain(team, params) {
225
230
  return data(this.client.post(`/domain`, params, {
226
- headers: { "x-faable-team": team },
231
+ headers: { 'x-faable-team': team }
227
232
  }));
228
233
  }
229
234
  async getDomain(domain_id, team) {
230
235
  return data(this.client.get(`/domain/${domain_id}`, {
231
- headers: { "x-faable-team": team },
236
+ headers: { 'x-faable-team': team }
232
237
  }));
233
238
  }
234
239
  async deleteDomain(domain_id, team) {
235
240
  return data(this.client.delete(`/domain/${domain_id}`, {
236
- headers: { "x-faable-team": team },
241
+ headers: { 'x-faable-team': team }
237
242
  }));
238
243
  }
239
244
  }
@@ -44,7 +44,16 @@ const loadLiveCredentials = async (store = new CredentialsStore()) => {
44
44
  log.debug?.("Refreshed access token");
45
45
  return next;
46
46
  }
47
- catch {
47
+ catch (e) {
48
+ // The auth server marks a suspension with a stable machine code on the
49
+ // token endpoint's RFC 6749 error body. "Run `faable login` again"
50
+ // would be a lie here — login is denied too — so say what actually
51
+ // happened and stop instead of letting a generic 401 mislead.
52
+ const body = e?.response?.data;
53
+ if (body?.error_code === "user_suspended") {
54
+ log.error("❌ Your account has been suspended. Contact support@faable.com.");
55
+ process.exit(1);
56
+ }
48
57
  // Refresh failed — keep the stale config; the API call's 401 (or
49
58
  // requireApi) will tell the user to run `faable login` again.
50
59
  }
@@ -4,6 +4,7 @@ import { log } from '../../log.js';
4
4
  import { link } from '../link/index.js';
5
5
  import { domains } from './domains/index.js';
6
6
  import { git_context } from './git_context.js';
7
+ import { cancel } from './inspect/cancel.js';
7
8
  import { deployments } from './inspect/deployments.js';
8
9
  import { inspect } from './inspect/inspect.js';
9
10
  import { apps_list } from './inspect/list.js';
@@ -37,6 +38,7 @@ const deploy = {
37
38
  .command(open_app)
38
39
  .command(trigger)
39
40
  .command(redeploy)
41
+ .command(cancel)
40
42
  .command(link)
41
43
  .positional('app_id', {
42
44
  type: 'string',
@@ -138,14 +140,14 @@ const deploy = {
138
140
  // ~1min, long before this 5min promotion timeout.
139
141
  const dep = await api.getDeployment(deployment.id);
140
142
  const phase = dep.status?.phase;
141
- if (phase === "ERROR" || phase === "BUILD_ERROR") {
143
+ if (phase === 'ERROR' || phase === 'BUILD_ERROR') {
142
144
  failure = { phase, reason: dep.status?.reason };
143
145
  break;
144
146
  }
145
147
  // CANCELED: the platform superseded this build before it produced a
146
148
  // runnable (a sibling deployment of the app won the promotion).
147
- if (phase === "CANCELED") {
148
- superseded = dep.status?.reason ?? "canceled";
149
+ if (phase === 'CANCELED') {
150
+ superseded = dep.status?.reason ?? 'canceled';
149
151
  break;
150
152
  }
151
153
  }
@@ -0,0 +1,47 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+
5
+ // Mirrors CANCELABLE_PHASES in the api (deployment/api/cancel.ts). Used only
6
+ // to pick a default target; the api is the one that enforces it, so a phase
7
+ // that changed between the list and the call is still refused server-side.
8
+ const IN_FLIGHT_PHASES = new Set(['UNKNOWN', 'QUEUED', 'BUILDING']);
9
+ const cancel = {
10
+ command: 'cancel [deployment]',
11
+ describe: 'Stop a deployment that is still queued or building',
12
+ builder: yargs => yargs
13
+ .positional('deployment', {
14
+ type: 'string',
15
+ description: 'Deployment id to cancel (defaults to the one currently in flight)'
16
+ })
17
+ .option('app', {
18
+ alias: 'a',
19
+ type: 'string',
20
+ description: 'App Identifier (defaults to the linked app)'
21
+ })
22
+ .example('$0 deploy cancel', 'Stop the build that is running right now')
23
+ .showHelpOnFail(false),
24
+ handler: async (args) => {
25
+ const ctx = await requireApi();
26
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
27
+ const app = await ctx.api.getApp(app_id);
28
+ let deployment_id = args.deployment;
29
+ if (!deployment_id) {
30
+ const rows = await ctx.api.listDeployments(app_id, app.team);
31
+ const inFlight = rows.find(d => IN_FLIGHT_PHASES.has(d.status?.phase ?? ''));
32
+ if (!inFlight) {
33
+ log.info(`✅ Nothing building for ${app.name} — nothing to cancel.`);
34
+ return;
35
+ }
36
+ deployment_id = inFlight.id;
37
+ }
38
+ // The api owns the guards: it refuses once a controller has claimed the
39
+ // deployment, and answers a repeated cancel with the same 200 so a retry
40
+ // is never an error the user has to read.
41
+ const canceled = await ctx.api.cancelDeployment(deployment_id, app.team);
42
+ log.info(`🛑 Canceled ${canceled.id} (${app.name}).`);
43
+ log.info(`Production keeps serving the last promoted deployment. Deploy again with: faable deploy`);
44
+ }
45
+ };
46
+
47
+ export { cancel };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.38.4",
3
+ "version": "1.39.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",