@faable/faable 3.4.2 → 4.0.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 (35) hide show
  1. package/dist/api/FaableApi.js +49 -9
  2. package/dist/api/auth_admin.js +144 -35
  3. package/dist/api/context.js +9 -0
  4. package/dist/api/project.js +62 -0
  5. package/dist/commands/auth/accounts/index.js +76 -0
  6. package/dist/commands/auth/actions/create.js +2 -2
  7. package/dist/commands/auth/actions/get.js +3 -2
  8. package/dist/commands/auth/actions/list.js +13 -13
  9. package/dist/commands/auth/actions/update.js +2 -2
  10. package/dist/commands/auth/clients/create.js +2 -2
  11. package/dist/commands/auth/clients/get.js +2 -2
  12. package/dist/commands/auth/clients/list.js +13 -13
  13. package/dist/commands/auth/index.js +6 -3
  14. package/dist/commands/auth/logs/get.js +3 -2
  15. package/dist/commands/auth/logs/list.js +13 -13
  16. package/dist/commands/auth/options.js +5 -28
  17. package/dist/commands/auth/render.js +2 -14
  18. package/dist/commands/auth/users/get.js +5 -5
  19. package/dist/commands/auth/users/import.js +2 -2
  20. package/dist/commands/auth/users/list.js +13 -13
  21. package/dist/commands/auth/users/reinstate.js +2 -2
  22. package/dist/commands/auth/users/suspend.js +2 -2
  23. package/dist/commands/deploy/domains/list.js +10 -3
  24. package/dist/commands/deploy/inspect/deployments.js +12 -13
  25. package/dist/commands/deploy/inspect/list.js +18 -9
  26. package/dist/commands/deploy/secrets/list.js +11 -2
  27. package/dist/commands/deploy/waf/list.js +6 -2
  28. package/dist/commands/project/index.js +95 -0
  29. package/dist/commands/whoami/index.js +55 -5
  30. package/dist/index.js +28 -2
  31. package/dist/lib/ContextStore.js +30 -0
  32. package/dist/lib/listing.js +91 -0
  33. package/dist/log.js +12 -1
  34. package/package.json +1 -1
  35. package/dist/commands/auth/paging.js +0 -9
@@ -1,12 +1,41 @@
1
1
  import { log } from '../../log.js';
2
2
  import { getMe } from '../../api/auth.js';
3
+ import { resolveTenant } from '../../api/auth_admin.js';
4
+ import { FaableApi } from '../../api/FaableApi.js';
5
+ import { SOURCE_LABEL, requireProject } from '../../api/project.js';
3
6
  import { loadLiveCredentials } from '../../api/session.js';
7
+ import { bearer_strategy } from '../../api/strategies/bearer.strategy.js';
4
8
  import { CredentialsStore } from '../../lib/CredentialsStore.js';
9
+ import { print_json, print, json_option } from '../../lib/listing.js';
5
10
 
