@faable/faable 4.2.0 โ†’ 4.4.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.
Files changed (54) hide show
  1. package/README.md +47 -6
  2. package/dist/api/FaableApi.js +51 -1
  3. package/dist/api/auth.js +1 -5
  4. package/dist/api/auth_admin.js +6 -9
  5. package/dist/api/base_client.js +4 -1
  6. package/dist/api/client_id.js +22 -0
  7. package/dist/api/context.js +3 -5
  8. package/dist/api/session.js +2 -2
  9. package/dist/commands/auth/actions/rm.js +12 -15
  10. package/dist/commands/auth/clients/rm.js +17 -15
  11. package/dist/commands/auth/users/export.js +6 -12
  12. package/dist/commands/auth/users/import.js +5 -9
  13. package/dist/commands/auth/users/reinstate.js +10 -17
  14. package/dist/commands/auth/users/suspend.js +10 -17
  15. package/dist/commands/deploy/apps/create.js +132 -0
  16. package/dist/commands/deploy/apps/index.js +18 -2
  17. package/dist/commands/deploy/apps/set.js +70 -0
  18. package/dist/commands/deploy/domains/add.js +8 -2
  19. package/dist/commands/deploy/domains/check.js +8 -3
  20. package/dist/commands/deploy/domains/list.js +1 -1
  21. package/dist/commands/deploy/domains/rm.js +14 -17
  22. package/dist/commands/deploy/index.js +5 -0
  23. package/dist/commands/deploy/inspect/cancel.js +9 -3
  24. package/dist/commands/deploy/inspect/deployments.js +1 -1
  25. package/dist/commands/deploy/inspect/format.js +35 -1
  26. package/dist/commands/deploy/inspect/inspect.js +1 -1
  27. package/dist/commands/deploy/inspect/logs.js +47 -6
  28. package/dist/commands/deploy/inspect/open.js +1 -1
  29. package/dist/commands/deploy/inspect/redeploy.js +11 -3
  30. package/dist/commands/deploy/inspect/status.js +9 -3
  31. package/dist/commands/deploy/inspect/traffic.js +84 -0
  32. package/dist/commands/deploy/inspect/trigger.js +10 -4
  33. package/dist/commands/deploy/launch.js +26 -17
  34. package/dist/commands/deploy/project_usage.js +83 -0
  35. package/dist/commands/deploy/resolve_app_id.js +42 -4
  36. package/dist/commands/deploy/secrets/list.js +1 -1
  37. package/dist/commands/deploy/secrets/merge.js +4 -2
  38. package/dist/commands/deploy/secrets/rm.js +14 -17
  39. package/dist/commands/deploy/secrets/set.js +35 -9
  40. package/dist/commands/deploy/waf/add_rule.js +4 -1
  41. package/dist/commands/deploy/waf/block.js +8 -4
  42. package/dist/commands/deploy/waf/list.js +1 -1
  43. package/dist/commands/deploy/waf/rm.js +6 -3
  44. package/dist/commands/deploy/waf/sink.js +8 -4
  45. package/dist/commands/mcp/index.js +28 -0
  46. package/dist/commands/whoami/index.js +4 -6
  47. package/dist/index.js +34 -17
  48. package/dist/lib/errors.js +31 -0
  49. package/dist/lib/interactive.js +45 -0
  50. package/dist/lib/listing.js +5 -1
  51. package/dist/mcp/run_cli.js +90 -0
  52. package/dist/mcp/server.js +113 -0
  53. package/dist/mcp/tools.js +500 -0
  54. package/package.json +4 -2
@@ -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 Identifier (defaults to the linked 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
- log.info(`Track it with: faable deploy status ยท build output: faable deploy logs --build`);
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 Identifier (defaults to the linked 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 Identifier (defaults to the linked 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
- log.info(`๐Ÿš€ Building ${result.commit.slice(0, 7)} (${result.branch}) of ${app.name} server-side.`);
24
- log.info(`Track it with: faable deploy status ยท or in the dashboard: https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`);
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 Identifier (defaults to the app linked to this repository)'
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
- const ctx = await requireApi(args.app);
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
- log.error(`โŒ No app linked to ${workdir}. Connect a repository from the dashboard (or run "faable deploy link"), or target one with --app <app_id>.`);
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 (!args.yes && process.stdout.isTTY) {
65
- const { confirm } = await prompts({
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
- initial: false,
70
- active: 'yes',
71
- inactive: 'no'
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
- const app_id = explicit || ctxAppId || Configuration.instance().app_id;
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 Error('No app linked to this repository. Link it from the dashboard (or run "faable deploy link"), or pass one with --app <app_id>.');
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 };
@@ -12,7 +12,7 @@ const secrets_list = {
12
12
  .option('app', {
13
13
  alias: 'a',
14
14
  type: 'string',
15
- description: 'App Identifier (defaults to the linked app)'
15
+ description: 'App id, name or slug (defaults to the linked app)'
16
16
  })
17
17
  .option('show', {
18
18
  type: 'boolean',
@@ -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 Error(`"${name}" is inherited from the team profile and cannot be removed from the app. Manage team secrets from the dashboard.`);
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 Error(`Secret "${name}" not found. Existing secrets: ${names}`);
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 Identifier (defaults to the linked 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
- if (!args.yes) {
39
- // In a non-TTY run without --yes, prompts resolves undefined โ†’ cancel.
40
- const { confirm } = await prompts({
41
- type: 'toggle',
42
- name: 'confirm',
43
- message: `Remove secret "${args.name}" from ${app_id}?`,
44
- initial: false,
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 Identifier (defaults to the linked 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) => [