Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@
"receipt:typecheck": "pnpm --filter @sub-rosa/receipt-cli typecheck",
"time:test": "pnpm --filter @sub-rosa/time test",
"time:guard": "node scripts/check-direct-time-access.mjs",
"time:guard:test": "node --test scripts/check-direct-time-access.test.mjs"
"time:guard:test": "node --test scripts/check-direct-time-access.test.mjs",
"command:test": "pnpm --filter @sub-rosa/command test",
"command:typecheck": "pnpm --filter @sub-rosa/command typecheck",
"command-runner:check": "node scripts/check-command-runner.mjs",
"command-runner:test": "node --test scripts/check-command-runner.test.mjs"
},
"devDependencies": {
"@sub-rosa/command": "workspace:*",
"@sub-rosa/logging": "workspace:*"
}
}
49 changes: 49 additions & 0 deletions packages/command/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@sub-rosa/command",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Standardized command runner with preflight, signal lifecycle, cleanup registration, and exit codes.",
"repository": {
"type": "git",
"url": "https://github.com/Sub-Rosa-Issue/sub-rosa-issue.git",
"directory": "packages/command"
},
"main": "src/index.js",
"types": "src/index.d.ts",
"exports": {
".": {
"types": "./src/index.d.ts",
"default": "./src/index.js"
},
"./errors": {
"types": "./src/errors.d.ts",
"default": "./src/errors.js"
},
"./types": {
"types": "./src/types.d.ts",
"default": "./src/types.js"
},
"./runner": {
"types": "./src/runner.d.ts",
"default": "./src/runner.js"
},
"./repo-root": {
"types": "./src/repo-root.d.ts",
"default": "./src/repo-root.js"
}
},
"scripts": {
"test": "node --import tsx --test test/runner.test.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@sub-rosa/logging": "workspace:*",
"@sub-rosa/time": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.9.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
}
31 changes: 31 additions & 0 deletions packages/command/src/errors.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export declare const ExitCode: {
readonly SUCCESS: 0;
readonly UNEXPECTED: 1;
readonly USAGE: 2;
readonly CONFIG: 3;
readonly DEPENDENCY: 4;
readonly INTERRUPTED: 130;
};

export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];

export declare class CommandError extends Error {
readonly exitCode: number;
constructor(message: string, exitCode?: number);
}

export declare class UsageError extends CommandError {
constructor(message: string);
}

export declare class ConfigError extends CommandError {
constructor(message: string);
}

export declare class DependencyError extends CommandError {
constructor(message: string);
}

export declare class InterruptedError extends CommandError {
constructor(message?: string);
}
78 changes: 78 additions & 0 deletions packages/command/src/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Stable exit-code categories for command execution.
*/
export const ExitCode = Object.freeze({
SUCCESS: 0,
UNEXPECTED: 1,
USAGE: 2,
CONFIG: 3,
DEPENDENCY: 4,
INTERRUPTED: 130,
});

/**
* Base error class for operational command failures with exit codes.
*/
export class CommandError extends Error {
/**
* @param {string} message
* @param {number} [exitCode=ExitCode.UNEXPECTED]
*/
constructor(message, exitCode = ExitCode.UNEXPECTED) {
super(message);
this.name = "CommandError";
this.exitCode = exitCode;
}
}

/**
* Error indicating invalid CLI arguments, options, or command usage.
*/
export class UsageError extends CommandError {
/**
* @param {string} message
*/
constructor(message) {
super(message, ExitCode.USAGE);
this.name = "UsageError";
}
}

/**
* Error indicating missing or invalid environment variables or configuration.
*/
export class ConfigError extends CommandError {
/**
* @param {string} message
*/
constructor(message) {
super(message, ExitCode.CONFIG);
this.name = "ConfigError";
}
}

/**
* Error indicating external service, binary, RPC, or network failure.
*/
export class DependencyError extends CommandError {
/**
* @param {string} message
*/
constructor(message) {
super(message, ExitCode.DEPENDENCY);
this.name = "DependencyError";
}
}

/**
* Error indicating process cancellation via abort signal or interrupt.
*/
export class InterruptedError extends CommandError {
/**
* @param {string} [message="Command interrupted"]
*/
constructor(message = "Command interrupted") {
super(message, ExitCode.INTERRUPTED);
this.name = "InterruptedError";
}
}
17 changes: 17 additions & 0 deletions packages/command/src/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export {
CommandError,
ConfigError,
DependencyError,
ExitCode,
InterruptedError,
UsageError,
} from "./errors.js";
export { findRepoRoot } from "./repo-root.js";
export { formatHelp, runCommand } from "./runner.js";
export type {
CommandContext,
CommandDefinition,
CommandOption,
PositionalDescription,
RunOptions,
} from "./types.js";
10 changes: 10 additions & 0 deletions packages/command/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export {
CommandError,
ConfigError,
DependencyError,
ExitCode,
InterruptedError,
UsageError,
} from "./errors.js";
export { findRepoRoot } from "./repo-root.js";
export { formatHelp, runCommand } from "./runner.js";
7 changes: 7 additions & 0 deletions packages/command/src/repo-root.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Ascends the filesystem hierarchy to find the repository root directory.
*
* @param startDir - Initial directory to start searching from.
* @returns Absolute path to the repository root.
*/
export declare function findRepoRoot(startDir?: string): string;
25 changes: 25 additions & 0 deletions packages/command/src/repo-root.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";

/**
* Ascends the filesystem hierarchy to find the repository root directory.
*
* @param {string} [startDir] - Initial directory to start searching from.
* @returns {string} - Absolute path to the repository root.
*/
export function findRepoRoot(startDir) {
let current = resolve(startDir || process.cwd());
while (true) {
if (
existsSync(resolve(current, "pnpm-workspace.yaml")) ||
existsSync(resolve(current, ".git"))
) {
return current;
}
const parent = dirname(current);
if (parent === current) {
return resolve(startDir || process.cwd());
}
current = parent;
}
}
21 changes: 21 additions & 0 deletions packages/command/src/runner.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { CommandDefinition, RunOptions } from "./types.js";

/**
* Formats help text for a command definition.
*
* @param definition - Command definition to generate help text for.
* @returns Formatted help string.
*/
export declare function formatHelp(definition: CommandDefinition<any>): string;

/**
* Executes a command definition with lifecycle management.
*
* @param definition - Command specification including arguments, environment, and run handler.
* @param options - Execution options controlling arguments, environment, streams, and termination.
* @returns Promise resolving to the numeric exit code.
*/
export declare function runCommand<TValues = Record<string, any>>(
definition: CommandDefinition<TValues>,
options?: RunOptions
): Promise<number>;
Loading
Loading