11
+ // Who you are, and what the CLI acts on: the active project and the Auth
12
+ // tenant `faable auth` would manage. The context is best-effort — a missing
13
+ // project is something to show, not a reason to fail `whoami`.
14
+ const describeContext = async (token) => {
15
+ const api = FaableApi.create({ authStrategy: bearer_strategy, auth: { token } });
16
+ let project;
17
+ let tenant;
18
+ try {
19
+ const { id, source } = await requireProject(api);
20
+ const p = await api.getProject(id).catch(() => undefined);
21
+ project = { id, name: p?.name, source };
22
+ }
23
+ catch (e) {
24
+ project = { error: e.message };
25
+ }
26
+ try {
27
+ tenant = await resolveTenant({}, api);
28
+ }
29
+ catch (e) {
30
+ tenant = { error: e.message };
31
+ }
32
+ return { project, tenant };
33
+ };
6
34
  const whoami = {
7
35
  command: "whoami",
8
- describe: "Display the current logged in user",
9
- handler: async () => {
36
+ describe: "Show the logged in user, the active project and its Auth tenant",
37
+ builder: (yargs) => json_option(yargs),
38
+ handler: async (args) => {
10
39
  const store = new CredentialsStore();
11
40
  // Auto-refreshes an expired token via the stored refresh_token.
12
41
  const config = await loadLiveCredentials(store);
@@ -16,22 +45,43 @@ const whoami = {
16
45
  // API-key sessions can't be introspected at the auth server's /me; fall
17
46
  // back to the email captured at login time.
18
47
  if (config?.apikey && config.email) {
19
- log.info(`Logged in as: ${config.email} (API key)`);
48
+ if (args.json)
49
+ return print_json({ email: config.email, credential: "apikey" });
50
+ print(`Logged in as: ${config.email} (API key)`);
20
51
  return;
21
52
  }
22
53
  log.error("❌ Not logged in. Run 'faable login' first.");
23
54
  process.exit(1);
24
55
  }
56
+ let me;
25
57
  try {
26
58
  // Validate against the Auth server (it issued the token); the deploy API
27
59
  // has no /me route.
28
- const me = await getMe(token);
29
- log.info(`Logged in as: ${me.email}`);
60
+ me = await getMe(token);
30
61
  }
31
62
  catch {
32
63
  log.error("❌ Not logged in or session expired");
33
64
  process.exit(1);
34
65
  }
66
+ const { project, tenant } = await describeContext(token);
67
+ if (args.json) {
68
+ return print_json({ email: me.email, credential: "token", project, auth_tenant: tenant });
69
+ }
70
+ print(`Logged in as: ${me.email}`);
71
+ if ("error" in project) {
72
+ print(`Project: none — faable project use <id|name>`);
73
+ }
74
+ else {
75
+ const name = project.name ? `${project.name} (${project.id})` : project.id;
76
+ print(`Project: ${name} [${SOURCE_LABEL[project.source]}]`);
77
+ }
78
+ if ("error" in tenant) {
79
+ print(`Auth tenant: none — ${tenant.error}`);
80
+ }
81
+ else {
82
+ const parts = [tenant.name, tenant.account && `(${tenant.account})`, tenant.domain];
83
+ print(`Auth tenant: ${parts.filter(Boolean).join(" ")}`);
84
+ }
35
85
  },
36
86
  };
37
87
 
package/dist/index.js CHANGED
@@ -5,13 +5,16 @@ import { deploy } from './commands/deploy/index.js';
5
5
  import { link_deprecated } from './commands/link/index.js';
6
6
  import { login } from './commands/login/index.js';
7
7
  import { logout } from './commands/logout/index.js';
8
+ import { project } from './commands/project/index.js';
8
9
  import { upgrade } from './commands/upgrade/index.js';
9
10
  import { whoami } from './commands/whoami/index.js';
10
11
  import { version } from './config.js';
11
12
  import { Configuration } from './lib/Configuration.js';
13
+ import { setProjectFlag } from './api/project.js';
12
14
  import { notifyIfUpdateAvailable } from './lib/UpdateChecker.js';
13
15
  import { log } from './log.js';
14
16
 
17
+ const wants_json = () => process.argv.includes('--json');
15
18
  // yargs re-runs before-validation middlewares once per nested command level
16
19
  // (`deploy secrets` = 2 runs), so keep the banner and update check to one.
17
20
  let banner_shown = false;
@@ -27,7 +30,8 @@ yg.scriptName('faable')
27
30
  // update-check notice). Same for `auth users export` without a file,
28
31
  // whose stdout IS the export.
29
32
  const exports_to_stdout = argv._.slice(0, 3).join(' ') === 'auth users export' && !argv.file;
30
- if (argv.json || exports_to_stdout)
33
+ // Nor when nobody is watching stderr (a script, the MCP server).
34
+ if (argv.json || exports_to_stdout || !process.stderr.isTTY)
31
35
  return;
32
36
  log.info(`Faable CLI ${version}`);
33
37
  // `upgrade` does its own (forced) check
@@ -39,8 +43,15 @@ yg.scriptName('faable')
39
43
  alias: 'config',
40
44
  description: 'Path to the local `faable.json` file',
41
45
  string: true
46
+ })
47
+ .option('p', {
48
+ alias: 'project',
49
+ description: 'Project to act on, id or name (env FAABLE_PROJECT; default: `faable project use`)',
50
+ string: true,
51
+ global: true
42
52
  })
