@axium/server 0.43.0 → 0.44.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/main.js CHANGED
@@ -53,845 +53,565 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
53
53
  });
54
54
  import { apps } from '@axium/core';
55
55
  import { AuditFilter, severityNames } from '@axium/core/audit';
56
- import { formatDateRange } from '@axium/core/format';
56
+ import { formatBytes, formatDateRange, formatMs } from '@axium/core/format';
57
57
  import { outputDaemonStatus, pluginText } from '@axium/core/node';
58
58
  import { _findPlugin, plugins, runIntegrations } from '@axium/core/plugins';
59
59
  import { Argument, Option, program } from 'commander';
60
60
  import * as io from 'ioium/node';
61
61
  import { allLogLevels } from 'logzen';
62
- import { createWriteStream } from 'node:fs';
63
- import { access } from 'node:fs/promises';
62
+ import { createWriteStream, readFileSync } from 'node:fs';
63
+ import { access, watch } from 'node:fs/promises';
64
64
  import { join, resolve } from 'node:path/posix';
65
- import { createInterface } from 'node:readline/promises';
66
65
  import { parseArgs, styleText } from 'node:util';
67
66
  import { getByString, isJSON, setByString } from 'utilium';
67
+ import { searchForWorkspaceRoot } from 'vite';
68
68
  import * as z from 'zod';
69
69
  import $pkg from '../package.json' with { type: 'json' };
70
70
  import { audit, getEvents, styleSeverity } from './audit.js';
71
- import { diffUpdate, lookupUser, userText } from './cli.js';
71
+ import { build } from './build.js';
72
+ import { diffUpdate, lookupUser, cliOptions as opts, rl, rlConfirm, userText } from './cli.js';
72
73
  import config, { ConfigFile, configFiles, reloadConfigs, saveConfigTo } from './config.js';
73
- import * as db from './database.js';
74
+ import { dbInitTables } from './db/cli.js';
75
+ import './db/cli.js';
76
+ import * as db from './db/index.js';
74
77
  import { _portActions, _portMethods, dirs, logger, restrictedPorts } from './io.js';
75
78
  import { linkRoutes, listRouteLinks, unlinkRoutes, writePluginHooks } from './linking.js';
76
79
  import { serve } from './serve.js';
