@faable/faable 3.4.2 → 4.1.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 (36) 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 +146 -0
  29. package/dist/commands/project/picker.js +56 -0
  30. package/dist/commands/whoami/index.js +55 -5
  31. package/dist/index.js +44 -3
  32. package/dist/lib/ContextStore.js +30 -0
  33. package/dist/lib/listing.js +91 -0
  34. package/dist/log.js +12 -1
  35. package/package.json +1 -1
  36. package/dist/commands/auth/paging.js +0 -9
@@ -33,6 +33,11 @@ const allPages = async (fetch_page) => {
33
33
  } while (next);
34
34
  return items;
35
35
  };
36
+ // Only what's set: the list endpoints answer 400 to an unknown or empty param.
37
+ const page_params = ({ pageSize, next }) => ({
38
+ ...(pageSize ? { pageSize } : {}),
39
+ ...(next ? { next } : {})
40
+ });
36
41
  const data = async (res) => {
37
42
  const items = (await res).data;
38
43
  return items;
@@ -40,6 +45,10 @@ const data = async (res) => {
40
45
  class FaableApi {
41
46
  client;
42
47
  strategy;
48
+ // The active project (`--project`, FAABLE_PROJECT, `faable project use`),
49
+ // when there is one. Project-wide listings are scoped to it; calls about
50
+ // one app keep using that app's own project.
51
+ project;
43
52
  constructor(config) {
44
53
  const { authStrategy, auth } = config;
45
54
  this.client = create_base_client();
@@ -110,10 +119,35 @@ class FaableApi {
110
119
  async issueAuthAccountToken(account_id) {
111
120
  return data(this.client.post(`/auth-accounts/${account_id}/token`));
112
121
  }
122
+ // Every app the caller can see, across projects. Used to match the working
123
+ // directory's repository to its app: the link is global, so a repo whose
124
+ // app lives outside the active project must still resolve.
113
125
  async list() {
114
- return allPages(next => data(this.client.get(`/app`, {
115
- params: { pageSize: 200, ...(next ? { next } : {}) }
116
- })));
126
+ return allPages(next => this.listApps({ pageSize: 200, next }, null));
127
+ }
128
+ // One page of apps, scoped to `project` (default: the active project).
129
+ // `null` = unscoped. The api filters by the header, so an admin session
130
+ // doesn't page through the whole platform.
131
+ async listApps(params, project = this.project ?? null) {
132
+ return data(this.client.get(`/app`, {
133
+ params: page_params(params),
134
+ ...(project ? { headers: projectHeader(project) } : {})
135
+ }));
136
+ }
137
+ // Projects the caller belongs to (an admin session sees all of them).
138
+ // `q` searches name/description/slug; `user_id` keeps the ones they own.
139
+ async listProjects(params) {
140
+ const { q, user_id, ...page } = params;
141
+ return data(this.client.get(`/project`, {
142
+ params: { ...page_params(page), ...(q ? { q } : {}), ...(user_id ? { user_id } : {}) }
143
+ }));
144
+ }
145
+ async getProject(project_id) {
146
+ return data(this.client.get(`/project/${project_id}`));
147
+ }
148
+ // The Faable Auth tenants of a project. Membership-checked by the api.
149
+ async listProjectAuthAccounts(project_id, params = {}) {
150
+ return data(this.client.get(`/project/${project_id}/auth-accounts`, { params: page_params(params) }));
117
151
  }
118
152
  async getBySlug(slug) {
119
153
  return data(this.client.get(`/app/slug/${slug}`));
@@ -209,10 +243,13 @@ class FaableApi {
209
243
  // Deployments of an app, newest first (the API's list index sorts
210
244
  // createdAt desc). Team pinned via header — same reason as domains.
211
245
  async listDeployments(app_id, team) {
212
- return firstPage(data(this.client.get(`/deployment`, {
213
- params: { app_id },
246
+ return firstPage(this.listDeploymentsPage(app_id, team));
247
+ }
248
+ async listDeploymentsPage(app_id, team, params = {}) {
249
+ return data(this.client.get(`/deployment`, {
250
+ params: { app_id, ...page_params(params) },
214
251
  headers: projectHeader(team)
215
- })));
252
+ }));
216
253
  }
217
254
  // Build and deploy the current head of the deploy branch server-side —
218
255
  // the same path a push webhook takes, same-commit dedupe included.
@@ -236,10 +273,13 @@ class FaableApi {
236
273
  // so every call pins the app's project via `x-faable-project` (same pattern
237
274
  // as createSecretsBatch).
238
275
  async listDomains(app_id, team) {
239
- return firstPage(data(this.client.get(`/domain`, {
240
- params: { app_id },
276
+ return firstPage(this.listDomainsPage(app_id, team));
277
+ }
278
+ async listDomainsPage(app_id, team, params = {}) {
279
+ return data(this.client.get(`/domain`, {
280
+ params: { app_id, ...page_params(params) },
241
281
  headers: projectHeader(team)
242
- })));
282
+ }));
243
283
  }
244
284
  async createDomain(team, params) {
245
285
  return data(this.client.post(`/domain`, params, {
@@ -1,48 +1,157 @@
1
+ import fs from 'fs-extra';
2
+ import os from 'os';
3
+ import path__default from 'path';
4
+ import { ContextStore } from '../lib/ContextStore.js';
1
5
  import { CredentialsStore } from '../lib/CredentialsStore.js';
2
6
  import { log } from '../log.js';
3
7
  import { FaableApi } from './FaableApi.js';
4
- import { createBearerAuthApi, createAnonymousAuthApi } from './auth.js';
5
- import { loadLiveCredentials } from './session.js';
8
+ import { AUTH_DOMAIN, createAnonymousAuthApi, createBearerAuthApi } from './auth.js';
9
+ import { requireProject } from './project.js';
10
+ import { loadLiveCredentials, isTokenLive } from './session.js';
6
11
  import { bearer_strategy } from './strategies/bearer.strategy.js';
7
12
 
8
- // Default tenant host. `faable auth` is customer-facing: a customer targets
9
- // their own tenant with --auth-url https://<account>.auth.faable.link (or
10
- // FAABLE_AUTH_URL); the default points at the Faable tenant.
11
- const DEFAULT_AUTH_URL = 'https://faable.auth.faable.link';
12
- // Build a management-API client for the Auth server reusing the CLI session
13
- // (FAABLE_TOKEN → `faable login` credentials, auto-refreshed). The bearer is
14
- // sent as-is and the SERVER decides whether it may manage the target tenant
15
- // (today MGMT_AUTH_ENFORCE is authz dry-run; when enforced, tokens without the
16
- // management audience/scopes for the tenant will get 403 — see
17
- // auth/docs/management-api-m2m-token.md for the M2M path).
18
- const requireAuthAdmin = async (opts = {}) => {
19
- let token = process.env.FAABLE_TOKEN;
20
- if (!token) {
21
- const store = new CredentialsStore();
22
- const config = await loadLiveCredentials(store);
23
- if (config?.apikey && !config.token) {
24
- // Deploy API keys are not Auth management credentials.
25
- log.error("❌ You are logged in with an API key, but `faable auth` needs a browser session. Run 'faable login' (without --apikey) first.");
26
- process.exit(1);
27
- }
28
- token = config?.token;
13
+ // The session bearer: FAABLE_TOKEN, else `faable login` (auto-refreshed).
14
+ const requireSessionToken = async () => {
15
+ if (process.env.FAABLE_TOKEN)
16
+ return process.env.FAABLE_TOKEN;
17
+ const config = await loadLiveCredentials(new CredentialsStore());
18
+ if (config?.apikey && !config.token) {
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);
29
22
  }
30
- if (!token) {
23
+ if (!config?.token) {
31
24
  log.error("❌ Not logged in. Run 'faable login' first.");
32
25
  process.exit(1);
33
26
  }
34
- const domain = opts.authUrl || process.env.FAABLE_AUTH_URL || DEFAULT_AUTH_URL;
35
- const account = opts.account || process.env.FAABLE_AUTH_ACCOUNT;
36
- const tenant = account ?? (await resolveAccountByHost(domain));
37
- const tenantToken = tenant ? await issueTenantToken(token, tenant) : undefined;
38
- // Prefer the per-tenant management token; the session's bearer is the
39
- // fallback (the platform tenant, or an api that can't issue one), exactly
40
- // what `faable auth` sent before phase 2. Either way, scoped to the tenant.
27
+ return config.token;
28
+ };
29
+ // Which Auth tenant `faable auth` manages, in precedence order:
30
+ //
31
+ // 1. --account / FAABLE_AUTH_ACCOUNT — an explicit tenant id;
32
+ // 2. --auth-url / FAABLE_AUTH_URL — a tenant host, looked up;
33
+ // 3. the active project's tenant — GET /project/:id/auth-accounts. One
34
+ // tenant: that one. Several: the one `faable auth use` stored for the
35
+ // project, or an error listing them.
36
+ //
37
+ // There is no default host any more: it used to be the Faable tenant, so a
38
+ // customer who forgot --auth-url was listing (or suspending) OUR users.
39
+ const resolveTenant = async (opts, api, store = new ContextStore(), env = process.env) => {
40
+ const account = opts.account || env.FAABLE_AUTH_ACCOUNT;
41
+ const authUrl = opts.authUrl || env.FAABLE_AUTH_URL;
42
+ if (account) {
43
+ return { account, domain: authUrl || AUTH_DOMAIN, source: 'account' };
44
+ }
45
+ if (authUrl) {
46
+ return {
47
+ account: await resolveAccountByHost(authUrl),
48
+ domain: authUrl,
49
+ source: 'auth-url'
50
+ };
51
+ }
52
+ const project = await requireProject(api, store, env);
53
+ const accounts = (await api.listProjectAuthAccounts(project.id, { pageSize: 200 }))
54
+ .results;
55
+ const chosen = await chooseAccount(project.id, accounts, store);
56
+ return {
57
+ account: chosen.id,
58
+ domain: `https://${chosen.domain}`,
59
+ source: 'project',
60
+ project: project.id,
61
+ name: chosen.name
62
+ };
63
+ };
64
+ const chooseAccount = async (project, accounts, store = new ContextStore()) => {
65
+ if (accounts.length === 1)
66
+ return accounts[0];
67
+ if (accounts.length === 0) {
68
+ throw new Error(`Project ${project} has no Faable Auth tenant. Create one in the dashboard (https://dashboard.faable.com), or target one with --account <account_id>.`);
69
+ }
70
+ const stored = (await store.load()).auth_accounts?.[project];
71
+ const match = accounts.find(a => a.id === stored);
72
+ if (match)
73
+ return match;
74
+ const list = accounts.map(a => `${a.name} (${a.id})`).join(', ');
75
+ throw new Error(`Project ${project} has ${accounts.length} Auth tenants: ${list}. Pick one with: faable auth use <account_id>, or pass --account <account_id>.`);
76
+ };
77
+ // A management-API client for the tenant `resolveTenant` picks, with the
78
+ // per-tenant token the deploy api issues (cached until it expires); the
79
+ // session bearer is the fallback where no such token exists.
80
+ const requireAuthAdmin = async (opts = {}) => {
81
+ const token = await requireSessionToken();
82
+ const deploy = FaableApi.create({
83
+ authStrategy: bearer_strategy,
84
+ auth: { token }
85
+ });
86
+ const tenant = await resolveTenant(opts, deploy);
87
+ const tenantToken = tenant.account
88
+ ? await cachedTenantToken(token, tenant.account, deploy)
89
+ : undefined;
41
90
  return createBearerAuthApi(tenantToken ?? token, {
42
- domain,
43
- account: tenant ?? account
91
+ domain: tenant.domain,
92
+ account: tenant.account
44
93
  });
45
94
  };
95
+ // Every `faable auth` call used to mint a fresh tenant token — one extra
96
+ // round trip per command, and a burst of them from a script or the MCP
97
+ // server. It lives for minutes (`expires_in`), so it's kept next to the
98
+ // credentials (0600) and reused while it's live, for the same session only.
99
+ const cachedTenantToken = async (session_token, account_id, api, cache = new TenantTokenCache()) => {
100
+ const sub = subject(session_token);
101
+ const hit = await cache.get(account_id, sub);
102
+ if (hit)
103
+ return hit;
104
+ const issued = await issueTenantToken(session_token, account_id, api);
105
+ if (issued)
106
+ await cache.set(account_id, sub, issued);
107
+ return issued;
108
+ };
109
+ const subject = (token) => {
110
+ try {
111
+ const claims = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
112
+ return typeof claims.sub === 'string' ? claims.sub : undefined;
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ };
118
+ class TenantTokenCache {
119
+ file;
120
+ constructor(file = path__default.join(os.homedir(), '.faable', 'tenant-tokens.json')) {
121
+ this.file = file;
122
+ }
123
+ async read() {
124
+ try {
125
+ return (await fs.readJSON(this.file)) || {};
126
+ }
127
+ catch {
128
+ return {};
129
+ }
130
+ }
131
+ async get(account_id, sub) {
132
+ const entry = (await this.read())[account_id];
133
+ if (!entry || entry.sub !== sub || !isTokenLive(entry.token))
134
+ return;
135
+ return entry.token;
136
+ }
137
+ async set(account_id, sub, token) {
138
+ try {
139
+ const all = await this.read();
140
+ // Drop the dead ones while we're here.
141
+ for (const [id, entry] of Object.entries(all)) {
142
+ if (!isTokenLive(entry.token))
143
+ delete all[id];
144
+ }
145
+ all[account_id] = { token, sub };
146
+ await fs.ensureDir(path__default.dirname(this.file));
147
+ await fs.writeJSON(this.file, all, { spaces: 2, mode: 0o600 });
148
+ await fs.chmod(this.file, 0o600);
149
+ }
150
+ catch {
151
+ // A cache that can't be written is a cache miss next time, not an error.
152
+ }
153
+ }
154
+ }
46
155
  // The tenant behind an Auth host (`<slug>.auth.faable.link` or a custom
47
156
  // domain), through the anonymous public lookup. `undefined` when it can't be
48
157
  // resolved: the server then picks the tenant from the host, as before.
@@ -87,7 +196,7 @@ const hintAuthError = (e) => {
87
196
  throw new Error("Unauthorized (401) by the Auth management API. Your session may have expired — run 'faable login' and retry.");
88
197
  }
89
198
  if (status === 403) {
90
- throw new Error('Forbidden (403): your session is not allowed to manage this tenant. Check --auth-url/--account, or use credentials with management access for it.');
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.');
91
200
  }
92
201
  throw e;
93
202
  };
@@ -103,4 +212,4 @@ const withAuthHints = (handler) => {
103
212
  };
104
213
  };
105
214
 
106
- export { hintAuthError, issueTenantToken, requireAuthAdmin, resolveAccountByHost, withAuthHints };
215
+ export { TenantTokenCache, cachedTenantToken, chooseAccount, hintAuthError, issueTenantToken, requireAuthAdmin, requireSessionToken, resolveAccountByHost, resolveTenant, subject, withAuthHints };
@@ -6,6 +6,7 @@ import { bearer_strategy } from './strategies/bearer.strategy.js';
6
6
  import { CredentialsStore } from '../lib/CredentialsStore.js';
7
7
  import { loadLiveCredentials } from './session.js';
8
8
  import { log } from '../log.js';
9
+ import { configuredProject, resolveProjectRef } from './project.js';
9
10
 
10
11
  const context = async (targetAppId) => {
11
12
  let api;
@@ -49,6 +50,14 @@ const context = async (targetAppId) => {
49
50
  }
50
51
  }
51
52
  }
53
+ // The active project scopes project-wide listings (`deploy list`). Only
54
+ // when one is configured: picking a default here would cost a request on
55
+ // every command, including a deploy.
56
+ if (api) {
57
+ const configured = await configuredProject();
58
+ if (configured)
59
+ api.project = await resolveProjectRef(api, configured.ref);
60
+ }
52
61
  const appId = await api?.strategy?.app_id?.();
53
62
  return {
54
63
  api,
@@ -0,0 +1,62 @@
1
+ import { ContextStore } from '../lib/ContextStore.js';
2
+
3
+ let flag_value;
4
+ // Set by the global middleware in src/index.ts.
5
+ const setProjectFlag = (value) => {
6
+ flag_value = value || undefined;
7
+ };
8
+ const configuredProject = async (store = new ContextStore(), env = process.env) => {
9
+ if (flag_value)
10
+ return { ref: flag_value, source: 'flag' };
11
+ if (env.FAABLE_PROJECT)
12
+ return { ref: env.FAABLE_PROJECT, source: 'env' };
13
+ const { project } = await store.load();
14
+ if (project)
15
+ return { ref: project, source: 'config' };
16
+ return undefined;
17
+ };
18
+ const ID_RE = /^(project|team)_[a-f0-9]{24}$/;
19
+ // project_<hex>: the api's form. A team_<hex> (the app rows' form) is the
20
+ // same project.
21
+ const toProjectId = (id) => id.replace(/^team_/, 'project_');
22
+ // An id is taken as-is (the api checks membership on use); a name or slug is
23
+ // looked up — exactly one project must match.
24
+ const resolveProjectRef = async (api, ref) => {
25
+ if (ID_RE.test(ref))
26
+ return toProjectId(ref);
27
+ const page = await api.listProjects({ q: ref, pageSize: 50 });
28
+ const wanted = ref.toLowerCase();
29
+ const matches = page.results.filter(p => p.name.toLowerCase() === wanted || p.slug?.toLowerCase() === wanted);
30
+ if (matches.length === 1)
31
+ return matches[0].id;
32
+ if (matches.length === 0) {
33
+ throw new Error(`No project named "${ref}". See them with: faable project list`);
34
+ }
35
+ throw new Error(`Several projects are named "${ref}": ${describe(matches)}. Use its id.`);
36
+ };
37
+ const describe = (projects) => projects.map(p => `${p.name} (${p.id})`).join(', ');
38
+ // The project a project-wide command needs. With none configured, a caller
39
+ // who belongs to exactly one project gets that one; otherwise it's an error
40
+ // that says how to choose.
41
+ const requireProject = async (api, store = new ContextStore(), env = process.env) => {
42
+ const configured = await configuredProject(store, env);
43
+ if (configured) {
44
+ return {
45
+ id: await resolveProjectRef(api, configured.ref),
46
+ source: configured.source
47
+ };
48
+ }
49
+ const page = await api.listProjects({ pageSize: 2 });
50
+ if (page.results.length === 1 && !page.next) {
51
+ return { id: page.results[0].id, source: 'only' };
52
+ }
53
+ throw new Error('No project selected. Pick one with: faable project use <id|name> (list them with: faable project list), or pass --project <id|name>.');
54
+ };
55
+ const SOURCE_LABEL = {
56
+ flag: '--project',
57
+ env: 'FAABLE_PROJECT',
58
+ config: 'faable project use',
59
+ only: 'your only project'
60
+ };
61
+
62
+ export { SOURCE_LABEL, configuredProject, requireProject, resolveProjectRef, setProjectFlag, toProjectId };
@@ -0,0 +1,76 @@
1
+ import { requireSessionToken } from '../../../api/auth_admin.js';
2
+ import { FaableApi } from '../../../api/FaableApi.js';
3
+ import { requireProject } from '../../../api/project.js';
4
+ import { bearer_strategy } from '../../../api/strategies/bearer.strategy.js';
5
+ import { ContextStore } from '../../../lib/ContextStore.js';
6
+ import { fetch_page, print_json, table_lines, print, more_hint, list_options } from '../../../lib/listing.js';
7
+ import { log } from '../../../log.js';
8
+
9
+ const deploy_api = async () => FaableApi.create({
10
+ authStrategy: bearer_strategy,
11
+ auth: { token: await requireSessionToken() }
12
+ });
13
+ // `faable auth accounts list` — the Auth tenants of the active project, the
14
+ // ones `faable auth` can manage without --account.
15
+ const accounts_list = {
16
+ command: 'list',
17
+ describe: "List the active project's Auth tenants",
18
+ builder: yargs => list_options(yargs).showHelpOnFail(false),
19
+ handler: async (args) => {
20
+ const api = await deploy_api();
21
+ const project = await requireProject(api);
22
+ const page = await fetch_page(p => api.listProjectAuthAccounts(project.id, p), args);
23
+ if (args.json)
24
+ return print_json(page);
25
+ if (page.data.length === 0) {
26
+ log.info(`📭 Project ${project.id} has no Auth tenants.`);
27
+ return;
28
+ }
29
+ const stored = (await new ContextStore().load()).auth_accounts?.[project.id];
30
+ const active = page.data.length === 1 ? page.data[0].id : stored;
31
+ log.info(`🔐 ${page.data.length} Auth tenant(s) in ${project.id}:`);
32
+ for (const line of table_lines(['', 'ID', 'NAME', 'DOMAIN'], page.data.map(a => [a.id === active ? '*' : '', a.id, a.name, a.domain]))) {
33
+ print(line);
34
+ }
35
+ const hint = more_hint(page);
36
+ if (hint)
37
+ log.info(hint);
38
+ }
39
+ };
40
+ const accounts = {
41
+ command: 'accounts',
42
+ describe: "The active project's Auth tenants",
43
+ builder: yargs => yargs.command(accounts_list).demandCommand(1).showHelpOnFail(false),
44
+ handler: () => { }
45
+ };
46
+ // `faable auth use <account_id>` — which tenant `faable auth` manages in the
47
+ // active project, for a project with more than one.
48
+ const auth_use = {
49
+ command: 'use <account>',
50
+ describe: 'Set the Auth tenant to manage in the active project',
51
+ builder: yargs => yargs
52
+ .positional('account', { type: 'string', demandOption: true })
53
+ .showHelpOnFail(false),
54
+ handler: async (args) => {
55
+ const api = await deploy_api();
56
+ const project = await requireProject(api);
57
+ const { results } = await api.listProjectAuthAccounts(project.id, {
58
+ pageSize: 200
59
+ });
60
+ const wanted = args.account.toLowerCase();
61
+ const account = results.find(a => a.id === args.account ||
62
+ a.slug?.toLowerCase() === wanted ||
63
+ a.domain.toLowerCase() === wanted);
64
+ if (!account) {
65
+ const list = results.map(a => `${a.name} (${a.id})`).join(', ') || 'none';
66
+ throw new Error(`${args.account} is not an Auth tenant of ${project.id}. Its tenants: ${list}.`);
67
+ }
68
+ await new ContextStore().update(ctx => ({
69
+ ...ctx,
70
+ auth_accounts: { ...ctx.auth_accounts, [project.id]: account.id }
71
+ }));
72
+ log.info(`✅ faable auth now manages ${account.name} (${account.domain}) in ${project.id}.`);
73
+ }
74
+ };
75
+
76
+ export { accounts, auth_use };
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs-extra';
2
2
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
3
3
  import { log } from '../../../log.js';
4
- import { json_option, tenant_options } from '../options.js';
5
- import { print_json } from '../render.js';
4
+ import { tenant_options } from '../options.js';
5
+ import { json_option, print_json } from '../../../lib/listing.js';
6
6
  import { formatTriggers } from './triggers.js';
7
7
 
8
8
  const actions_create = {
@@ -1,8 +1,9 @@
1
1
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
- import { json_option, tenant_options } from '../options.js';
4
- import { print_json, yes_no } from '../render.js';
3
+ import { tenant_options } from '../options.js';
4
+ import { yes_no } from '../render.js';
5
5
  import { formatTriggers } from './triggers.js';
6
+ import { json_option, print_json } from '../../../lib/listing.js';
6
7
 
7
8
  const actions_get = {
8
9
  command: 'get <action_id>',
@@ -1,14 +1,14 @@
1
1
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
- import { json_option, list_options, tenant_options } from '../options.js';
4
- import { fetch_items } from '../paging.js';
5
- import { print_json, yes_no, when, table_lines } from '../render.js';
3
+ import { list_options, fetch_page, from_paginator, print_json, table_lines, print, more_hint } from '../../../lib/listing.js';
4
+ import { tenant_options } from '../options.js';
5
+ import { yes_no, when } from '../render.js';
6
6
  import { formatTriggers } from './triggers.js';
7
7
 
8
8
  const actions_list = {
9
9
  command: 'list',
10
10
  describe: 'List actions',
11
- builder: yargs => json_option(list_options(tenant_options(yargs)))
11
+ builder: yargs => list_options(tenant_options(yargs))
12
12
  .option('query', {
13
13
  type: 'string',
14
14
  description: 'FaableQL filter, e.g. "enabled:true"'
@@ -16,15 +16,15 @@ const actions_list = {
16
16
  .showHelpOnFail(false),
17
17
  handler: withAuthHints(async (args) => {
18
18
  const api = await requireAuthAdmin(args);
19
- const { items, more } = await fetch_items(api.actionList({ query: args.query, pageSize: args.limit }), args.all);
19
+ const page = await fetch_page(from_paginator(api.actionList({ query: args.query })), args);
20
20
  if (args.json)
21
- return print_json(items);
22
- if (items.length === 0) {
21
+ return print_json(page);
22
+ if (page.data.length === 0) {
23
23
  log.info('📭 No actions.');
24
24
  return;
25
25
  }
26
- log.info(`⚙️ ${items.length} action(s):`);
27
- const rows = items.map(a => [
26
+ log.info(`⚙️ ${page.data.length} action(s):`);
27
+ const rows = page.data.map(a => [
28
28
  a.id ?? '-',
29
29
  a.name ?? '-',
30
30
  formatTriggers(a),
@@ -33,11 +33,11 @@ const actions_list = {
33
33
  when(a.createdAt)
34
34
  ]);
35
35
  for (const line of table_lines(['ID', 'NAME', 'TRIGGER', 'ENABLED', 'ORDER', 'CREATED'], rows)) {
36
- log.info(` ${line}`);
37
- }
38
- if (more) {
39
- log.info('… more results available: raise --limit or use --all.');
36
+ print(line);
40
37
  }
38
+ const hint = more_hint(page);
39
+ if (hint)
40
+ log.info(hint);
41
41
  })
42
42
  };
43
43
 
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs-extra';
2
2
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
3
3
  import { log } from '../../../log.js';
4
- import { json_option, tenant_options } from '../options.js';
5
- import { print_json } from '../render.js';
4
+ import { tenant_options } from '../options.js';
5
+ import { json_option, print_json } from '../../../lib/listing.js';
6
6
  import { formatTriggers } from './triggers.js';
7
7
 
8
8
  const actions_update = {
@@ -1,7 +1,7 @@
1
1
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
- import { json_option, tenant_options } from '../options.js';
4
- import { print_json } from '../render.js';
3
+ import { tenant_options } from '../options.js';
4
+ import { json_option, print_json } from '../../../lib/listing.js';
5
5
 
6
6
  const clients_create = {
7
7
  command: 'create',
@@ -1,7 +1,7 @@
1
1
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
- import { json_option, tenant_options } from '../options.js';
4
- import { print_json } from '../render.js';
3
+ import { tenant_options } from '../options.js';
4
+ import { json_option, print_json } from '../../../lib/listing.js';
5
5
  import { resolve_client } from './resolve.js';
6
6
 
7
7
  const clients_get = {
@@ -1,13 +1,13 @@
1
1
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
- import { json_option, list_options, tenant_options } from '../options.js';
4
- import { fetch_items } from '../paging.js';
5
- import { print_json, truncate, when, table_lines } from '../render.js';
3
+ import { list_options, fetch_page, from_paginator, print_json, table_lines, print, more_hint } from '../../../lib/listing.js';
4
+ import { tenant_options } from '../options.js';
5
+ import { truncate, when } from '../render.js';
6
6
 
7
7
  const clients_list = {
8
8
  command: 'list',
9
9
  describe: 'List OAuth clients',
10
- builder: yargs => json_option(list_options(tenant_options(yargs)))
10
+ builder: yargs => list_options(tenant_options(yargs))
11
11
  .option('q', {
12
12
  type: 'string',
13
13
  description: 'Full-text search over name/description/client_id'
@@ -15,26 +15,26 @@ const clients_list = {
15
15
  .showHelpOnFail(false),
16
16
  handler: withAuthHints(async (args) => {
17
17
  const api = await requireAuthAdmin(args);
18
- const { items, more } = await fetch_items(api.clientList({ q: args.q, pageSize: args.limit }), args.all);
18
+ const page = await fetch_page(from_paginator(api.clientList({ q: args.q })), args);
19
19
  if (args.json)
20
- return print_json(items);
21
- if (items.length === 0) {
20
+ return print_json(page);
21
+ if (page.data.length === 0) {
22
22
  log.info('📭 No clients.');
23
23
  return;
24
24
  }
25
- log.info(`🔑 ${items.length} client(s):`);
26
- const rows = items.map(c => [
25
+ log.info(`🔑 ${page.data.length} client(s):`);
26
+ const rows = page.data.map(c => [
27
27
  c.client_id ?? '-',
28
28
  truncate(c.name, 28),
29
29
  String(c.callbacks?.length ?? 0),
30
30
  when(c.createdAt)
31
31
  ]);
32
32
  for (const line of table_lines(['CLIENT_ID', 'NAME', 'CALLBACKS', 'CREATED'], rows)) {
33
- log.info(` ${line}`);
34
- }
35
- if (more) {
36
- log.info('… more results available: raise --limit or use --all.');
33
+ print(line);
37
34
  }
35
+ const hint = more_hint(page);
36
+ if (hint)
37
+ log.info(hint);
38
38
  })
39
39
  };
40
40