500 lines
14 KiB
TypeScript
500 lines
14 KiB
TypeScript
import { readSync } from "node:fs";
|
|
import {
|
|
colorEnabled,
|
|
configureColor,
|
|
errorLabel,
|
|
fixLabel,
|
|
heading,
|
|
} from "./aidlc-color.ts";
|
|
|
|
export const ROUTE_NAMESPACE_DECLARATIONS = {
|
|
public: { trustedPrefix: false },
|
|
engine: { trustedPrefix: true },
|
|
system: { trustedPrefix: false },
|
|
} as const;
|
|
|
|
export type RouteNamespaceName = keyof typeof ROUTE_NAMESPACE_DECLARATIONS;
|
|
|
|
const trustedNamespaces = Object.entries(ROUTE_NAMESPACE_DECLARATIONS)
|
|
.filter(([, declaration]) => declaration.trustedPrefix)
|
|
.map(([namespace]) => namespace as RouteNamespaceName);
|
|
|
|
if (trustedNamespaces.length !== 1) {
|
|
throw new Error(
|
|
`route namespace declarations must identify exactly one trusted prefix; found ${trustedNamespaces.length}`,
|
|
);
|
|
}
|
|
|
|
export const TRUSTED_ROUTE_NAMESPACE = trustedNamespaces[0];
|
|
export const TRUSTED_COMMAND_PREFIX = `aidlc ${TRUSTED_ROUTE_NAMESPACE}`;
|
|
export const TRUSTED_COMMAND_TOKENS = ["aidlc", TRUSTED_ROUTE_NAMESPACE] as const;
|
|
export const UNTRUSTED_ROUTE_NAMESPACES = Object.keys(ROUTE_NAMESPACE_DECLARATIONS)
|
|
.filter((namespace) =>
|
|
namespace !== "public" && namespace !== TRUSTED_ROUTE_NAMESPACE
|
|
) as RouteNamespaceName[];
|
|
|
|
export function trustedCommand(suffix = ""): string {
|
|
return suffix ? `${TRUSTED_COMMAND_PREFIX} ${suffix}` : TRUSTED_COMMAND_PREFIX;
|
|
}
|
|
|
|
// Lightweight dispatcher grammar. aidlc-lib.ts retains the same public
|
|
// workspace helpers for methodology callers; keeping this copy in the existing
|
|
// command module avoids loading the full methodology graph at CLI startup.
|
|
export const PINNED_TOP_LEVEL_ROUTES = [
|
|
"next",
|
|
"continue",
|
|
"report",
|
|
"park",
|
|
"team-board",
|
|
"unit",
|
|
"compose",
|
|
"--claim",
|
|
"--release",
|
|
"--status",
|
|
"--resume",
|
|
"--scope",
|
|
] as const;
|
|
|
|
export const PINNED_SYSTEM_GROUPS = ["workspace-sync"] as const;
|
|
|
|
const LAUNCHER_FLAG_VALUES = new Set(["--project-dir"]);
|
|
const LAUNCHER_GLOBAL_FLAGS = new Set([
|
|
"--json",
|
|
"--quiet",
|
|
"--no-color",
|
|
"--yes",
|
|
"--offline",
|
|
"--verbose",
|
|
]);
|
|
|
|
export function launcherRouteUsesPin(argv: readonly string[]): boolean {
|
|
const route: string[] = [];
|
|
for (let index = 0; index < argv.length && route.length < 2; index++) {
|
|
const token = argv[index];
|
|
if (LAUNCHER_FLAG_VALUES.has(token)) {
|
|
index++;
|
|
continue;
|
|
}
|
|
if (LAUNCHER_GLOBAL_FLAGS.has(token)) continue;
|
|
route.push(token);
|
|
}
|
|
const [head, noun] = route;
|
|
if (head === "engine") {
|
|
return Boolean(noun && noun !== "--help" && noun !== "-h");
|
|
}
|
|
if (head === "system") {
|
|
return PINNED_SYSTEM_GROUPS.includes(
|
|
noun as (typeof PINNED_SYSTEM_GROUPS)[number],
|
|
);
|
|
}
|
|
return PINNED_TOP_LEVEL_ROUTES.includes(
|
|
head as (typeof PINNED_TOP_LEVEL_ROUTES)[number],
|
|
);
|
|
}
|
|
|
|
type DispatcherWorkspaceNoun = "intent" | "space";
|
|
|
|
const DISPATCHER_INTENT_VERBS = new Set(["list", "switch", "create"]);
|
|
const DISPATCHER_SPACE_VERBS = new Set(["list", "switch", "create"]);
|
|
const DISPATCHER_RESERVED_FUTURE = new Set([
|
|
"archive",
|
|
"rename",
|
|
"show",
|
|
"birth",
|
|
]);
|
|
|
|
export type DispatcherWorkspaceCommand =
|
|
| { kind: "list"; noun: DispatcherWorkspaceNoun; json: boolean }
|
|
| {
|
|
kind: "switch";
|
|
noun: DispatcherWorkspaceNoun;
|
|
name: string;
|
|
explicit: boolean;
|
|
}
|
|
| { kind: "create"; noun: "space"; name: string }
|
|
| { kind: "create-intent"; noun: "intent"; rest: string[] }
|
|
| { kind: "help"; noun: DispatcherWorkspaceNoun }
|
|
| {
|
|
kind: "error";
|
|
noun: DispatcherWorkspaceNoun;
|
|
message: string;
|
|
}
|
|
| { kind: "not-workspace" };
|
|
|
|
function missingDispatcherWorkspaceName(
|
|
noun: DispatcherWorkspaceNoun,
|
|
verb: "switch" | "create" | "space-create",
|
|
): DispatcherWorkspaceCommand {
|
|
const usage = verb === "space-create"
|
|
? "space-create <name>"
|
|
: `${noun} ${verb} <name>`;
|
|
return {
|
|
kind: "error",
|
|
noun,
|
|
message: `Usage: aidlc ${usage}`,
|
|
};
|
|
}
|
|
|
|
function isDispatcherWorkspaceNoun(
|
|
token: string | undefined,
|
|
): token is DispatcherWorkspaceNoun {
|
|
return token === "intent" || token === "space";
|
|
}
|
|
|
|
export function parseDispatcherWorkspaceCommand(
|
|
tokens: string[],
|
|
): DispatcherWorkspaceCommand {
|
|
const head = tokens[0];
|
|
if (head === "space-create") {
|
|
const name = tokens[1];
|
|
return name === undefined
|
|
? missingDispatcherWorkspaceName("space", "space-create")
|
|
: { kind: "create", noun: "space", name };
|
|
}
|
|
if (!isDispatcherWorkspaceNoun(head)) return { kind: "not-workspace" };
|
|
const noun = head;
|
|
const verbOrName = tokens[1];
|
|
if (verbOrName === undefined) return { kind: "list", noun, json: false };
|
|
if (verbOrName === "--json") return { kind: "list", noun, json: true };
|
|
if (verbOrName === "help" || verbOrName === "-h") {
|
|
return { kind: "help", noun };
|
|
}
|
|
if (DISPATCHER_RESERVED_FUTURE.has(verbOrName)) {
|
|
return {
|
|
kind: "error",
|
|
noun,
|
|
message:
|
|
`${noun} ${verbOrName} is reserved for a future workspace verb and is not implemented yet. ` +
|
|
`Use ${noun} switch ${verbOrName} to select an existing record with that name.`,
|
|
};
|
|
}
|
|
if (noun === "intent") {
|
|
if (verbOrName === "list") return { kind: "list", noun, json: tokens[2] === "--json" };
|
|
if (verbOrName === "switch") {
|
|
const name = tokens[2];
|
|
return name === undefined
|
|
? missingDispatcherWorkspaceName(noun, "switch")
|
|
: { kind: "switch", noun, name, explicit: true };
|
|
}
|
|
if (verbOrName === "create") {
|
|
return { kind: "create-intent", noun, rest: tokens.slice(2) };
|
|
}
|
|
}
|
|
if (noun === "space") {
|
|
if (verbOrName === "list") return { kind: "list", noun, json: tokens[2] === "--json" };
|
|
if (verbOrName === "switch") {
|
|
const name = tokens[2];
|
|
return name === undefined
|
|
? missingDispatcherWorkspaceName(noun, "switch")
|
|
: { kind: "switch", noun, name, explicit: true };
|
|
}
|
|
if (verbOrName === "create") {
|
|
const name = tokens[2];
|
|
return name === undefined
|
|
? missingDispatcherWorkspaceName(noun, "create")
|
|
: { kind: "create", noun, name };
|
|
}
|
|
}
|
|
if (
|
|
(noun === "intent" && DISPATCHER_INTENT_VERBS.has(verbOrName)) ||
|
|
(noun === "space" && DISPATCHER_SPACE_VERBS.has(verbOrName))
|
|
) {
|
|
return { kind: "error", noun, message: `invalid ${noun} command` };
|
|
}
|
|
return { kind: "switch", noun, name: verbOrName, explicit: false };
|
|
}
|
|
|
|
export function dispatcherWorkspaceUtilityArgv(
|
|
command: DispatcherWorkspaceCommand,
|
|
): string[] | null {
|
|
switch (command.kind) {
|
|
case "list":
|
|
return command.json ? [command.noun, "--json"] : [command.noun];
|
|
case "switch":
|
|
return command.explicit
|
|
? [command.noun, "switch", command.name]
|
|
: [command.noun, command.name];
|
|
case "create":
|
|
return ["space-create", command.name];
|
|
case "create-intent":
|
|
return ["intent-create", ...command.rest];
|
|
case "help":
|
|
return ["help"];
|
|
case "error":
|
|
case "not-workspace":
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export type DispatcherPluginCommand =
|
|
| { kind: "not-plugin" }
|
|
| { kind: "help" }
|
|
| { kind: "error"; message: string }
|
|
| { kind: "run"; argv: string[] };
|
|
|
|
export function parseDispatcherPluginCommand(
|
|
args: string[],
|
|
): DispatcherPluginCommand {
|
|
if (args[0] !== "plugin") return { kind: "not-plugin" };
|
|
const verb = args[1];
|
|
if (verb === "help" || verb === "-h" || verb === "--help") {
|
|
return { kind: "help" };
|
|
}
|
|
const target = verb === "select"
|
|
? "select-plugins"
|
|
: verb === "list"
|
|
? "plugin-list"
|
|
: verb === "sync"
|
|
? "plugin-sync"
|
|
: verb === "validate"
|
|
? "plugin-validate"
|
|
: verb === "build"
|
|
? "plugin-build"
|
|
: undefined;
|
|
if (target !== undefined) {
|
|
return { kind: "run", argv: [target, ...args.slice(2)] };
|
|
}
|
|
const detail = verb ? `unknown verb '${verb}'` : "missing verb";
|
|
return {
|
|
kind: "error",
|
|
message: `aidlc: ${detail} for noun 'plugin'; try 'aidlc help --all'`,
|
|
};
|
|
}
|
|
|
|
export type NamespaceRouteShape = {
|
|
namespace: string;
|
|
group: string;
|
|
kind: string;
|
|
verbs: readonly string[];
|
|
};
|
|
|
|
export type NamespaceInvocation = {
|
|
file: string;
|
|
line: number;
|
|
source: "aidlc" | "aidlc";
|
|
namespace: "engine" | "system";
|
|
noun?: string;
|
|
verb?: string;
|
|
command: string;
|
|
resolves: boolean;
|
|
};
|
|
|
|
function cleanInvocationToken(token: string | undefined): string | undefined {
|
|
return token
|
|
?.replace(/^[([`"']+/, "")
|
|
.replace(/[\]),.:;\\`"']+$/, "");
|
|
}
|
|
|
|
export function namespaceInvocationResolves(
|
|
routes: readonly NamespaceRouteShape[],
|
|
namespace: "engine" | "system",
|
|
noun: string | undefined,
|
|
verb: string | undefined,
|
|
): boolean {
|
|
if (
|
|
!noun ||
|
|
noun === "*" ||
|
|
noun === ".*" ||
|
|
noun === "--help" ||
|
|
noun === "-h" ||
|
|
noun.includes("<") ||
|
|
noun.startsWith("$")
|
|
) {
|
|
return true;
|
|
}
|
|
const namespaceRoutes = routes.filter((route) => route.namespace === namespace);
|
|
if (
|
|
namespaceRoutes.some((route) =>
|
|
route.group === "top" && route.verbs.includes(noun)
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
const grouped = namespaceRoutes.filter((route) => route.group === noun);
|
|
if (grouped.length === 0) return false;
|
|
if (!verb) return true;
|
|
if (verb.startsWith("<") || verb.startsWith("$")) return true;
|
|
if (verb.startsWith("--")) {
|
|
return grouped.some((route) => route.kind === "routing-only");
|
|
}
|
|
return grouped.some((route) =>
|
|
route.kind === "routing-only" ||
|
|
route.verbs.some((candidate) =>
|
|
candidate === verb ||
|
|
candidate.startsWith(`${verb} `) ||
|
|
candidate.startsWith("<")
|
|
)
|
|
);
|
|
}
|
|
|
|
export function scanNamespaceInvocations(
|
|
file: string,
|
|
value: string,
|
|
routes: readonly NamespaceRouteShape[],
|
|
): NamespaceInvocation[] {
|
|
const invocations: NamespaceInvocation[] = [];
|
|
for (const [index, line] of value.split(/\r?\n/).entries()) {
|
|
const invocation =
|
|
/(\baidlc|\{\{INVOKE\}\})\s+(engine|system)(?:\s+([^\s`"'|;&(){}]+))?(?:\s+([^\s`"'|;&(){}]+))?/g;
|
|
for (const match of line.matchAll(invocation)) {
|
|
const source = match[1] === "aidlc" ? "aidlc" : "aidlc";
|
|
const namespace = match[2] as "engine" | "system";
|
|
const noun = cleanInvocationToken(match[3]);
|
|
const verb = cleanInvocationToken(match[4]);
|
|
invocations.push({
|
|
file,
|
|
line: index + 1,
|
|
source,
|
|
namespace,
|
|
noun,
|
|
verb,
|
|
command: [source, namespace, noun, verb].filter(Boolean).join(" "),
|
|
resolves: namespaceInvocationResolves(routes, namespace, noun, verb),
|
|
});
|
|
}
|
|
}
|
|
return invocations;
|
|
}
|
|
|
|
export const EXIT = {
|
|
ok: 0,
|
|
failure: 1,
|
|
usage: 2,
|
|
unavailable: 3,
|
|
integrity: 4,
|
|
actionNeeded: 5,
|
|
} as const;
|
|
|
|
export type OutputMode = "human" | "quiet" | "json";
|
|
|
|
export type CommandResult = {
|
|
ok: boolean;
|
|
code: number;
|
|
status: string;
|
|
message: string;
|
|
data?: unknown;
|
|
remediation?: string;
|
|
};
|
|
|
|
export type GlobalOptions = {
|
|
mode: OutputMode;
|
|
color: boolean;
|
|
yes: boolean;
|
|
offline: boolean;
|
|
verbose: boolean;
|
|
};
|
|
|
|
export function globalOptions(argv: readonly string[]): GlobalOptions {
|
|
configureColor(argv);
|
|
return {
|
|
mode: argv.includes("--json") ? "json" : argv.includes("--quiet") ? "quiet" : "human",
|
|
color: colorEnabled(process.stdout),
|
|
yes: argv.includes("--yes"),
|
|
offline: argv.includes("--offline") || process.env.AIDLC_OFFLINE === "1",
|
|
verbose: argv.includes("--verbose"),
|
|
};
|
|
}
|
|
|
|
export function valueAfter(argv: readonly string[], flag: string): string | undefined {
|
|
const index = argv.indexOf(flag);
|
|
const value = index >= 0 ? argv[index + 1] : undefined;
|
|
return value && !value.startsWith("--") ? value : undefined;
|
|
}
|
|
|
|
export function valuesAfter(argv: readonly string[], flag: string): string[] {
|
|
const values: string[] = [];
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] === flag && argv[i + 1] && !argv[i + 1].startsWith("--")) {
|
|
values.push(argv[++i]);
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
export function emitResult(result: CommandResult, options: GlobalOptions): void {
|
|
if (options.mode === "json") {
|
|
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, ...result })}\n`);
|
|
} else if (options.mode === "quiet") {
|
|
const output = result.ok ? result.message : (result.remediation ?? result.message);
|
|
if (output) process.stdout.write(`${output}\n`);
|
|
} else {
|
|
if (result.ok) {
|
|
process.stdout.write(`${result.message}\n`);
|
|
} else if (result.code === EXIT.actionNeeded) {
|
|
process.stdout.write(`action: ${result.message}\n`);
|
|
} else {
|
|
process.stdout.write(`${errorLabel("error:", process.stdout)} ${result.message}\n`);
|
|
}
|
|
if (result.remediation) {
|
|
process.stdout.write(
|
|
`${
|
|
result.status === "usage"
|
|
? heading("usage:", process.stdout)
|
|
: fixLabel("fix:", process.stdout)
|
|
} ${result.remediation}\n`,
|
|
);
|
|
} else if (result.status === "usage") {
|
|
process.stdout.write(
|
|
`${heading("usage:", process.stdout)} rerun with --help for valid usage\n`,
|
|
);
|
|
}
|
|
}
|
|
process.exitCode = result.code;
|
|
}
|
|
|
|
export function usage(message: string, remediation?: string): CommandResult {
|
|
return { ok: false, code: EXIT.usage, status: "usage", message, remediation };
|
|
}
|
|
|
|
export function failure(
|
|
message: string,
|
|
code: number = EXIT.failure,
|
|
remediation?: string,
|
|
): CommandResult {
|
|
return { ok: false, code, status: "failed", message, remediation };
|
|
}
|
|
|
|
export function success(message: string, data?: unknown): CommandResult {
|
|
return { ok: true, code: EXIT.ok, status: "ok", message, data };
|
|
}
|
|
|
|
// Reads one answer from the terminal. Bun's global `prompt()` returns `null`
|
|
// for an empty line, which makes "press Enter to accept the default"
|
|
// indistinguishable from a cancelled prompt. This reader returns `""` for an
|
|
// empty line and `null` only when the input is closed (EOF), so callers can
|
|
// treat Enter as "accept the default" and EOF as "cancel". It reads one byte at
|
|
// a time so nothing past the newline is consumed from the input.
|
|
export function readTerminalLine(label: string, fd = 0): string | null {
|
|
process.stdout.write(`${label} `);
|
|
const bytes: number[] = [];
|
|
const byte = Buffer.alloc(1);
|
|
let sawNewline = false;
|
|
let sawEof = false;
|
|
while (!sawNewline && !sawEof) {
|
|
let read: number;
|
|
try {
|
|
read = readSync(fd, byte, 0, 1, null);
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code === "EAGAIN") {
|
|
Bun.sleepSync(10);
|
|
continue;
|
|
}
|
|
if (code === "EOF") {
|
|
sawEof = true;
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
if (read === 0) {
|
|
sawEof = true;
|
|
} else if (byte[0] === 0x0a) {
|
|
sawNewline = true;
|
|
} else {
|
|
bytes.push(byte[0]);
|
|
}
|
|
}
|
|
if (sawEof && bytes.length === 0) return null;
|
|
return Buffer.from(bytes).toString("utf-8").replace(/\r$/, "");
|
|
}
|