@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.
package/dist/api/FaableApi.js
CHANGED
|
@@ -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([
|
|
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 ??
|
|
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(
|
|
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(
|
|
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(
|
|
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 ||
|
|
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(
|
|
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: {
|
|
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(
|
|
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(
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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 ===
|
|
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 ===
|
|
148
|
-
superseded = dep.status?.reason ??
|
|
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 };
|