@faable/faable 1.30.0 → 1.32.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.
- package/dist/api/FaableApi.js +17 -1
- package/dist/api/auth_admin.js +67 -0
- package/dist/commands/auth/actions/create.js +62 -0
- package/dist/commands/auth/actions/get.js +40 -0
- package/dist/commands/auth/actions/index.js +19 -0
- package/dist/commands/auth/actions/list.js +43 -0
- package/dist/commands/auth/actions/rm.js +45 -0
- package/dist/commands/auth/clients/create.js +56 -0
- package/dist/commands/auth/clients/get.js +46 -0
- package/dist/commands/auth/clients/index.js +19 -0
- package/dist/commands/auth/clients/list.js +41 -0
- package/dist/commands/auth/clients/resolve.js +21 -0
- package/dist/commands/auth/clients/rm.js +45 -0
- package/dist/commands/auth/index.js +23 -0
- package/dist/commands/auth/logs/get.js +48 -0
- package/dist/commands/auth/logs/index.js +15 -0
- package/dist/commands/auth/logs/list.js +90 -0
- package/dist/commands/auth/options.js +34 -0
- package/dist/commands/auth/paging.js +9 -0
- package/dist/commands/auth/query.js +36 -0
- package/dist/commands/auth/render.js +48 -0
- package/dist/commands/auth/users/get.js +42 -0
- package/dist/commands/auth/users/ids.js +45 -0
- package/dist/commands/auth/users/index.js +17 -0
- package/dist/commands/auth/users/list.js +55 -0
- package/dist/commands/auth/users/suspend.js +87 -0
- package/dist/commands/deploy/index.js +3 -1
- package/dist/commands/deploy/resolve_app_id.js +3 -2
- package/dist/commands/link/index.js +21 -19
- package/dist/index.js +14 -1
- package/dist/lib/Configuration.js +0 -9
- package/package.json +2 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
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 { compose_query, term, time_term } from '../query.js';
|
|
6
|
+
import { print_json, log_status_badge, truncate, table_lines } from '../render.js';
|
|
7
|
+
|
|
8
|
+
const logs_list = {
|
|
9
|
+
command: 'list',
|
|
10
|
+
describe: 'List and filter audit logs',
|
|
11
|
+
builder: yargs => json_option(list_options(tenant_options(yargs)))
|
|
12
|
+
.option('query', {
|
|
13
|
+
type: 'string',
|
|
14
|
+
description: 'Raw FaableQL filter (combined with the flags below)'
|
|
15
|
+
})
|
|
16
|
+
.option('q', {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Full-text search over the log message'
|
|
19
|
+
})
|
|
20
|
+
.option('type', {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Exact event type, e.g. admin.user.updated'
|
|
23
|
+
})
|
|
24
|
+
.option('status', {
|
|
25
|
+
type: 'string',
|
|
26
|
+
choices: ['success', 'failed', 'skipped', 'info'],
|
|
27
|
+
description: 'Event status'
|
|
28
|
+
})
|
|
29
|
+
.option('origin', {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Subsystem prefix, e.g. oauth (matches oauth.*)'
|
|
32
|
+
})
|
|
33
|
+
.option('user', {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Filter by subject user id'
|
|
36
|
+
})
|
|
37
|
+
.option('client', {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Filter by subject client id'
|
|
40
|
+
})
|
|
41
|
+
.option('since', {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'From date: unix-millis or YYYY-MM-DD'
|
|
44
|
+
})
|
|
45
|
+
.option('until', {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'To date: unix-millis or YYYY-MM-DD'
|
|
48
|
+
})
|
|
49
|
+
.example('$0 auth logs list --user user_abc123 --since 2026-08-01', "One user's audit trail since August 1st")
|
|
50
|
+
.example('$0 auth logs list --origin oauth --status failed', 'Failed OAuth events')
|
|
51
|
+
.showHelpOnFail(false),
|
|
52
|
+
handler: withAuthHints(async (args) => {
|
|
53
|
+
const query = compose_query([
|
|
54
|
+
term('type', args.type),
|
|
55
|
+
term('status', args.status),
|
|
56
|
+
term('origin', args.origin),
|
|
57
|
+
term('user', args.user),
|
|
58
|
+
term('client', args.client),
|
|
59
|
+
time_term('since', args.since),
|
|
60
|
+
time_term('until', args.until)
|
|
61
|
+
], args.query);
|
|
62
|
+
const api = await requireAuthAdmin(args);
|
|
63
|
+
const { items, more } = await fetch_items(api.logList({ query, q: args.q, pageSize: args.limit }), args.all);
|
|
64
|
+
if (args.json)
|
|
65
|
+
return print_json(items);
|
|
66
|
+
if (items.length === 0) {
|
|
67
|
+
log.info('📭 No audit log entries match.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
log.info(`📜 ${items.length} entr${items.length === 1 ? 'y' : 'ies'}:`);
|
|
71
|
+
const rows = items.map(entry => [
|
|
72
|
+
entry.createdAt ?? '-',
|
|
73
|
+
entry.type ?? '-',
|
|
74
|
+
log_status_badge(entry.status),
|
|
75
|
+
typeof entry.user === 'string'
|
|
76
|
+
? entry.user
|
|
77
|
+
: (entry.user?.id ?? '-'),
|
|
78
|
+
truncate(entry.message, 48),
|
|
79
|
+
entry.id ?? '-'
|
|
80
|
+
]);
|
|
81
|
+
for (const line of table_lines(['DATE', 'TYPE', 'STATUS', 'USER', 'MESSAGE', 'ID'], rows)) {
|
|
82
|
+
log.info(` ${line}`);
|
|
83
|
+
}
|
|
84
|
+
if (more) {
|
|
85
|
+
log.info('… more results available: raise --limit, use --all, or narrow with --since/--type.');
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export { logs_list };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const tenant_options = (yargs) => yargs
|
|
2
|
+
.option('auth-url', {
|
|
3
|
+
type: 'string',
|
|
4
|
+
description: 'Auth tenant base URL, e.g. https://<account>.auth.faable.link (env FAABLE_AUTH_URL)'
|
|
5
|
+
})
|
|
6
|
+
.option('account', {
|
|
7
|
+
type: 'string',
|
|
8
|
+
description: 'Resolve a different account id on the target host (env FAABLE_AUTH_ACCOUNT)'
|
|
9
|
+
});
|
|
10
|
+
const json_option = (yargs) => yargs.option('json', {
|
|
11
|
+
type: 'boolean',
|
|
12
|
+
default: false,
|
|
13
|
+
description: 'Output raw JSON (for scripting)'
|
|
14
|
+
});
|
|
15
|
+
const list_options = (yargs) => yargs
|
|
16
|
+
.option('limit', {
|
|
17
|
+
type: 'number',
|
|
18
|
+
default: 100,
|
|
19
|
+
description: 'Max items to fetch in one page (1-200)'
|
|
20
|
+
})
|
|
21
|
+
.option('all', {
|
|
22
|
+
type: 'boolean',
|
|
23
|
+
default: false,
|
|
24
|
+
description: 'Fetch every page (ignores --limit)'
|
|
25
|
+
})
|
|
26
|
+
.check(argv => {
|
|
27
|
+
const limit = argv.limit;
|
|
28
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) {
|
|
29
|
+
throw new Error('--limit must be an integer between 1 and 200');
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export { json_option, list_options, tenant_options };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Pure helpers to compose FaableQL filter strings from CLI flags.
|
|
2
|
+
//
|
|
3
|
+
// FaableQL grammar (server-side, @faablecloud/faableql): space-separated
|
|
4
|
+
// `field:value` terms, ANDed; values only allow [alnum _ - @ .], so a `:`
|
|
5
|
+
// inside a value (e.g. an ISO-8601 timestamp) is a parse error — dates must be
|
|
6
|
+
// unix-millis or YYYY-MM-DD.
|
|
7
|
+
// Join flag-derived terms with a user-provided --query passthrough.
|
|
8
|
+
const compose_query = (terms, passthrough) => {
|
|
9
|
+
const parts = terms.filter((t) => !!t);
|
|
10
|
+
if (passthrough?.trim())
|
|
11
|
+
parts.push(passthrough.trim());
|
|
12
|
+
return parts.length ? parts.join(' ') : undefined;
|
|
13
|
+
};
|
|
14
|
+
// Build a single `field:value` term, refusing values FaableQL cannot parse
|
|
15
|
+
// (anything outside [alnum _ - @ .]) with a readable error instead of a
|
|
16
|
+
// server-side 400.
|
|
17
|
+
const term = (field, value) => {
|
|
18
|
+
if (value === undefined || value === '')
|
|
19
|
+
return undefined;
|
|
20
|
+
if (!/^[a-zA-Z0-9_\-@.]+$/.test(value)) {
|
|
21
|
+
throw new Error(`Invalid value for --${field}: "${value}" (allowed: letters, digits, _ - @ .)`);
|
|
22
|
+
}
|
|
23
|
+
return `${field}:${value}`;
|
|
24
|
+
};
|
|
25
|
+
// Validate a --since/--until value: unix-millis or YYYY-MM-DD. ISO timestamps
|
|
26
|
+
// carry ':' which FaableQL rejects, so we fail fast with the fix.
|
|
27
|
+
const time_term = (field, value) => {
|
|
28
|
+
if (value === undefined || value === '')
|
|
29
|
+
return undefined;
|
|
30
|
+
if (!/^\d+$/.test(value) && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
31
|
+
throw new Error(`Invalid --${field}: "${value}". Use unix-millis or YYYY-MM-DD (ISO timestamps with ':' are not supported)`);
|
|
32
|
+
}
|
|
33
|
+
return `${field}:${value}`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export { compose_query, term, time_term };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Pure rendering helpers for the `faable auth` read commands.
|
|
2
|
+
const print_json = (data) => {
|
|
3
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
4
|
+
};
|
|
5
|
+
// Left-padded fixed-width table lines. Cells are stringified as-is; column
|
|
6
|
+
// width = max(header, cells).
|
|
7
|
+
const table_lines = (headers, rows) => {
|
|
8
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => (r[i] ?? '').length)));
|
|
9
|
+
const render = (cells) => cells
|
|
10
|
+
.map((c, i) => (i === cells.length - 1 ? c : (c ?? '').padEnd(widths[i])))
|
|
11
|
+
.join(' ')
|
|
12
|
+
.trimEnd();
|
|
13
|
+
return [render(headers), ...rows.map(render)];
|
|
14
|
+
};
|
|
15
|
+
const yes_no = (v) => (v ? '✓' : '-');
|
|
16
|
+
const truncate = (s, max) => {
|
|
17
|
+
if (!s)
|
|
18
|
+
return '-';
|
|
19
|
+
const one_line = s.replace(/\s+/g, ' ').trim();
|
|
20
|
+
return one_line.length > max ? one_line.slice(0, max - 1) + '…' : one_line;
|
|
21
|
+
};
|
|
22
|
+
// Relative age, matching the deploy commands' `when` formatting.
|
|
23
|
+
const when = (iso) => {
|
|
24
|
+
if (!iso)
|
|
25
|
+
return '-';
|
|
26
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
27
|
+
const minutes = Math.floor(ms / 60_000);
|
|
28
|
+
if (minutes < 1)
|
|
29
|
+
return 'just now';
|
|
30
|
+
if (minutes < 60)
|
|
31
|
+
return `${minutes}m ago`;
|
|
32
|
+
const hours = Math.floor(minutes / 60);
|
|
33
|
+
if (hours < 48)
|
|
34
|
+
return `${hours}h ago`;
|
|
35
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
36
|
+
};
|
|
37
|
+
const STATUS_ICONS = {
|
|
38
|
+
success: '🟢',
|
|
39
|
+
info: '🔵',
|
|
40
|
+
skipped: '⚪',
|
|
41
|
+
failed: '🔴'
|
|
42
|
+
};
|
|
43
|
+
const log_status_badge = (status) => {
|
|
44
|
+
const s = status || 'info';
|
|
45
|
+
return `${STATUS_ICONS[s] ?? '⚪'} ${s}`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export { log_status_badge, print_json, table_lines, truncate, when, yes_no };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { json_option, tenant_options } from '../options.js';
|
|
4
|
+
import { print_json, yes_no, when } from '../render.js';
|
|
5
|
+
|
|
6
|
+
const users_get = {
|
|
7
|
+
command: 'get <user_id>',
|
|
8
|
+
describe: 'Show a user',
|
|
9
|
+
builder: yargs => json_option(tenant_options(yargs))
|
|
10
|
+
.positional('user_id', {
|
|
11
|
+
type: 'string',
|
|
12
|
+
demandOption: true,
|
|
13
|
+
description: 'User identifier (user_…)'
|
|
14
|
+
})
|
|
15
|
+
.showHelpOnFail(false),
|
|
16
|
+
handler: withAuthHints(async (args) => {
|
|
17
|
+
const api = await requireAuthAdmin(args);
|
|
18
|
+
const user = await api.userGet(args.user_id);
|
|
19
|
+
if (args.json)
|
|
20
|
+
return print_json(user);
|
|
21
|
+
log.info(`👤 ${user.id}`);
|
|
22
|
+
log.info(` Email: ${user.email ?? '-'} (verified: ${yes_no(user.email_verified)})`);
|
|
23
|
+
log.info(` Name: ${user.name ?? '-'}`);
|
|
24
|
+
if (user.phone)
|
|
25
|
+
log.info(` Phone: ${user.phone}`);
|
|
26
|
+
if (user.country_iso)
|
|
27
|
+
log.info(` Country: ${user.country_iso}`);
|
|
28
|
+
log.info(` Created: ${user.createdAt ?? '-'}`);
|
|
29
|
+
log.info(` Last login: ${user.last_login ? `${user.last_login} (${when(user.last_login)})` : '-'}${user.last_ip ? ` from ${user.last_ip}` : ''}`);
|
|
30
|
+
log.info(` Logins: ${user.logins_count ?? 0}`);
|
|
31
|
+
if (user.suspended) {
|
|
32
|
+
log.info(` Suspended: 🔴 yes (${user.suspended_at ?? 'unknown date'})`);
|
|
33
|
+
if (user.suspended_reason)
|
|
34
|
+
log.info(` Reason: ${user.suspended_reason}`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
log.info(' Suspended: no');
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export { users_get };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Resolve the target user ids for a bulk operation. Ids come from argv, from
|
|
2
|
+
// stdin when none are given and input is piped (`… list --json | jq -r … |
|
|
3
|
+
// faable auth users suspend`), or from a '-' placeholder. NB: yargs drops a
|
|
4
|
+
// bare '-' from positionals, so piping with NO ids is the supported spelling;
|
|
5
|
+
// '-' is still honored if it survives parsing. Dedupes, keeps order, and
|
|
6
|
+
// rejects anything that doesn't look like a user id BEFORE any mutation runs.
|
|
7
|
+
const wants_stdin = (args, is_tty) => args.includes('-') || (args.length === 0 && !is_tty);
|
|
8
|
+
const parse_user_ids = (args, stdin) => {
|
|
9
|
+
const stdin_tokens = stdin === null ? null : stdin.split(/\s+/).filter(Boolean);
|
|
10
|
+
const raw = [];
|
|
11
|
+
let stdin_used = false;
|
|
12
|
+
for (const a of args) {
|
|
13
|
+
if (a === '-') {
|
|
14
|
+
if (stdin_tokens === null) {
|
|
15
|
+
throw new Error("'-' requires ids on stdin (one or more, whitespace-separated)");
|
|
16
|
+
}
|
|
17
|
+
raw.push(...stdin_tokens);
|
|
18
|
+
stdin_used = true;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
raw.push(a);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (stdin_tokens !== null && !stdin_used) {
|
|
25
|
+
raw.push(...stdin_tokens);
|
|
26
|
+
}
|
|
27
|
+
const ids = [...new Set(raw)];
|
|
28
|
+
if (ids.length === 0) {
|
|
29
|
+
throw new Error('No user ids given (pass ids, or pipe them via stdin)');
|
|
30
|
+
}
|
|
31
|
+
const invalid = ids.filter(id => !/^user_[a-zA-Z0-9]+$/.test(id));
|
|
32
|
+
if (invalid.length > 0) {
|
|
33
|
+
throw new Error(`Invalid user id(s): ${invalid.join(', ')} (expected user_…)`);
|
|
34
|
+
}
|
|
35
|
+
return ids;
|
|
36
|
+
};
|
|
37
|
+
const read_stdin = async () => {
|
|
38
|
+
const chunks = [];
|
|
39
|
+
for await (const chunk of process.stdin) {
|
|
40
|
+
chunks.push(Buffer.from(chunk));
|
|
41
|
+
}
|
|
42
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export { parse_user_ids, read_stdin, wants_stdin };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { users_get } from './get.js';
|
|
2
|
+
import { users_list } from './list.js';
|
|
3
|
+
import { users_suspend } from './suspend.js';
|
|
4
|
+
|
|
5
|
+
const users = {
|
|
6
|
+
command: 'users',
|
|
7
|
+
describe: 'List, inspect and suspend users',
|
|
8
|
+
builder: yargs => yargs
|
|
9
|
+
.command(users_list)
|
|
10
|
+
.command(users_get)
|
|
11
|
+
.command(users_suspend)
|
|
12
|
+
.demandCommand(1)
|
|
13
|
+
.showHelpOnFail(false),
|
|
14
|
+
handler: () => { }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export { users };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { json_option, list_options, tenant_options } from '../options.js';
|
|
4
|
+
import { compose_query } from '../query.js';
|
|
5
|
+
import { fetch_items } from '../paging.js';
|
|
6
|
+
import { print_json, truncate, yes_no, when, table_lines } from '../render.js';
|
|
7
|
+
|
|
8
|
+
const users_list = {
|
|
9
|
+
command: 'list',
|
|
10
|
+
describe: 'List and filter users',
|
|
11
|
+
builder: yargs => json_option(list_options(tenant_options(yargs)))
|
|
12
|
+
.option('query', {
|
|
13
|
+
type: 'string',
|
|
14
|
+
description: 'FaableQL filter, e.g. "suspended:true email_verified:false" (fields: email, name, phone, suspended, email_verified, country_iso, locale, last_ip)'
|
|
15
|
+
})
|
|
16
|
+
.option('q', {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Full-text search over name/email/phone'
|
|
19
|
+
})
|
|
20
|
+
.option('suspended', {
|
|
21
|
+
type: 'boolean',
|
|
22
|
+
description: 'Only suspended users (shorthand for query suspended:true)'
|
|
23
|
+
})
|
|
24
|
+
.example('$0 auth users list --suspended', 'List suspended users')
|
|
25
|
+
.example('$0 auth users list --query email_verified:false --limit 50', 'First 50 unverified users')
|
|
26
|
+
.showHelpOnFail(false),
|
|
27
|
+
handler: withAuthHints(async (args) => {
|
|
28
|
+
const api = await requireAuthAdmin(args);
|
|
29
|
+
const query = compose_query([args.suspended !== undefined && `suspended:${args.suspended}`], args.query);
|
|
30
|
+
const { items, more } = await fetch_items(api.userList({ query, q: args.q, pageSize: args.limit }), args.all);
|
|
31
|
+
if (args.json)
|
|
32
|
+
return print_json(items);
|
|
33
|
+
if (items.length === 0) {
|
|
34
|
+
log.info('📭 No users match.');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
log.info(`👥 ${items.length} user(s):`);
|
|
38
|
+
const rows = items.map(u => [
|
|
39
|
+
u.id ?? '-',
|
|
40
|
+
truncate(u.email, 32),
|
|
41
|
+
truncate(u.name, 24),
|
|
42
|
+
yes_no(u.email_verified),
|
|
43
|
+
u.suspended ? '🔴 suspended' : '-',
|
|
44
|
+
when(u.last_login)
|
|
45
|
+
]);
|
|
46
|
+
for (const line of table_lines(['ID', 'EMAIL', 'NAME', 'VERIFIED', 'SUSPENDED', 'LAST LOGIN'], rows)) {
|
|
47
|
+
log.info(` ${line}`);
|
|
48
|
+
}
|
|
49
|
+
if (more) {
|
|
50
|
+
log.info('… more results available: raise --limit, use --all, or refine the filter.');
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export { users_list };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
3
|
+
import { log } from '../../../log.js';
|
|
4
|
+
import { json_option, tenant_options } from '../options.js';
|
|
5
|
+
import { print_json } from '../render.js';
|
|
6
|
+
import { wants_stdin, read_stdin, parse_user_ids } from './ids.js';
|
|
7
|
+
|
|
8
|
+
const users_suspend = {
|
|
9
|
+
command: 'suspend [user_ids..]',
|
|
10
|
+
describe: 'Suspend one or more users (blocks logins, refresh tokens and sessions)',
|
|
11
|
+
builder: yargs => json_option(tenant_options(yargs))
|
|
12
|
+
.positional('user_ids', {
|
|
13
|
+
type: 'string',
|
|
14
|
+
array: true,
|
|
15
|
+
description: 'User ids (user_…); omit them to read ids from stdin'
|
|
16
|
+
})
|
|
17
|
+
.option('reason', {
|
|
18
|
+
alias: 'r',
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Suspension reason, recorded on the user (max 512 chars)'
|
|
21
|
+
})
|
|
22
|
+
.option('yes', {
|
|
23
|
+
alias: 'y',
|
|
24
|
+
type: 'boolean',
|
|
25
|
+
default: false,
|
|
26
|
+
description: 'Skip the confirmation prompt'
|
|
27
|
+
})
|
|
28
|
+
.example('$0 auth users suspend user_abc123 --reason "abuse: proxy payload"', 'Suspend one user')
|
|
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')
|
|
31
|
+
.showHelpOnFail(false),
|
|
32
|
+
handler: withAuthHints(async (args) => {
|
|
33
|
+
const argv_ids = args.user_ids ?? [];
|
|
34
|
+
const stdin = wants_stdin(argv_ids, !!process.stdin.isTTY)
|
|
35
|
+
? await read_stdin()
|
|
36
|
+
: null;
|
|
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
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const api = await requireAuthAdmin(args);
|
|
55
|
+
const results = [];
|
|
56
|
+
for (const id of ids) {
|
|
57
|
+
try {
|
|
58
|
+
const user = await api.userUpdate(id, {
|
|
59
|
+
suspended: true,
|
|
60
|
+
...(args.reason ? { suspended_reason: args.reason } : {})
|
|
61
|
+
});
|
|
62
|
+
results.push({ id, suspended: !!user.suspended });
|
|
63
|
+
// Progress lines stay off stdout in --json mode (machine-clean pipe).
|
|
64
|
+
if (!args.json) {
|
|
65
|
+
log.info(`🔴 Suspended ${id}${user.email ? ` (${user.email})` : ''}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
70
|
+
results.push({ id, suspended: false, error: message });
|
|
71
|
+
if (!args.json)
|
|
72
|
+
log.error(`❌ Failed to suspend ${id}: ${message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (args.json)
|
|
76
|
+
print_json(results);
|
|
77
|
+
const failed = results.filter(r => r.error);
|
|
78
|
+
if (failed.length > 0) {
|
|
79
|
+
throw new Error(`${failed.length} of ${ids.length} suspension(s) failed${args.json ? '' : ' — see errors above'}`);
|
|
80
|
+
}
|
|
81
|
+
if (!args.json) {
|
|
82
|
+
log.info(`✅ ${ids.length} user(s) suspended. Live access tokens against external APIs remain valid until expiry (≤24h).`);
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export { users_suspend };
|
|
@@ -19,7 +19,9 @@ import { is_superseded } from './superseded.js';
|
|
|
19
19
|
|
|
20
20
|
const deploy = {
|
|
21
21
|
command: 'deploy [app_id]',
|
|
22
|
-
|
|
22
|
+
// Name the subcommand groups so `faable --help` makes them discoverable
|
|
23
|
+
// without digging into `faable deploy --help`.
|
|
24
|
+
describe: 'Deploy a faable app and manage it (secrets, domains, logs, deployments…)',
|
|
23
25
|
builder: yargs => {
|
|
24
26
|
// Product subcommands live under `deploy` (yargs matches them before the
|
|
25
27
|
// app_id positional, so `faable deploy <app_id>` keeps working).
|
|
@@ -5,10 +5,11 @@ import { log } from '../../log.js';
|
|
|
5
5
|
// app_id resolution (the user never has to look one up):
|
|
6
6
|
// 1. explicit (positional on `deploy`, --app on subcommands)
|
|
7
7
|
// 2. OIDC in CI — the backend resolves the app from the linked repository
|
|
8
|
-
// 3. locally —
|
|
8
|
+
// 3. locally — a legacy app_id in faable.json (older CLIs wrote it on
|
|
9
|
+
// `faable deploy link`; the current link only persists in the API)
|
|
9
10
|
// 4. locally — the app whose linked repository matches the git origin remote
|
|
10
11
|
// of the working directory (repos are connected in the dashboard when the
|
|
11
|
-
// app is created,
|
|
12
|
+
// app is created, or via `faable deploy link`)
|
|
12
13
|
const resolve_app_id = async (explicit, ctxAppId, api, workdir = process.cwd()) => {
|
|
13
14
|
const app_id = explicit || ctxAppId || Configuration.instance().app_id;
|
|
14
15
|
if (app_id)
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { requireApi } from '../../api/context.js';
|
|
2
2
|
import prompts from 'prompts';
|
|
3
3
|
import { log } from '../../log.js';
|
|
4
|
-
import { Configuration } from '../../lib/Configuration.js';
|
|
5
4
|
import { getGitRemoteUrl } from '../../lib/git_remote.js';
|
|
6
5
|
|
|
7
6
|
const DEPLOY_DOCS_URL = "https://faable.com/docs/deploy/github-actions";
|
|
@@ -27,9 +26,27 @@ const link = {
|
|
|
27
26
|
log.warn('Passing an app_id to "faable link" is deprecated and ignored. ' +
|
|
28
27
|
'Just run "faable link" and select the app from the list.');
|
|
29
28
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
log.info("Checking local git repository...");
|
|
30
|
+
const gitUrl = await getGitRemoteUrl(workdir);
|
|
31
|
+
if (!gitUrl) {
|
|
32
|
+
log.error("No git remote URL detected. Add a GitHub 'origin' remote and try again.");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const { api } = await requireApi();
|
|
36
|
+
const apps = await api.list();
|
|
37
|
+
if (apps.length === 0) {
|
|
38
|
+
log.error("No apps found in your account. Create one first at https://faable.com");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// The link lives in the API (app.repository) — every command resolves the
|
|
42
|
+
// app from the git remote, so nothing is written locally. Warn (and
|
|
43
|
+
// confirm) when the repository is already linked to an app.
|
|
44
|
+
const alreadyLinked = apps.filter((app) => app.repository === gitUrl);
|
|
45
|
+
if (alreadyLinked.length > 0) {
|
|
46
|
+
const names = alreadyLinked
|
|
47
|
+
.map((app) => `"${app.name}" (${app.id})`)
|
|
48
|
+
.join(", ");
|
|
49
|
+
log.info(`This repository is already linked to: ${names}`);
|
|
33
50
|
const { relink } = await prompts({
|
|
34
51
|
type: "toggle",
|
|
35
52
|
name: "relink",
|
|
@@ -42,14 +59,6 @@ const link = {
|
|
|
42
59
|
return;
|
|
43
60
|
}
|
|
44
61
|
}
|
|
45
|
-
const { api } = await requireApi();
|
|
46
|
-
log.info("Checking local git repository...");
|
|
47
|
-
const gitUrl = await getGitRemoteUrl(workdir);
|
|
48
|
-
const apps = await api.list();
|
|
49
|
-
if (apps.length === 0) {
|
|
50
|
-
log.error("No apps found in your account. Create one first at https://faable.com");
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
62
|
const { selectedApp } = await prompts({
|
|
54
63
|
type: "select",
|
|
55
64
|
name: "selectedApp",
|
|
@@ -64,10 +73,6 @@ const link = {
|
|
|
64
73
|
return;
|
|
65
74
|
}
|
|
66
75
|
log.info(`Linking to "${selectedApp.name}" (${selectedApp.id})...`);
|
|
67
|
-
if (!gitUrl) {
|
|
68
|
-
log.error("No git remote URL detected. Add a GitHub 'origin' remote and try again.");
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
76
|
// The API verifies that the user has a connected GitHub identity AND
|
|
72
77
|
// access to the repository before persisting the link.
|
|
73
78
|
let linked;
|
|
@@ -95,9 +100,6 @@ const link = {
|
|
|
95
100
|
return;
|
|
96
101
|
}
|
|
97
102
|
log.info(`Linked repository ${gitUrl} to ${selectedApp.name}.`);
|
|
98
|
-
// Save locally for CLI convenience (only after the API confirms the link)
|
|
99
|
-
Configuration.instance().saveConfig({ app_slug: selectedApp.name, app_id: selectedApp.id });
|
|
100
|
-
log.info(`Successfully linked local repository to ${selectedApp.name}.`);
|
|
101
103
|
// Deploy v4: push-to-deploy is server-side by default — no workflow to
|
|
102
104
|
// scaffold. A repo that brings its own Faable workflow keeps deploying
|
|
103
105
|
// through it (the API leaves the trigger on the Action in that case).
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import yargs from 'yargs';
|
|
2
2
|
import { hideBin } from 'yargs/helpers';
|
|
3
|
+
import { auth } from './commands/auth/index.js';
|
|
3
4
|
import { deploy } from './commands/deploy/index.js';
|
|
4
5
|
import { link_deprecated } from './commands/link/index.js';
|
|
5
6
|
import { login } from './commands/login/index.js';
|
|
@@ -22,6 +23,10 @@ yg.scriptName('faable')
|
|
|
22
23
|
if (banner_shown)
|
|
23
24
|
return;
|
|
24
25
|
banner_shown = true;
|
|
26
|
+
// --json mode is for piping: keep stdout machine-clean (no banner, no
|
|
27
|
+
// update-check notice).
|
|
28
|
+
if (argv.json)
|
|
29
|
+
return;
|
|
25
30
|
log.info(`Faable CLI ${version}`);
|
|
26
31
|
// `upgrade` does its own (forced) check
|
|
27
32
|
if (argv._[0] !== 'upgrade') {
|
|
@@ -44,12 +49,17 @@ yg.scriptName('faable')
|
|
|
44
49
|
}
|
|
45
50
|
}, true)
|
|
46
51
|
.command(deploy)
|
|
52
|
+
.command(auth)
|
|
47
53
|
.command(login)
|
|
48
54
|
.command(logout)
|
|
49
55
|
.command(whoami)
|
|
50
56
|
.command(upgrade)
|
|
51
57
|
.command(link_deprecated)
|
|
52
58
|
.demandCommand(1)
|
|
59
|
+
// Reject unknown (sub)commands loudly. Without this, a typo'd or
|
|
60
|
+
// not-yet-existing command (`faable secrets list` on a version where it
|
|
61
|
+
// lived elsewhere) exited silently with just the version banner.
|
|
62
|
+
.strictCommands()
|
|
53
63
|
.help()
|
|
54
64
|
.fail(function (msg, err) {
|
|
55
65
|
if (err) {
|
|
@@ -58,8 +68,11 @@ yg.scriptName('faable')
|
|
|
58
68
|
return;
|
|
59
69
|
}
|
|
60
70
|
if (msg) {
|
|
71
|
+
// Validation failure (unknown command, missing subcommand…): show the
|
|
72
|
+
// help, then fail red — a bad invocation must not exit 0.
|
|
61
73
|
yg.showHelp();
|
|
62
|
-
log.
|
|
74
|
+
log.error(`❌ ${msg}`);
|
|
75
|
+
process.exit(1);
|
|
63
76
|
}
|
|
64
77
|
})
|
|
65
78
|
.parse(hideBin(process.argv), {});
|
|
@@ -23,12 +23,6 @@ class Configuration {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
saveConfig(updates) {
|
|
27
|
-
this.config = { ...this.config, ...updates };
|
|
28
|
-
const config_path = path__default.join(process.cwd(), this.config_file);
|
|
29
|
-
fs.writeJSONSync(config_path, this.config, { spaces: 2 });
|
|
30
|
-
log.info(`Configuration saved to: ${this.config_file}`);
|
|
31
|
-
}
|
|
32
26
|
static instance() {
|
|
33
27
|
if (!Configuration._instance) {
|
|
34
28
|
Configuration._instance = new Configuration();
|
|
@@ -58,9 +52,6 @@ class Configuration {
|
|
|
58
52
|
next: this.config.next,
|
|
59
53
|
};
|
|
60
54
|
}
|
|
61
|
-
get app_slug() {
|
|
62
|
-
return this.config.app_slug;
|
|
63
|
-
}
|
|
64
55
|
get app_id() {
|
|
65
56
|
return this.config.app_id;
|
|
66
57
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faable/faable",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Pomar <marc@faable.com>",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@actions/core": "^3.0.0",
|
|
30
|
+
"@faable/auth-sdk": "^2.5.18",
|
|
30
31
|
"axios": "^1.18.1",
|
|
31
32
|
"fs-extra": "^11.3.2",
|
|
32
33
|
"handlebars": "^4.7.8",
|