77
- async function rlConfirm(question = 'Is this ok') {
78
- const { data, error } = z
79
- .stringbool()
80
- .default(false)
81
- .safeParse(await rl.question(question + ' [y/N]: ').catch(() => io.exit('Aborted.')));
82
- if (error || !data)
83
- io.exit('Aborted.');
80
+ process.on('SIGHUP', () => {
81
+ io.info('Reloading configuration due to SIGHUP.');
82
+ void reloadConfigs();
83
+ });
84
+ // Need these before Command is set up (e.g. for CLI integrations)
85
+ const { safe, debug, config: configFromCLI, } = parseArgs({
86
+ options: {
87
+ safe: { type: 'boolean', default: z.stringbool().default(false).parse(process.env.SAFE?.toLowerCase()) },
88
+ debug: { type: 'boolean', default: z.stringbool().default(false).parse(process.env.DEBUG?.toLowerCase()) },
89
+ config: { type: 'string', short: 'c' },
90
+ },
91
+ allowPositionals: true,
92
+ strict: false,
93
+ }).values;
94
+ if (debug) {
95
+ io._setDebugOutput(true);
96
+ config.set({ debug: true });
84
97
  }
85
- async function dbInitTables() {
86
- const env_2 = { stack: [], error: void 0, hasError: false };
98
+ await config.loadDefaults(safe);
99
+ if (configFromCLI)
100
+ await config.load(configFromCLI, { safe });
101
+ await runIntegrations();
102
+ program
103
+ .version($pkg.version)
104
+ .name('axium')
105
+ .description('Axium server CLI')
106
+ .configureHelp({ showGlobalOptions: true })
107
+ .option('--safe', 'do not execute code from plugins', false)
108
+ .option('--debug', 'override debug mode')
109
+ .option('--no-debug', 'override debug mode')
110
+ .option('-c, --config <path>', 'path to the config file')
111
+ .hook('preAction', (_, action) => {
112
+ const opt = action.optsWithGlobals();
113
+ opt.force && io.warn('--force: Protections disabled.');
114
+ if (typeof opt.debug == 'boolean') {
115
+ config.set({ debug: opt.debug });
116
+ io._setDebugOutput(opt.debug);
117
+ }
87
118
  try {
119
+ db.connect();
120
+ }
121
+ catch (e) {
122
+ if (!noAutoDB.includes(action.name()))
123
+ throw e;
124
+ }
125
+ })
126
+ .hook('postAction', async (_, action) => {
127
+ if (!noAutoDB.includes(action.name()))
128
+ await db.database.destroy();
129
+ })
130
+ .on('option:debug', () => config.set({ debug: true }));
131
+ const noAutoDB = ['init', 'serve', 'check'];
132
+ const axiumConfig = program
133
+ .command('config')
134
+ .description('Manage the configuration')
135
+ .addOption(opts.global)
136
+ .option('-j, --json', 'values are JSON encoded', false)
137
+ .option('-r, --redact', 'Do not output sensitive values', false);
138
+ function configReplacer(opt) {
139
+ return (key, value) => {
140
+ return opt.redact && ['password', 'secret'].includes(key) ? '[redacted]' : value;
141
+ };
142
+ }
143
+ axiumConfig
144
+ .command('dump')
145
+ .description('Output the entire current configuration')
146
+ .action(function axium_config_dump() {
147
+ const opt = this.optsWithGlobals();
148
+ const value = config.plain();
149
+ console.log(opt.json ? JSON.stringify(value, configReplacer(opt), 4) : value);
150
+ });
151
+ axiumConfig
152
+ .command('get')
153
+ .description('Get a config value')
154
+ .argument('<key>', 'the key to get')
155
+ .action(function axium_config_get(key) {
156
+ const opt = this.optsWithGlobals();
157
+ const value = getByString(config.plain(), key);
158
+ console.log(opt.json ? JSON.stringify(value, configReplacer(opt), 4) : value);
159
+ });
160
+ axiumConfig
161
+ .command('set')
162
+ .description('Set a config value. Note setting objects is not supported.')
163
+ .argument('<key>', 'the key to set')
164
+ .argument('<value>', 'the value')
165
+ .action(function axium_config_set(key, value) {
166
+ const opt = this.optsWithGlobals();
167
+ if (opt.json && !isJSON(value))
168
+ io.exit('Invalid JSON');
169
+ const obj = {};
170
+ setByString(obj, key, opt.json ? JSON.parse(value) : value);
171
+ config.save(obj, opt.global);
172
+ });
173
+ axiumConfig
174
+ .command('list')
175
+ .alias('ls')
176
+ .alias('files')
177
+ .description('List loaded config files')
178
+ .action(() => {
179
+ for (const path of config.files.keys())
180
+ console.log(path);
181
+ });
182
+ axiumConfig
183
+ .command('schema')
184
+ .description('Get the JSON schema for the configuration file')
185
+ .action(() => {
186
+ const opt = axiumConfig.optsWithGlobals();
187
+ try {
188
+ const schema = z.toJSONSchema(ConfigFile, { io: 'input' });
189
+ console.log(opt.json ? JSON.stringify(schema, configReplacer(opt), 4) : schema);
190
+ }
191
+ catch (e) {
192
+ io.exit(e);
193
+ }
194
+ });
195
+ const axiumPlugin = program.command('plugin').alias('plugins').description('Manage plugins').addOption(opts.global);
196
+ axiumPlugin
197
+ .command('list')
198
+ .alias('ls')
199
+ .description('List loaded plugins')
200
+ .option('-l, --long', 'use the long listing format')
201
+ .option('--no-versions', 'do not show plugin versions')
202
+ .action(opt => {
203
+ if (!plugins.size) {
204
+ console.log('No plugins loaded.');
205
+ return;
206
+ }
207
+ if (!opt.long) {
208
+ console.log(Array.from(plugins.keys()).join(', '));
209
+ return;
210
+ }
211
+ console.log(styleText('whiteBright', plugins.size + ' plugin(s) loaded:'));
212
+ for (const plugin of plugins.values()) {
213
+ console.log(plugin.name, opt.versions ? plugin.version : '');
214
+ }
215
+ });
216
+ axiumPlugin
217
+ .command('info')
218
+ .description('Get information about a plugin')
219
+ .argument('<plugin>', 'the plugin to get information about')
220
+ .action((search) => {
221
+ const plugin = _findPlugin(search);
222
+ for (const line of pluginText(plugin))
223
+ console.log(line);
224
+ });
225
+ axiumPlugin
226
+ .command('remove')
227
+ .alias('rm')
228
+ .description('Remove a plugin')
229
+ .argument('<plugin>', 'the plugin to remove')
230
+ .action(async (search, opt) => {
231
+ const plugin = _findPlugin(search);
232
+ await plugin._hooks?.remove?.(opt);
233
+ for (const [path, data] of configFiles) {
234
+ if (!data.plugins)
235
+ continue;
236
+ data.plugins = data.plugins.filter(p => p !== plugin.specifier);
237
+ saveConfigTo(path, data);
238
+ }
239
+ plugins.delete(plugin.name);
240
+ });
241
+ axiumPlugin
242
+ .command('init')
243
+ .alias('setup')
244
+ .alias('install')
245
+ .description('Initialize a plugin. This could include adding tables to the database or linking routes.')
246
+ .addOption(opts.timeout)
247
+ .addOption(opts.check)
248
+ .argument('<plugin>', 'the plugin to initialize')
249
+ .action(async (search) => {
250
+ const env_1 = { stack: [], error: void 0, hasError: false };
251
+ try {
252
+ const plugin = _findPlugin(search);
253
+ if (!plugin)
254
+ io.exit(`Can't find a plugin matching "${search}"`);
255
+ const _ = __addDisposableResource(env_1, db.connect(), true);
88
256
  const info = db.getUpgradeInfo();
89
- const schema = db.schema.getFull({ exclude: Object.keys(info.current) });
257
+ const exclude = Object.keys(info.current);
258
+ if (exclude.includes(plugin.name))
259
+ io.exit('Plugin is already initialized (database)');
260
+ const schema = db.schema.getFull({ exclude });
90
261
  const delta = db.delta.compute({ tables: {}, indexes: {} }, schema);
91
- if (db.delta.isEmpty(delta))
262
+ if (db.delta.isEmpty(delta)) {
263
+ io.info('Plugin does not define any database schema.');
92
264
  return;
265
+ }
93
266
  for (const text of db.delta.display(delta))
94
267
  console.log(text);
95
268
  await rlConfirm();
96
- const _ = __addDisposableResource(env_2, db.connect(), true);
97
269
  await db.delta.apply(delta);
98
270
  Object.assign(info.current, schema.versions);
99
271
  db.setUpgradeInfo(info);
100
272
  }
101
- catch (e_2) {
102
- env_2.error = e_2;
103
- env_2.hasError = true;
273
+ catch (e_1) {
274
+ env_1.error = e_1;
275
+ env_1.hasError = true;
104
276
  }
105
277
  finally {
106
- const result_1 = __disposeResources(env_2);
278
+ const result_1 = __disposeResources(env_1);
107
279
  if (result_1)
108
280
  await result_1;
109
281
  }
110
- }
111
- function configReplacer(opt) {
112
- return (key, value) => {
113
- return opt.redact && ['password', 'secret'].includes(key) ? '[redacted]' : value;
114
- };
115
- }
116
- var rl, safe, debug, configFromCLI, noAutoDB, opts, axiumDB, axiumConfig, axiumPlugin, axiumApps, argUserLookup;
117
- const env_1 = { stack: [], error: void 0, hasError: false };
118
- try {
119
- rl = __addDisposableResource(env_1, createInterface({
120
- input: process.stdin,
121
- output: process.stdout,
122
- }), false);
123
- process.on('SIGHUP', () => {
124
- io.info('Reloading configuration due to SIGHUP.');
125
- void reloadConfigs();
126
- });
127
- // Need these before Command is set up (e.g. for CLI integrations)
128
- ({
129
- safe,
130
- debug,
131
- config: configFromCLI
132
- } = parseArgs({
133
- options: {
134
- safe: { type: 'boolean', default: z.stringbool().default(false).parse(process.env.SAFE?.toLowerCase()) },
135
- debug: { type: 'boolean', default: z.stringbool().default(false).parse(process.env.DEBUG?.toLowerCase()) },
136
- config: { type: 'string', short: 'c' },
137
- },
138
- allowPositionals: true,
139
- strict: false,
140
- }).values);
141
- if (debug) {
142
- io._setDebugOutput(true);
143
- config.set({ debug: true });
144
- }
145
- await config.loadDefaults(safe);
146
- if (configFromCLI)
147
- await config.load(configFromCLI, { safe });
148
- await runIntegrations();
149
- program
150
- .version($pkg.version)
151
- .name('axium')
152
- .description('Axium server CLI')
153
- .configureHelp({ showGlobalOptions: true })
154
- .option('--safe', 'do not execute code from plugins', false)
155
- .option('--debug', 'override debug mode')
156
- .option('--no-debug', 'override debug mode')
157
- .option('-c, --config <path>', 'path to the config file')
158
- .hook('preAction', (_, action) => {
159
- const opt = action.optsWithGlobals();
160
- opt.force && io.warn('--force: Protections disabled.');
161
- if (typeof opt.debug == 'boolean') {
162
- config.set({ debug: opt.debug });
163
- io._setDebugOutput(opt.debug);
164
- }
165
- try {
166
- db.connect();
167
- }
168
- catch (e) {
169
- if (!noAutoDB.includes(action.name()))
170
- throw e;
171
- }
172
- })
173
- .hook('postAction', async (_, action) => {
174
- if (!noAutoDB.includes(action.name()))
175
- await db.database.destroy();
176
- })
177
- .on('option:debug', () => config.set({ debug: true }));
178
- noAutoDB = ['init', 'serve', 'check'];
179
- // Options shared by multiple (sub)commands
180
- opts = {
181
- check: new Option('--check', 'check the database schema after initialization').default(false),
182
- force: new Option('-f, --force', 'force the operation').default(false),
183
- global: new Option('-g, --global', 'apply the operation globally').default(false),
184
- timeout: new Option('-t, --timeout <ms>', 'how long to wait for commands to complete.').default(1000).argParser(value => {
185
- const timeout = parseInt(value);
186
- if (!Number.isSafeInteger(timeout) || timeout < 0)
187
- io.warn('Invalid timeout value, using default.');
188
- io.setCommandTimeout(timeout);
189
- }),
190
- };
191
- axiumDB = program.command('db').alias('database').description('Manage the database').addOption(opts.timeout);
192
- axiumDB
193
- .command('init')
194
- .description('Initialize the database')
195
- .addOption(opts.force)
196
- .option('-s, --skip', 'If the user, database, or schema already exists, skip trying to create it.', false)
197
- .addOption(opts.check)
198
- .action(async function axium_db_init() {
199
- const opt = this.optsWithGlobals();
200
- await db.init(opt).catch(io.exit);
201
- await dbInitTables().catch(io.exit);
202
- });
203
- axiumDB
204
- .command('status')
205
- .alias('stats')
206
- .description('Check the status of the database')
207
- .action(async () => {
208
- try {
209
- console.log(await db.statText());
210
- }
211
- catch {
212
- io.error('Unavailable');
213
- process.exitCode = 1;
214
- }
215
- });
216
- axiumDB
217
- .command('drop')
218
- .description('Drop the Axium database and user')
219
- .addOption(opts.force)
220
- .action(async (opt) => {
221
- const stats = await db.count('users', 'passkeys', 'sessions').catch(io.exit);
222
- if (!opt.force)
223
- for (const key of ['users', 'passkeys', 'sessions']) {
224
- if (stats[key] == 0)
225
- continue;
226
- io.warn(`Database has existing ${key}. Use --force if you really want to drop the database.`);
227
- process.exit(2);
228
- }
229
- await db._sql('DROP DATABASE axium', 'Dropping database').catch(io.exit);
230
- await db._sql('REVOKE ALL PRIVILEGES ON SCHEMA public FROM axium', 'Revoking schema privileges').catch(io.exit);
231
- await db._sql('DROP USER axium', 'Dropping user').catch(io.exit);
232
- await db
233
- .getHBA(opt)
234
- .then(([content, writeBack]) => {
235
- io.start('Checking for Axium HBA configuration');
236
- if (!content.includes(db._pgHba))
237
- throw 'missing.';
238
- io.done();
239
- io.start('Removing Axium HBA configuration');
240
- const newContent = content.replace(db._pgHba, '');
241
- io.done();
242
- writeBack(newContent);
282
+ });
283
+ const axiumApps = program.command('apps').description('Manage Axium apps').addOption(opts.global);
284
+ axiumApps
285
+ .command('list')
286
+ .alias('ls')
287
+ .description('List apps added by plugins')
288
+ .option('-l, --long', 'use the long listing format')
289
+ .option('-b, --builtin', 'include built-in apps')
290
+ .action(opt => {
291
+ if (!apps.size) {
292
+ console.log('No apps.');
293
+ return;
294
+ }
295
+ if (!opt.long) {
296
+ console.log(Array.from(apps.values().map(app => app.name)).join(', '));
297
+ return;
298
+ }
299
+ console.log(styleText('whiteBright', apps.size + ' app(s) loaded:'));
300
+ for (const app of apps.values()) {
301
+ console.log(app.name, styleText('dim', `(${app.id})`));
302
+ }
303
+ });
304
+ const argUserLookup = new Argument('<user>', 'the UUID or email of the user to operate on').argParser(lookupUser);
305
+ program
306
+ .command('user')
307
+ .description('Get or change information about a user')
308
+ .addArgument(argUserLookup)
309
+ .option('-S, --sessions', 'show user sessions')
310
+ .option('-P, --passkeys', 'show user passkeys')
311
+ .option('--add-role <role...>', 'add roles to the user')
312
+ .option('--remove-role <role...>', 'remove roles from the user')
313
+ .option('--tag <tag...>', 'Add tags to the user')
314
+ .option('--untag <tag...>', 'Remove tags from the user')
315
+ .option('--delete', 'Delete the user')
316
+ .option('--suspend', 'Suspend the user')
317
+ .addOption(new Option('--unsuspend', 'Un-suspend the user').conflicts('suspend'))
318
+ .action(async (_user, opt) => {
319
+ let user = await _user;
320
+ const [updatedRoles, roles, rolesDiff] = diffUpdate(user.roles, opt.addRole, opt.removeRole);
321
+ const [updatedTags, tags, tagsDiff] = diffUpdate(user.tags, opt.tag, opt.untag);
322
+ const changeSuspend = (opt.suspend || opt.unsuspend) && user.isSuspended != (opt.suspend ?? !opt.unsuspend);
323
+ if (updatedRoles || updatedTags || changeSuspend) {
324
+ user = await db.database
325
+ .updateTable('users')
326
+ .where('id', '=', user.id)
327
+ .set({ roles, tags, isSuspended: !changeSuspend ? user.isSuspended : (opt.suspend ?? !opt.unsuspend) })
328
+ .returningAll()
329
+ .executeTakeFirstOrThrow()
330
+ .then(u => {
331
+ if (updatedRoles && rolesDiff)
332
+ console.log(`> Updated roles: ${rolesDiff}`);
333
+ if (updatedTags && tagsDiff)
334
+ console.log(`> Updated tags: ${tagsDiff}`);
335
+ if (changeSuspend)
336
+ console.log(opt.suspend ? '> Suspended' : '> Un-suspended');
337
+ return u;
243
338
  })
244
- .catch(io.warn);
245
- });
246
- axiumDB
247
- .command('wipe')
248
- .description('Wipe the database')
249
- .addOption(opts.force)
250
- .action(async (opt) => {
251
- const tables = new Map();
252
- for (const [plugin, schema] of db.schema.getFiles()) {
253
- for (const table of schema.wipe) {
254
- const maybePlugin = tables.get(table);
255
- tables.set(table, maybePlugin ? `${maybePlugin}, ${plugin}` : plugin);
256
- }
257
- }
258
- if (!opt.force) {
259
- const stats = await db.count(...tables.keys()).catch(io.exit);
260
- const nonEmpty = Object.entries(stats)
261
- .filter(([, v]) => v)
262
- .map(([k]) => k);
263
- if (nonEmpty.length) {
264
- io.exit(`Some tables are not empty, use --force if you really want to wipe them: ${nonEmpty.join(', ')}`, 2);
265
- }
266
- }
267
- const maxTableName = Math.max(5, ...Array.from(tables.keys()).map(t => t.length));
268
- console.log('Table' + ' '.repeat(maxTableName - 5), '|', 'Plugin(s)');
269
- console.log('-'.repeat(maxTableName), '|', '-'.repeat(20));
270
- for (const [table, plugins] of [...tables].sort((a, b) => a[0].localeCompare(b[0]))) {
271
- console.log(table + ' '.repeat(maxTableName - table.length), '|', plugins);
272
- }
273
- await rlConfirm('Are you sure you want to wipe these tables and any dependents');
274
- await db.database.deleteFrom(Array.from(tables.keys())).execute().catch(io.exit);
275
- });
276
- axiumDB
277
- .command('check')
278
- .description('Check the structure of the database')
279
- .option('-s, --strict', 'Throw errors instead of emitting warnings for most column problems')
280
- .action(async (opt) => {
281
- const env_3 = { stack: [], error: void 0, hasError: false };
282
- try {
283
- await io.run('Checking for sudo', 'which sudo').catch(io.exit);
284
- await io.run('Checking for psql', 'which psql').catch(io.exit);
285
- const throwUnlessRows = (text) => {
286
- if (text.includes('(0 rows)'))
287
- throw 'missing.';
288
- return text;
289
- };
290
- await db._sql(`SELECT 1 FROM pg_database WHERE datname = 'axium'`, 'Checking for database').then(throwUnlessRows).catch(io.exit);
291
- await db._sql(`SELECT 1 FROM pg_roles WHERE rolname = 'axium'`, 'Checking for user').then(throwUnlessRows).catch(io.exit);
292
- io.start('Connecting to database');
293
- const _ = __addDisposableResource(env_3, db.connect(), true);
294
- io.done();
295
- io.start('Getting schema metadata');
296
- const schemas = await db.database.introspection.getSchemas().catch(io.exit);
297
- io.done();
298
- io.start('Checking for acl schema');
299
- if (!schemas.find(s => s.name == 'acl'))
300
- io.exit('missing.');
301
- io.done();
302
- io.start('Getting table metadata');
303
- const tablePromises = await Promise.all([
304
- db.database.introspection.getTables(),
305
- db.database.withSchema('acl').introspection.getTables(),
306
- ]).catch(io.exit);
307
- const tableMetadata = tablePromises.flat();
308
- const tables = Object.fromEntries(tableMetadata.map(t => [t.schema == 'public' ? t.name : `${t.schema}.${t.name}`, t]));
309
- io.done();
310
- io.start('Resolving database schemas');
311
- let schema;
312
- try {
313
- schema = db.schema.getFull();
314
- io.done();
315
- }
316
- catch (e) {
317
- io.exit(e);
318
- }
319
- for (const [name, table] of Object.entries(schema.tables)) {
320
- await db.checkTableTypes(name, table, opt, tableMetadata);
321
- delete tables[name];
322
- }
323
- io.start('Checking for extra tables');
324
- const unchecked = Object.keys(tables).join(', ');
325
- if (!unchecked.length)
326
- io.done();
327
- else if (opt.strict)
328
- io.exit(unchecked);
329
- else
330
- io.warn(unchecked);
331
- }
332
- catch (e_3) {
333
- env_3.error = e_3;
334
- env_3.hasError = true;
335
- }
336
- finally {
337
- const result_2 = __disposeResources(env_3);
338
- if (result_2)
339
- await result_2;
340
- }
341
- });
342
- axiumDB
343
- .command('clean')
344
- .description('Remove expired rows')
345
- .addOption(opts.force)
346
- .action(async (opt) => {
347
- await db.clean(opt).catch(io.exit);
348
- });
349
- axiumDB
350
- .command('rotate-password')
351
- .description('Generate a new password for the database user and update the config')
352
- .action(db.rotatePassword);
353
- axiumDB
354
- .command('json-schema')
355
- .description('Get the JSON schema for the database configuration file')
356
- .option('-j, --json', 'values are JSON encoded')
357
- .action(opt => {
358
- try {
359
- const schema = z.toJSONSchema(db.schema.SchemaFile, { io: 'input' });
360
- console.log(opt.json ? JSON.stringify(schema, null, 4) : schema);
361
- }
362
- catch (e) {
363
- io.exit(e);
364
- }
365
- });
366
- axiumDB
367
- .command('upgrade')
368
- .alias('update')
369
- .alias('up')
370
- .description('Upgrade the database to the latest version')
371
- .option('--abort', 'Rollback changes instead of committing them')
372
- .action(async function axium_db_upgrade(opt) {
373
- const deltas = [];
374
- const info = db.getUpgradeInfo();
375
- let empty = true;
376
- const from = {}, to = {};
377
- for (const [name, schema] of db.schema.getFiles()) {
378
- if (!(name in info.current))
379
- io.exit('Plugin is not initialized: ' + name);
380
- const currentVersion = info.current[name];
381
- const target = schema.latest ?? schema.versions.length - 1;
382
- if (currentVersion >= target)
383
- continue;
384
- from[name] = currentVersion;
385
- to[name] = target;
386
- info.current[name] = target;
387
- let versions = schema.versions.slice(currentVersion + 1);
388
- const v0 = schema.versions[0];
389
- if (v0.delta)
390
- throw 'Initial version can not be a delta';
391
- for (const [i, v] of versions.toReversed().entries()) {
392
- if (v.delta || v == v0)
393
- continue;
394
- versions = [db.delta.compute(v0, v), ...versions.slice(-i)];
395
- break;
396
- }
397
- const delta = db.delta.collapse(versions);
398
- deltas.push(delta);
399
- console.log('Upgrading', name, styleText('dim', currentVersion.toString() + '->') + styleText('blueBright', target.toString()) + ':');
400
- if (!db.delta.isEmpty(delta))
401
- empty = false;
402
- for (const text of db.delta.display(delta))
403
- console.log(text);
404
- }
405
- if (empty) {
406
- console.log('Already up to date.');
407
- return;
408
- }
409
- if (opt.abort) {
410
- io.warn('--abort: Changes will be rolled back instead of being committed.');
411
- }
412
- await rlConfirm();
413
- io.start('Computing delta');
414
- let delta;
415
- try {
416
- delta = db.delta.collapse(deltas);
417
- io.done();
418
- }
419
- catch (e) {
420
- io.exit(e);
421
- }
422
- io.start('Validating delta');
423
- try {
424
- db.delta.validate(delta);
425
- io.done();
426
- }
427
- catch (e) {
428
- io.exit(e);
429
- }
430
- console.log('Applying delta.');
431
- await db.delta.apply(delta, opt.abort).catch(io.exit);
432
- info.upgrades.push({ timestamp: new Date(), from, to });
433
- db.setUpgradeInfo(info);
434
- });
435
- axiumDB
436
- .command('upgrade-history')
437
- .alias('update-history')
438
- .description('Show the history of database upgrades')
439
- .action(() => {
440
- const info = db.getUpgradeInfo();
441
- if (!info.upgrades.length) {
442
- console.log('No upgrade history.');
443
- return;
444
- }
445
- for (const up of info.upgrades) {
446
- console.log(styleText(['whiteBright', 'underline'], up.timestamp.toString()) + ':');
447
- for (const [name, from] of Object.entries(up.from)) {
448
- console.log(name, styleText('dim', from.toString() + '->') + styleText('blueBright', up.to[name].toString()));
449
- }
450
- }
451
- });
452
- axiumDB
453
- .command('versions')
454
- .description('Show information about database versions')
455
- .action(() => {
456
- const { current: currentVersions } = db.getUpgradeInfo();
457
- const lengths = { name: 4, current: 7, latest: 6, available: 9 };
458
- const entries = [
459
- { name: 'Name', current: 'Current', latest: 'Latest', available: 'Available' },
460
- ];
461
- for (const [name, file] of db.schema.getFiles()) {
462
- const available = (file.versions.length - 1).toString();
463
- const latest = (file.latest ?? available).toString();
464
- const current = currentVersions[name]?.toString();
465
- entries.push({ name, latest, available, current });
466
- lengths.name = Math.max(lengths.name || 0, name.length);
467
- lengths.current = Math.max(lengths.current || 0, current.length);
468
- lengths.latest = Math.max(lengths.latest || 0, latest.length);
469
- lengths.available = Math.max(lengths.available || 0, available.length);
470
- }
471
- for (const [i, entry] of entries.entries()) {
472
- console.log(...['name', 'current', 'latest', 'available'].map(key => styleText(i === 0 ? ['whiteBright', 'underline'] : entry[key] === undefined ? 'reset' : [], entry[key].padStart(lengths[key]))));
473
- }
474
- });
475
- axiumDB
476
- .command('export-schema')
477
- .description('Export the DB schema')
478
- .addOption(new Option('-f, --format <format>', 'Output format').choices(['sql', 'graph']).default('sql'))
479
- .option('-o, --output <file>', 'Output file path')
480
- .action(opt => {
481
- const schema = db.schema.getFull();
482
- const it = opt.format == 'sql' ? db.schema.toSQL(schema) : db.schema.toGraph(schema);
483
- const out = opt.output ? createWriteStream(opt.output) : process.stdout;
484
- for (const data of it)
485
- out.write(data);
486
- if (opt.output)
487
- out.close();
488
- });
489
- axiumConfig = program
490
- .command('config')
491
- .description('Manage the configuration')
492
- .addOption(opts.global)
493
- .option('-j, --json', 'values are JSON encoded', false)
494
- .option('-r, --redact', 'Do not output sensitive values', false);
495
- axiumConfig
496
- .command('dump')
497
- .description('Output the entire current configuration')
498
- .action(function axium_config_dump() {
499
- const opt = this.optsWithGlobals();
500
- const value = config.plain();
501
- console.log(opt.json ? JSON.stringify(value, configReplacer(opt), 4) : value);
502
- });
503
- axiumConfig
504
- .command('get')
505
- .description('Get a config value')
506
- .argument('<key>', 'the key to get')
507
- .action(function axium_config_get(key) {
508
- const opt = this.optsWithGlobals();
509
- const value = getByString(config.plain(), key);
510
- console.log(opt.json ? JSON.stringify(value, configReplacer(opt), 4) : value);
511
- });
512
- axiumConfig
513
- .command('set')
514
- .description('Set a config value. Note setting objects is not supported.')
515
- .argument('<key>', 'the key to set')
516
- .argument('<value>', 'the value')
517
- .action(function axium_config_set(key, value) {
518
- const opt = this.optsWithGlobals();
519
- if (opt.json && !isJSON(value))
520
- io.exit('Invalid JSON');
521
- const obj = {};
522
- setByString(obj, key, opt.json ? JSON.parse(value) : value);
523
- config.save(obj, opt.global);
524
- });
525
- axiumConfig
526
- .command('list')
527
- .alias('ls')
528
- .alias('files')
529
- .description('List loaded config files')
530
- .action(() => {
531
- for (const path of config.files.keys())
532
- console.log(path);
533
- });
534
- axiumConfig
535
- .command('schema')
536
- .description('Get the JSON schema for the configuration file')
537
- .action(() => {
538
- const opt = axiumConfig.optsWithGlobals();
539
- try {
540
- const schema = z.toJSONSchema(ConfigFile, { io: 'input' });
541
- console.log(opt.json ? JSON.stringify(schema, configReplacer(opt), 4) : schema);
542
- }
543
- catch (e) {
544
- io.exit(e);
545
- }
546
- });
547
- axiumPlugin = program.command('plugin').alias('plugins').description('Manage plugins').addOption(opts.global);
548
- axiumPlugin
549
- .command('list')
550
- .alias('ls')
551
- .description('List loaded plugins')
552
- .option('-l, --long', 'use the long listing format')
553
- .option('--no-versions', 'do not show plugin versions')
554
- .action(opt => {
555
- if (!plugins.size) {
556
- console.log('No plugins loaded.');
557
- return;
558
- }
559
- if (!opt.long) {
560
- console.log(Array.from(plugins.keys()).join(', '));
561
- return;
562
- }
563
- console.log(styleText('whiteBright', plugins.size + ' plugin(s) loaded:'));
564
- for (const plugin of plugins.values()) {
565
- console.log(plugin.name, opt.versions ? plugin.version : '');
566
- }
567
- });
568
- axiumPlugin
569
- .command('info')
570
- .description('Get information about a plugin')
571
- .argument('<plugin>', 'the plugin to get information about')
572
- .action((search) => {
573
- const plugin = _findPlugin(search);
574
- for (const line of pluginText(plugin))
575
- console.log(line);
576
- });
577
- axiumPlugin
578
- .command('remove')
579
- .alias('rm')
580
- .description('Remove a plugin')
581
- .argument('<plugin>', 'the plugin to remove')
582
- .action(async (search, opt) => {
583
- const plugin = _findPlugin(search);
584
- await plugin._hooks?.remove?.(opt);
585
- for (const [path, data] of configFiles) {
586
- if (!data.plugins)
587
- continue;
588
- data.plugins = data.plugins.filter(p => p !== plugin.specifier);
589
- saveConfigTo(path, data);
590
- }
591
- plugins.delete(plugin.name);
592
- });
593
- axiumPlugin
594
- .command('init')
595
- .alias('setup')
596
- .alias('install')
597
- .description('Initialize a plugin. This could include adding tables to the database or linking routes.')
598
- .addOption(opts.timeout)
599
- .addOption(opts.check)
600
- .argument('<plugin>', 'the plugin to initialize')
601
- .action(async (search) => {
602
- const env_4 = { stack: [], error: void 0, hasError: false };
603
- try {
604
- const plugin = _findPlugin(search);
605
- if (!plugin)
606
- io.exit(`Can't find a plugin matching "${search}"`);
607
- const _ = __addDisposableResource(env_4, db.connect(), true);
608
- const info = db.getUpgradeInfo();
609
- const exclude = Object.keys(info.current);
610
- if (exclude.includes(plugin.name))
611
- io.exit('Plugin is already initialized (database)');
612
- const schema = db.schema.getFull({ exclude });
613
- const delta = db.delta.compute({ tables: {}, indexes: {} }, schema);
614
- if (db.delta.isEmpty(delta)) {
615
- io.info('Plugin does not define any database schema.');
616
- return;
617
- }
618
- for (const text of db.delta.display(delta))
619
- console.log(text);
620
- await rlConfirm();
621
- await db.delta.apply(delta);
622
- Object.assign(info.current, schema.versions);
623
- db.setUpgradeInfo(info);
624
- }
625
- catch (e_4) {
626
- env_4.error = e_4;
627
- env_4.hasError = true;
628
- }
629
- finally {
630
- const result_3 = __disposeResources(env_4);
631
- if (result_3)
632
- await result_3;
633
- }
634
- });
635
- axiumApps = program.command('apps').description('Manage Axium apps').addOption(opts.global);
636
- axiumApps
637
- .command('list')
638
- .alias('ls')
639
- .description('List apps added by plugins')
640
- .option('-l, --long', 'use the long listing format')
641
- .option('-b, --builtin', 'include built-in apps')
642
- .action(opt => {
643
- if (!apps.size) {
644
- console.log('No apps.');
645
- return;
646
- }
647
- if (!opt.long) {
648
- console.log(Array.from(apps.values().map(app => app.name)).join(', '));
649
- return;
650
- }
651
- console.log(styleText('whiteBright', apps.size + ' app(s) loaded:'));
652
- for (const app of apps.values()) {
653
- console.log(app.name, styleText('dim', `(${app.id})`));
654
- }
655
- });
656
- argUserLookup = new Argument('<user>', 'the UUID or email of the user to operate on').argParser(lookupUser);
657
- program
658
- .command('user')
659
- .description('Get or change information about a user')
660
- .addArgument(argUserLookup)
661
- .option('-S, --sessions', 'show user sessions')
662
- .option('-P, --passkeys', 'show user passkeys')
663
- .option('--add-role <role...>', 'add roles to the user')
664
- .option('--remove-role <role...>', 'remove roles from the user')
665
- .option('--tag <tag...>', 'Add tags to the user')
666
- .option('--untag <tag...>', 'Remove tags from the user')
667
- .option('--delete', 'Delete the user')
668
- .option('--suspend', 'Suspend the user')
669
- .addOption(new Option('--unsuspend', 'Un-suspend the user').conflicts('suspend'))
670
- .action(async (_user, opt) => {
671
- let user = await _user;
672
- const [updatedRoles, roles, rolesDiff] = diffUpdate(user.roles, opt.addRole, opt.removeRole);
673
- const [updatedTags, tags, tagsDiff] = diffUpdate(user.tags, opt.tag, opt.untag);
674
- const changeSuspend = (opt.suspend || opt.unsuspend) && user.isSuspended != (opt.suspend ?? !opt.unsuspend);
675
- if (updatedRoles || updatedTags || changeSuspend) {
676
- user = await db.database
677
- .updateTable('users')
339
+ .catch(e => io.exit('Failed to update user: ' + e.message));
340
+ }
341
+ if (opt.delete) {
342
+ const confirmed = await rl
343
+ .question(`Are you sure you want to delete ${userText(user, true)}? (y/N) `)
344
+ .then(v => z.stringbool().parseAsync(v))
345
+ .catch(() => false);
346
+ if (!confirmed)
347
+ console.log(styleText('dim', '> Delete aborted.'));
348
+ else
349
+ await db.database
350
+ .deleteFrom('users')
678
351
  .where('id', '=', user.id)
679
- .set({ roles, tags, isSuspended: !changeSuspend ? user.isSuspended : (opt.suspend ?? !opt.unsuspend) })
680
- .returningAll()
681
352
  .executeTakeFirstOrThrow()
682
- .then(u => {
683
- if (updatedRoles && rolesDiff)
684
- console.log(`> Updated roles: ${rolesDiff}`);
685
- if (updatedTags && tagsDiff)
686
- console.log(`> Updated tags: ${tagsDiff}`);
687
- if (changeSuspend)
688
- console.log(opt.suspend ? '> Suspended' : '> Un-suspended');
689
- return u;
690
- })
691
- .catch(e => io.exit('Failed to update user: ' + e.message));
692
- }
693
- if (opt.delete) {
694
- const confirmed = await rl
695
- .question(`Are you sure you want to delete ${userText(user, true)}? (y/N) `)
696
- .then(v => z.stringbool().parseAsync(v))
697
- .catch(() => false);
698
- if (!confirmed)
699
- console.log(styleText('dim', '> Delete aborted.'));
700
- else
701
- await db.database
702
- .deleteFrom('users')
703
- .where('id', '=', user.id)
704
- .executeTakeFirstOrThrow()
705
- .then(() => console.log(styleText(['red', 'bold'], '> Deleted')))
706
- .catch(e => io.exit('Failed to delete user: ' + e.message));
707
- }
708
- console.log([
709
- user.isSuspended && styleText('yellowBright', 'Suspended'),
710
- user.isAdmin && styleText('redBright', 'Administrator'),
711
- 'UUID: ' + user.id,
712
- 'Name: ' + user.name,
713
- `Email: ${user.email}, ${user.emailVerified ? 'verified on ' + formatDateRange(user.emailVerified) : styleText(config.auth.email_verification ? 'yellow' : 'dim', 'not verified')}`,
714
- 'Registered ' + formatDateRange(user.registeredAt),
715
- `Roles: ${user.roles.length ? user.roles.join(', ') : styleText('dim', '(none)')}`,
716
- `Tags: ${user.tags.length ? user.tags.join(', ') : styleText('dim', '(none)')}`,
717
- ]
718
- .filter(v => v)
719
- .join('\n'));
720
- if (opt.sessions) {
721
- const sessions = await db.database.selectFrom('sessions').where('userId', '=', user.id).selectAll().execute();
722
- console.log(styleText('bold', 'Sessions:'));
723
- if (!sessions.length)
724
- console.log(styleText('dim', '(none)'));
725
- else
726
- for (const session of sessions) {
727
- console.log(`\t${session.id}\tcreated ${formatDateRange(session.created).padEnd(40)}\texpires ${formatDateRange(session.expires).padEnd(40)}\t${session.elevated ? styleText('yellow', '(elevated)') : ''}`);
728
- }
729
- }
730
- if (opt.passkeys) {
731
- const passkeys = await db.database.selectFrom('passkeys').where('userId', '=', user.id).selectAll().execute();
732
- console.log(styleText('bold', 'Passkeys:'));
733
- for (const passkey of passkeys) {
734
- console.log(`\t${passkey.id}: created ${formatDateRange(passkey.createdAt).padEnd(40)} used ${passkey.counter} times. ${passkey.deviceType}, ${passkey.backedUp ? '' : 'not '}backed up; transports are [${passkey.transports.join(', ')}], ${passkey.name ? 'named ' + JSON.stringify(passkey.name) : 'unnamed'}.`);
353
+ .then(() => console.log(styleText(['red', 'bold'], '> Deleted')))
354
+ .catch(e => io.exit('Failed to delete user: ' + e.message));
355
+ }
356
+ console.log([
357
+ user.isSuspended && styleText('yellowBright', 'Suspended'),
358
+ user.isAdmin && styleText('redBright', 'Administrator'),
359
+ 'UUID: ' + user.id,
360
+ 'Name: ' + user.name,
361
+ `Email: ${user.email}, ${user.emailVerified ? 'verified on ' + formatDateRange(user.emailVerified) : styleText(config.auth.email_verification ? 'yellow' : 'dim', 'not verified')}`,
362
+ 'Registered ' + formatDateRange(user.registeredAt),
363
+ `Roles: ${user.roles.length ? user.roles.join(', ') : styleText('dim', '(none)')}`,
364
+ `Tags: ${user.tags.length ? user.tags.join(', ') : styleText('dim', '(none)')}`,
365
+ ]
366
+ .filter(v => v)
367
+ .join('\n'));
368
+ if (opt.sessions) {
369
+ const sessions = await db.database.selectFrom('sessions').where('userId', '=', user.id).selectAll().execute();
370
+ console.log(styleText('bold', 'Sessions:'));
371
+ if (!sessions.length)
372
+ console.log(styleText('dim', '(none)'));
373
+ else
374
+ for (const session of sessions) {
375
+ console.log(`\t${session.id}\tcreated ${formatDateRange(session.created).padEnd(40)}\texpires ${formatDateRange(session.expires).padEnd(40)}\t${session.elevated ? styleText('yellow', '(elevated)') : ''}`);
735
376
  }
377
+ }
378
+ if (opt.passkeys) {
379
+ const passkeys = await db.database.selectFrom('passkeys').where('userId', '=', user.id).selectAll().execute();
380
+ console.log(styleText('bold', 'Passkeys:'));
381
+ for (const passkey of passkeys) {
382
+ console.log(`\t${passkey.id}: created ${formatDateRange(passkey.createdAt).padEnd(40)} used ${passkey.counter} times. ${passkey.deviceType}, ${passkey.backedUp ? '' : 'not '}backed up; transports are [${passkey.transports.join(', ')}], ${passkey.name ? 'named ' + JSON.stringify(passkey.name) : 'unnamed'}.`);
736
383
  }
384
+ }
385
+ });
386
+ program
387
+ .command('toggle-admin')
388
+ .description('Toggle whether a user is an administrator')
389
+ .addArgument(argUserLookup)
390
+ .action(async (_user) => {
391
+ const user = await _user;
392
+ const isAdmin = !user.isAdmin;
393
+ await db.database.updateTable('users').set({ isAdmin }).where('id', '=', user.id).executeTakeFirstOrThrow();
394
+ await audit('admin_change', undefined, { user: user.id });
395
+ console.log(`${userText(user)} is ${isAdmin ? 'now' : 'no longer'} an administrator. (${styleText(['whiteBright', 'bold'], isAdmin.toString())})`);
396
+ });
397
+ program
398
+ .command('status')
399
+ .alias('stats')
400
+ .description('Get information about the server')
401
+ .action(async () => {
402
+ console.log('Axium Server v' + $pkg.version);
403
+ console.log(styleText('whiteBright', 'Debug mode:'), config.debug ? styleText('yellow', 'enabled') : 'disabled');
404
+ const configFiles = config.files.keys().toArray();
405
+ console.log(styleText('whiteBright', 'Loaded config files:'), styleText(['dim', 'bold'], `(${configFiles.length})`), configFiles.join(', '));
406
+ outputDaemonStatus('axium');
407
+ process.stdout.write(styleText('whiteBright', 'Database: '));
408
+ try {
409
+ console.log(await db.statText());
410
+ }
411
+ catch {
412
+ console.log(styleText('red', 'Unavailable'));
413
+ }
414
+ console.log(styleText('whiteBright', 'Loaded plugins:'), styleText(['dim', 'bold'], `(${plugins.size || 'none'})`), Array.from(plugins.keys()).join(', '));
415
+ for (const plugin of plugins.values()) {
416
+ if (!plugin._hooks?.statusText)
417
+ continue;
418
+ const text = await plugin._hooks?.statusText();
419
+ console.log(styleText('bold', plugin.name), plugin.version + ':', text.includes('\n') ? '\n' + text : text);
420
+ }
421
+ });
422
+ program
423
+ .command('ports')
424
+ .description('Enable or disable use of restricted ports (e.g. 443)')
425
+ .addArgument(new Argument('<action>', 'The action to take').choices(_portActions))
426
+ .addOption(new Option('-m, --method <method>', 'the method to use').choices(_portMethods).default('node-cap'))
427
+ .option('-N, --node <path>', 'the path to the node binary')
428
+ .action(async (action, opt) => {
429
+ await restrictedPorts({ ...opt, action });
430
+ });
431
+ program
432
+ .command('init')
433
+ .description('Install Axium server')
434
+ .addOption(opts.force)
435
+ .addOption(opts.check)
436
+ .option('-s, --skip', 'Skip already initialized steps', false)
437
+ .action(async (opt) => {
438
+ await db.init(opt);
439
+ await dbInitTables();
440
+ await restrictedPorts({ method: 'node-cap', action: 'enable' });
441
+ });
442
+ program
443
+ .command('serve')
444
+ .description('Start the Axium server')
445
+ .option('-p, --port <port>', 'the port to listen on', Number.parseInt, config.web.port)
446
+ .option('--ssl <prefix>', 'the prefix for the cert.pem and key.pem SSL files')
447
+ .option('-b, --build <path>', 'the path to the handler build')
448
+ .action(async (opt) => {
449
+ if (opt.port < 1 || opt.port > 65535)
450
+ io.exit('Invalid port');
451
+ const server = await serve({
452
+ secure: opt.ssl ? true : config.web.secure,
453
+ ssl_cert: opt.ssl ? join(opt.ssl, 'cert.pem') : config.web.ssl_cert,
454
+ ssl_key: opt.ssl ? join(opt.ssl, 'key.pem') : config.web.ssl_key,
455
+ build: opt.build ? resolve(opt.build) : config.web.build,
737
456
  });
738
- program
739
- .command('toggle-admin')
740
- .description('Toggle whether a user is an administrator')
741
- .addArgument(argUserLookup)
742
- .action(async (_user) => {
743
- const user = await _user;
744
- const isAdmin = !user.isAdmin;
745
- await db.database.updateTable('users').set({ isAdmin }).where('id', '=', user.id).executeTakeFirstOrThrow();
746
- await audit('admin_change', undefined, { user: user.id });
747
- console.log(`${userText(user)} is ${isAdmin ? 'now' : 'no longer'} an administrator. (${styleText(['whiteBright', 'bold'], isAdmin.toString())})`);
457
+ logger.attach(createWriteStream(join(dirs.at(-1), 'server.log')), { output: allLogLevels });
458
+ db.connect();
459
+ await db.clean({});
460
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
461
+ process.on('beforeExit', () => db.database.destroy());
462
+ server.listen(opt.port, () => {
463
+ console.log('Server is listening on port ' + opt.port);
748
464
  });
749
- program
750
- .command('status')
751
- .alias('stats')
752
- .description('Get information about the server')
753
- .action(async () => {
754
- console.log('Axium Server v' + $pkg.version);
755
- console.log(styleText('whiteBright', 'Debug mode:'), config.debug ? styleText('yellow', 'enabled') : 'disabled');
756
- const configFiles = config.files.keys().toArray();
757
- console.log(styleText('whiteBright', 'Loaded config files:'), styleText(['dim', 'bold'], `(${configFiles.length})`), configFiles.join(', '));
758
- outputDaemonStatus('axium');
759
- process.stdout.write(styleText('whiteBright', 'Database: '));
760
- try {
761
- console.log(await db.statText());
762
- }
763
- catch {
764
- console.log(styleText('red', 'Unavailable'));
765
- }
766
- console.log(styleText('whiteBright', 'Loaded plugins:'), styleText(['dim', 'bold'], `(${plugins.size || 'none'})`), Array.from(plugins.keys()).join(', '));
767
- for (const plugin of plugins.values()) {
768
- if (!plugin._hooks?.statusText)
465
+ });
466
+ program
467
+ .command('link')
468
+ .description('Link routes provided by plugins and the server')
469
+ .addOption(new Option('-l, --list', 'list route links').conflicts('delete'))
470
+ .option('-d, --delete', 'delete route links')
471
+ .argument('[name...]', 'List of plugin names to operate on. If not specified, operates on all plugins and built-in routes.')
472
+ .action(async function axium_link(names) {
473
+ const opt = this.optsWithGlobals();
474
+ const linkOpts = { only: names };
475
+ if (opt.list) {
476
+ for (const link of listRouteLinks(linkOpts)) {
477
+ const idText = link.id.startsWith('#') ? `(${link.id.slice(1)})` : link.id;
478
+ const fromColor = await access(link.from)
479
+ .then(() => 'cyanBright')
480
+ .catch(() => 'redBright');
481
+ console.log(`${idText}:\t ${styleText(fromColor, link.from)}\t->\t${link.to.replace(/.*\/node_modules\//, styleText('dim', '$&'))}`);
482
+ }
483
+ return;
484
+ }
485
+ if (opt.delete) {
486
+ unlinkRoutes(linkOpts);
487
+ return;
488
+ }
489
+ io.start('Linking routes');
490
+ linkRoutes(linkOpts);
491
+ io.done();
492
+ io.start('Writing web client hooks for plugins');
493
+ writePluginHooks();
494
+ io.done();
495
+ });
496
+ program
497
+ .command('audit')
498
+ .description('View audit logs')
499
+ .option('-x, --extra', 'Include the extra object when listing events')
500
+ .option('-t, --include-tags', 'Include tags when listing events')
501
+ .addOption(new Option('-s, --summary', 'Summarize audit log entries instead of displaying individual ones').conflicts(['extra', 'includeTags']))
502
+ .optionsGroup('Filters:')
503
+ .option('--since <date>', 'Filter for events since a date')
504
+ .option('--until <date>', 'Filter for events until a date')
505
+ .option('--user <uuid|null>', 'Filter for events triggered by a user')
506
+ .addOption(new Option('--severity <level>', 'Filter for events at or above a severity level').choices(severityNames))
507
+ .option('--source <source>', 'Filter by source')
508
+ .option('--tag <tag...>', 'Filter by tag(s)')
509
+ .option('--event <event>', 'Filter by event name')
510
+ .action(async (opt) => {
511
+ const filter = await AuditFilter.parseAsync(opt).catch(e => io.exit('Invalid filter: ' + z.prettifyError(e)));
512
+ const events = await getEvents(filter).execute();
513
+ if (opt.summary) {
514
+ const groups = Object.groupBy(events, e => e.severity);
515
+ const maxGroupLength = Math.max(...Object.values(groups).map(g => g.length.toString().length), 0);
516
+ for (const [severity, group] of Object.entries(groups)) {
517
+ if (!group?.length)
769
518
  continue;
770
- const text = await plugin._hooks?.statusText();
771
- console.log(styleText('bold', plugin.name), plugin.version + ':', text.includes('\n') ? '\n' + text : text);
519
+ console.log(styleText('white', group.length.toString().padStart(maxGroupLength)), styleSeverity(severity, true), 'events. Latest occurred', group.at(-1).timestamp.toLocaleString());
772
520
  }
773
- });
774
- program
775
- .command('ports')
776
- .description('Enable or disable use of restricted ports (e.g. 443)')
777
- .addArgument(new Argument('<action>', 'The action to take').choices(_portActions))
778
- .addOption(new Option('-m, --method <method>', 'the method to use').choices(_portMethods).default('node-cap'))
779
- .option('-N, --node <path>', 'the path to the node binary')
780
- .action(async (action, opt) => {
781
- await restrictedPorts({ ...opt, action }).catch(io.exit);
782
- });
783
- program
784
- .command('init')
785
- .description('Install Axium server')
786
- .addOption(opts.force)
787
- .addOption(opts.check)
788
- .option('-s, --skip', 'Skip already initialized steps', false)
789
- .action(async (opt) => {
790
- await db.init(opt).catch(io.exit);
791
- await dbInitTables().catch(io.exit);
792
- await restrictedPorts({ method: 'node-cap', action: 'enable' }).catch(io.exit);
793
- });
794
- program
795
- .command('serve')
796
- .description('Start the Axium server')
797
- .option('-p, --port <port>', 'the port to listen on', Number.parseInt, config.web.port)
798
- .option('--ssl <prefix>', 'the prefix for the cert.pem and key.pem SSL files')
799
- .option('-b, --build <path>', 'the path to the handler build')
800
- .action(async (opt) => {
801
- if (opt.port < 1 || opt.port > 65535)
802
- io.exit('Invalid port');
803
- const server = await serve({
804
- secure: opt.ssl ? true : config.web.secure,
805
- ssl_cert: opt.ssl ? join(opt.ssl, 'cert.pem') : config.web.ssl_cert,
806
- ssl_key: opt.ssl ? join(opt.ssl, 'key.pem') : config.web.ssl_key,
807
- build: opt.build ? resolve(opt.build) : config.web.build,
808
- });
809
- logger.attach(createWriteStream(join(dirs.at(-1), 'server.log')), { output: allLogLevels });
810
- db.connect();
811
- await db.clean({});
812
- // eslint-disable-next-line @typescript-eslint/no-misused-promises
813
- process.on('beforeExit', () => db.database.destroy());
814
- server.listen(opt.port, () => {
815
- console.log('Server is listening on port ' + opt.port);
816
- });
817
- });
818
- program
819
- .command('link')
820
- .description('Link routes provided by plugins and the server')
821
- .addOption(new Option('-l, --list', 'list route links').conflicts('delete'))
822
- .option('-d, --delete', 'delete route links')
823
- .argument('[name...]', 'List of plugin names to operate on. If not specified, operates on all plugins and built-in routes.')
824
- .action(async function axium_link(names) {
825
- const opt = this.optsWithGlobals();
826
- const linkOpts = { only: names };
827
- if (opt.list) {
828
- for (const link of listRouteLinks(linkOpts)) {
829
- const idText = link.id.startsWith('#') ? `(${link.id.slice(1)})` : link.id;
830
- const fromColor = await access(link.from)
831
- .then(() => 'cyanBright')
832
- .catch(() => 'redBright');
833
- console.log(`${idText}:\t ${styleText(fromColor, link.from)}\t->\t${link.to.replace(/.*\/node_modules\//, styleText('dim', '$&'))}`);
834
- }
835
- return;
521
+ return;
522
+ }
523
+ let maxSource = 0, maxName = 0, maxTags = 0, maxExtra = 0;
524
+ for (const event of events) {
525
+ maxSource = Math.max(maxSource, event.source.length);
526
+ maxName = Math.max(maxName, event.name.length);
527
+ event._tags = !event.tags.length
528
+ ? ''
529
+ : opt.includeTags
530
+ ? '# ' + event.tags.join(', ')
531
+ : `(${event.tags.length} tag${event.tags.length == 1 ? '' : 's'})`;
532
+ maxTags = Math.max(maxTags, event._tags.length);
533
+ const extraKeys = Object.keys(event.extra);
534
+ event._extra = !extraKeys.length ? '' : opt.extra ? JSON.stringify(event.extra) : '+' + extraKeys.length;
535
+ maxExtra = Math.max(maxExtra, event._extra.length);
536
+ }
537
+ for (const event of events) {
538
+ console.log(styleSeverity(event.severity, true), styleText('dim', io.prettyDate(event.timestamp)), event.source.padEnd(maxSource), styleText('whiteBright', event.name.padEnd(maxName)), styleText('gray', event._tags.padEnd(maxTags)), 'by', event.userId ? event.userId : styleText(['dim', 'italic'], 'unknown'.padEnd(36)), styleText('blue', event._extra));
539
+ }
540
+ });
541
+ program
542
+ .command('build')
543
+ .description('Create the Vite build for the server')
544
+ .option('--show-garbage-output', 'Show all output from the build process')
545
+ .option('-s, --diagnostics', 'Show build time and bundle size')
546
+ .option('-m, --no-minify', 'Whether to use minification')
547
+ .action(async (options) => {
548
+ io.start('Building');
549
+ const { time, size } = await build(options);
550
+ io.done();
551
+ if (options.diagnostics) {
552
+ console.log('Took', styleText('blueBright', formatMs(time)), 'with a bundle size of', styleText('blueBright', formatBytes(size)));
553
+ }
554
+ });
555
+ program
556
+ .command('develop')
557
+ .alias('dev')
558
+ .description('Develop with axium')
559
+ .argument('[dir]', 'The project directory', searchForWorkspaceRoot(process.cwd()))
560
+ .option('-g, --git', 'Use .gitignore to ignore files (can improve performance)')
561
+ .action(async (dir, opts) => {
562
+ let buildId = 0, server;
563
+ logger.attach(createWriteStream(join(dirs.at(-1), 'server.log')), { output: allLogLevels });
564
+ db.connect();
565
+ await db.clean({});
566
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
567
+ process.on('beforeExit', () => db.database.destroy());
568
+ async function rebuild() {
569
+ server?.close();
570
+ process.stdout.clearLine(0);
571
+ process.stdout.cursorTo(0);
572
+ io.start('Building');
573
+ const { time } = await build({ minify: false });
574
+ buildId++;
575
+ process.stdout.clearLine(0);
576
+ process.stdout.cursorTo(0);
577
+ server = await serve(config.web);
578
+ server.listen(config.web.port);
579
+ process.stdout.write(`Build #${buildId} finished in ${formatMs(time)}`);
580
+ }
581
+ const ignore = ['node_modules', '.git'];
582
+ try {
583
+ if (!opts.git)
584
+ throw null;
585
+ const gitignore = readFileSync(join(dir, '.gitignore'), 'utf8');
586
+ for (const rawLine of gitignore.split('\n')) {
587
+ const line = rawLine.trim();
588
+ if (!line || line[0] == '#')
589
+ continue;
590
+ ignore.push(line);
836
591
  }
837
- if (opt.delete) {
838
- unlinkRoutes(linkOpts);
839
- return;
592
+ }
593
+ catch {
594
+ // It's fine if we don't have a .gitignore
595
+ }
596
+ io.debug('Watching', dir);
597
+ await rebuild();
598
+ try {
599
+ for await (const _event of watch(dir, { recursive: true, ignore })) {
600
+ // @todo see if we can be more efficient based on event data
601
+ await rebuild();
840
602
  }
841
- linkRoutes(linkOpts);
842
- writePluginHooks();
843
- });
844
- program
845
- .command('audit')
846
- .description('View audit logs')
847
- .option('-x, --extra', 'Include the extra object when listing events')
848
- .option('-t, --include-tags', 'Include tags when listing events')
849
- .addOption(new Option('-s, --summary', 'Summarize audit log entries instead of displaying individual ones').conflicts(['extra', 'includeTags']))
850
- .optionsGroup('Filters:')
851
- .option('--since <date>', 'Filter for events since a date')
852
- .option('--until <date>', 'Filter for events until a date')
853
- .option('--user <uuid|null>', 'Filter for events triggered by a user')
854
- .addOption(new Option('--severity <level>', 'Filter for events at or above a severity level').choices(severityNames))
855
- .option('--source <source>', 'Filter by source')
856
- .option('--tag <tag...>', 'Filter by tag(s)')
857
- .option('--event <event>', 'Filter by event name')
858
- .action(async (opt) => {
859
- const filter = await AuditFilter.parseAsync(opt).catch(e => io.exit('Invalid filter: ' + z.prettifyError(e)));
860
- const events = await getEvents(filter).execute();
861
- if (opt.summary) {
862
- const groups = Object.groupBy(events, e => e.severity);
863
- const maxGroupLength = Math.max(...Object.values(groups).map(g => g.length.toString().length), 0);
864
- for (const [severity, group] of Object.entries(groups)) {
865
- if (!group?.length)
866
- continue;
867
- console.log(styleText('white', group.length.toString().padStart(maxGroupLength)), styleSeverity(severity, true), 'events. Latest occurred', group.at(-1).timestamp.toLocaleString());
868
- }
603
+ }
604
+ catch (err) {
605
+ if (err.name === 'AbortError')
869
606
  return;
870
- }
871
- let maxSource = 0, maxName = 0, maxTags = 0, maxExtra = 0;
872
- for (const event of events) {
873
- maxSource = Math.max(maxSource, event.source.length);
874
- maxName = Math.max(maxName, event.name.length);
875
- event._tags = !event.tags.length
876
- ? ''
877
- : opt.includeTags
878
- ? '# ' + event.tags.join(', ')
879
- : `(${event.tags.length} tag${event.tags.length == 1 ? '' : 's'})`;
880
- maxTags = Math.max(maxTags, event._tags.length);
881
- const extraKeys = Object.keys(event.extra);
882
- event._extra = !extraKeys.length ? '' : opt.extra ? JSON.stringify(event.extra) : '+' + extraKeys.length;
883
- maxExtra = Math.max(maxExtra, event._extra.length);
884
- }
885
- for (const event of events) {
886
- console.log(styleSeverity(event.severity, true), styleText('dim', io.prettyDate(event.timestamp)), event.source.padEnd(maxSource), styleText('whiteBright', event.name.padEnd(maxName)), styleText('gray', event._tags.padEnd(maxTags)), 'by', event.userId ? event.userId : styleText(['dim', 'italic'], 'unknown'.padEnd(36)), styleText('blue', event._extra));
887
- }
888
- });
607
+ throw err;
608
+ }
609
+ });
610
+ try {
889
611
  await program.parseAsync();
890
612
  }
891
- catch (e_1) {
892
- env_1.error = e_1;
893
- env_1.hasError = true;
894
- }
895
- finally {
896
- __disposeResources(env_1);
613
+ catch (e) {
614
+ if (typeof e == 'number')
615
+ process.exit(e);
616
+ io.exit(e);
897
617
  }