@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
package/README.md CHANGED
@@ -28,8 +28,10 @@ npm i -g @faable/faable
28
28
  | Group | What it does | Docs |
29
29
  | --- | --- | --- |
30
30
  | `faable login` / `whoami` / `logout` | Session management (device flow; CI uses OIDC, no login needed) | [Authentication](https://faable.com/docs/cli#authentication) |
31
- | `faable deploy` | `launch` (the default: a bare `faable deploy` deploys the current directory), then `status`, `logs`, `deployments`, `inspect`, `trigger`, `redeploy`, `cancel`, `open`, `list` | [Deployment](https://faable.com/docs/cli#deployment) |
32
- | `faable deploy secrets` | Environment variables: `list`, `set` (`KEY=VALUE` or `--env-file`), `rm` | [Secrets](https://faable.com/docs/cli#secrets) |
31
+ | `faable project` | The project commands act on: `list`, `use`, `current`, `clear` (or `--project` on any command) | [Projects](https://faable.com/docs/cli#projects) |
32
+ | `faable deploy` | `launch` (the default: a bare `faable deploy` deploys the current directory), then `status`, `logs`, `deployments`, `inspect`, `trigger`, `redeploy`, `cancel`, `traffic`, `usage`, `quota`, `open` | [Deployment](https://faable.com/docs/cli#deployment) |
33
+ | `faable deploy apps` | `list`, `get`, `create` (from a GitHub repository, first deploy included), `set` (branch, root directory, deploy mode) | [Apps](https://faable.com/docs/cli#apps) |
34
+ | `faable deploy secrets` | Environment variables: `list`, `set` (`KEY=VALUE`, `--env-file`, or `-f -` from stdin), `rm` | [Secrets](https://faable.com/docs/cli#secrets) |
33
35
  | `faable deploy domains` | Custom domains: `add` (prints the CNAME), `list`, `check`, `rm` | [Domains](https://faable.com/docs/cli#domains) |
34
36
  | `faable deploy waf` | Edge rules: `block` (403), `sink` (404 without waking the app), `list`, `rm` | [Edge rules](https://faable.com/docs/cli#edge-rules-waf) |
35
37
  | `faable auth users` | `list` (FaableQL filters), `get` (federated identities included), `suspend`, `reinstate` | [Users](https://faable.com/docs/cli#users) |
@@ -37,18 +39,40 @@ npm i -g @faable/faable
37
39
  | `faable auth clients` | OAuth clients: `list`, `get --secret`, `create`, `rm` | [OAuth clients](https://faable.com/docs/cli#oauth-clients) |
38
40
  | `faable auth logs` | Audit log (read-only): `list` with filters, `get` | [Audit logs](https://faable.com/docs/cli#audit-logs) |
39
41
 
40
- Every command has `--help`; read commands accept `--json` for output you can pipe
41
- to `jq`. The complete table is in the
42
+ Every command has `--help` and `--json`. `--app` takes an app id, name or slug.
43
+ The complete table is in the
42
44
  [command reference](https://faable.com/docs/cli#command-reference).
43
45
 
46
+ ### Scripting and agents
47
+
48
+ Data goes to stdout, messages to stderr. With `--json`:
49
+
50
+ - every listing is `{"object":"list","data":[…],"has_more","next_cursor"}`
51
+ (`--limit`, `--starting-after`, `--all`);
52
+ - every write returns what it changed, and a write with nothing to do returns
53
+ `{"result":"noop","reason"}`;
54
+ - a failure exits 1 with `{"error":{"message","code","status","action"}}` on
55
+ stderr. Codes: `not_logged_in`, `session_expired`, `account_suspended`,
56
+ `apikey_session`, `forbidden`, `not_found`, `confirmation_required`,
57
+ `app_required`, `usage`, or the API's own code.
58
+
59
+ A program driving the CLI should set `FAABLE_NONINTERACTIVE=1` (or pass
60
+ `--non-interactive`): nothing prompts — a confirmation without `--yes` fails
61
+ with `confirmation_required` —, the app is never inferred from the working
62
+ directory, and `faable deploy launch` needs `--app`, `--workdir` and `--yes`.
63
+ Pass secret values on stdin (`faable deploy secrets set -f -`), never as
64
+ arguments.
65
+
44
66
  ### Deploy
45
67
 
46
68
  ```bash
47
69
  faable login
48
70
  faable deploy # deploy the current directory (= deploy launch)
49
71
  faable deploy launch --app app_x # deploy another app, from anywhere
72
+ faable deploy apps create --repo acme/web # create, link, first deploy
50
73
  faable deploy status # phase, URL and detected stack
51
- faable deploy logs --build # build output (--follow to tail)
74
+ faable deploy logs --build -n 100 # end of the build output (--follow to tail)
75
+ faable deploy traffic --since 7d # status codes, top and failing paths
52
76
  faable deploy secrets set KEY=value
53
77
  faable deploy domains add app.example.com
54
78
  ```
@@ -65,7 +89,7 @@ faable auth users get user_abc123 # includes GitHub/… identi
65
89
  faable auth users suspend user_abc123 -r "abuse: crypto miner"
66
90
 
67
91
  # Bulk: pipe ids from a filtered listing
68
- faable auth users list --suspended --json | jq -r '.[].id' | faable auth users reinstate -y
92
+ faable auth users list --suspended --json | jq -r '.data[].id' | faable auth users reinstate -y
69
93
 
70
94
  faable auth actions create -n add-claims -t post-login -f ./claims.js
71
95
  faable auth clients create -n my-app --callback https://app.example.com/callback
@@ -90,13 +90,19 @@ class FaableApi {
90
90
  if (res.status === 401) {
91
91
  const expired = new Error('Your Faable session has expired or is invalid. Run `faable login` to sign in again.', { cause: error });
92
92
  expired.status = 401;
93
+ expired.code = 'session_expired';
93
94
  throw expired;
94
95
  }
95
96
  const serverMessage = res.data?.message || res.statusText || 'Unknown Error';
96
97
  const wrapped = new Error(`FaableApi ${url} ${res.status}: ${serverMessage}`, { cause: error });
97
98
  wrapped.status = res.status;
99
+ // The api's own code wins; a bare 403/404 still gets a stable one.
98
100
  if (res.data?.code)
99
101
  wrapped.code = res.data.code;
102
+ else if (res.status === 403)
103
+ wrapped.code = 'forbidden';
104
+ else if (res.status === 404)
105
+ wrapped.code = 'not_found';
100
106
  if (res.data?.action)
101
107
  wrapped.action = res.data.action;
102
108
  throw wrapped;
@@ -254,7 +260,10 @@ class FaableApi {
254
260
  // Build and deploy the current head of the deploy branch server-side —
255
261
  // the same path a push webhook takes, same-commit dedupe included.
256
262
  async deployNow(app_id) {
257
- return data(this.client.post(`/app/${app_id}/deploy`));
263
+ return data(
264
+ // deployment_id: the build this call started (api ≥ the release that
265
+ // added it; absent from older ones).
266
+ this.client.post(`/app/${app_id}/deploy`));
258
267
  }
259
268
  // Rebuild a failed deployment from its recorded source (CAS manifest or
260
269
  // git ref). The API enforces the guards: failed phase only, never older
@@ -296,6 +305,47 @@ class FaableApi {
296
305
  headers: projectHeader(team)
297
306
  }));
298
307
  }
308
+ // ── apps: create, configure, observe ─────────────────────────────────────
309
+ // A bare app in a project; `linkRepository` gives it something to build.
310
+ async createApp(project, params) {
311
+ return data(this.client.post(`/app`, params, {
312
+ headers: projectHeader(project)
313
+ }));
314
+ }
315
+ async deleteApp(app_id, team) {
316
+ return data(this.client.delete(`/app/${app_id}`, { headers: projectHeader(team) }));
317
+ }
318
+ async setDeployBranch(app_id, github_branch) {
319
+ return data(this.client.post(`/app/${app_id}/deploy-branch`, { github_branch }));
320
+ }
321
+ // null clears the platform override (faable.json's rootDir applies again).
322
+ async setRootDir(app_id, root_dir) {
323
+ return data(this.client.post(`/app/${app_id}/root-dir`, { root_dir }));
324
+ }
325
+ // push: every push deploys · ci: deploys once CI tags a release ·
326
+ // workflow: the repo's own GitHub workflow runs `faable deploy`.
327
+ async setDeployMode(app_id, mode) {
328
+ return data(this.client.post(`/app/${app_id}/deploy-mode`, { mode }));
329
+ }
330
+ // Edge traffic of one app (ClickHouse, trails reality by up to 15 min).
331
+ // from/to are unix seconds.
332
+ async getAppTraffic(app_id, team, params = {}) {
333
+ return data(this.client.get(`/app/${app_id}/traffic`, {
334
+ params,
335
+ headers: projectHeader(team)
336
+ }));
337
+ }
338
+ // The project's usage this billing period. `traffic: false` keeps it to
339
+ // the database (the Stripe-backed traffic figures are the slow half).
340
+ async getUsageSummary(project, params = {}) {
341
+ return data(this.client.get(`/usage/summary`, {
342
+ params,
343
+ headers: projectHeader(project)
344
+ }));
345
+ }
346
+ async getDeployQuota(project) {
347
+ return data(this.client.get(`/deploy-quota`, { headers: projectHeader(project) }));
348
+ }
299
349
  // ── per-app WAF ───────────────────────────────────────────────────────────
300
350
  //
301
351
  // No `x-faable-project` header on any of these: the routes are scoped by the
package/dist/api/auth.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { FaableAuthApi, authBearer } from '@faable/auth-sdk';
2
2
  import os from 'os';
3
- import { version } from '../config.js';
3
+ import { CLI_CLIENT } from './client_id.js';
4
4
 
5
5
  // The CLI talks to the Faable tenant's auth server through the same SDK the
6
6
  // rest of the platform uses. It used to be a bare axios instance, which meant
@@ -18,10 +18,6 @@ import { version } from '../config.js';
18
18
  // whatever `loadLiveCredentials` refreshed is what goes on the wire.
19
19
  const AUTH_DOMAIN = "https://faable.auth.faable.link";
20
20
  const CLIENT_ID = "c879023b-e34f-4b0c-a262-210e556bc2e4";
21
- // `<name>/<version>`. No commit: the CLI has no release SHA constant, and a
22
- // dev build (`0.0.0-development`) is identifiable as such by the version
23
- // alone. auth caps this segment at 32 chars.
24
- const CLI_CLIENT = `faable-cli/${version}`;
25
21
  // No `x-faable-instance`: the SDK's default is `${HOSTNAME}:${pid}`, which on
26
22
  // a laptop is the user's machine name in our audit log. An empty string
27
23
  // suppresses the header (the helper drops falsy parts).
@@ -3,7 +3,7 @@ import os from 'os';
3
3
  import path__default from 'path';
4
4
  import { ContextStore } from '../lib/ContextStore.js';
5
5
  import { CredentialsStore } from '../lib/CredentialsStore.js';
6
- import { log } from '../log.js';
6
+ import { CliError, NOT_LOGGED_IN } from '../lib/errors.js';
7
7
  import { FaableApi } from './FaableApi.js';
8
8
  import { AUTH_DOMAIN, createAnonymousAuthApi, createBearerAuthApi } from './auth.js';
9
9
  import { requireProject } from './project.js';
@@ -17,13 +17,10 @@ const requireSessionToken = async () => {
17
17
  const config = await loadLiveCredentials(new CredentialsStore());
18
18
  if (config?.apikey && !config.token) {
19
19
  // Deploy API keys are not Auth management credentials.
20
- log.error("❌ You are logged in with an API key, but `faable auth` needs a browser session. Run 'faable login' (without --apikey) first.");
21
- process.exit(1);
22
- }
23
- if (!config?.token) {
24
- log.error("❌ Not logged in. Run 'faable login' first.");
25
- process.exit(1);
20
+ throw new CliError('apikey_session', "You are logged in with an API key, but `faable auth` needs a browser session. Run 'faable login' (without --apikey) first.");
26
21
  }
22
+ if (!config?.token)
23
+ throw new CliError('not_logged_in', NOT_LOGGED_IN);
27
24
  return config.token;
28
25
  };
29
26
  // Which Auth tenant `faable auth` manages, in precedence order:
@@ -193,10 +190,10 @@ const issueTenantToken = async (session_token, account_id, api = FaableApi.creat
193
190
  const hintAuthError = (e) => {
194
191
  const status = e?.response?.status;
195
192
  if (status === 401) {
196
- throw new Error("Unauthorized (401) by the Auth management API. Your session may have expired — run 'faable login' and retry.");
193
+ throw new CliError('session_expired', "Unauthorized (401) by the Auth management API. Your session may have expired — run 'faable login' and retry.", { status, cause: e });
197
194
  }
198
195
  if (status === 403) {
199
- throw new Error('Forbidden (403): your session is not allowed to manage this tenant. Check the tenant (`faable whoami`, --account, --project), or use credentials with management access for it.');
196
+ throw new CliError('forbidden', 'Forbidden (403): your session is not allowed to manage this tenant. Check the tenant (`faable whoami`, --account, --project), or use credentials with management access for it.', { status, cause: e });
200
197
  }
201
198
  throw e;
202
199
  };
@@ -1,4 +1,5 @@
1
1
  import axios from 'axios';
2
+ import { CLI_CLIENT } from './client_id.js';
2
3
 
3
4
  // FAABLE_API_URL: point the CLI at another Deploy API frontend (e.g.
4
5
  // https://deploy.staging.faable.com for staging-gated testing). BASE_URL is
@@ -9,7 +10,9 @@ const BASE_URL = process.env.FAABLE_API_URL ||
9
10
  const create_base_client = () => {
10
11
  return axios.create({
11
12
  baseURL: BASE_URL,
12
- timeout: 10000
13
+ timeout: 10000,
14
+ // Who is calling: the api tags its request log by this name.
15
+ headers: { "x-faable-client": CLI_CLIENT }
13
16
  });
14
17
  };
15
18
 
@@ -0,0 +1,22 @@
1
+ import { version } from '../config.js';
2
+
3
+ // What the CLI says it is, in `x-faable-client` on every request — to the
4
+ // Deploy API (base_client.ts) and to the auth server (auth.ts) alike. The api
5
+ // tags its request log by the name before the `/`, so traffic from the CLI —
6
+ // and from the MCP server driving it — can be counted apart.
7
+ //
8
+ // `<name>/<version>`. No commit: the CLI has no release SHA constant, and a
9
+ // dev build (`0.0.0-development`) is identifiable as such by the version
10
+ // alone. A `+suffix` would be read as a commit sha, so the driver is told by
11
+ // the name, not by a suffix. auth caps the version segment at 32 chars.
12
+ //
13
+ // FAABLE_CLIENT_NAME: who drives this CLI when it isn't a person. The Faable
14
+ // MCP server sets `faable-mcp`. Only `faable-*` names: anything else is
15
+ // ignored rather than letting an environment impersonate another client.
16
+ const client_name = (env = process.env) => {
17
+ const name = env.FAABLE_CLIENT_NAME?.trim().toLowerCase() ?? '';
18
+ return /^faable-[a-z0-9-]{1,24}$/.test(name) ? name : 'faable-cli';
19
+ };
20
+ const CLI_CLIENT = `${client_name()}/${version}`;
21
+
22
+ export { CLI_CLIENT, client_name };
@@ -5,8 +5,8 @@ import { oidc_strategy } from './strategies/oidc.strategy.js';
5
5
  import { bearer_strategy } from './strategies/bearer.strategy.js';
6
6
  import { CredentialsStore } from '../lib/CredentialsStore.js';
7
7
  import { loadLiveCredentials } from './session.js';
8
- import { log } from '../log.js';
9
8
  import { configuredProject, resolveProjectRef } from './project.js';
9
+ import { CliError, NOT_LOGGED_IN } from '../lib/errors.js';
10
10
 
11
11
  const context = async (targetAppId) => {
12
12
  let api;
@@ -70,10 +70,8 @@ const context = async (targetAppId) => {
70
70
  // whose first call returns 401 — FaableApi maps that to the same re-login hint.
71
71
  const requireApi = async (targetAppId) => {
72
72
  const ctx = await context(targetAppId);
73
- if (!ctx.api) {
74
- log.error("❌ Not logged in. Run 'faable login' first.");
75
- process.exit(1);
76
- }
73
+ if (!ctx.api)
74
+ throw new CliError("not_logged_in", NOT_LOGGED_IN);
77
75
  return ctx;
78
76
  };
79
77
 
@@ -1,6 +1,7 @@
1
1
  import { CredentialsStore } from '../lib/CredentialsStore.js';
2
2
  import { refreshToken } from './auth.js';
3
3
  import { log } from '../log.js';
4
+ import { CliError } from '../lib/errors.js';
4
5
 
5
6
  // True when a stored JWT access token is still valid (has a future `exp`).
6
7
  // Anything we can't decode, or without `exp`, counts as NOT live — callers then
@@ -54,8 +55,7 @@ const loadLiveCredentials = async (store = new CredentialsStore()) => {
54
55
  const body = e
55
56
  ?.response?.data;
56
57
  if (body?.error_code === "user_suspended") {
57
- log.error("❌ Your account has been suspended. Contact support@faable.com.");
58
- process.exit(1);
58
+ throw new CliError("account_suspended", "Your account has been suspended. Contact support@faable.com.");
59
59
  }
60
60
  // Refresh failed — keep the stale config; the API call's 401 (or
61
61
  // requireApi) will tell the user to run `faable login` again.
@@ -1,13 +1,14 @@
1
- import prompts from 'prompts';
2
1
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
3
2
  import { log } from '../../../log.js';
3
+ import { json_option, print_json } from '../../../lib/listing.js';
4
4
  import { formatTriggers } from './triggers.js';
5
5
  import { tenant_options } from '../options.js';
6
+ import { confirm } from '../../../lib/interactive.js';
6
7
 
7
8
  const actions_rm = {
8
9
  command: 'rm <action_id>',
9
10
  describe: 'Delete an action',
10
- builder: yargs => tenant_options(yargs)
11
+ builder: yargs => json_option(tenant_options(yargs))
11
12
  .positional('action_id', {
12
13
  type: 'string',
13
14
  demandOption: true,
@@ -24,21 +25,17 @@ const actions_rm = {
24
25
  const api = await requireAuthAdmin(args);
25
26
  // Fetch first: shows WHAT will be deleted and 404s before the prompt.
26
27
  const action = await api.actionGet(args.action_id);
27
- if (!args.yes) {
28
- const { confirm } = await prompts({
29
- type: 'toggle',
30
- name: 'confirm',
31
- message: `Delete action "${action.name}" (${action.id}, triggers ${formatTriggers(action)})?`,
32
- initial: false,
33
- active: 'yes',
34
- inactive: 'no'
35
- });
36
- if (!confirm) {
37
- log.info('Cancelled.');
38
- return;
39
- }
28
+ const go = await confirm({
29
+ message: `Delete action "${action.name}" (${action.id}, triggers ${formatTriggers(action)})?`,
30
+ yes: args.yes
31
+ });
32
+ if (!go) {
33
+ log.info('Cancelled.');
34
+ return;
40
35
  }
41
36
  await api.actionDelete(args.action_id);
37
+ if (args.json)
38
+ return print_json({ deleted: true, id: args.action_id });
42
39
  log.info(`🗑️ Deleted action ${args.action_id}.`);
43
40
  })
44
41
  };
@@ -1,13 +1,14 @@
1
- import prompts from 'prompts';
2
1
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
3
2
  import { log } from '../../../log.js';
3
+ import { json_option, print_json } from '../../../lib/listing.js';
4
4
  import { tenant_options } from '../options.js';
5
5
  import { resolve_client } from './resolve.js';
6
+ import { confirm } from '../../../lib/interactive.js';
6
7
 
7
8
  const clients_rm = {
8
9
  command: 'rm <client_id>',
9
10
  describe: 'Delete an OAuth client',
10
- builder: yargs => tenant_options(yargs)
11
+ builder: yargs => json_option(tenant_options(yargs))
11
12
  .positional('client_id', {
12
13
  type: 'string',
13
14
  demandOption: true,
@@ -23,21 +24,22 @@ const clients_rm = {
23
24
  handler: withAuthHints(async (args) => {
24
25
  const api = await requireAuthAdmin(args);
25
26
  const client = await resolve_client(api, args.client_id);
26
- if (!args.yes) {
27
- const { confirm } = await prompts({
28
- type: 'toggle',
29
- name: 'confirm',
30
- message: `Delete client "${client.name}" (${client.client_id})? Apps using it will stop authenticating.`,
31
- initial: false,
32
- active: 'yes',
33
- inactive: 'no'
34
- });
35
- if (!confirm) {
36
- log.info('Cancelled.');
37
- return;
38
- }
27
+ const go = await confirm({
28
+ message: `Delete client "${client.name}" (${client.client_id})? Apps using it will stop authenticating.`,
29
+ yes: args.yes
30
+ });
31
+ if (!go) {
32
+ log.info('Cancelled.');
33
+ return;
39
34
  }
40
35
  await api.clientDelete(client.id);
36
+ if (args.json) {
37
+ return print_json({
38
+ deleted: true,
39
+ id: client.id,
40
+ client_id: client.client_id
41
+ });
42
+ }
41
43
  log.info(`🗑️ Deleted client ${client.client_id} (${client.id}).`);
42
44
  })
43
45
  };
@@ -1,8 +1,8 @@
1
1
  import { openSync, fchmodSync, writeSync, closeSync } from 'node:fs';
2
- import prompts from 'prompts';
3
2
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
4
3
  import { log } from '../../../log.js';
5
4
  import { tenant_options } from '../options.js';
5
+ import { confirm } from '../../../lib/interactive.js';
6
6
 
7
7
  // GET /user/export pages at most this many users.
8
8
  const PAGE_SIZE = 500;
@@ -45,18 +45,12 @@ const users_export = {
45
45
  if (hashes && to_stdout && process.stdout.isTTY && !args.force) {
46
46
  throw new Error('Refusing to print password hashes to a terminal: pass a file, pipe stdout, or add --force');
47
47
  }
48
- if (hashes && !args.yes) {
49
- // The prompt goes to stderr so a piped stdout stays pure NDJSON.
50
- const { confirm } = await prompts({
51
- type: 'toggle',
52
- name: 'confirm',
48
+ if (hashes) {
49
+ const go = await confirm({
53
50
  message: 'Export password hashes? Anyone holding the file can attack every password offline. The export is audited and notifies the tenant subscribers.',
54
- initial: false,
55
- active: 'yes',
56
- inactive: 'no',
57
- stdout: process.stderr
58
- }, { onCancel: () => false });
59
- if (!confirm) {
51
+ yes: args.yes
52
+ });
53
+ if (!go) {
60
54
  process.stderr.write('Cancelled.\n');
61
55
  return;
62
56
  }
@@ -1,11 +1,11 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import prompts from 'prompts';
3
2
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
4
3
  import { log } from '../../../log.js';
5
4
  import { tenant_options } from '../options.js';
6
5
  import { json_option, print_json } from '../../../lib/listing.js';
7
6
  import { SOURCES, ADAPTERS, SOURCE_FORMAT } from './import/adapters.js';
8
7
  import { parse_records } from './import/records.js';
8
+ import { confirm } from '../../../lib/interactive.js';
9
9
 
10
10
  // The server takes at most this many rows per request (POST /user/import).
11
11
  const BATCH_SIZE = 500;
@@ -119,16 +119,12 @@ const users_import = {
119
119
  throw new Error(`No importable users in ${args.file} (${total} record(s) read)`);
120
120
  }
121
121
  const dry_run = !!args['dry-run'];
122
- if (!args.yes && !dry_run) {
123
- const { confirm } = await prompts({
124
- type: 'toggle',
125
- name: 'confirm',
122
+ if (!dry_run) {
123
+ const go = await confirm({
126
124
  message: `Import ${rows.length} user(s) from ${args.from}?`,
127
- initial: false,
128
- active: 'yes',
129
- inactive: 'no'
125
+ yes: args.yes
130
126
  });
131
- if (!confirm) {
127
+ if (!go) {
132
128
  log.info('Cancelled.');
133
129
  return;
134
130
  }
@@ -1,9 +1,9 @@
1
- import prompts from 'prompts';
2
1
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
3
2
  import { log } from '../../../log.js';
4
3
  import { tenant_options } from '../options.js';
5
4
  import { json_option, print_json } from '../../../lib/listing.js';
6
5
  import { wants_stdin, read_stdin, parse_user_ids } from './ids.js';
6
+ import { confirm } from '../../../lib/interactive.js';
7
7
 
8
8
  const users_reinstate = {
9
9
  command: 'reinstate [user_ids..]',
@@ -21,7 +21,7 @@ const users_reinstate = {
21
21
  description: 'Skip the confirmation prompt'
22
22
  })
23
23
  .example('$0 auth users reinstate user_abc123', 'Reinstate one user')
24
- .example('faable auth users list --suspended --json | jq -r ".[].id" | $0 auth users reinstate -y', 'Bulk-reinstate ids piped from a filtered listing')
24
+ .example('faable auth users list --suspended --json | jq -r ".data[].id" | $0 auth users reinstate -y', 'Bulk-reinstate ids piped from a filtered listing')
25
25
  .showHelpOnFail(false),
26
26
  handler: withAuthHints(async (args) => {
27
27
  const argv_ids = args.user_ids ?? [];
@@ -29,21 +29,14 @@ const users_reinstate = {
29
29
  ? await read_stdin()
30
30
  : null;
31
31
  const ids = parse_user_ids(argv_ids, stdin);
32
- if (!args.yes) {
33
- const preview = ids.slice(0, 5).join(', ') + (ids.length > 5 ? ', …' : '');
34
- // In a non-TTY run without --yes, prompts resolves undefined → cancel.
35
- const { confirm } = await prompts({
36
- type: 'toggle',
37
- name: 'confirm',
38
- message: `Reinstate ${ids.length} user(s) (${preview})?`,
39
- initial: false,
40
- active: 'yes',
41
- inactive: 'no'
42
- });
43
- if (!confirm) {
44
- log.info('Cancelled.');
45
- return;
46
- }
32
+ const preview = ids.slice(0, 5).join(', ') + (ids.length > 5 ? ', …' : '');
33
+ const go = await confirm({
34
+ message: `Reinstate ${ids.length} user(s) (${preview})?`,
35
+ yes: args.yes
36
+ });
37
+ if (!go) {
38
+ log.info('Cancelled.');
39
+ return;
47
40
  }
48
41
  const api = await requireAuthAdmin(args);
49
42
  const results = [];
@@ -1,9 +1,9 @@
1
- import prompts from 'prompts';
2
1
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
3
2
  import { log } from '../../../log.js';
4
3
  import { tenant_options } from '../options.js';
5
4
  import { json_option, print_json } from '../../../lib/listing.js';
6
5
  import { wants_stdin, read_stdin, parse_user_ids } from './ids.js';
6
+ import { confirm } from '../../../lib/interactive.js';
7
7
 
8
8
  const users_suspend = {
9
9
  command: 'suspend [user_ids..]',
@@ -27,7 +27,7 @@ const users_suspend = {
27
27
  })
28
28
  .example('$0 auth users suspend user_abc123 --reason "abuse: proxy payload"', 'Suspend one user')
29
29
  .example('$0 auth users suspend user_a user_b user_c -y -r "abuse wave"', 'Suspend several users without prompting')
30
- .example('faable auth users list --query email_verified:false --json | jq -r ".[].id" | $0 auth users suspend -y', 'Bulk-suspend ids piped from a filtered listing')
30
+ .example('faable auth users list --query email_verified:false --json | jq -r ".data[].id" | $0 auth users suspend -y', 'Bulk-suspend ids piped from a filtered listing')
31
31
  .showHelpOnFail(false),
32
32
  handler: withAuthHints(async (args) => {
33
33
  const argv_ids = args.user_ids ?? [];
@@ -35,21 +35,14 @@ const users_suspend = {
35
35
  ? await read_stdin()
36
36
  : null;
37
37
  const ids = parse_user_ids(argv_ids, stdin);
38
- if (!args.yes) {
39
- const preview = ids.slice(0, 5).join(', ') + (ids.length > 5 ? ', …' : '');
40
- // In a non-TTY run without --yes, prompts resolves undefined → cancel.
41
- const { confirm } = await prompts({
42
- type: 'toggle',
43
- name: 'confirm',
44
- message: `Suspend ${ids.length} user(s) (${preview})?`,
45
- initial: false,
46
- active: 'yes',
47
- inactive: 'no'
48
- });
49
- if (!confirm) {
50
- log.info('Cancelled.');
51
- return;
52
- }
38
+ const preview = ids.slice(0, 5).join(', ') + (ids.length > 5 ? ', …' : '');
39
+ const go = await confirm({
40
+ message: `Suspend ${ids.length} user(s) (${preview})?`,
41
+ yes: args.yes
42
+ });
43
+ if (!go) {
44
+ log.info('Cancelled.');
45
+ return;
53
46
  }
54
47
  const api = await requireAuthAdmin(args);
55
48
  const results = [];