Files
oikos/web/node_modules/cac/deno/Command.ts
dtoro d4d99a7473
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 1 — extract the client (web SPA + desktop) to dtoro/oikos-web
Problem: the hexagonal refactor churns the backend tree for nine more
phases; the UI delivery stack (web/ SPA, cmd/desktop Wails wrapper,
compose/web image) must move to its own repo first so doc/layout
rewrites land once on a backend-only tree.

Change:
- New repo git.hubris.network/dtoro/oikos-web (v0.33.0): web/, desktop/
  (updateURL repointed to oikos-web releases), compose/, own CI (web +
  desktop jobs), own deploy script (CI-green gate, TOCTOU guard,
  version-tagged images, prune-to-3), own webhook receiver on :9798 +
  launchd unit, own compose project publishing the same 8091:80.
- Cutover executed on mac-mini in order: oikos stack's web service
  stopped+removed, oikos-web project brought up on 8091; outer Caddy
  untouched (targets the published port) — serving + Authentik flow +
  /wails 404 quirk verified post-cutover.
- Stripped from oikos: web/, cmd/desktop/, compose/web/, desktop CI
  workflow, ci.yml web job, Makefile ui/desktop/desktop-package/install
  targets, the compose web service, oikos-web from deploy.sh's fallback
  prune list; wails + go-keyring dropped from go.mod, vendor synced.
- README / CONTRIBUTING / AGENTS.md / .agents dev+operations docs now
  point at the new repo; mbse + mascot design docs carry a path note.

Risk: production SPA serving depends on the new pipeline now; rollback
is versioned-image re-up of the old web service from a pre-split
checkout (port 8091). Desktop builds installed before the split still
check dtoro/oikos releases — one manual reinstall, noted in the
oikos-web release notes.

Verification: go vet, make test (race), make generate-check, golangci
(no new findings; baseline down 400→365); post-cutover curls —
localhost:8091 200, /wails/runtime.js 404, outer Caddy 302 Authentik.
2026-08-15 22:27:52 +02:00

269 lines
6.8 KiB
TypeScript

