diff --git a/tribes-cli/cli.ts b/tribes-cli/cli.ts index 4f25b982..fedfe014 100644 --- a/tribes-cli/cli.ts +++ b/tribes-cli/cli.ts @@ -1,173 +1,24 @@ -interface BaseArg { - kind: 'string' | 'number' | 'bool'; - name: string; - invert?: true; -} +import { parseArgs } from '@std/cli'; +import { tribe } from './tribe/mod.ts'; +import { remote } from './remote/mod.ts'; +import { ParsedSubcommand, parseSubcommand } from './utils.ts'; -type Argument = BaseArg & { optional?: true }; -type Option = BaseArg & { short?: string }; - -interface BaseCommand { - options: Option[]; - name: string; -} - -type Command = - | ( - & BaseCommand - & ({ - arguments: Argument[]; - } | { - subcommands: Command[]; - }) - ) - | BaseCommand; - -interface ParseResult { - [key: string]: boolean | string | number | ParseResult; -} - -const parseArgs = (args: string[], config: Command) => { - const result: ParseResult = {}; - - let i = 0; - const opt = (kind: BaseArg['kind'], idx: number) => kind === 'number' ? Number(args[idx]) : args[idx]; - const doOption = (cfg: Option, skip = -1, blockLength = -1) => { - if (cfg.kind === 'number' || cfg.kind === 'string') { - if (skip === -1) { - result[cfg.name] = opt(cfg.kind, i + 1); - i += 1; - } else { - result[cfg.name] = opt(cfg.kind, i + skip); - if (skip === blockLength) { - i += skip; - } - } - } else { - result[cfg.name] = !cfg.invert; - } - }; - - if ('arguments' in config && config.arguments.length) { - const argumentCount = args.filter((arg) => !arg.startsWith('-')).length; - const last = config.arguments.at(-1); - const requiredCount = config.arguments.length - (last?.optional ? 1 : 0); - if (argumentCount !== requiredCount) throw new Error('Invalid number of arguments passed!'); - } - - let argumentsParsed = 0; - for (i; i < args.length; i++) { - const current = args[i]; - if (current[0] === '-') { - if (current.startsWith('--')) { - const opt = current.slice(2); - const cfg = config.options.find((o) => o.name === opt); - if (!cfg) continue; - doOption(cfg); - } else { - const block = current.slice(1).split(''); - for (let idx = 0; idx < block.length; idx++) { - const cfg = config.options.find((o) => o.short === block[idx]); - if (!cfg) continue; - doOption(cfg, idx + 1, block.length); - } - } - } else { - if ('subcommands' in config) { - const subcommand = config.subcommands.find((cmd) => cmd.name === current); - if (subcommand) { - result[subcommand.name] = parseArgs(args.slice(i + 1), subcommand); - } - } else if ('arguments' in config) { - const arg = config.arguments[argumentsParsed++]; - if (arg.kind === 'bool') { - if (args[i] !== 'true' && args[i] !== 'false') { - throw new Error(`Non-boolean value passed for boolean argument ${arg.name}!`); - } - result[arg.name] = args[i] === 'true' ? true : false; - } else if (arg.kind === 'string') { - result[arg.name] = args[i]; - } else if (arg.kind === 'number') { - result[arg.name] = Number(args[i]); - } - } - } - } - return result; +const commands = { + tribe, + remote, }; -/** - * TODOs - * - get instance list from dokku for pushing updates - * - fix item duplication issues with colocated postgres - * - make dokku work with existing postgres - * - make new-tribe script work with custom domains - * - get ssh key as -i argument - * - - */ +let parserArgs: Partial = { + string: ['identity-file'], +}; -const res = parseArgs(Deno.args, { - name: 'tribes-cli', - options: [{ - kind: 'string', - name: 'identity-file', - short: 'i', - }], - subcommands: [{ - name: 'tribe', - options: [{ - name: 'alice', - short: 'a', - kind: 'number', - }, { - name: 'bob', - short: 'b', - kind: 'string', - }, { - name: 'charlie', - short: 'c', - kind: 'number', - }], - subcommands: [{ - name: 'new', - arguments: [{ - name: 'name', - kind: 'string', - }], - options: [], - }], - }], -}); +for (const [_name, body] of Object.entries(commands)) { + for (const subcommand in body) { + const s = body[subcommand]; + parserArgs = parseSubcommand(s, parserArgs); + } -console.log(res); - -Deno.exit(1); -/* -if (import.meta.main) { - const tribes = - new Command('tribes-cli') - .description('CLI for managing Ditto Tribes instances.') - .version("0.0.1") - .option('-i --identity-file ', 'Path to the ssh identity file.', defaultIdentityFile()) - .action(async args => { - if (!args.identityFile) { - throw new InvalidArgumentError(`Invalid or missing identity file ${args.identityFile || ''}`); - } - try { - console.log(args.identityFile); - const file = await Deno.readTextFile(args.identityFile); - sessionStorage.setItem('identity', file); - console.log(`Read identity file from ${args.identityFile} successfully.`); - } - catch (e) { - throw new InvalidArgumentError(`Error reading identity file: ${e.message}`); - } - }) - .showHelpAfterError(); - - [tribe, remote].forEach(cmd => tribes.addCommand(cmd)); - const foo = await tribes.parseAsync(Deno.args, { from: 'user' }); - console.log(foo); + // TODO: construct a help string here } -*/ +console.log(parserArgs); diff --git a/tribes-cli/remote/init.ts b/tribes-cli/remote/init.ts new file mode 100644 index 00000000..ad07e351 --- /dev/null +++ b/tribes-cli/remote/init.ts @@ -0,0 +1,2 @@ +export const init = () => { +}; diff --git a/tribes-cli/remote/mod.ts b/tribes-cli/remote/mod.ts new file mode 100644 index 00000000..188c96f8 --- /dev/null +++ b/tribes-cli/remote/mod.ts @@ -0,0 +1,9 @@ +import { Subcommand } from '../utils.ts'; +import { init } from './init.ts'; + +export const remote: Record = { + init: { + action: init, + description: 'Initialise a brand new Ditto remote.', + }, +}; diff --git a/tribes-cli/tribe/config.ts b/tribes-cli/tribe/config.ts new file mode 100644 index 00000000..9d8aeb6d --- /dev/null +++ b/tribes-cli/tribe/config.ts @@ -0,0 +1,5 @@ +/** + * Change a tribe's configuration. + */ +export const config = () => { +}; diff --git a/tribes-cli/tribe/destroy.ts b/tribes-cli/tribe/destroy.ts new file mode 100644 index 00000000..f1282701 --- /dev/null +++ b/tribes-cli/tribe/destroy.ts @@ -0,0 +1,6 @@ +/** + * Destroy a tribe. Non-reversible. + */ + +export const destroy = () => { +}; diff --git a/tribes-cli/tribe/mod.ts b/tribes-cli/tribe/mod.ts new file mode 100644 index 00000000..99bcc733 --- /dev/null +++ b/tribes-cli/tribe/mod.ts @@ -0,0 +1,18 @@ +import { create } from './new.ts'; +import { config } from './config.ts'; +import { Subcommand } from '../utils.ts'; + +export const tribe: Record = { + create: { + action: create, + description: 'Create a new tribe.', + options: { + '-c --custom-domain': { + description: 'Do not use a subdomain of the tribes server; instead, use a custom domain.', + }, + '-s --subdomain': { + description: 'Use a subdomain of the tribes server to host the new Ditto instance.', + }, + }, + }, +}; diff --git a/tribes-cli/tribe/new.ts b/tribes-cli/tribe/new.ts new file mode 100644 index 00000000..e94d1765 --- /dev/null +++ b/tribes-cli/tribe/new.ts @@ -0,0 +1,2 @@ +export const create = () => { +}; diff --git a/tribes-cli/tribe/update.ts b/tribes-cli/tribe/update.ts new file mode 100644 index 00000000..f1161b35 --- /dev/null +++ b/tribes-cli/tribe/update.ts @@ -0,0 +1,6 @@ +/** + * Update a tribe's software. + */ + +export const update = () => { +}; diff --git a/tribes-cli/utils.ts b/tribes-cli/utils.ts new file mode 100644 index 00000000..3975ab7f --- /dev/null +++ b/tribes-cli/utils.ts @@ -0,0 +1,76 @@ +import { join } from '@std/path'; + +let identity = ''; +export const defaultIdentityFile = async () => { + if (!identity) { + const home = Deno.env.get('HOME'); + if (home) return identity = await Deno.readTextFile(join(home, '.ssh', 'id_rsa')); + } + return ''; +}; + +export interface Subcommand { + action: Function; + description: string; + options?: Record< + string, + & { description: string } + & ({ + default?: string; + } | { + bool: true; + default?: boolean; + }) + >; + commands?: Record; +} + +export const cleanArg = (arg: string) => { + return arg.replace(/^--?/g, ''); +}; + +export interface ParsedSubcommand { + string: string[]; + boolean: string[]; + alias: Record; + default: Record; +} + +export const defaultParsedSubcommand = (): ParsedSubcommand => { + return { + string: [], + boolean: [], + alias: {}, + default: {}, + }; +}; + +export const parseSubcommand = (command: Subcommand, existing?: Partial): ParsedSubcommand => { + const res = Object.assign(defaultParsedSubcommand(), existing); + + if (command.options) { + for (const option in command.options) { + const split = option.split(' ').toSorted((a, b) => b.length - a.length).map(cleanArg); + const name = split[0]; + const body = command.options[option]; + if ('bool' in body) { + res.boolean.push(name); + } else { + res.string.push(name); + } + res.alias[name] = split; + } + } + + if (command.commands) { + for (const subcommand in command.commands) { + const parsed = parseSubcommand(command.commands[subcommand]); + Object.assign(res.alias, parsed.alias); + Object.assign(res.default, parsed.default); + res.boolean.push(...parsed.boolean); + res.string.push(...parsed.string); + } + } + + return res; +};