@faable/faable 1.38.5 → 1.39.1

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
  }
@@ -1,9 +1,11 @@
1
+ import prompts from 'prompts';
1
2
  import { requireApi } from '../../api/context.js';
2
3
  import { Configuration } from '../../lib/Configuration.js';
3
4
  import { log } from '../../log.js';
4
5
  import { link } from '../link/index.js';
5
6
  import { domains } from './domains/index.js';
6
7
  import { git_context } from './git_context.js';
8
+ import { cancel } from './inspect/cancel.js';
7
9
  import { deployments } from './inspect/deployments.js';
8
10
  import { inspect } from './inspect/inspect.js';
9
11
  import { apps_list } from './inspect/list.js';
@@ -37,6 +39,7 @@ const deploy = {
37
39
  .command(open_app)
38
40
  .command(trigger)
39
41
  .command(redeploy)
42
+ .command(cancel)
40
43
  .command(link)
41
44
  .positional('app_id', {
42
45
  type: 'string',
@@ -50,6 +53,12 @@ const deploy = {
50
53
  .option('release', {
51
54
  type: 'string',
52
55
  description: 'Release version to record on the deployment (injected as FAABLE_RELEASE). Defaults to FAABLE_RELEASE env or the latest git tag'
56
+ })
57
+ .option('yes', {
58
+ alias: 'y',
59
+ type: 'boolean',
60
+ default: false,
61
+ description: 'Skip the confirmation prompt (only asked in interactive terminals — CI is unaffected)'
53
62
  })
54
63
  .showHelpOnFail(false);
55
64
  },
@@ -62,6 +71,25 @@ const deploy = {
62
71
  const config = Configuration.instance().deployConfig();
63
72
  const app_id = await resolve_app_id(args.app_id, ctx.appId, api, workdir);
64
73
  const app = await api.getApp(app_id);
74
+ // Guard against the `faable deploy <app_id> <subcommand>` typo: yargs
75
+ // doesn't recognize an unmatched trailing token as the subcommand, so it
76
+ // silently falls through to THIS handler and deploys `workdir` instead.
77
+ // Only prompts in a real terminal — CI (non-TTY) keeps deploying
78
+ // unattended exactly as before, so existing pipelines need no changes.
79
+ if (!args.yes && process.stdout.isTTY) {
80
+ const { confirm } = await prompts({
81
+ type: 'toggle',
82
+ name: 'confirm',
83
+ message: `Deploy "${app.name}" (${app.id}) from ${workdir}?`,
84
+ initial: false,
85
+ active: 'yes',
86
+ inactive: 'no'
87
+ });
88
+ if (!confirm) {
89
+ log.info('Cancelled.');
90
+ return;
91
+ }
92
+ }
65
93
  // Monorepo Root Directory — single precedence rule everywhere
66
94
  // (arch/deploy/root-dir-faable-json.md): App.root_dir (platform
67
95
  // override, exceptional) > repo faable.json rootDir (the supported
@@ -138,14 +166,14 @@ const deploy = {
138
166
  // ~1min, long before this 5min promotion timeout.
139
167
  const dep = await api.getDeployment(deployment.id);
140
168
  const phase = dep.status?.phase;
141
- if (phase === "ERROR" || phase === "BUILD_ERROR") {
169
+ if (phase === 'ERROR' || phase === 'BUILD_ERROR') {
142
170
  failure = { phase, reason: dep.status?.reason };
143
171
  break;
144
172
  }
145
173
  // CANCELED: the platform superseded this build before it produced a
146
174
  // runnable (a sibling deployment of the app won the promotion).
147
- if (phase === "CANCELED") {
148
- superseded = dep.status?.reason ?? "canceled";
175
+ if (phase === 'CANCELED') {
176
+ superseded = dep.status?.reason ?? 'canceled';
149
177
  break;
150
178
  }
151
179
  }
@@ -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.5",
3
+ "version": "1.39.1",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",