timeld-cli
Version:
Live shared timesheets command interface
279 lines (268 loc) • 8.97 kB
JavaScript
import { AccountOwnedId, Env } from 'timeld-common';
import { createWriteStream } from 'fs';
import { once } from 'events';
import GatewayClient from './GatewayClient.mjs';
import DomainConfigurator from './DomainConfigurator.mjs';
import TimesheetSession from './TimesheetSession.mjs';
import readline from 'readline';
import { promisify } from 'util';
import AdminSession from './AdminSession.mjs';
export default class Cli {
/**
* @param {Env} env
* @param {string[]} [args]
* @param input
* @param output
* @param console
* @param {CloneFactory} cloneFactory
*/
constructor(env, {
args = undefined,
input = process.stdin,
output = process.stdout,
console = global.console
}, cloneFactory) {
this.args = args;
this.env = env;
this.input = input;
this.output = output;
this.console = console;
this.cloneFactory = cloneFactory;
}
async start() {
return this.addOptions(await this.env.yargs(this.args))
.middleware(argv => {
// Legacy support: if only an Ably key is available, use it as the auth key
if (argv.ably?.key && !argv.auth)
argv.auth = { key: argv.ably.key };
}, true)
.command(
['config', 'cfg'],
'Inspect or set local configuration',
yargs => yargs,
argv => this.configCmd(argv)
)
.command(
['list', 'ls'],
'List local timesheets',
yargs => yargs,
() => this.listCmd()
)
.command(
['remove <timesheet>', 'rm'],
'Remove a local timesheet',
yargs => yargs
.boolean('force')
.positional('timesheet', { type: 'string' }),
argv => this.removeCmd(argv)
)
.command(
['open <timesheet>', 'o'],
'Open a timesheet session',
yargs => yargs
.positional('timesheet', {
describe: 'Timesheet identity, can include ' +
'account as `account/timesheet`',
type: 'string'
})
.middleware(argv => {
// Interpret a timesheet with account and/or gateway
const { name, account, gateway } =
AccountOwnedId.fromString(argv.timesheet);
if (gateway != null)
argv.gateway = gateway;
if (account != null)
argv.account = account;
argv.timesheet = name;
// If a user is provided but no account, use the user account
if (argv.gateway != null && argv.account == null)
argv.account = argv.user;
}, true)
.option('create', {
type: 'boolean', describe: 'Force creation of new timesheet'
})
// Timesheet account must exist; user account checked in domain config
.demandOption('account')
.demandOption('user')
.check(argv => {
const { timesheet, account } = argv;
new AccountOwnedId({ name: timesheet, account }).validate();
AccountOwnedId.checkComponentId(argv.user);
return true;
}),
argv => this.openCmd(argv)
)
.command(
['admin', 'a'],
'Start an administration session',
yargs => yargs
.middleware(argv => {
// If a user is provided but no account, use the user account
if (argv.account == null)
argv.account = argv.user;
}, true)
.demandOption('gateway')
.demandOption('account')
.demandOption('user')
.check(argv => {
AccountOwnedId.checkComponentId(argv.account);
AccountOwnedId.checkComponentId(argv.user);
return true;
}),
argv => this.adminCmd(argv)
)
.demandCommand()
.strictCommands()
.help()
.parseAsync();
}
/**
* @param {yargs.Argv<{}>} argv
* @returns {yargs.Argv<{}>} provided yargs with options added
*/
addOptions(argv) {
return argv
.option('account', {
alias: 'acc',
type: 'string',
describe: 'The default account for creating timesheets or admin'
})
.option('gateway', {
alias: 'gw',
/*no type, allows --no-gateway*/
describe: 'The timeld Gateway, as a URL or domain name'
})
.option('user', {
alias: 'u',
type: 'string',
describe: 'The user account, as a URL or a name'
});
}
/**
* @param {Partial<TimeldCliConfig>} argv
* @returns {Promise<void>}
*/
async openCmd(argv) {
const gateway = argv.gateway ? await this.openGatewayClient(argv) : null;
const { config, principal } = await new DomainConfigurator(argv, gateway).load();
try {
// Start the m-ld clone
const { meld, logFile } = await this.createMeldClone(config, principal);
return this.createSession(config, principal, meld, logFile)
.start({ console: this.console });
} catch (e) {
if (e.status === 5031) {
this.console.info('This timesheet does not exist.');
this.console.info('Use the --create flag to create it');
}
throw e;
}
}
async adminCmd(argv) {
const gateway = await this.openGatewayClient(argv);
const { account, logLevel } = argv;
new AdminSession({ gateway, account, logLevel })
.start({ console: this.console });
}
async openGatewayClient(argv) {
const { input, output } = this;
const rl = readline.createInterface({ input, output });
try {
const ask = promisify(rl.question).bind(rl);
const gateway = new GatewayClient(argv);
await gateway.activate(ask);
await this.env.updateConfig(gateway.accessConfig);
return gateway;
} finally {
rl.close();
}
}
/**
* @param {TimeldCliConfig} config
* @param {import('@m-ld/m-ld').AppPrincipal} principal
* @returns {Promise<{meld: import('@m-ld/m-ld').MeldClone, logFile: string}>}
*/
async createMeldClone(config, principal) {
const tsId = AccountOwnedId.fromDomain(config['@domain']);
const logFile = await this.setUpLogging(tsId.toPath());
const dataDir = await this.env.readyPath('data', ...tsId.toPath());
// noinspection JSCheckFunctionSignatures
return { meld: await this.cloneFactory.clone(config, dataDir, principal), logFile };
}
/**
* @param {TimeldCliConfig} config
* @param {import('@m-ld/m-ld').AppPrincipal} principal
* @param {import('@m-ld/m-ld').MeldClone} meld
* @param {string} logFile
* @returns {TimesheetSession}
*/
createSession(config, principal, meld, logFile) {
return new TimesheetSession({
id: config['@id'],
timesheet: config.timesheet,
providerId: principal['@id'],
meld,
logFile,
logLevel: config.logLevel
});
}
/**
* @param {string[]} path
* @returns {Promise<string>} the log file being used
* @private
*/
async setUpLogging(path) {
// Substitute the global console, so we don't get m-ld logging
const logFile = `${await this.env.readyPath('log', ...path)}.log`;
const logStream = createWriteStream(logFile, { flags: 'a' });
await once(logStream, 'open');
global.console = new console.Console(logStream, logStream);
return logFile;
}
/**
* `config` command handler for setting or showing configuration
* @param {object} argv
*/
async configCmd(argv) {
// Determine what the options would be without env and config
const cliArgv = this.addOptions(this.env.baseYargs(this.args)).argv;
if (Object.keys(cliArgv).some(Env.isConfigKey)) {
// Setting one or more config options
await this.env.updateConfig(cliArgv);
} else {
// Showing config options
const allArgv = { ...argv }; // yargs not happy if argv is edited
for (let key in cliArgv)
delete allArgv[key];
this.console.log('Current configuration:', allArgv);
}
}
async listCmd() {
for (let dir of await this.env.envDirs('data'))
this.console.log(AccountOwnedId.fromPath(dir).toString());
}
/**
* @param {*} argv.timesheet as tmId
* @param {boolean} [argv.force]
*/
async removeCmd(argv) {
const { name, account, gateway } = AccountOwnedId.fromString(argv.timesheet);
const pattern = new RegExp(
`${account || '[\\w-]+'}/${name || '[\\w-]+'}@${gateway || '[\\w-.]*'}`,
'g');
if (!argv.force)
this.console.info('If you use --force, ' +
'these local timesheets will be deleted:');
for (let path of await this.env.envDirs('data')) {
const tsId = AccountOwnedId.fromPath(path);
if (tsId.toString().match(pattern)) {
if (argv.force) {
await this.env.delEnvDir('data', path, { force: true });
await this.env.delEnvFile('log', (path.join('/') + '.log').split('/'));
} else {
this.console.log(tsId.toString());
}
}
}
}
}