@faable/faable 4.1.0 โ 4.3.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.
- package/README.md +30 -6
- package/dist/api/FaableApi.js +51 -1
- package/dist/api/auth.js +1 -5
- package/dist/api/auth_admin.js +6 -9
- package/dist/api/base_client.js +4 -1
- package/dist/api/client_id.js +22 -0
- package/dist/api/context.js +3 -5
- package/dist/api/session.js +2 -2
- package/dist/commands/auth/actions/rm.js +12 -15
- package/dist/commands/auth/clients/rm.js +17 -15
- package/dist/commands/auth/users/export.js +6 -12
- package/dist/commands/auth/users/import.js +5 -9
- package/dist/commands/auth/users/reinstate.js +10 -17
- package/dist/commands/auth/users/suspend.js +10 -17
- package/dist/commands/deploy/apps/create.js +132 -0
- package/dist/commands/deploy/apps/index.js +37 -0
- package/dist/commands/deploy/apps/set.js +70 -0
- package/dist/commands/deploy/domains/add.js +8 -2
- package/dist/commands/deploy/domains/check.js +8 -3
- package/dist/commands/deploy/domains/list.js +1 -1
- package/dist/commands/deploy/domains/rm.js +14 -17
- package/dist/commands/deploy/index.js +8 -2
- package/dist/commands/deploy/inspect/cancel.js +9 -3
- package/dist/commands/deploy/inspect/deployments.js +1 -1
- package/dist/commands/deploy/inspect/format.js +35 -1
- package/dist/commands/deploy/inspect/inspect.js +1 -1
- package/dist/commands/deploy/inspect/logs.js +47 -6
- package/dist/commands/deploy/inspect/open.js +1 -1
- package/dist/commands/deploy/inspect/redeploy.js +11 -3
- package/dist/commands/deploy/inspect/status.js +9 -3
- package/dist/commands/deploy/inspect/traffic.js +84 -0
- package/dist/commands/deploy/inspect/trigger.js +10 -4
- package/dist/commands/deploy/launch.js +26 -17
- package/dist/commands/deploy/project_usage.js +83 -0
- package/dist/commands/deploy/resolve_app_id.js +42 -4
- package/dist/commands/deploy/secrets/list.js +1 -1
- package/dist/commands/deploy/secrets/merge.js +4 -2
- package/dist/commands/deploy/secrets/rm.js +14 -17
- package/dist/commands/deploy/secrets/set.js +35 -9
- package/dist/commands/deploy/waf/add_rule.js +4 -1
- package/dist/commands/deploy/waf/block.js +8 -4
- package/dist/commands/deploy/waf/list.js +1 -1
- package/dist/commands/deploy/waf/rm.js +6 -3
- package/dist/commands/deploy/waf/sink.js +8 -4
- package/dist/commands/whoami/index.js +4 -6
- package/dist/index.js +32 -17
- package/dist/lib/errors.js +31 -0
- package/dist/lib/interactive.js +45 -0
- package/dist/lib/listing.js +5 -1
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { print_noop, print_json, json_option } from '../../../lib/listing.js';
|
|
2
3
|
import { log } from '../../../log.js';
|
|
3
4
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
4
5
|
|
|
@@ -6,7 +7,7 @@ const FAILED_PHASES = new Set(['ERROR', 'BUILD_ERROR']);
|
|
|
6
7
|
const redeploy = {
|
|
7
8
|
command: 'redeploy [deployment]',
|
|
8
9
|
describe: 'Rebuild a failed deployment from its recorded source',
|
|
9
|
-
builder: yargs => yargs
|
|
10
|
+
builder: yargs => json_option(yargs)
|
|
10
11
|
.positional('deployment', {
|
|
11
12
|
type: 'string',
|
|
12
13
|
description: 'Deployment id to rebuild (defaults to the latest failed one)'
|
|
@@ -14,7 +15,7 @@ const redeploy = {
|
|
|
14
15
|
.option('app', {
|
|
15
16
|
alias: 'a',
|
|
16
17
|
type: 'string',
|
|
17
|
-
description: 'App
|
|
18
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
18
19
|
})
|
|
19
20
|
.example('$0 deploy redeploy', 'Retry the latest failed deployment')
|
|
20
21
|
.showHelpOnFail(false),
|
|
@@ -27,6 +28,9 @@ const redeploy = {
|
|
|
27
28
|
const rows = await ctx.api.listDeployments(app_id, app.team);
|
|
28
29
|
const failed = rows.find(d => FAILED_PHASES.has(d.status?.phase ?? ''));
|
|
29
30
|
if (!failed) {
|
|
31
|
+
if (args.json) {
|
|
32
|
+
return print_noop(`${app.name} has no failed deployment to retry`);
|
|
33
|
+
}
|
|
30
34
|
log.info(`โ
No failed deployments to retry for ${app.name}. To rebuild the repo HEAD use: faable deploy trigger`);
|
|
31
35
|
return;
|
|
32
36
|
}
|
|
@@ -35,8 +39,12 @@ const redeploy = {
|
|
|
35
39
|
// The API enforces the guards (failed phase only, never older than what
|
|
36
40
|
// production serves) and answers with an actionable refusal otherwise.
|
|
37
41
|
const clone = await ctx.api.redeployDeployment(deployment_id, app.team);
|
|
42
|
+
if (args.json)
|
|
43
|
+
return print_json(clone);
|
|
38
44
|
log.info(`๐ Rebuilding ${deployment_id} as ${clone.id}.`);
|
|
39
|
-
|
|
45
|
+
// With -a: these lines get copied, and outside the app's checkout a
|
|
46
|
+
// bare command resolves no app (see next_steps in detail.ts).
|
|
47
|
+
log.info(`Track it with: faable deploy status -a ${app.id} ยท build output: faable deploy logs --build -d ${clone.id} -a ${app.id}`);
|
|
40
48
|
}
|
|
41
49
|
};
|
|
42
50
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { print_json, json_option } from '../../../lib/listing.js';
|
|
2
3
|
import { log } from '../../../log.js';
|
|
3
4
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
4
5
|
import { phase_badge, detected_summary, short_commit, when } from './format.js';
|
|
@@ -6,11 +7,11 @@ import { phase_badge, detected_summary, short_commit, when } from './format.js';
|
|
|
6
7
|
const status = {
|
|
7
8
|
command: 'status',
|
|
8
9
|
describe: 'Show what is live for the app',
|
|
9
|
-
builder: yargs => yargs
|
|
10
|
+
builder: yargs => json_option(yargs)
|
|
10
11
|
.option('app', {
|
|
11
12
|
alias: 'a',
|
|
12
13
|
type: 'string',
|
|
13
|
-
description: 'App
|
|
14
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
14
15
|
})
|
|
15
16
|
.showHelpOnFail(false),
|
|
16
17
|
handler: async (args) => {
|
|
@@ -19,6 +20,11 @@ const status = {
|
|
|
19
20
|
const app = await ctx.api.getApp(app_id);
|
|
20
21
|
const deployments = await ctx.api.listDeployments(app_id, app.team);
|
|
21
22
|
const latest = deployments[0];
|
|
23
|
+
// The app record plus its newest deployment โ which may not be the live
|
|
24
|
+
// one (status.deployment): a failed build leaves production as it was.
|
|
25
|
+
if (args.json) {
|
|
26
|
+
return print_json({ ...app, latest_deployment: latest ?? null });
|
|
27
|
+
}
|
|
22
28
|
log.info(`${phase_badge(app.status?.phase)} ${app.name} (${app.id})`);
|
|
23
29
|
log.info(` URL: https://${app.url}`);
|
|
24
30
|
const stack = detected_summary(app.detected);
|
|
@@ -34,7 +40,7 @@ const status = {
|
|
|
34
40
|
log.info(` Latest: ${latest.id} โ ${phase_badge(latest.status?.phase)} (${short_commit(latest.github_commit)}, ${when(latest.createdAt)})`);
|
|
35
41
|
if (latest.status?.reason) {
|
|
36
42
|
log.info(` Reason: ${latest.status.reason.split('\n')[0]}`);
|
|
37
|
-
log.info(` Full error: faable deploy logs --build`);
|
|
43
|
+
log.info(` Full error: faable deploy logs --build -d ${latest.id} -a ${app.id}`);
|
|
38
44
|
}
|
|
39
45
|
}
|
|
40
46
|
if (deployments.length === 0) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { CliError } from '../../../lib/errors.js';
|
|
3
|
+
import { print_json, print, table_lines, json_option } from '../../../lib/listing.js';
|
|
4
|
+
import { log } from '../../../log.js';
|
|
5
|
+
import { resolve_app_id } from '../resolve_app_id.js';
|
|
6
|
+
|
|
7
|
+
const UNITS = { m: 60, h: 3600, d: 86400 };
|
|
8
|
+
// "24h", "7d", "30m" โ seconds. The api keeps about a month of edge logs.
|
|
9
|
+
const parse_since = (since) => {
|
|
10
|
+
const m = /^(\d+)([mhd])$/.exec(since.trim());
|
|
11
|
+
if (!m || Number(m[1]) < 1) {
|
|
12
|
+
throw new CliError('usage', `--since takes a duration like 30m, 24h or 7d (got "${since}")`);
|
|
13
|
+
}
|
|
14
|
+
return Number(m[1]) * UNITS[m[2]];
|
|
15
|
+
};
|
|
16
|
+
// `faable deploy traffic` โ what the edge saw for the app: busiest paths,
|
|
17
|
+
// failing paths and status codes. Trails reality by up to 15 minutes.
|
|
18
|
+
const traffic = {
|
|
19
|
+
command: 'traffic',
|
|
20
|
+
describe: 'Requests the app served: top paths, errors and status codes',
|
|
21
|
+
builder: yargs => json_option(yargs)
|
|
22
|
+
.option('app', {
|
|
23
|
+
alias: 'a',
|
|
24
|
+
type: 'string',
|
|
25
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
26
|
+
})
|
|
27
|
+
.option('since', {
|
|
28
|
+
type: 'string',
|
|
29
|
+
default: '24h',
|
|
30
|
+
description: 'How far back: 30m, 24h, 7dโฆ'
|
|
31
|
+
})
|
|
32
|
+
.option('deployment', {
|
|
33
|
+
alias: 'd',
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Only the traffic one deployment served'
|
|
36
|
+
})
|
|
37
|
+
.example('$0 deploy traffic', 'The last 24h of the linked app')
|
|
38
|
+
.example('$0 deploy traffic --since 7d -a my-app', 'A week of another app')
|
|
39
|
+
.showHelpOnFail(false),
|
|
40
|
+
handler: async (args) => {
|
|
41
|
+
const seconds = parse_since(args.since ?? '24h');
|
|
42
|
+
const ctx = await requireApi();
|
|
43
|
+
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
44
|
+
const app = await ctx.api.getApp(app_id);
|
|
45
|
+
const to = Math.floor(Date.now() / 1000);
|
|
46
|
+
const data = (await ctx.api.getAppTraffic(app_id, app.team, {
|
|
47
|
+
from: to - seconds,
|
|
48
|
+
to,
|
|
49
|
+
...(args.deployment ? { deployment_id: args.deployment } : {})
|
|
50
|
+
}));
|
|
51
|
+
if (args.json)
|
|
52
|
+
return print_json({ app_id, ...data });
|
|
53
|
+
const total = (data.status_codes ?? []).reduce((n, s) => n + s.requests, 0);
|
|
54
|
+
log.info(`๐ ${app.name} โ last ${args.since}: ${total} request(s)`);
|
|
55
|
+
if (data.degraded?.length) {
|
|
56
|
+
log.warn(`โ ๏ธ Partial: ${data.degraded.join(', ')} could not be read โ those are missing, not zero.`);
|
|
57
|
+
}
|
|
58
|
+
if (total === 0)
|
|
59
|
+
return;
|
|
60
|
+
print('');
|
|
61
|
+
print('Status codes');
|
|
62
|
+
for (const line of table_lines([' STATUS', 'REQUESTS'], (data.status_codes ?? []).map(s => [` ${s.status}`, String(s.requests)]))) {
|
|
63
|
+
print(line);
|
|
64
|
+
}
|
|
65
|
+
if (data.top_endpoints?.length) {
|
|
66
|
+
print('');
|
|
67
|
+
print('Top paths');
|
|
68
|
+
for (const line of table_lines([' REQUESTS', 'PATH'], data.top_endpoints.slice(0, 10).map(e => [` ${e.requests}`, e.path]))) {
|
|
69
|
+
print(line);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (data.error_endpoints?.length) {
|
|
73
|
+
print('');
|
|
74
|
+
print('Failing paths');
|
|
75
|
+
for (const line of table_lines([' ERRORS', 'STATUS', 'PATH'], data.error_endpoints
|
|
76
|
+
.slice(0, 10)
|
|
77
|
+
.map(e => [` ${e.errors}`, String(e.status), e.path]))) {
|
|
78
|
+
print(line);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export { parse_since, traffic };
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { print_json, json_option } from '../../../lib/listing.js';
|
|
2
3
|
import { log } from '../../../log.js';
|
|
3
4
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
4
5
|
|
|
5
6
|
const trigger = {
|
|
6
7
|
command: 'trigger',
|
|
7
8
|
describe: 'Build and deploy the latest commit of the deploy branch, server-side',
|
|
8
|
-
builder: yargs => yargs
|
|
9
|
+
builder: yargs => json_option(yargs)
|
|
9
10
|
.option('app', {
|
|
10
11
|
alias: 'a',
|
|
11
12
|
type: 'string',
|
|
12
|
-
description: 'App
|
|
13
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
13
14
|
})
|
|
14
15
|
.example('$0 deploy trigger', 'Deploy the repo HEAD without uploading anything from this machine')
|
|
15
16
|
.showHelpOnFail(false),
|
|
@@ -20,8 +21,13 @@ const trigger = {
|
|
|
20
21
|
// Takes the exact same path a git push would (same-commit dedupe
|
|
21
22
|
// included) โ the API answers with an actionable refusal otherwise.
|
|
22
23
|
const result = await ctx.api.deployNow(app_id);
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
if (args.json)
|
|
25
|
+
return print_json({ app_id, ...result });
|
|
26
|
+
log.info(`๐ Building ${result.commit.slice(0, 7)} (${result.branch}) of ${app.name} server-side${result.deployment_id ? ` as ${result.deployment_id}` : ''}.`);
|
|
27
|
+
if (result.deployment_id) {
|
|
28
|
+
log.info(`Follow the build: faable deploy logs --build -d ${result.deployment_id} -a ${app.id} --follow`);
|
|
29
|
+
}
|
|
30
|
+
log.info(`Track it with: faable deploy status -a ${app.id} ยท or in the dashboard: https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`);
|
|
25
31
|
}
|
|
26
32
|
};
|
|
27
33
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import prompts from 'prompts';
|
|
2
1
|
import { requireApi } from '../../api/context.js';
|
|
3
2
|
import { Configuration } from '../../lib/Configuration.js';
|
|
3
|
+
import { CliError } from '../../lib/errors.js';
|
|
4
|
+
import { is_non_interactive, confirm } from '../../lib/interactive.js';
|
|
4
5
|
import { log } from '../../log.js';
|
|
5
6
|
import { git_context } from './git_context.js';
|
|
6
7
|
import { show_deploy_help } from './help.js';
|
|
@@ -21,7 +22,7 @@ const launch = {
|
|
|
21
22
|
.option('app', {
|
|
22
23
|
alias: 'a',
|
|
23
24
|
type: 'string',
|
|
24
|
-
description: 'App
|
|
25
|
+
description: 'App id, name or slug (defaults to the app linked to this repository)'
|
|
25
26
|
})
|
|
26
27
|
.option('workdir', {
|
|
27
28
|
alias: 'w',
|
|
@@ -42,10 +43,25 @@ const launch = {
|
|
|
42
43
|
.example('$0 deploy --app app_a1b2c3', 'Deploy another app (monorepo: several apps, one repo)')
|
|
43
44
|
.showHelpOnFail(false),
|
|
44
45
|
handler: async (args) => {
|
|
46
|
+
// Driven by a program: nothing about the target is left to the process'
|
|
47
|
+
// working directory, and the go-ahead is explicit (see lib/interactive).
|
|
48
|
+
if (is_non_interactive()) {
|
|
49
|
+
const missing = [
|
|
50
|
+
!args.app && '--app',
|
|
51
|
+
!args.workdir && '--workdir',
|
|
52
|
+
!args.yes && '--yes'
|
|
53
|
+
].filter(Boolean);
|
|
54
|
+
if (missing.length) {
|
|
55
|
+
throw new CliError(missing.includes('--yes') && missing.length === 1
|
|
56
|
+
? 'confirmation_required'
|
|
57
|
+
: 'usage', `In non-interactive mode a deploy needs ${missing.join(', ')}: the app, the directory to upload and the go-ahead are never inferred.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
45
60
|
const workdir = args.workdir || process.cwd();
|
|
46
61
|
// Pass the explicit app target to the OIDC exchange so a monorepo (several
|
|
47
62
|
// apps, one repo) can be disambiguated in CI.
|
|
48
|
-
|
|
63
|
+
// Only an id: the OIDC exchange in CI takes an app id, not a name.
|
|
64
|
+
const ctx = await requireApi(args.app?.startsWith('app_') ? args.app : undefined);
|
|
49
65
|
const { api } = ctx;
|
|
50
66
|
const config = Configuration.instance().deployConfig();
|
|
51
67
|
const app_id = await find_app_id(args.app, ctx.appId, api, workdir);
|
|
@@ -55,25 +71,18 @@ const launch = {
|
|
|
55
71
|
// answering a bare `faable deploy` with an error and nothing else.
|
|
56
72
|
if (!app_id) {
|
|
57
73
|
show_deploy_help();
|
|
58
|
-
|
|
59
|
-
process.exit(1);
|
|
74
|
+
throw new CliError('app_required', `No app linked to ${workdir}. Connect a repository from the dashboard (or run "faable deploy link"), or target one with --app <app_id>.`);
|
|
60
75
|
}
|
|
61
76
|
const app = await api.getApp(app_id);
|
|
62
77
|
// Confirm which app/dir is about to go out. Only in a real terminal โ CI
|
|
63
78
|
// (non-TTY) deploys unattended, so pipelines need no changes.
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
type: 'toggle',
|
|
67
|
-
name: 'confirm',
|
|
79
|
+
if (process.stdout.isTTY &&
|
|
80
|
+
!(await confirm({
|
|
68
81
|
message: `Deploy "${app.name}" (${app.id}) from ${workdir}?`,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (!confirm) {
|
|
74
|
-
log.info('Cancelled.');
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
82
|
+
yes: args.yes
|
|
83
|
+
}))) {
|
|
84
|
+
log.info('Cancelled.');
|
|
85
|
+
return;
|
|
77
86
|
}
|
|
78
87
|
// Monorepo Root Directory โ single precedence rule everywhere
|
|
79
88
|
// (arch/deploy/root-dir-faable-json.md): App.root_dir (platform
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { requireApi } from '../../api/context.js';
|
|
2
|
+
import { requireProject } from '../../api/project.js';
|
|
3
|
+
import { print_json, print, json_option } from '../../lib/listing.js';
|
|
4
|
+
import { log } from '../../log.js';
|
|
5
|
+
import { until } from './inspect/format.js';
|
|
6
|
+
|
|
7
|
+
// `faable deploy quota` โ why a deploy did not start today. A held build is
|
|
8
|
+
// waiting for the daily allowance, not failing.
|
|
9
|
+
const quota = {
|
|
10
|
+
command: 'quota',
|
|
11
|
+
describe: "Today's deploy allowance of the project, and builds held by it",
|
|
12
|
+
builder: yargs => json_option(yargs).showHelpOnFail(false),
|
|
13
|
+
handler: async (args) => {
|
|
14
|
+
const { api } = await requireApi();
|
|
15
|
+
const { id: project } = await requireProject(api);
|
|
16
|
+
const quota = await api.getDeployQuota(project);
|
|
17
|
+
if (args.json)
|
|
18
|
+
return print_json({ project, ...quota });
|
|
19
|
+
const limit = quota.limit === null ? 'unlimited' : String(quota.limit);
|
|
20
|
+
print(`Deploys today: ${quota.used} of ${limit}`);
|
|
21
|
+
print(`Resets: ${until(quota.resets_at)} (${quota.resets_at})`);
|
|
22
|
+
if (quota.held > 0) {
|
|
23
|
+
print(`Held: ${quota.held} build(s) waiting for the allowance`);
|
|
24
|
+
}
|
|
25
|
+
if (quota.over) {
|
|
26
|
+
log.warn('โ ๏ธ The daily allowance is spent: new builds wait until it resets, or upgrade the plan.');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
// `faable deploy usage` โ this billing period, for the active project. The
|
|
31
|
+
// api answers for every project of the caller; the active one is picked out
|
|
32
|
+
// (by its id in either form: project_โฆ / team_โฆ).
|
|
33
|
+
const usage = {
|
|
34
|
+
command: 'usage',
|
|
35
|
+
describe: "This billing period's usage of the project (apps, deploys, traffic)",
|
|
36
|
+
builder: yargs => json_option(yargs)
|
|
37
|
+
.option('traffic', {
|
|
38
|
+
type: 'boolean',
|
|
39
|
+
default: true,
|
|
40
|
+
description: 'Include egress (the slow part; --no-traffic skips it)'
|
|
41
|
+
})
|
|
42
|
+
.showHelpOnFail(false),
|
|
43
|
+
handler: async (args) => {
|
|
44
|
+
const { api } = await requireApi();
|
|
45
|
+
const { id: project } = await requireProject(api);
|
|
46
|
+
const summary = (await api.getUsageSummary(project, {
|
|
47
|
+
traffic: args.traffic
|
|
48
|
+
}));
|
|
49
|
+
const hex = project.replace(/^project_/, '');
|
|
50
|
+
const mine = summary.teams?.find(t => t.id.endsWith(hex)) ?? null;
|
|
51
|
+
if (args.json) {
|
|
52
|
+
return print_json({
|
|
53
|
+
project,
|
|
54
|
+
usage: mine,
|
|
55
|
+
billing_available: summary.billing_available ?? null
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!mine) {
|
|
59
|
+
log.info(`๐ญ No usage recorded for ${project} this period.`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
print(`${mine.name} (${project})`);
|
|
63
|
+
if (mine.period) {
|
|
64
|
+
print(`Period: ${mine.period.start.slice(0, 10)} โ ${mine.period.end.slice(0, 10)}`);
|
|
65
|
+
}
|
|
66
|
+
if (mine.subscription) {
|
|
67
|
+
print(`Plan: ${mine.subscription.plan ?? 'free'} (${mine.subscription.status})`);
|
|
68
|
+
}
|
|
69
|
+
else if (summary.billing_available === false) {
|
|
70
|
+
// null then means "unknown", not "free".
|
|
71
|
+
print(`Plan: unknown (billing did not answer)`);
|
|
72
|
+
}
|
|
73
|
+
if (mine.resources) {
|
|
74
|
+
const r = mine.resources;
|
|
75
|
+
print(`Resources: ${r.apps} app(s), ${r.domains} domain(s), ${r.deployments} deployment(s)`);
|
|
76
|
+
}
|
|
77
|
+
if (mine.traffic) {
|
|
78
|
+
print(`Egress: ${mine.traffic.used_gb.toFixed(2)} GB`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export { quota, usage };
|
|
@@ -1,9 +1,37 @@
|
|
|
1
|
+
import { requireProject } from '../../api/project.js';
|
|
1
2
|
import { Configuration } from '../../lib/Configuration.js';
|
|
3
|
+
import { CliError } from '../../lib/errors.js';
|
|
4
|
+
import { is_non_interactive } from '../../lib/interactive.js';
|
|
5
|
+
import { fetch_page } from '../../lib/listing.js';
|
|
2
6
|
import { getGitRemoteUrl } from '../../lib/git_remote.js';
|
|
3
7
|
import { log } from '../../log.js';
|
|
4
8
|
|
|
9
|
+
// --app takes an id, a name or a slug. An id (`app_โฆ`) is used as-is โ the
|
|
10
|
+
// api checks access on use. A name or slug is looked up among the apps of ONE
|
|
11
|
+
// project (--project, FAABLE_PROJECT, `faable project use`, or your only
|
|
12
|
+
// one) and must match exactly one: never across projects, where a staff
|
|
13
|
+
// session would see every customer's "dashboard" too.
|
|
14
|
+
const resolve_app_ref = async (api, ref,
|
|
15
|
+
// The project to search; by default the configured one (see above).
|
|
16
|
+
in_project) => {
|
|
17
|
+
if (ref.startsWith('app_'))
|
|
18
|
+
return ref;
|
|
19
|
+
const project = (await requireProject(api)).id;
|
|
20
|
+
const apps = await fetch_page(p => api.listApps(p, project), {
|
|
21
|
+
limit: 200,
|
|
22
|
+
all: true
|
|
23
|
+
});
|
|
24
|
+
const wanted = ref.toLowerCase();
|
|
25
|
+
const matches = apps.data.filter(a => a.name.toLowerCase() === wanted || a.slug?.toLowerCase() === wanted);
|
|
26
|
+
if (matches.length === 1)
|
|
27
|
+
return matches[0].id;
|
|
28
|
+
if (matches.length === 0) {
|
|
29
|
+
throw new CliError('not_found', `No app named "${ref}" in ${project}. See them with: faable deploy apps list`);
|
|
30
|
+
}
|
|
31
|
+
throw new CliError('usage', `Several apps are named "${ref}": ${matches.map(a => a.id).join(', ')}. Use its id.`);
|
|
32
|
+
};
|
|
5
33
|
// app_id resolution (the user never has to look one up):
|
|
6
|
-
// 1. explicit (--app, on `deploy` and on every subcommand)
|
|
34
|
+
// 1. explicit (--app, on `deploy` and on every subcommand; id, name or slug)
|
|
7
35
|
// 2. OIDC in CI โ the backend resolves the app from the linked repository
|
|
8
36
|
// 3. locally โ a legacy app_id in faable.json (older CLIs wrote it on
|
|
9
37
|
// `faable deploy link`; the current link only persists in the API)
|
|
@@ -11,12 +39,20 @@ import { log } from '../../log.js';
|
|
|
11
39
|
// of the working directory (repos are connected in the dashboard when the
|
|
12
40
|
// app is created, or via `faable deploy link`)
|
|
13
41
|
//
|
|
42
|
+
// Non-interactive mode stops at 2: the working directory of a process a
|
|
43
|
+
// program spawned (the MCP server) says nothing about which app the user
|
|
44
|
+
// meant, and guessing from it is how a deploy lands on the wrong app.
|
|
45
|
+
//
|
|
14
46
|
// "No app here" is an answer, not an error โ a bare `faable deploy` in an
|
|
15
47
|
// unlinked directory lists the subcommands instead of failing, and only the
|
|
16
48
|
// caller knows which of the two it wants. An AMBIGUOUS repository does throw:
|
|
17
49
|
// several apps is a question only the user can answer.
|
|
18
50
|
const find_app_id = async (explicit, ctxAppId, api, workdir = process.cwd()) => {
|
|
19
|
-
|
|
51
|
+
if (explicit)
|
|
52
|
+
return resolve_app_ref(api, explicit);
|
|
53
|
+
if (is_non_interactive())
|
|
54
|
+
return ctxAppId || null;
|
|
55
|
+
const app_id = ctxAppId || Configuration.instance().app_id;
|
|
20
56
|
if (app_id)
|
|
21
57
|
return app_id;
|
|
22
58
|
const repository = await getGitRemoteUrl(workdir);
|
|
@@ -40,7 +76,9 @@ const resolve_app_id = async (explicit, ctxAppId, api, workdir = process.cwd())
|
|
|
40
76
|
const app_id = await find_app_id(explicit, ctxAppId, api, workdir);
|
|
41
77
|
if (app_id)
|
|
42
78
|
return app_id;
|
|
43
|
-
throw new
|
|
79
|
+
throw new CliError('app_required', is_non_interactive()
|
|
80
|
+
? 'Pass the app with --app <app_id> (non-interactive mode never infers it from the working directory).'
|
|
81
|
+
: 'No app linked to this repository. Link it from the dashboard (or run "faable deploy link"), or pass one with --app <app_id>.');
|
|
44
82
|
};
|
|
45
83
|
|
|
46
|
-
export { find_app_id, resolve_app_id };
|
|
84
|
+
export { find_app_id, resolve_app_id, resolve_app_ref };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CliError } from '../../../lib/errors.js';
|
|
2
|
+
|
|
1
3
|
// The mutation endpoint (`/secret/createbatch`) replaces the app's whole
|
|
2
4
|
// secret set, so every change is a read-merge-replace over the CURRENT
|
|
3
5
|
// app-scoped secrets. GET /secret/:app_id also returns secrets inherited
|
|
@@ -23,10 +25,10 @@ const remove_app_secret = (existing, name) => {
|
|
|
23
25
|
const app_secrets = app_scoped(existing);
|
|
24
26
|
if (!app_secrets.some(p => p.name === name)) {
|
|
25
27
|
if (existing.some(s => s.related_model === 'profile' && s.name === name)) {
|
|
26
|
-
throw new
|
|
28
|
+
throw new CliError('usage', `"${name}" is inherited from the team profile and cannot be removed from the app. Manage team secrets from the dashboard.`);
|
|
27
29
|
}
|
|
28
30
|
const names = app_secrets.map(p => p.name).sort().join(', ') || '(none)';
|
|
29
|
-
throw new
|
|
31
|
+
throw new CliError('not_found', `Secret "${name}" not found. Existing secrets: ${names}`);
|
|
30
32
|
}
|
|
31
33
|
return app_secrets.filter(p => p.name !== name);
|
|
32
34
|
};
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import prompts from 'prompts';
|
|
2
1
|
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { print_json, json_option } from '../../../lib/listing.js';
|
|
3
3
|
import { log } from '../../../log.js';
|
|
4
4
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
5
5
|
import { remove_app_secret } from './merge.js';
|
|
6
|
+
import { confirm } from '../../../lib/interactive.js';
|
|
6
7
|
|
|
7
8
|
const secrets_rm = {
|
|
8
9
|
command: 'rm <name>',
|
|
9
10
|
describe: 'Remove a secret by name',
|
|
10
|
-
builder: yargs => yargs
|
|
11
|
+
builder: yargs => json_option(yargs)
|
|
11
12
|
.positional('name', {
|
|
12
13
|
type: 'string',
|
|
13
14
|
demandOption: true,
|
|
@@ -16,7 +17,7 @@ const secrets_rm = {
|
|
|
16
17
|
.option('app', {
|
|
17
18
|
alias: 'a',
|
|
18
19
|
type: 'string',
|
|
19
|
-
description: 'App
|
|
20
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
20
21
|
})
|
|
21
22
|
.option('yes', {
|
|
22
23
|
alias: 'y',
|
|
@@ -35,22 +36,18 @@ const secrets_rm = {
|
|
|
35
36
|
const app = await ctx.api.getApp(app_id);
|
|
36
37
|
const secrets = await ctx.api.getAppSecrets(app_id);
|
|
37
38
|
const remaining = remove_app_secret(secrets, args.name);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
active: 'yes',
|
|
46
|
-
inactive: 'no'
|
|
47
|
-
});
|
|
48
|
-
if (!confirm) {
|
|
49
|
-
log.info('Cancelled.');
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
39
|
+
const go = await confirm({
|
|
40
|
+
message: `Remove secret "${args.name}" from ${app_id}?`,
|
|
41
|
+
yes: args.yes
|
|
42
|
+
});
|
|
43
|
+
if (!go) {
|
|
44
|
+
log.info('Cancelled.');
|
|
45
|
+
return;
|
|
52
46
|
}
|
|
53
47
|
await ctx.api.createSecretsBatch(app.id, app.team, remaining);
|
|
48
|
+
if (args.json) {
|
|
49
|
+
return print_json({ deleted: true, name: args.name, restarting: true });
|
|
50
|
+
}
|
|
54
51
|
log.info(`๐๏ธ Removed secret ${args.name} from ${app_id}.`);
|
|
55
52
|
log.info(`โน๏ธ The app is restarting to apply the changes.`);
|
|
56
53
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { requireApi } from '../../../api/context.js';
|
|
4
|
+
import { print_json, json_option } from '../../../lib/listing.js';
|
|
4
5
|
import { log } from '../../../log.js';
|
|
5
6
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
6
7
|
import { managed_warning } from './managed_names.js';
|
|
@@ -11,7 +12,17 @@ import { parse_pairs } from './parse_pairs.js';
|
|
|
11
12
|
// `--env-file` with no value means "the .env in this directory", the case
|
|
12
13
|
// people actually have.
|
|
13
14
|
const DEFAULT_ENV_FILE = '.env';
|
|
15
|
+
// `-` reads the .env from stdin: the way to pass values without putting them
|
|
16
|
+
// in argv, where `ps` and any process log can read them (the MCP server
|
|
17
|
+
// always sets secrets this way).
|
|
18
|
+
//
|
|
19
|
+
// Spell it `-f -`. Node โฅ 22 scans the WHOLE argv for its own `--env-file`,
|
|
20
|
+
// even after the script, and dies with "node: -: not found" before the CLI
|
|
21
|
+
// runs; the short alias is invisible to it.
|
|
22
|
+
const STDIN = '-';
|
|
14
23
|
const read_env_file = (raw_path) => {
|
|
24
|
+
if (raw_path === STDIN)
|
|
25
|
+
return parse_env(readFileSync(0, 'utf8'), 'stdin');
|
|
15
26
|
const path = raw_path || DEFAULT_ENV_FILE;
|
|
16
27
|
let content;
|
|
17
28
|
try {
|
|
@@ -45,7 +56,7 @@ const MAX_DETAILED = 10;
|
|
|
45
56
|
const secrets_set = {
|
|
46
57
|
command: 'set [pairs...]',
|
|
47
58
|
describe: 'Set secrets as KEY=VALUE pairs or from a .env file',
|
|
48
|
-
builder: yargs => yargs
|
|
59
|
+
builder: yargs => json_option(yargs)
|
|
49
60
|
.positional('pairs', {
|
|
50
61
|
type: 'string',
|
|
51
62
|
array: true,
|
|
@@ -54,17 +65,18 @@ const secrets_set = {
|
|
|
54
65
|
.option('app', {
|
|
55
66
|
alias: 'a',
|
|
56
67
|
type: 'string',
|
|
57
|
-
description: 'App
|
|
68
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
58
69
|
})
|
|
59
70
|
.option('env-file', {
|
|
60
71
|
alias: 'f',
|
|
61
72
|
type: 'string',
|
|
62
|
-
description: 'Load variables from a .env file (defaults to ./.env)'
|
|
73
|
+
description: 'Load variables from a .env file (defaults to ./.env; `-f -` reads stdin)'
|
|
63
74
|
})
|
|
64
75
|
.example('$0 deploy secrets set API_KEY=abc123', 'Set a single secret')
|
|
65
76
|
.example('$0 deploy secrets set A=1 DB_URL=postgres://u:p@host/db', 'Set several at once (values may contain "=")')
|
|
66
77
|
.example('$0 deploy secrets set --env-file', 'Upload every variable in ./.env')
|
|
67
78
|
.example('$0 deploy secrets set -f .env.production', 'Upload another env file')
|
|
79
|
+
.example('op read op://vault/api/env | $0 deploy secrets set -f -', 'Read the variables from stdin, never from the command line')
|
|
68
80
|
.showHelpOnFail(false),
|
|
69
81
|
handler: async (args) => {
|
|
70
82
|
const env_file = args['env-file'];
|
|
@@ -77,7 +89,7 @@ const secrets_set = {
|
|
|
77
89
|
const from_file = env_file === undefined ? [] : read_env_file(env_file);
|
|
78
90
|
const parsed = combine(from_file, parse_pairs(pairs));
|
|
79
91
|
if (env_file !== undefined) {
|
|
80
|
-
const path = env_file || DEFAULT_ENV_FILE;
|
|
92
|
+
const path = env_file === STDIN ? 'stdin' : env_file || DEFAULT_ENV_FILE;
|
|
81
93
|
if (from_file.length === 0) {
|
|
82
94
|
throw new Error(`${path} has no variables to set.`);
|
|
83
95
|
}
|
|
@@ -103,6 +115,25 @@ const secrets_set = {
|
|
|
103
115
|
.filter(s => s.related_model === 'app')
|
|
104
116
|
.map(s => [s.name, s.value]));
|
|
105
117
|
const updated = parsed.filter(({ name }) => current.has(name)).length;
|
|
118
|
+
// Only claim the restart when something actually changed. Setting a name
|
|
119
|
+
// to the value it already has is a no-op end to end โ the API compares
|
|
120
|
+
// before writing and never emits the event, so the pod keeps running โ
|
|
121
|
+
// and this line used to promise a restart that was not coming.
|
|
122
|
+
const changed = parsed.some(({ name, value }) => current.get(name) !== value);
|
|
123
|
+
// Names only: a value never goes back out.
|
|
124
|
+
if (args.json) {
|
|
125
|
+
return print_json({
|
|
126
|
+
app_id,
|
|
127
|
+
added: parsed.filter(p => !current.has(p.name)).map(p => p.name),
|
|
128
|
+
updated: parsed
|
|
129
|
+
.filter(p => current.has(p.name) && current.get(p.name) !== p.value)
|
|
130
|
+
.map(p => p.name),
|
|
131
|
+
unchanged: parsed
|
|
132
|
+
.filter(p => current.get(p.name) === p.value)
|
|
133
|
+
.map(p => p.name),
|
|
134
|
+
restarting: changed
|
|
135
|
+
});
|
|
136
|
+
}
|
|
106
137
|
if (parsed.length <= MAX_DETAILED) {
|
|
107
138
|
for (const { name } of parsed) {
|
|
108
139
|
log.info(current.has(name)
|
|
@@ -114,11 +145,6 @@ const secrets_set = {
|
|
|
114
145
|
log.info(`๐ ${parsed.length - updated} added, ${updated} updated`);
|
|
115
146
|
}
|
|
116
147
|
log.info(`โ
${parsed.length} secret(s) saved to ${app_id}.`);
|
|
117
|
-
// Only claim the restart when something actually changed. Setting a name
|
|
118
|
-
// to the value it already has is a no-op end to end โ the API compares
|
|
119
|
-
// before writing and never emits the event, so the pod keeps running โ
|
|
120
|
-
// and this line used to promise a restart that was not coming.
|
|
121
|
-
const changed = parsed.some(({ name, value }) => current.get(name) !== value);
|
|
122
148
|
log.info(changed
|
|
123
149
|
? `โน๏ธ The app is restarting to apply the changes.`
|
|
124
150
|
: `โน๏ธ Already up to date โ the app was not restarted.`);
|
|
@@ -25,7 +25,7 @@ const rule_matches_request = (rule, want, action) => rule.action === action &&
|
|
|
25
25
|
* verify, explain, tell the user how to check) lives here once.
|
|
26
26
|
*/
|
|
27
27
|
const add_rule = async (opts) => {
|
|
28
|
-
const { api, app_id, app_name, app_url, pattern, user_agent, query, action, description, force } = opts;
|
|
28
|
+
const { api, app_id, app_name, app_url, pattern, user_agent, query, action, description, force, json } = opts;
|
|
29
29
|
const want = { pattern, user_agent, query };
|
|
30
30
|
const waf = await api.addAppWafRule(app_id, {
|
|
31
31
|
pattern,
|
|
@@ -60,6 +60,8 @@ const add_rule = async (opts) => {
|
|
|
60
60
|
throw new Error(`This Faable server does not support ${query ? 'query-parameter' : 'user-agent'} rules yet, so the rule was not created.\n` +
|
|
61
61
|
` Update the CLI and try again, or ask support to upgrade the platform.`);
|
|
62
62
|
}
|
|
63
|
+
if (json)
|
|
64
|
+
return waf;
|
|
63
65
|
const answer = action === 'deny' ? '403' : '404';
|
|
64
66
|
log.info(`๐ก๏ธ ${describe_selector(want)} โ ${answer} at the edge`);
|
|
65
67
|
log.info(` for ${app_name} (${app_id}).`);
|
|
@@ -85,6 +87,7 @@ const add_rule = async (opts) => {
|
|
|
85
87
|
: ` curl -s -o /dev/null -w '%{http_code}\\n' https://${app_url}<path>`);
|
|
86
88
|
log.info(``);
|
|
87
89
|
log.info(`Undo: faable deploy waf rm ${undo_args(want)} -a ${app_id}`);
|
|
90
|
+
return waf;
|
|
88
91
|
};
|
|
89
92
|
/** The exact arguments that reverse this rule. */
|
|
90
93
|
const undo_args = (s) => [
|