@faable/faable 4.2.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.
Files changed (50) hide show
  1. package/README.md +30 -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/whoami/index.js +4 -6
  46. package/dist/index.js +32 -17
  47. package/dist/lib/errors.js +31 -0
  48. package/dist/lib/interactive.js +45 -0
  49. package/dist/lib/listing.js +5 -1
  50. package/package.json +1 -1
@@ -1,11 +1,12 @@
1
1
  import { requireApi } from '../../../api/context.js';
2
+ import { print_json, json_option } from '../../../lib/listing.js';
2
3
  import { resolve_app_id } from '../resolve_app_id.js';
3
4
  import { add_rule } from './add_rule.js';
4
5
 
5
6
  const waf_block = {
6
7
  command: 'block [pattern]',
7
8
  describe: 'Block requests at the edge with a 403 (they never reach your app)',
8
- builder: yargs => yargs
9
+ builder: yargs => json_option(yargs)
9
10
  .positional('pattern', {
10
11
  type: 'string',
11
12
  description: 'Anchored path regex, e.g. ^/\\.well-known/ (quote it in your shell)'
@@ -23,7 +24,7 @@ const waf_block = {
23
24
  .option('app', {
24
25
  alias: 'a',
25
26
  type: 'string',
26
- description: 'App Identifier (defaults to the linked app)'
27
+ description: 'App id, name or slug (defaults to the linked app)'
27
28
  })
28
29
  .option('description', {
29
30
  alias: 'd',
@@ -56,7 +57,7 @@ const waf_block = {
56
57
  const ctx = await requireApi();
57
58
  const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
58
59
  const app = await ctx.api.getApp(app_id);
59
- await add_rule({
60
+ const waf = await add_rule({
60
61
  api: ctx.api,
61
62
  app_id,
62
63
  app_name: app.name,
@@ -66,8 +67,11 @@ const waf_block = {
66
67
  query: args.query,
67
68
  action: 'deny',
68
69
  description: args.description,
69
- force: args.force
70
+ force: args.force,
71
+ json: args.json
70
72
  });
73
+ if (args.json)
74
+ print_json(waf);
71
75
  }
72
76
  };
73
77
 
@@ -11,7 +11,7 @@ const waf_list = {
11
11
  .option('app', {
12
12
  alias: 'a',
13
13
  type: 'string',
14
- description: 'App Identifier (defaults to the linked app)'
14
+ description: 'App id, name or slug (defaults to the linked app)'
15
15
  })
16
16
  .example('$0 deploy waf list', 'Show the rules protecting the linked app')
17
17
  .showHelpOnFail(false),
@@ -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 { describe_selector } from './add_rule.js';
@@ -6,7 +7,7 @@ import { describe_selector } from './add_rule.js';
6
7
  const waf_rm = {
7
8
  command: 'rm [pattern]',
8
9
  describe: 'Remove one of your WAF rules',
9
- builder: yargs => yargs
10
+ builder: yargs => json_option(yargs)
10
11
  .positional('pattern', {
11
12
  type: 'string',
12
13
  description: 'The exact pattern to remove (see `faable deploy waf list`)'
@@ -32,7 +33,7 @@ const waf_rm = {
32
33
  .option('app', {
33
34
  alias: 'a',
34
35
  type: 'string',
35
- description: 'App Identifier (defaults to the linked app)'
36
+ description: 'App id, name or slug (defaults to the linked app)'
36
37
  })
37
38
  .check(({ pattern, userAgent, query }) => {
38
39
  if (!pattern && !userAgent && !query) {
@@ -47,12 +48,14 @@ const waf_rm = {
47
48
  const ctx = await requireApi();
48
49
  const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
49
50
  const app = await ctx.api.getApp(app_id);
50
- await ctx.api.removeAppWafRule(app_id, {
51
+ const waf = await ctx.api.removeAppWafRule(app_id, {
51
52
  pattern: args.pattern,
52
53
  user_agent: args.userAgent,
53
54
  query: args.query,
54
55
  action: args.action
55
56
  });
57
+ if (args.json)
58
+ return print_json(waf);
56
59
  log.info(`🗑️ Removed ${describe_selector({
57
60
  pattern: args.pattern,
58
61
  user_agent: args.userAgent,
@@ -1,11 +1,12 @@
1
1
  import { requireApi } from '../../../api/context.js';
2
+ import { print_json, json_option } from '../../../lib/listing.js';
2
3
  import { resolve_app_id } from '../resolve_app_id.js';
3
4
  import { add_rule } from './add_rule.js';
4
5
 
5
6
  const waf_sink = {
6
7
  command: 'sink [pattern]',
7
8
  describe: 'Answer requests with a 404 from Faable, without waking your app',
8
- builder: yargs => yargs
9
+ builder: yargs => json_option(yargs)
9
10
  .positional('pattern', {
10
11
  type: 'string',
11
12
  description: 'Anchored path regex, e.g. ^/robots\\.txt$ (quote it in your shell)'
@@ -23,7 +24,7 @@ const waf_sink = {
23
24
  .option('app', {
24
25
  alias: 'a',
25
26
  type: 'string',
26
- description: 'App Identifier (defaults to the linked app)'
27
+ description: 'App id, name or slug (defaults to the linked app)'
27
28
  })
28
29
  .option('description', {
29
30
  alias: 'd',
@@ -57,7 +58,7 @@ const waf_sink = {
57
58
  const ctx = await requireApi();
58
59
  const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
59
60
  const app = await ctx.api.getApp(app_id);
60
- await add_rule({
61
+ const waf = await add_rule({
61
62
  api: ctx.api,
62
63
  app_id,
63
64
  app_name: app.name,
@@ -67,8 +68,11 @@ const waf_sink = {
67
68
  query: args.query,
68
69
  action: 'sink',
69
70
  description: args.description,
70
- force: args.force
71
+ force: args.force,
72
+ json: args.json
71
73
  });
74
+ if (args.json)
75
+ print_json(waf);
72
76
  }
73
77
  };
74
78
 
@@ -1,4 +1,3 @@
1
- import { log } from '../../log.js';
2
1
  import { getMe } from '../../api/auth.js';
3
2
  import { resolveTenant } from '../../api/auth_admin.js';
4
3
  import { FaableApi } from '../../api/FaableApi.js';
@@ -7,6 +6,7 @@ import { loadLiveCredentials } from '../../api/session.js';
7
6
  import { bearer_strategy } from '../../api/strategies/bearer.strategy.js';
8
7
  import { CredentialsStore } from '../../lib/CredentialsStore.js';
9
8
  import { print_json, print, json_option } from '../../lib/listing.js';
9
+ import { CliError, NOT_LOGGED_IN } from '../../lib/errors.js';
10
10
 
11
11
  // Who you are, and what the CLI acts on: the active project and the Auth
12
12
  // tenant `faable auth` would manage. The context is best-effort — a missing
@@ -50,8 +50,7 @@ const whoami = {
50
50
  print(`Logged in as: ${config.email} (API key)`);
51
51
  return;
52
52
  }
53
- log.error("❌ Not logged in. Run 'faable login' first.");
54
- process.exit(1);
53
+ throw new CliError("not_logged_in", NOT_LOGGED_IN);
55
54
  }
56
55
  let me;
57
56
  try {
@@ -59,9 +58,8 @@ const whoami = {
59
58
  // has no /me route.
60
59
  me = await getMe(token);
61
60
  }
62
- catch {
63
- log.error("❌ Not logged in or session expired");
64
- process.exit(1);
61
+ catch (e) {
62
+ throw new CliError("session_expired", "Your Faable session has expired or is invalid. Run `faable login` to sign in again.", { status: 401, cause: e });
65
63
  }
66
64
  const { project, tenant } = await describeContext(token);
67
65
  if (args.json) {
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ import { version } from './config.js';
12
12
  import { Configuration } from './lib/Configuration.js';
13
13
  import { setProjectFlag, configuredProject } from './api/project.js';
14
14
  import { ContextStore } from './lib/ContextStore.js';
15
+ import { CliError, error_json } from './lib/errors.js';
16
+ import { is_non_interactive } from './lib/interactive.js';
15
17
  import { notifyIfUpdateAvailable } from './lib/UpdateChecker.js';
16
18
  import { log } from './log.js';
17
19
 
@@ -27,7 +29,11 @@ const project_badge = async () => {
27
29
  const { project_name } = await new ContextStore().load();
28
30
  return ` · project ${project_name ?? configured.ref}`;
29
31
  };
30
- const wants_json = () => process.argv.includes('--json');
32
+ // A program is reading: errors go out as JSON on stderr.
33
+ const wants_json = () => process.argv.includes('--json') || is_non_interactive();
34
+ const fail_json = (err) => {
35
+ process.stderr.write(JSON.stringify(error_json(err)) + '\n');
36
+ };
31
37
  // yargs re-runs before-validation middlewares once per nested command level
32
38
  // (`deploy secrets` = 2 runs), so keep the banner and update check to one.
33
39
  let banner_shown = false;
@@ -46,7 +52,10 @@ yg.scriptName('faable')
46
52
  // whose stdout IS the export.
47
53
  const exports_to_stdout = argv._.slice(0, 3).join(' ') === 'auth users export' && !argv.file;
48
54
  // Nor when nobody is watching stderr (a script, the MCP server).
49
- if (argv.json || exports_to_stdout || !process.stderr.isTTY)
55
+ if (argv.json ||
56
+ exports_to_stdout ||
57
+ !process.stderr.isTTY ||
58
+ is_non_interactive())
50
59
  return;
51
60
  log.info(`Faable CLI ${version}${await project_badge()}`);
52
61
  // `upgrade` does its own (forced) check
@@ -58,6 +67,11 @@ yg.scriptName('faable')
58
67
  alias: 'config',
59
68
  description: 'Path to the local `faable.json` file',
60
69
  string: true
70
+ })
71
+ .option('non-interactive', {
72
+ description: 'Never prompt nor infer the app from the working directory; errors as JSON (env FAABLE_NONINTERACTIVE=1)',
73
+ boolean: true,
74
+ global: true
61
75
  })
62
76
  .option('p', {
63
77
  alias: 'project',
@@ -93,16 +107,10 @@ yg.scriptName('faable')
93
107
  .fail(function (msg, err) {
94
108
  if (err) {
95
109
  // With --json the caller is a program: the error as JSON too, on
96
- // stderr, so it can tell a 404 from a 403 without parsing prose.
110
+ // stderr, so it can tell a 404 from a 403 without parsing prose
111
+ // (codes in lib/errors.ts).
97
112
  if (wants_json()) {
98
- const e = err;
99
- process.stderr.write(JSON.stringify({
100
- error: {
101
- message: e.message,
102
- ...(e.code ? { code: e.code } : {}),
103
- ...(e.status ? { status: e.status } : {})
104
- }
105
- }) + '\n');
113
+ fail_json(err);
106
114
  }
107
115
  else {
108
116
  log.error(`❌ ${err.message}`);
@@ -111,18 +119,25 @@ yg.scriptName('faable')
111
119
  return;
112
120
  }
113
121
  if (msg) {
114
- // Validation failure (unknown command, missing subcommand…): show the
115
- // help, then fail red — a bad invocation must not exit 0.
116
- yg.showHelp();
117
- log.error(`❌ ${msg}`);
118
122
  // The one unknown command worth naming: an app id passed positionally.
119
123
  // `faable deploy <app_id> secrets list` used to deploy the working
120
124
  // directory instead of listing secrets, so the id now lives in a flag —
121
125
  // say so, or the migration reads as the CLI having lost a feature.
122
126
  const stray = /Unknown commands?: (app_[A-Za-z0-9_-]+)/.exec(msg);
123
- if (stray) {
124
- log.error(`The app id goes in a flag, not as an argument: faable deploy --app ${stray[1]} [subcommand]`);
127
+ const hint = stray
128
+ ? `The app id goes in a flag, not as an argument: faable deploy --app ${stray[1]} [subcommand]`
129
+ : undefined;
130
+ if (wants_json()) {
131
+ // A program built a bad argv: no help screen, just the reason.
132
+ fail_json(new CliError('usage', hint ? `${msg}. ${hint}` : msg));
133
+ process.exit(1);
125
134
  }
135
+ // Validation failure (unknown command, missing subcommand…): show the
136
+ // help, then fail red — a bad invocation must not exit 0.
137
+ yg.showHelp();
138
+ log.error(`❌ ${msg}`);
139
+ if (hint)
140
+ log.error(hint);
126
141
  process.exit(1);
127
142
  }
128
143
  })
@@ -0,0 +1,31 @@
1
+ class CliError extends Error {
2
+ code;
3
+ status;
4
+ action;
5
+ constructor(code, message, extra = {}) {
6
+ super(message, extra.cause === undefined ? undefined : { cause: extra.cause });
7
+ this.name = 'CliError';
8
+ this.code = code;
9
+ if (extra.status !== undefined)
10
+ this.status = extra.status;
11
+ if (extra.action !== undefined)
12
+ this.action = extra.action;
13
+ }
14
+ }
15
+ // The JSON body of a failure, from any error: a CliError, a FaableApi error
16
+ // (which carries status/code/action) or a plain Error.
17
+ const error_json = (err) => {
18
+ const e = (err ?? {});
19
+ const message = e.message || String(err);
20
+ return {
21
+ error: {
22
+ message,
23
+ ...(e.code ? { code: e.code } : {}),
24
+ ...(e.status ? { status: e.status } : {}),
25
+ ...(e.action ? { action: e.action } : {})
26
+ }
27
+ };
28
+ };
29
+ const NOT_LOGGED_IN = "Not logged in. Run 'faable login' first.";
30
+
31
+ export { CliError, NOT_LOGGED_IN, error_json };
@@ -0,0 +1,45 @@
1
+ import prompts from 'prompts';
2
+ import { CliError } from './errors.js';
3
+
4
+ // Non-interactive mode: FAABLE_NONINTERACTIVE=1 or the global
5
+ // --non-interactive flag. A program drives the CLI (the Faable MCP server sets
6
+ // it on every call), so:
7
+ //
8
+ // - no prompt is ever shown: a confirmation without --yes is an error
9
+ // (`confirmation_required`), never a silent "Cancelled." with exit 0;
10
+ // - nothing is inferred from the working directory: the app comes from
11
+ // --app, never from faable.json or the git remote of wherever the process
12
+ // happens to run (see find_app_id);
13
+ // - `deploy launch` needs --yes, --app and --workdir spelled out;
14
+ // - errors are JSON on stderr, as with --json.
15
+ //
16
+ // Opt-in rather than "no TTY": CI runs without one and `faable deploy` there
17
+ // must keep deploying unattended.
18
+ const is_non_interactive = (env = process.env, argv = process.argv) => ['1', 'true'].includes((env.FAABLE_NONINTERACTIVE ?? '').toLowerCase()) ||
19
+ argv.includes('--non-interactive');
20
+ // Ask before something destructive. True to go ahead, false when the person
21
+ // said no. Throws `confirmation_required` when nobody can answer: in
22
+ // non-interactive mode, or when stdin/stderr are not a terminal (prompts
23
+ // would otherwise resolve undefined and the command would report a
24
+ // cancellation as a clean exit 0 that a caller reads as success).
25
+ //
26
+ // The prompt is drawn on stderr: stdout carries data only.
27
+ const confirm = async (opts) => {
28
+ if (opts.yes)
29
+ return true;
30
+ if (is_non_interactive() || !process.stdin.isTTY || !process.stderr.isTTY) {
31
+ throw new CliError('confirmation_required', `${opts.message} — confirmation needed: pass --yes.`);
32
+ }
33
+ const { confirm } = await prompts({
34
+ type: 'toggle',
35
+ name: 'confirm',
36
+ message: opts.message,
37
+ initial: false,
38
+ active: 'yes',
39
+ inactive: 'no',
40
+ stdout: process.stderr
41
+ }, { onCancel: () => false });
42
+ return !!confirm;
43
+ };
44
+
45
+ export { confirm, is_non_interactive };
@@ -72,6 +72,10 @@ const print = (line = '') => {
72
72
  const print_json = (data) => {
73
73
  print(JSON.stringify(data, null, 2));
74
74
  };
75
+ // A write that had nothing to do (no failed deployment to retry, nothing in
76
+ // flight to cancel). Exit 0 — it is not an error — but said in the data, so a
77
+ // caller never mistakes it for the thing having happened.
78
+ const print_noop = (reason) => print_json({ result: 'noop', reason });
75
79
  // The rerun hint for the next page, on stderr (the logger's): shown to a
76
80
  // person, invisible to a pipe.
77
81
  const more_hint = (page) => page.has_more
@@ -88,4 +92,4 @@ const table_lines = (headers, rows) => {
88
92
  return [render(headers), ...rows.map(render)];
89
93
  };
90
94
 
91
- export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, as_list, fetch_page, from_paginator, json_option, list_options, more_hint, print, print_json, table_lines };
95
+ export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, as_list, fetch_page, from_paginator, json_option, list_options, more_hint, print, print_json, print_noop, table_lines };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",