import CAC from "./CAC.ts";
import Option, { OptionConfig } from "./Option.ts";
import { removeBrackets, findAllBrackets, findLongest, padRight, CACError } from "./utils.ts";
import { platformInfo } from "./deno.ts";
interface CommandArg {
required: boolean;
value: string;
variadic: boolean;
}
interface HelpSection {
title?: string;
body: string;
}
interface CommandConfig {
allowUnknownOptions?: boolean;
ignoreOptionDefaultValue?: boolean;
}
type HelpCallback = (sections: HelpSection[]) => void | HelpSection[];
type CommandExample = ((bin: string) => string) | string;
class Command {
options: Option[];
aliasNames: string[];
/* Parsed command name */
name: string;
args: CommandArg[];
commandAction?: (...args: any[]) => any;
usageText?: string;
versionNumber?: string;
examples: CommandExample[];
helpCallback?: HelpCallback;
globalCommand?: GlobalCommand;
constructor(public rawName: string, public description: string, public config: CommandConfig = {}, public cli: CAC) {
this.options = [];
this.aliasNames = [];
this.name = removeBrackets(rawName);
this.args = findAllBrackets(rawName);
this.examples = [];
}
usage(text: string) {
this.usageText = text;
return this;
}
allowUnknownOptions() {
this.config.allowUnknownOptions = true;
return this;
}
ignoreOptionDefaultValue() {
this.config.ignoreOptionDefaultValue = true;
return this;
}
version(version: string, customFlags = '-v, --version') {
this.versionNumber = version;
this.option(customFlags, 'Display version number');
return this;
}
example(example: CommandExample) {
this.examples.push(example);
return this;
}
/**
* Add a option for this command
* @param rawName Raw option name(s)
* @param description Option description
* @param config Option config
*/
option(rawName: string, description: string, config?: OptionConfig) {
const option = new Option(rawName, description, config);
this.options.push(option);
return this;
}
alias(name: string) {
this.aliasNames.push(name);
return this;
}
action(callback: (...args: any[]) => any) {
this.commandAction = callback;
return this;
}
/**
* Check if a command name is matched by this command
* @param name Command name
*/
isMatched(name: string) {
return this.name === name || this.aliasNames.includes(name);
}
get isDefaultCommand() {
return this.name === '' || this.aliasNames.includes('!');
}
get isGlobalCommand(): boolean {
return this instanceof GlobalCommand;
}
/**
* Check if an option is registered in this command
* @param name Option name
*/
hasOption(name: string) {
name = name.split('.')[0];
return this.options.find(option => {
return option.names.includes(name);
});
}
outputHelp() {
const {
name,
commands
} = this.cli;
const {
versionNumber,
options: globalOptions,
helpCallback
} = this.cli.globalCommand;
let sections: HelpSection[] = [{
body: `${name}${versionNumber ? `/${versionNumber}` : ''}`
}];
sections.push({
title: 'Usage',
body: ` $ ${name} ${this.usageText || this.rawName}`
});
const showCommands = (this.isGlobalCommand || this.isDefaultCommand) && commands.length > 0;
if (showCommands) {
const longestCommandName = findLongest(commands.map(command => command.rawName));
sections.push({
title: 'Commands',
body: commands.map(command => {
return ` ${padRight(command.rawName, longestCommandName.length)} ${command.description}`;
}).join('\n')
});
sections.push({
title: `For more info, run any command with the \`--help\` flag`,
body: commands.map(command => ` $ ${name}${command.name === '' ? '' : ` ${command.name}`} --help`).join('\n')
});
}
let options = this.isGlobalCommand ? globalOptions : [...this.options, ...(globalOptions || [])];
if (!this.isGlobalCommand && !this.isDefaultCommand) {
options = options.filter(option => option.name !== 'version');
}
if (options.length > 0) {
const longestOptionName = findLongest(options.map(option => option.rawName));
sections.push({
title: 'Options',
body: options.map(option => {
return ` ${padRight(option.rawName, longestOptionName.length)} ${option.description} ${option.config.default === undefined ? '' : `(default: ${option.config.default})`}`;
}).join('\n')
});
}
if (this.examples.length > 0) {
sections.push({
title: 'Examples',
body: this.examples.map(example => {
if (typeof example === 'function') {
return example(name);
}
return example;
}).join('\n')
});
}
if (helpCallback) {
sections = helpCallback(sections) || sections;
}
console.log(sections.map(section => {
return section.title ? `${section.title}:\n${section.body}` : section.body;
}).join('\n\n'));
}
outputVersion() {
const {
name
} = this.cli;
const {
versionNumber
} = this.cli.globalCommand;
if (versionNumber) {
console.log(`${name}/${versionNumber} ${platformInfo}`);
}
}
checkRequiredArgs() {
const minimalArgsCount = this.args.filter(arg => arg.required).length;
if (this.cli.args.length < minimalArgsCount) {
throw new CACError(`missing required args for command \`${this.rawName}\``);
}
}
/**
* Check if the parsed options contain any unknown options
*
* Exit and output error when true
*/
checkUnknownOptions() {
const {
options,
globalCommand
} = this.cli;
if (!this.config.allowUnknownOptions) {
for (const name of Object.keys(options)) {
if (name !== '--' && !this.hasOption(name) && !globalCommand.hasOption(name)) {
throw new CACError(`Unknown option \`${name.length > 1 ? `--${name}` : `-${name}`}\``);
}
}
}
}
/**
* Check if the required string-type options exist
*/
checkOptionValue() {
const {
options: parsedOptions,
globalCommand
} = this.cli;
const options = [...globalCommand.options, ...this.options];
for (const option of options) {
const value = parsedOptions[option.name.split('.')[0]]; // Check required option value
if (option.required) {
const hasNegated = options.some(o => o.negated && o.names.includes(option.name));
if (value === true || value === false && !hasNegated) {
throw new CACError(`option \`${option.rawName}\` value is missing`);
}
}
}
}
}
class GlobalCommand extends Command {
constructor(cli: CAC) {
super('@@global@@', '', {}, cli);
}
}
export type { HelpCallback, CommandExample, CommandConfig };
export { GlobalCommand };
export default Command;