43
53
  .middleware(function (argv) {
54
+ setProjectFlag(argv.project);
44
55
  if (argv.config) {
45
56
  Configuration.instance().setConfigFile(argv.config, {
46
57
  ignoreWarnings: false
@@ -52,6 +63,7 @@ yg.scriptName('faable')
52
63
  }, true)
53
64
  .command(deploy)
54
65
  .command(auth)
66
+ .command(project)
55
67
  .command(login)
56
68
  .command(logout)
57
69
  .command(whoami)
@@ -65,7 +77,21 @@ yg.scriptName('faable')
65
77
  .help()
66
78
  .fail(function (msg, err) {
67
79
  if (err) {
68
- log.error(`❌ ${err.message}`);
80
+ // With --json the caller is a program: the error as JSON too, on
81
+ // stderr, so it can tell a 404 from a 403 without parsing prose.
82
+ if (wants_json()) {
83
+ const e = err;
84
+ process.stderr.write(JSON.stringify({
85
+ error: {
86
+ message: e.message,
87
+ ...(e.code ? { code: e.code } : {}),
88
+ ...(e.status ? { status: e.status } : {})
89
+ }
90
+ }) + '\n');
91
+ }
92
+ else {
93
+ log.error(`❌ ${err.message}`);
94
+ }
69
95
  process.exit(1);
70
96
  return;
71
97
  }
@@ -0,0 +1,30 @@
1
+ import fs from 'fs-extra';
2
+ import os from 'os';
3
+ import path__default from 'path';
4
+
5
+ class ContextStore {
6
+ faable_home;
7
+ constructor(faable_home = path__default.join(os.homedir(), '.faable')) {
8
+ this.faable_home = faable_home;
9
+ }
10
+ get path() {
11
+ return path__default.join(this.faable_home, 'config.json');
12
+ }
13
+ async load() {
14
+ try {
15
+ return (await fs.readJSON(this.path)) || {};
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ async update(patch) {
22
+ const next = patch(await this.load());
23
+ await fs.ensureDir(this.faable_home);
24
+ await fs.writeJSON(this.path, next, { spaces: 2 });
25
+ await fs.chmod(this.path, 0o600);
26
+ return next;
27
+ }
28
+ }
29
+
30
+ export { ContextStore };
@@ -0,0 +1,91 @@
1
+ // One listing contract for every `faable … list`, modelled on Stripe's CLI so
2
+ // a script — or the MCP server that will drive this CLI — learns it once:
3
+ //
4
+ // --limit N one page of up to N items (default 100, max 200)
5
+ // --starting-after CUR the page after CUR (the previous page's next_cursor)
6
+ // --all walk every page
7
+ // --json {"object":"list","data":[…],"has_more",…,"next_cursor"}
8
+ //
9
+ // The servers page by cursor (`pageSize` + `next`, arch/api/pagination.md);
10
+ // this is the translation. There is no --ending-before: the cursor only runs
11
+ // forward.
12
+ const MAX_PAGE_SIZE = 200;
13
+ const DEFAULT_PAGE_SIZE = 100;
14
+ const json_option = (yargs) => yargs.option('json', {
15
+ type: 'boolean',
16
+ default: false,
17
+ description: 'Output JSON on stdout (for scripting)'
18
+ });
19
+ const list_options = (yargs, { default_limit = DEFAULT_PAGE_SIZE } = {}) => json_option(yargs)
20
+ .option('limit', {
21
+ alias: 'n',
22
+ type: 'number',
23
+ default: default_limit,
24
+ description: `Items per page (1-${MAX_PAGE_SIZE})`
25
+ })
26
+ .option('starting-after', {
27
+ alias: 'cursor',
28
+ type: 'string',
29
+ description: 'Cursor to continue from (the next_cursor of the previous page)'
30
+ })
31
+ .option('all', {
32
+ type: 'boolean',
33
+ default: false,
34
+ description: 'Fetch every page'
35
+ })
36
+ .check(argv => {
37
+ const limit = argv.limit;
38
+ if (limit !== undefined &&
39
+ (!Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE)) {
40
+ throw new Error(`--limit must be an integer between 1 and ${MAX_PAGE_SIZE}`);
41
+ }
42
+ return true;
43
+ });
44
+ // A page of the server's `{ results, next }` from the listing flags. With
45
+ // --all the cursor is walked here rather than by the SDK paginator's `.all()`,
46
+ // which gives up at 100 pages.
47
+ const fetch_page = async (fetch, args) => {
48
+ const pageSize = args.all ? MAX_PAGE_SIZE : args.limit;
49
+ let next = args.startingAfter || undefined;
50
+ const data = [];
51
+ do {
52
+ const page = await fetch({ pageSize, ...(next ? { next } : {}) });
53
+ data.push(...page.results);
54
+ const following = page.next || undefined;
55
+ if (following && following === next)
56
+ throw new Error('Bad next cursor');
57
+ next = following;
58
+ } while (args.all && next);
59
+ return as_list(data, next ?? null);
60
+ };
61
+ const from_paginator = (listing) => ({ pageSize, next }) => listing.pass({ pageSize: String(pageSize), ...(next ? { cursor: next } : {}) });
62
+ const as_list = (data, next_cursor = null) => ({
63
+ object: 'list',
64
+ data,
65
+ has_more: !!next_cursor,
66
+ next_cursor
67
+ });
68
+ // Data goes to stdout, untouched by the logger (which writes to stderr).
69
+ const print = (line = '') => {
70
+ process.stdout.write(line + '\n');
71
+ };
72
+ const print_json = (data) => {
73
+ print(JSON.stringify(data, null, 2));
74
+ };
75
+ // The rerun hint for the next page, on stderr (the logger's): shown to a
76
+ // person, invisible to a pipe.
77
+ const more_hint = (page) => page.has_more
78
+ ? `… more results: rerun with --starting-after ${page.next_cursor} (or --all)`
79
+ : undefined;
80
+ // Left-padded fixed-width table lines. Cells are stringified as-is; column
81
+ // width = max(header, cells).
82
+ const table_lines = (headers, rows) => {
83
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => (r[i] ?? '').length)));
84
+ const render = (cells) => cells
85
+ .map((c, i) => (i === cells.length - 1 ? c : (c ?? '').padEnd(widths[i])))
86
+ .join(' ')
87
+ .trimEnd();
88
+ return [render(headers), ...rows.map(render)];
89
+ };
90
+
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 };
package/dist/log.js CHANGED
@@ -6,6 +6,10 @@ import { buildLog } from './lib/log_buffer.js';
6
6
  // transport: the worker's MessagePort can stay referenced after the last log
7
7
  // (thread-stream race) and intermittently keep the CLI from exiting.
8
8
  //
9
+ // Messages go to STDERR. stdout is for data only — a listing, a JSON
10
+ // document, logs being streamed — so `faable … --json | jq` and the MCP
11
+ // server reading this CLI never get a progress line mixed into it.
12
+ //
9
13
  // Tee: terminal stream (colorized) + a plain-text copy into the build-log
10
14
  // buffer so CLI messages land in the logs attached to the deployment.
11
15
  // What the terminal shows: just the message. pino's envelope
@@ -35,7 +39,14 @@ const toPlainText = prettyFactory({ colorize: false, sync: true });
35
39
  // NB: the two-arg form matters — pino(multistream) alone would treat the
36
40
  // multistream object as the options bag and log raw JSON to stdout.
37
41
  const log = pino({}, pino.multistream([
38
- { stream: pretty({ colorize: true, sync: true, ...TERMINAL_FORMAT }) },
42
+ {
43
+ stream: pretty({
44
+ colorize: process.stderr.isTTY ?? false,
45
+ sync: true,
46
+ destination: 2,
47
+ ...TERMINAL_FORMAT,
48
+ }),
49
+ },
39
50
  {
40
51
  stream: {
41
52
  write(line) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "3.4.2",
3
+ "version": "4.0.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",
@@ -1,9 +0,0 @@
1
- const fetch_items = async (listing, all) => {
2
- if (all) {
3
- return { items: await listing.all(), more: false };
4
- }
5
- const page = await listing.pass();
6
- return { items: page.results, more: !!page.next };
7
- };
8
-
9
- export { fetch_items };