feat: sync Node Factory Droid hook integration
This commit is contained in:
1 parent
9cbcba21f5
commit
865248a767
12 files changed
+1478
-5
No files matched your search
@@ -28,7 +28,7 @@ This repository publishes the open-source server, console, integration catalog,
|
||||
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
||||
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline.
|
||||
- Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open.
|
||||
- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Edit only effective user sources; keep project and organization sources read-only.
|
||||
- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits and require `/hooks` review after external changes.
|
||||
|
||||
## Mirror Workflow
|
||||
|
||||
|
||||
@@ -186,8 +186,8 @@
|
||||
"mechanism": "exit_code_2",
|
||||
"failure_mode": "fail_open"
|
||||
},
|
||||
"adapters": { "node": false, "python": false, "go": false },
|
||||
"delivery_state": "planned",
|
||||
"adapters": { "node": true, "python": false, "go": false },
|
||||
"delivery_state": "partial",
|
||||
"source_url": "https://docs.factory.ai/reference/hooks-reference",
|
||||
"contract_variants": [
|
||||
{
|
||||
|
||||
@@ -56,6 +56,8 @@ Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex aft
|
||||
|
||||
Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The guard translates a frozen agent into Cline's JSON stdout cancellation control.
|
||||
|
||||
Factory Droid installation preserves the active user source across `~/.factory/hooks.json`, the `hooks` field in `~/.factory/settings.json`, and the legacy `~/.factory/hooks/hooks.json` path. Hook files store events at the document root; settings stores the same event map under `hooks`. Project and organization sources remain unchanged. Droid snapshots hooks for each session, so run `/hooks` after installation to review and apply the external change.
|
||||
|
||||
Kimi installation writes the strict hook contract to each detected runtime: Kimi Code's `$KIMI_CODE_HOME/config.toml` (default `~/.kimi-code/config.toml`) and the migrating Python CLI's `~/.kimi/config.toml`. A fresh installation targets current Kimi Code, avoiding cross-runtime migration markers. Both runtimes load the hooks globally; run `/hooks` to inspect them.
|
||||
|
||||
Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged.
|
||||
@@ -81,6 +83,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera
|
||||
| Claude Code | `claudecode` |
|
||||
| OpenAI Codex | `codex` |
|
||||
| Cline | `cline` |
|
||||
| Factory Droid | `droid` |
|
||||
| Kimi Code | `kimi` |
|
||||
| Grok Build | `grok` |
|
||||
| Cursor | `cursor` |
|
||||
|
||||
Generated
+8
-1
@@ -9,7 +9,8 @@
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@decimalturn/toml-patch": "^2.0.0"
|
||||
"@decimalturn/toml-patch": "^2.0.0",
|
||||
"jsonc-parser": "^3.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"elydora": "dist/cli.js"
|
||||
@@ -41,6 +42,12 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@decimalturn/toml-patch": "^2.0.0"
|
||||
"@decimalturn/toml-patch": "^2.0.0",
|
||||
"jsonc-parser": "^3.3.1"
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { copilotPlugin } from './plugins/copilot.js';
|
||||
import { lettaPlugin } from './plugins/letta.js';
|
||||
import { codexPlugin } from './plugins/codex.js';
|
||||
import { clinePlugin } from './plugins/cline.js';
|
||||
import { droidPlugin } from './plugins/droid.js';
|
||||
import { kimiPlugin } from './plugins/kimi.js';
|
||||
import { grokPlugin } from './plugins/grok.js';
|
||||
|
||||
@@ -34,6 +35,7 @@ const PLUGINS: ReadonlyMap<string, AgentPlugin> = new Map([
|
||||
['letta', lettaPlugin],
|
||||
['codex', codexPlugin],
|
||||
['cline', clinePlugin],
|
||||
['droid', droidPlugin],
|
||||
['kimi', kimiPlugin],
|
||||
['grok', grokPlugin],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
applyEdits,
|
||||
modify,
|
||||
parse,
|
||||
parseTree,
|
||||
printParseErrorCode,
|
||||
type FormattingOptions,
|
||||
type JSONPath,
|
||||
type Node,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
import {
|
||||
TOOL_EVENTS,
|
||||
type DroidGroup,
|
||||
type DroidHookSettings,
|
||||
type JsonObject,
|
||||
type ToolEvent,
|
||||
hasOwn,
|
||||
isObject,
|
||||
managedRemovals,
|
||||
readHookSettings,
|
||||
} from './droid-contract.js';
|
||||
|
||||
export const OWNED_FILE_MARKER = '// Managed by Elydora';
|
||||
|
||||
export type DroidDocumentKind = 'hooks' | 'legacy' | 'settings';
|
||||
|
||||
export interface DroidDocument {
|
||||
readonly kind: DroidDocumentKind;
|
||||
readonly filePath: string;
|
||||
readonly exists: boolean;
|
||||
readonly raw: string;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: DroidHookSettings;
|
||||
readonly basePath: JSONPath;
|
||||
readonly hasHooksContainer: boolean;
|
||||
readonly ownedFile: boolean;
|
||||
}
|
||||
|
||||
export interface DroidSources {
|
||||
readonly rootPath: string;
|
||||
readonly primary?: DroidDocument;
|
||||
readonly settings: DroidDocument;
|
||||
}
|
||||
|
||||
export interface InstallationTargets {
|
||||
readonly targets: ReadonlyMap<ToolEvent, DroidDocument>;
|
||||
readonly createdRoot?: DroidDocument;
|
||||
}
|
||||
|
||||
export interface RenderedDocument {
|
||||
readonly document: DroidDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
interface DocumentOptions {
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly kind: DroidDocumentKind;
|
||||
readonly raw: string;
|
||||
}
|
||||
|
||||
function rejectDuplicateKeys(node: Node | undefined, label: string, path: string[] = []): void {
|
||||
if (!node) return;
|
||||
if (node.type === 'object') {
|
||||
const keys = new Set<string>();
|
||||
for (const property of node.children ?? []) {
|
||||
const key = String(property.children?.[0]?.value);
|
||||
const location = [...path, key];
|
||||
if (keys.has(key)) throw new Error(`${label} contains duplicate field "${location.join('.')}"`);
|
||||
keys.add(key);
|
||||
rejectDuplicateKeys(property.children?.[1], label, location);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.type === 'array') {
|
||||
for (const child of node.children ?? []) rejectDuplicateKeys(child, label, path);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsoncObject(raw: string, label: string): JsonObject {
|
||||
const errors: ParseError[] = [];
|
||||
const value: unknown = parse(raw, errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
const details = errors
|
||||
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
|
||||
.join(', ');
|
||||
throw new Error(`Failed to parse ${label}: ${details}`);
|
||||
}
|
||||
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
|
||||
rejectDuplicateKeys(parseTree(raw, [], {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
}), label);
|
||||
return value;
|
||||
}
|
||||
|
||||
function labelFor(kind: DroidDocumentKind, filePath: string): string {
|
||||
if (kind === 'settings') return `Factory Droid settings at ${filePath}`;
|
||||
if (kind === 'legacy') return `Factory Droid legacy hooks at ${filePath}`;
|
||||
return `Factory Droid hooks at ${filePath}`;
|
||||
}
|
||||
|
||||
export function parseDocument(options: DocumentOptions): DroidDocument {
|
||||
const label = labelFor(options.kind, options.filePath);
|
||||
const root = parseJsoncObject(options.raw, label);
|
||||
if (options.kind === 'settings') {
|
||||
const hasHooksContainer = hasOwn(root, 'hooks');
|
||||
const hooks = hasHooksContainer
|
||||
? readHookSettings(root.hooks, `${label} field "hooks"`)
|
||||
: {};
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
hooks,
|
||||
basePath: ['hooks'],
|
||||
hasHooksContainer,
|
||||
ownedFile: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
hooks: readHookSettings(root, label),
|
||||
basePath: [],
|
||||
hasHooksContainer: true,
|
||||
ownedFile: options.raw.startsWith(OWNED_FILE_MARKER),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSettingsDocument(filePath: string): DroidDocument {
|
||||
return parseDocument({ exists: false, filePath, kind: 'settings', raw: '{}\n' });
|
||||
}
|
||||
|
||||
export function createOwnedHookDocument(filePath: string): DroidDocument {
|
||||
return parseDocument({
|
||||
exists: false,
|
||||
filePath,
|
||||
kind: 'hooks',
|
||||
raw: `${OWNED_FILE_MARKER}\n{}\n`,
|
||||
});
|
||||
}
|
||||
|
||||
function eventPath(document: DroidDocument, event: string): JSONPath {
|
||||
return [...document.basePath, event];
|
||||
}
|
||||
|
||||
function formatting(raw: string): FormattingOptions {
|
||||
const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1];
|
||||
const insertSpaces = !indentation?.includes('\t');
|
||||
return {
|
||||
eol: raw.includes('\r\n') ? '\r\n' : '\n',
|
||||
insertSpaces,
|
||||
tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function change(
|
||||
raw: string,
|
||||
path: JSONPath,
|
||||
value: unknown,
|
||||
isArrayInsertion = false,
|
||||
): string {
|
||||
return applyEdits(raw, modify(raw, path, value, {
|
||||
formattingOptions: formatting(raw),
|
||||
isArrayInsertion,
|
||||
}));
|
||||
}
|
||||
|
||||
function currentDocument(document: DroidDocument, raw: string): DroidDocument {
|
||||
return parseDocument({
|
||||
exists: document.exists,
|
||||
filePath: document.filePath,
|
||||
kind: document.kind,
|
||||
raw,
|
||||
});
|
||||
}
|
||||
|
||||
function removeManagedEntries(
|
||||
document: DroidDocument,
|
||||
raw: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
const removals = managedRemovals(document.hooks, agentId);
|
||||
for (const event of TOOL_EVENTS) {
|
||||
const eventRemovals = removals
|
||||
.filter((removal) => removal.event === event)
|
||||
.sort((left, right) => right.groupIndex - left.groupIndex);
|
||||
if (eventRemovals.length === 0) continue;
|
||||
for (const removal of eventRemovals) {
|
||||
const groupPath = [...eventPath(document, event), removal.groupIndex];
|
||||
if (removal.removeGroup) {
|
||||
raw = change(raw, groupPath, undefined);
|
||||
continue;
|
||||
}
|
||||
for (const handlerIndex of [...removal.handlerIndexes].sort((left, right) => right - left)) {
|
||||
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||
}
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if ((current.hooks[event] ?? []).length === 0) {
|
||||
raw = change(raw, eventPath(document, event), undefined);
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function appendGroup(
|
||||
document: DroidDocument,
|
||||
raw: string,
|
||||
event: ToolEvent,
|
||||
group: DroidGroup,
|
||||
): string {
|
||||
const current = currentDocument(document, raw);
|
||||
if (hasOwn(current.hooks, event)) {
|
||||
const groups = current.hooks[event] ?? [];
|
||||
return change(raw, [...eventPath(document, event), groups.length], group, true);
|
||||
}
|
||||
return change(raw, eventPath(document, event), [group]);
|
||||
}
|
||||
|
||||
function hookFileIsEmpty(document: DroidDocument, raw: string): boolean {
|
||||
if (document.kind === 'settings') return false;
|
||||
return Object.keys(currentDocument(document, raw).hooks).length === 0;
|
||||
}
|
||||
|
||||
export function renderDocument(
|
||||
document: DroidDocument,
|
||||
agentId: string | undefined,
|
||||
additions: ReadonlyMap<ToolEvent, DroidGroup>,
|
||||
): RenderedDocument {
|
||||
let raw = removeManagedEntries(document, document.raw, agentId);
|
||||
for (const event of TOOL_EVENTS) {
|
||||
const group = additions.get(event);
|
||||
if (group) raw = appendGroup(document, raw, event, group);
|
||||
}
|
||||
currentDocument(document, raw);
|
||||
if (additions.size === 0 && document.ownedFile && hookFileIsEmpty(document, raw)) {
|
||||
return { document, changed: true, next: undefined };
|
||||
}
|
||||
return { document, changed: raw !== document.raw, next: raw };
|
||||
}
|
||||
|
||||
function eventTarget(
|
||||
event: ToolEvent,
|
||||
sources: DroidSources,
|
||||
createdRoot: () => DroidDocument,
|
||||
): DroidDocument {
|
||||
if (sources.primary && hasOwn(sources.primary.hooks, event)) return sources.primary;
|
||||
if (sources.settings.hasHooksContainer && hasOwn(sources.settings.hooks, event)) {
|
||||
return sources.settings;
|
||||
}
|
||||
if (sources.primary) return sources.primary;
|
||||
if (sources.settings.hasHooksContainer) return sources.settings;
|
||||
return createdRoot();
|
||||
}
|
||||
|
||||
export function installationTargets(sources: DroidSources): InstallationTargets {
|
||||
let root: DroidDocument | undefined;
|
||||
const createdRoot = (): DroidDocument => {
|
||||
root ??= createOwnedHookDocument(sources.rootPath);
|
||||
return root;
|
||||
};
|
||||
const targets = new Map<ToolEvent, DroidDocument>();
|
||||
for (const event of TOOL_EVENTS) targets.set(event, eventTarget(event, sources, createdRoot));
|
||||
return { targets, createdRoot: root };
|
||||
}
|
||||
|
||||
export function additionsFor(
|
||||
document: DroidDocument,
|
||||
targets: ReadonlyMap<ToolEvent, DroidDocument>,
|
||||
groups: ReadonlyMap<ToolEvent, DroidGroup>,
|
||||
): ReadonlyMap<ToolEvent, DroidGroup> {
|
||||
const additions = new Map<ToolEvent, DroidGroup>();
|
||||
for (const event of TOOL_EVENTS) {
|
||||
if (targets.get(event) === document) additions.set(event, groups.get(event)!);
|
||||
}
|
||||
return additions;
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export const AGENT_KEY = 'droid';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
export const TOOL_EVENTS = ['PreToolUse', 'PostToolUse'] as const;
|
||||
|
||||
const EVENT_NAMES = new Set([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'Notification',
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
]);
|
||||
const FLAG_NAMES = new Set(['hooksDisabled', 'showHookOutput']);
|
||||
const HANDLER_KEYS = ['command', 'timeout', 'type'];
|
||||
const GROUP_KEYS = ['hooks', 'matcher'];
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
export type ToolEvent = typeof TOOL_EVENTS[number];
|
||||
|
||||
export interface DroidHandler extends JsonObject {
|
||||
readonly type: 'command';
|
||||
readonly command: string;
|
||||
readonly timeout?: number;
|
||||
}
|
||||
|
||||
export interface DroidGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly commandRegex?: string;
|
||||
readonly hooks: DroidHandler[];
|
||||
}
|
||||
|
||||
export interface DroidHookSettings extends JsonObject {
|
||||
readonly PreToolUse?: DroidGroup[];
|
||||
readonly PostToolUse?: DroidGroup[];
|
||||
readonly hooksDisabled?: boolean;
|
||||
readonly showHookOutput?: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedRemoval {
|
||||
readonly event: string;
|
||||
readonly groupIndex: number;
|
||||
readonly handlerIndexes: number[];
|
||||
readonly removeGroup: boolean;
|
||||
}
|
||||
|
||||
interface ParsedArgument {
|
||||
readonly value: string;
|
||||
readonly next: number;
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function hasOwn(value: JsonObject, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
function quotePosix(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function quoteWindows(value: string): string {
|
||||
return `"${value.replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
export function buildCommand(scriptPath: string): string {
|
||||
const quote = process.platform === 'win32' ? quoteWindows : quotePosix;
|
||||
return `${quote(process.execPath)} ${quote(scriptPath)}`;
|
||||
}
|
||||
|
||||
export function buildGroup(scriptPath: string): DroidGroup {
|
||||
return {
|
||||
matcher: '*',
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: buildCommand(scriptPath),
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function validateRegex(value: string, label: string, allowWildcard: boolean): void {
|
||||
if (allowWildcard && (value === '*' || value === '')) return;
|
||||
try {
|
||||
new RegExp(value);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${label} must be a valid regular expression: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateHandler(
|
||||
value: unknown,
|
||||
label: string,
|
||||
groupIndex: number,
|
||||
handlerIndex: number,
|
||||
): DroidHandler {
|
||||
const location = `${label}[${groupIndex}].hooks[${handlerIndex}]`;
|
||||
if (!isObject(value)) throw new Error(`${location} must be an object`);
|
||||
if (value.type !== 'command') throw new Error(`${location} type must be "command"`);
|
||||
if (typeof value.command !== 'string') throw new Error(`${location} command must be a string`);
|
||||
if (value.timeout !== undefined
|
||||
&& (typeof value.timeout !== 'number'
|
||||
|| !Number.isFinite(value.timeout))) {
|
||||
throw new Error(`${location} timeout must be a finite number`);
|
||||
}
|
||||
return value as DroidHandler;
|
||||
}
|
||||
|
||||
function validateGroup(value: unknown, label: string, groupIndex: number): DroidGroup {
|
||||
const location = `${label}[${groupIndex}]`;
|
||||
if (!isObject(value)) throw new Error(`${location} must be an object`);
|
||||
if (value.matcher !== undefined) {
|
||||
if (typeof value.matcher !== 'string') throw new Error(`${location} matcher must be a string`);
|
||||
validateRegex(value.matcher, `${location} matcher`, true);
|
||||
}
|
||||
if (value.commandRegex !== undefined) {
|
||||
if (typeof value.commandRegex !== 'string') {
|
||||
throw new Error(`${location} commandRegex must be a string`);
|
||||
}
|
||||
validateRegex(value.commandRegex, `${location} commandRegex`, false);
|
||||
}
|
||||
if (!Array.isArray(value.hooks)) throw new Error(`${location} must contain a hooks array`);
|
||||
const handlers = value.hooks.map(
|
||||
(handler, handlerIndex) => validateHandler(handler, label, groupIndex, handlerIndex),
|
||||
);
|
||||
return { ...value, hooks: handlers } as DroidGroup;
|
||||
}
|
||||
|
||||
export function readHookSettings(value: unknown, label: string): DroidHookSettings {
|
||||
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
|
||||
const settings: DroidHookSettings = { ...value };
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (FLAG_NAMES.has(key)) {
|
||||
if (typeof item !== 'boolean') throw new Error(`${label} field "${key}" must be a boolean`);
|
||||
continue;
|
||||
}
|
||||
if (!EVENT_NAMES.has(key)) throw new Error(`${label} contains unsupported field "${key}"`);
|
||||
if (!Array.isArray(item)) throw new Error(`${label} field "${key}" must be an array`);
|
||||
settings[key] = item.map(
|
||||
(group, groupIndex) => validateGroup(group, `${label} field "${key}"`, groupIndex),
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
function readWindowsArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== '"') return undefined;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length; index += 1) {
|
||||
if (command[index] === '\\' && command[index + 1] === '"') {
|
||||
value += '"';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (command[index] === '"') return { value, next: index + 1 };
|
||||
value += command[index];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const POSIX_APOSTROPHE = `'"'"'`;
|
||||
|
||||
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length;) {
|
||||
if (command.startsWith(POSIX_APOSTROPHE, index)) {
|
||||
value += "'";
|
||||
index += POSIX_APOSTROPHE.length;
|
||||
continue;
|
||||
}
|
||||
if (command[index] === "'") return { value, next: index + 1 };
|
||||
value += command[index];
|
||||
index += 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseGeneratedCommand(command: string): readonly [string, string] | undefined {
|
||||
const readArgument = process.platform === 'win32' ? readWindowsArgument : readPosixArgument;
|
||||
const executable = readArgument(command, 0);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readArgument(command, executable.next + 1);
|
||||
if (!script || script.next !== command.length || !executable.value || !script.value) return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function samePath(left: string, right: string): boolean {
|
||||
const normalizedLeft = path.resolve(left);
|
||||
const normalizedRight = path.resolve(right);
|
||||
return process.platform === 'win32'
|
||||
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
||||
: normalizedLeft === normalizedRight;
|
||||
}
|
||||
|
||||
function sameAgentId(left: string, right: string): boolean {
|
||||
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||
}
|
||||
|
||||
export function managedAgentId(handler: DroidHandler, scriptName: string): string | undefined {
|
||||
if (Object.keys(handler).sort().join('\0') !== HANDLER_KEYS.join('\0')
|
||||
|| handler.type !== 'command'
|
||||
|| handler.timeout !== HOOK_TIMEOUT_SECONDS) return undefined;
|
||||
const parsed = parseGeneratedCommand(handler.command);
|
||||
if (!parsed || !samePath(parsed[0], process.execPath)) return undefined;
|
||||
const scriptPath = parsed[1];
|
||||
if (path.basename(scriptPath) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(scriptPath);
|
||||
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined;
|
||||
const agentId = path.basename(agentDirectory);
|
||||
return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined;
|
||||
}
|
||||
|
||||
function exactOwnedGroup(group: DroidGroup, managedIndexes: number[]): boolean {
|
||||
return Object.keys(group).sort().join('\0') === GROUP_KEYS.join('\0')
|
||||
&& group.matcher === '*'
|
||||
&& managedIndexes.length > 0
|
||||
&& managedIndexes.length === group.hooks.length;
|
||||
}
|
||||
|
||||
export function managedRemovals(
|
||||
settings: DroidHookSettings,
|
||||
agentId?: string,
|
||||
): ManagedRemoval[] {
|
||||
const removals: ManagedRemoval[] = [];
|
||||
for (const [event, scriptName] of [
|
||||
['PreToolUse', GUARD_SCRIPT],
|
||||
['PostToolUse', AUDIT_SCRIPT],
|
||||
] as const) {
|
||||
const groups = settings[event] ?? [];
|
||||
groups.forEach((group, groupIndex) => {
|
||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||
const managedId = managedAgentId(handler, scriptName);
|
||||
return managedId && (agentId === undefined || sameAgentId(managedId, agentId))
|
||||
? [handlerIndex]
|
||||
: [];
|
||||
});
|
||||
if (handlerIndexes.length > 0) {
|
||||
removals.push({
|
||||
event,
|
||||
groupIndex,
|
||||
handlerIndexes,
|
||||
removeGroup: exactOwnedGroup(group, handlerIndexes),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return removals;
|
||||
}
|
||||
|
||||
function managedIds(groups: DroidGroup[], scriptName: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const group of groups) {
|
||||
for (const handler of group.hooks) {
|
||||
const agentId = managedAgentId(handler, scriptName);
|
||||
if (agentId) ids.add(agentId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function runtimeContracts(settings: DroidHookSettings): RuntimeContract[] {
|
||||
const guards = managedIds(settings.PreToolUse ?? [], GUARD_SCRIPT);
|
||||
const audits = managedIds(settings.PostToolUse ?? [], AUDIT_SCRIPT);
|
||||
const root = path.join(os.homedir(), '.elydora');
|
||||
return [...guards]
|
||||
.filter((agentId) => [...audits].some((auditId) => sameAgentId(auditId, agentId)))
|
||||
.map((agentId) => ({
|
||||
agentId,
|
||||
guardPath: path.join(root, agentId, GUARD_SCRIPT),
|
||||
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
|
||||
}));
|
||||
}
|
||||
|
||||
export function mergeHookSettings(
|
||||
primary: DroidHookSettings | undefined,
|
||||
fallback: DroidHookSettings | undefined,
|
||||
): DroidHookSettings {
|
||||
return { ...(fallback ?? {}), ...(primary ?? {}) };
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fsp from 'node:fs/promises';
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
type JsonObject,
|
||||
type RuntimeContract,
|
||||
isObject,
|
||||
} from './droid-contract.js';
|
||||
import {
|
||||
type DroidSources,
|
||||
type RenderedDocument,
|
||||
createSettingsDocument,
|
||||
parseDocument,
|
||||
} from './droid-config.js';
|
||||
|
||||
interface FileChange {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly original?: string;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
interface StagedChange {
|
||||
readonly change: FileChange;
|
||||
readonly tempPath?: string;
|
||||
readonly rollbackPath?: string;
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return isObject(error) && error.code === code;
|
||||
}
|
||||
|
||||
function factoryPaths(): {
|
||||
readonly root: string;
|
||||
readonly legacy: string;
|
||||
readonly settings: string;
|
||||
} {
|
||||
const directory = path.join(os.homedir(), '.factory');
|
||||
return {
|
||||
root: path.join(directory, 'hooks.json'),
|
||||
legacy: path.join(directory, 'hooks', 'hooks.json'),
|
||||
settings: path.join(directory, 'settings.json'),
|
||||
};
|
||||
}
|
||||
|
||||
async function readOptional(filePath: string, label: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await fsp.readFile(filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, 'ENOENT')) return undefined;
|
||||
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSources(): Promise<DroidSources> {
|
||||
const paths = factoryPaths();
|
||||
const [rootRaw, settingsRaw] = await Promise.all([
|
||||
readOptional(paths.root, 'Factory Droid hooks'),
|
||||
readOptional(paths.settings, 'Factory Droid settings'),
|
||||
]);
|
||||
let primary;
|
||||
if (rootRaw !== undefined) {
|
||||
primary = parseDocument({
|
||||
exists: true,
|
||||
filePath: paths.root,
|
||||
kind: 'hooks',
|
||||
raw: rootRaw,
|
||||
});
|
||||
} else {
|
||||
const legacyRaw = await readOptional(paths.legacy, 'Factory Droid legacy hooks');
|
||||
if (legacyRaw !== undefined) {
|
||||
primary = parseDocument({
|
||||
exists: true,
|
||||
filePath: paths.legacy,
|
||||
kind: 'legacy',
|
||||
raw: legacyRaw,
|
||||
});
|
||||
}
|
||||
}
|
||||
const settings = settingsRaw === undefined
|
||||
? createSettingsDocument(paths.settings)
|
||||
: parseDocument({
|
||||
exists: true,
|
||||
filePath: paths.settings,
|
||||
kind: 'settings',
|
||||
raw: settingsRaw,
|
||||
});
|
||||
return { rootPath: paths.root, primary, settings };
|
||||
}
|
||||
|
||||
function labelFor(rendered: RenderedDocument): string {
|
||||
if (rendered.document.kind === 'settings') return 'Factory Droid settings';
|
||||
if (rendered.document.kind === 'legacy') return 'Factory Droid legacy hooks';
|
||||
return 'Factory Droid hooks';
|
||||
}
|
||||
|
||||
function fileChange(rendered: RenderedDocument): FileChange | undefined {
|
||||
if (!rendered.changed) return undefined;
|
||||
return {
|
||||
filePath: rendered.document.filePath,
|
||||
label: labelFor(rendered),
|
||||
original: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
next: rendered.next,
|
||||
};
|
||||
}
|
||||
|
||||
async function unlinkOptional(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExclusive(filePath: string, contents: string, label: string): Promise<void> {
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await fsp.open(filePath, 'wx', 0o600);
|
||||
await handle.writeFile(contents, 'utf-8');
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
const failures = [asError(error)];
|
||||
if (handle) {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (closeError) {
|
||||
failures.push(asError(closeError));
|
||||
}
|
||||
}
|
||||
try {
|
||||
await unlinkOptional(filePath);
|
||||
} catch (cleanupError) {
|
||||
failures.push(asError(cleanupError));
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, `Stage ${label} at ${filePath}`);
|
||||
}
|
||||
throw new Error(`Stage ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function assertUnchanged(change: FileChange): Promise<void> {
|
||||
const current = await readOptional(change.filePath, change.label);
|
||||
if (current !== change.original) {
|
||||
throw new Error(`${change.label} changed during installation: ${change.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function stage(change: FileChange): Promise<StagedChange> {
|
||||
await assertUnchanged(change);
|
||||
const directory = path.dirname(change.filePath);
|
||||
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const token = randomUUID();
|
||||
const tempPath = change.next === undefined
|
||||
? undefined
|
||||
: path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
|
||||
const rollbackPath = change.original === undefined
|
||||
? undefined
|
||||
: path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`);
|
||||
try {
|
||||
if (tempPath) await writeExclusive(tempPath, change.next!, change.label);
|
||||
if (rollbackPath && change.next !== undefined) {
|
||||
await writeExclusive(rollbackPath, change.original!, `${change.label} rollback`);
|
||||
}
|
||||
} catch (error) {
|
||||
const failures = [asError(error)];
|
||||
for (const stagedPath of [tempPath, rollbackPath]) {
|
||||
if (!stagedPath) continue;
|
||||
try {
|
||||
await unlinkOptional(stagedPath);
|
||||
} catch (cleanupError) {
|
||||
failures.push(asError(cleanupError));
|
||||
}
|
||||
}
|
||||
if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`);
|
||||
throw error;
|
||||
}
|
||||
return { change, tempPath, rollbackPath, committed: false };
|
||||
}
|
||||
|
||||
async function commit(staged: StagedChange): Promise<void> {
|
||||
await assertUnchanged(staged.change);
|
||||
if (staged.change.next === undefined) {
|
||||
if (!staged.rollbackPath) throw new Error(`Missing rollback path for ${staged.change.label}`);
|
||||
await fsp.rename(staged.change.filePath, staged.rollbackPath);
|
||||
} else {
|
||||
if (!staged.tempPath) throw new Error(`Missing staged file for ${staged.change.label}`);
|
||||
await fsp.rename(staged.tempPath, staged.change.filePath);
|
||||
}
|
||||
staged.committed = true;
|
||||
}
|
||||
|
||||
async function rollback(staged: StagedChange): Promise<void> {
|
||||
if (!staged.committed) return;
|
||||
if (staged.change.original === undefined) {
|
||||
await unlinkOptional(staged.change.filePath);
|
||||
return;
|
||||
}
|
||||
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
|
||||
await fsp.rename(staged.rollbackPath, staged.change.filePath);
|
||||
}
|
||||
|
||||
async function cleanup(staged: StagedChange): Promise<void> {
|
||||
const paths = [staged.tempPath, staged.rollbackPath].filter(
|
||||
(filePath): filePath is string => filePath !== undefined,
|
||||
);
|
||||
await Promise.all(paths.map(unlinkOptional));
|
||||
}
|
||||
|
||||
export async function writeDocuments(rendered: RenderedDocument[]): Promise<void> {
|
||||
const changes = rendered.flatMap((item) => {
|
||||
const next = fileChange(item);
|
||||
return next ? [next] : [];
|
||||
});
|
||||
if (changes.length === 0) return;
|
||||
const staged: StagedChange[] = [];
|
||||
try {
|
||||
for (const change of changes) staged.push(await stage(change));
|
||||
for (const item of staged) await commit(item);
|
||||
} catch (error) {
|
||||
const failures = [asError(error)];
|
||||
for (const item of [...staged].reverse()) {
|
||||
try {
|
||||
await rollback(item);
|
||||
} catch (rollbackError) {
|
||||
failures.push(asError(rollbackError));
|
||||
}
|
||||
}
|
||||
for (const item of staged) {
|
||||
try {
|
||||
await cleanup(item);
|
||||
} catch (cleanupError) {
|
||||
failures.push(asError(cleanupError));
|
||||
}
|
||||
}
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'Write Factory Droid hook sources');
|
||||
throw new Error(`Write Factory Droid hook sources: ${errorMessage(error)}`, {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
await Promise.all(staged.map(cleanup));
|
||||
}
|
||||
|
||||
export async function regularFileExists(filePath: string, label: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fsp.stat(filePath)).isFile();
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false;
|
||||
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireRuntime(filePath: string, label: string): Promise<void> {
|
||||
if (!filePath) throw new Error(`${label} path is required`);
|
||||
if (!await regularFileExists(filePath, label)) throw new Error(`${label} is missing: ${filePath}`);
|
||||
}
|
||||
|
||||
async function readRuntimeConfig(filePath: string): Promise<JsonObject | undefined> {
|
||||
const raw = await readOptional(filePath, 'Elydora runtime config');
|
||||
if (raw === undefined) return undefined;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`, {
|
||||
cause: asError(error),
|
||||
});
|
||||
}
|
||||
if (!isObject(value)) throw new Error(`Elydora runtime config at ${filePath} must contain a JSON object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
const agentDirectory = path.dirname(contract.guardPath);
|
||||
const runtimeConfig = await readRuntimeConfig(path.join(agentDirectory, 'config.json'));
|
||||
if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue;
|
||||
const files = await Promise.all([
|
||||
regularFileExists(contract.guardPath, 'Elydora guard runtime'),
|
||||
regularFileExists(contract.auditPath, 'Elydora audit runtime'),
|
||||
]);
|
||||
if (files.every(Boolean)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function displayConfigPath(sources: DroidSources): string {
|
||||
if (sources.primary) return sources.primary.filePath;
|
||||
if (sources.settings.exists && sources.settings.hasHooksContainer) return sources.settings.filePath;
|
||||
return sources.rootPath;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
TOOL_EVENTS,
|
||||
buildGroup,
|
||||
mergeHookSettings,
|
||||
runtimeContracts,
|
||||
type DroidGroup,
|
||||
type ToolEvent,
|
||||
} from './droid-contract.js';
|
||||
import {
|
||||
additionsFor,
|
||||
installationTargets,
|
||||
renderDocument,
|
||||
type DroidDocument,
|
||||
} from './droid-config.js';
|
||||
import {
|
||||
displayConfigPath,
|
||||
readSources,
|
||||
requireRuntime,
|
||||
runtimeFilesExist,
|
||||
writeDocuments,
|
||||
} from './droid-io.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function uniqueDocuments(documents: Array<DroidDocument | undefined>): DroidDocument[] {
|
||||
const unique = new Map<string, DroidDocument>();
|
||||
for (const document of documents) {
|
||||
if (document) unique.set(document.filePath, document);
|
||||
}
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
export const droidPlugin: AgentPlugin = {
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
if (!config.agentId) throw new Error('agentId is required');
|
||||
const sources = await readSources();
|
||||
await requireRuntime(config.guardScriptPath, 'Elydora guard runtime');
|
||||
await requireRuntime(config.hookScriptPath, 'Elydora audit runtime');
|
||||
|
||||
const selected = installationTargets(sources);
|
||||
const groups = new Map<ToolEvent, DroidGroup>([
|
||||
['PreToolUse', buildGroup(config.guardScriptPath)],
|
||||
['PostToolUse', buildGroup(config.hookScriptPath)],
|
||||
]);
|
||||
const documents = uniqueDocuments([
|
||||
sources.primary,
|
||||
sources.settings.hasHooksContainer ? sources.settings : undefined,
|
||||
...TOOL_EVENTS.map((event) => selected.targets.get(event)),
|
||||
]);
|
||||
const rendered = documents.map((document) => renderDocument(
|
||||
document,
|
||||
undefined,
|
||||
additionsFor(document, selected.targets, groups),
|
||||
));
|
||||
await writeDocuments(rendered);
|
||||
const locations = TOOL_EVENTS
|
||||
.map((event) => `${event}: ${selected.targets.get(event)!.filePath}`)
|
||||
.join(', ');
|
||||
console.log(` Factory Droid: ${locations}`);
|
||||
console.log(' Factory Droid: run /hooks to review the Elydora hook changes.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const sources = await readSources();
|
||||
const documents = uniqueDocuments([
|
||||
sources.primary,
|
||||
sources.settings.hasHooksContainer ? sources.settings : undefined,
|
||||
]);
|
||||
const rendered = documents.map((document) => renderDocument(
|
||||
document,
|
||||
agentId,
|
||||
new Map(),
|
||||
));
|
||||
await writeDocuments(rendered);
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const sources = await readSources();
|
||||
const effective = mergeHookSettings(sources.primary?.hooks, sources.settings.hooks);
|
||||
const contracts = runtimeContracts(effective);
|
||||
const hookConfigured = effective.hooksDisabled !== true && contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured ? await runtimeFilesExist(contracts) : false;
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath: displayConfigPath(sources),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map
|
||||
['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }],
|
||||
['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }],
|
||||
['cline', { name: 'Cline', configDir: '~/.cline/hooks', configFile: 'PreToolUse.mjs' }],
|
||||
['droid', { name: 'Factory Droid', configDir: '~/.factory', configFile: 'hooks.json' }],
|
||||
['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }],
|
||||
['grok', { name: 'Grok Build', configDir: '~/.grok/hooks', configFile: 'elydora-audit.json' }],
|
||||
]);
|
||||
@@ -0,0 +1,475 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { parse } from 'jsonc-parser';
|
||||
|
||||
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/droid.js')).href;
|
||||
const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
|
||||
const cliPath = path.resolve('dist/cli.js');
|
||||
|
||||
function runNode(args, env, input = '', cwd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
function runCommand(command, input = '') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
function parseJsonc(raw) {
|
||||
const errors = [];
|
||||
const value = parse(raw, errors, { allowTrailingComma: true });
|
||||
assert.deepEqual(errors, []);
|
||||
return value;
|
||||
}
|
||||
|
||||
function serialize(value) {
|
||||
return typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
async function writeConfig(filePath, value) {
|
||||
if (value === undefined) return;
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, serialize(value));
|
||||
}
|
||||
|
||||
async function runPlugin(fixture, method, argument) {
|
||||
const script = `
|
||||
import { droidPlugin } from ${JSON.stringify(pluginModuleUrl)};
|
||||
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
|
||||
const result = await droidPlugin[process.env.ELYDORA_TEST_METHOD](argument);
|
||||
if (result !== undefined) console.log(JSON.stringify(result));
|
||||
`;
|
||||
return runNode(
|
||||
['--input-type=module', '--eval', script],
|
||||
{
|
||||
HOME: fixture.homeDir,
|
||||
USERPROFILE: fixture.homeDir,
|
||||
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
|
||||
ELYDORA_TEST_METHOD: method,
|
||||
},
|
||||
'',
|
||||
fixture.workspaceDir,
|
||||
);
|
||||
}
|
||||
|
||||
async function createFixture({ hooks, legacyHooks, settings, guardSource, hookSource } = {}) {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-droid-'));
|
||||
const homeDir = path.join(rootDir, "home with spaces and 'quote");
|
||||
const workspaceDir = path.join(homeDir, 'workspace');
|
||||
const factoryDir = path.join(homeDir, '.factory');
|
||||
const configPath = path.join(factoryDir, 'hooks.json');
|
||||
const legacyConfigPath = path.join(factoryDir, 'hooks', 'hooks.json');
|
||||
const settingsPath = path.join(factoryDir, 'settings.json');
|
||||
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
|
||||
const guardScriptPath = path.join(agentDir, 'guard.js');
|
||||
const hookScriptPath = path.join(agentDir, 'hook.js');
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(
|
||||
guardScriptPath,
|
||||
guardSource ?? "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n",
|
||||
);
|
||||
await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n');
|
||||
await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({
|
||||
agent_id: 'agent-1',
|
||||
agent_name: 'droid',
|
||||
}));
|
||||
await writeConfig(configPath, hooks);
|
||||
await writeConfig(legacyConfigPath, legacyHooks);
|
||||
await writeConfig(settingsPath, settings);
|
||||
const fixture = {
|
||||
agentDir,
|
||||
configPath,
|
||||
factoryDir,
|
||||
guardScriptPath,
|
||||
homeDir,
|
||||
hookScriptPath,
|
||||
legacyConfigPath,
|
||||
settingsPath,
|
||||
workspaceDir,
|
||||
async close() { await rm(rootDir, { recursive: true, force: true }); },
|
||||
};
|
||||
fixture.installResult = await runPlugin(fixture, 'install', {
|
||||
agentName: 'droid',
|
||||
agentId: 'agent-1',
|
||||
guardScriptPath,
|
||||
hookScriptPath,
|
||||
});
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function managedHandler(groups, scriptPath) {
|
||||
for (const group of groups ?? []) {
|
||||
const handler = group.hooks.find(
|
||||
(candidate) => candidate.command?.includes(scriptPath),
|
||||
);
|
||||
if (handler) return handler;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
test('Factory Droid is registered in the SDK and CLI', async () => {
|
||||
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
||||
assert.deepEqual(SUPPORTED_AGENTS.get('droid'), {
|
||||
name: 'Factory Droid',
|
||||
configDir: '~/.factory',
|
||||
configFile: 'hooks.json',
|
||||
});
|
||||
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-droid-cli-'));
|
||||
const workspaceDir = path.join(homeDir, 'workspace');
|
||||
await mkdir(workspaceDir);
|
||||
try {
|
||||
const result = await runNode(['--no-warnings', cliPath, 'status'], {
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
}, '', workspaceDir);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.match(result.stdout, /Factory Droid \(droid\)/);
|
||||
} finally {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Droid install preserves JSONC and follows per-event source precedence', async () => {
|
||||
const hooks = `{
|
||||
// root hook source
|
||||
"PreToolUse": [
|
||||
// keep root group comment
|
||||
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }
|
||||
],
|
||||
"Notification": []
|
||||
}\n`;
|
||||
const settings = `{
|
||||
// general setting
|
||||
"theme": "dark",
|
||||
"hooks": {
|
||||
// settings fallback event
|
||||
"PostToolUse": [
|
||||
// keep settings group comment
|
||||
{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }
|
||||
],
|
||||
"showHookOutput": true,
|
||||
},
|
||||
}\n`;
|
||||
const fixture = await createFixture({ hooks, settings });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
const second = await runPlugin(fixture, 'install', {
|
||||
agentName: 'droid',
|
||||
agentId: 'agent-1',
|
||||
guardScriptPath: fixture.guardScriptPath,
|
||||
hookScriptPath: fixture.hookScriptPath,
|
||||
});
|
||||
assert.equal(second.code, 0, second.stderr);
|
||||
const hooksRaw = await readFile(fixture.configPath, 'utf-8');
|
||||
const settingsRaw = await readFile(fixture.settingsPath, 'utf-8');
|
||||
const root = parseJsonc(hooksRaw);
|
||||
const userSettings = parseJsonc(settingsRaw);
|
||||
assert.match(hooksRaw, /root hook source/);
|
||||
assert.match(hooksRaw, /keep root group comment/);
|
||||
assert.match(settingsRaw, /general setting/);
|
||||
assert.match(settingsRaw, /settings fallback event/);
|
||||
assert.match(settingsRaw, /keep settings group comment/);
|
||||
assert.equal(root.PreToolUse.length, 2);
|
||||
assert.equal(root.PreToolUse[0].hooks[0].command, 'root-user');
|
||||
assert.equal(root.PostToolUse, undefined);
|
||||
assert.equal(userSettings.theme, 'dark');
|
||||
assert.equal(userSettings.hooks.PostToolUse.length, 2);
|
||||
assert.equal(userSettings.hooks.PostToolUse[0].hooks[0].command, 'settings-user');
|
||||
assert.equal(userSettings.hooks.PreToolUse, undefined);
|
||||
assert.equal(userSettings.hooks.showHookOutput, true);
|
||||
for (const [groups, scriptPath] of [
|
||||
[root.PreToolUse, fixture.guardScriptPath],
|
||||
[userSettings.hooks.PostToolUse, fixture.hookScriptPath],
|
||||
]) {
|
||||
const handler = managedHandler(groups, scriptPath);
|
||||
assert.deepEqual(Object.keys(handler).sort(), ['command', 'timeout', 'type']);
|
||||
assert.equal(handler.timeout, 10);
|
||||
assert.equal(handler.type, 'command');
|
||||
}
|
||||
await assert.rejects(
|
||||
readFile(path.join(fixture.workspaceDir, '.factory', 'hooks.json')),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Droid keeps an active legacy source until Factory migrates it', async () => {
|
||||
const legacyHooks = {
|
||||
PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'legacy-user' }] }],
|
||||
};
|
||||
const settings = {
|
||||
hooks: {
|
||||
PostToolUse: [{ matcher: 'Edit', hooks: [{ type: 'command', command: 'settings-user' }] }],
|
||||
},
|
||||
};
|
||||
const fixture = await createFixture({ legacyHooks, settings });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
|
||||
const legacy = parseJsonc(await readFile(fixture.legacyConfigPath, 'utf-8'));
|
||||
const currentSettings = parseJsonc(await readFile(fixture.settingsPath, 'utf-8'));
|
||||
assert(managedHandler(legacy.PreToolUse, fixture.guardScriptPath));
|
||||
assert(managedHandler(currentSettings.hooks.PostToolUse, fixture.hookScriptPath));
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Droid reuses an existing settings hook container', async () => {
|
||||
const settingsSource = '{\r\n\t"owner": "user",\r\n\t"hooks": {}\r\n}\r\n';
|
||||
const fixture = await createFixture({ settings: settingsSource });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
|
||||
const settingsRaw = await readFile(fixture.settingsPath, 'utf-8');
|
||||
const settings = parseJsonc(settingsRaw);
|
||||
assert.match(settingsRaw, /\r\n\t\t"PreToolUse"/);
|
||||
assert.match(settingsRaw, /\r\n\t\t"PostToolUse"/);
|
||||
assert.equal(settings.owner, 'user');
|
||||
assert(managedHandler(settings.hooks.PreToolUse, fixture.guardScriptPath));
|
||||
assert(managedHandler(settings.hooks.PostToolUse, fixture.hookScriptPath));
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Droid commands block freezes and forward official input byte-for-byte', async () => {
|
||||
const capturePath = path.join(os.tmpdir(), `elydora-droid-event-${process.pid}-${Date.now()}.json`);
|
||||
const hookSource = `
|
||||
const fs = require('node:fs');
|
||||
const chunks = [];
|
||||
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
||||
process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks)));
|
||||
`;
|
||||
const fixture = await createFixture({ hookSource });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
const root = parseJsonc(await readFile(fixture.configPath, 'utf-8'));
|
||||
const guard = managedHandler(root.PreToolUse, fixture.guardScriptPath);
|
||||
const audit = managedHandler(root.PostToolUse, fixture.hookScriptPath);
|
||||
const prePayload = JSON.stringify({
|
||||
session_id: 'session-1',
|
||||
transcript_path: path.join(fixture.homeDir, 'transcript.jsonl'),
|
||||
cwd: fixture.workspaceDir,
|
||||
permission_mode: 'auto-high',
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Execute',
|
||||
tool_input: { command: 'echo test' },
|
||||
});
|
||||
const guardResult = await runCommand(guard.command, prePayload);
|
||||
assert.equal(guardResult.code, 2);
|
||||
assert.match(guardResult.stderr, /Agent is frozen by Elydora/);
|
||||
const postPayload = JSON.stringify({
|
||||
...JSON.parse(prePayload),
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_response: { output: 'test', success: true },
|
||||
});
|
||||
const auditResult = await runCommand(audit.command, postPayload);
|
||||
assert.equal(auditResult.code, 0, auditResult.stderr);
|
||||
assert.equal(await readFile(capturePath, 'utf-8'), postPayload);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
await rm(capturePath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Droid status requires an effective pair and both runtime files', async () => {
|
||||
const fixture = await createFixture({
|
||||
hooks: { PreToolUse: [] },
|
||||
settings: { hooks: { PostToolUse: [] } },
|
||||
});
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
let status = await runPlugin(fixture, 'status', null);
|
||||
assert.equal(status.code, 0, status.stderr);
|
||||
assert.equal(JSON.parse(status.stdout).installed, true);
|
||||
await rm(fixture.hookScriptPath);
|
||||
status = await runPlugin(fixture, 'status', null);
|
||||
assert.equal(JSON.parse(status.stdout).installed, false);
|
||||
const root = parseJsonc(await readFile(fixture.configPath, 'utf-8'));
|
||||
root.hooksDisabled = true;
|
||||
await writeFile(fixture.configPath, `${JSON.stringify(root, null, 2)}\n`);
|
||||
status = await runPlugin(fixture, 'status', null);
|
||||
assert.equal(JSON.parse(status.stdout).hookConfigured, false);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Droid uninstall removes exact ownership and preserves user sources', async () => {
|
||||
const hooks = `{
|
||||
// keep root comment
|
||||
"PreToolUse": [{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }]
|
||||
}\n`;
|
||||
const settings = `{
|
||||
"theme": "dark",
|
||||
"hooks": {
|
||||
// keep settings comment
|
||||
"PostToolUse": [{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }]
|
||||
}
|
||||
}\n`;
|
||||
const fixture = await createFixture({ hooks, settings });
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
const uninstallId = process.platform === 'win32' ? 'AGENT-1' : 'agent-1';
|
||||
const result = await runPlugin(fixture, 'uninstall', uninstallId);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
const hooksRaw = await readFile(fixture.configPath, 'utf-8');
|
||||
const settingsRaw = await readFile(fixture.settingsPath, 'utf-8');
|
||||
const root = parseJsonc(hooksRaw);
|
||||
const currentSettings = parseJsonc(settingsRaw);
|
||||
assert.match(hooksRaw, /keep root comment/);
|
||||
assert.match(settingsRaw, /keep settings comment/);
|
||||
assert.deepEqual(root.PreToolUse, [{
|
||||
matcher: 'Read',
|
||||
hooks: [{ type: 'command', command: 'root-user' }],
|
||||
}]);
|
||||
assert.deepEqual(currentSettings.hooks.PostToolUse, [{
|
||||
matcher: 'Edit',
|
||||
hooks: [{ type: 'command', command: 'settings-user' }],
|
||||
}]);
|
||||
assert.equal(currentSettings.theme, 'dark');
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Droid uninstall deletes only a hooks file marked as Elydora-owned', async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
assert.match(await readFile(fixture.configPath, 'utf-8'), /Managed by Elydora/);
|
||||
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Droid uninstall preserves mixed groups and lookalike commands', async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
const root = parseJsonc(await readFile(fixture.configPath, 'utf-8'));
|
||||
const managedGroup = root.PreToolUse.find(
|
||||
(group) => managedHandler([group], fixture.guardScriptPath),
|
||||
);
|
||||
const command = managedGroup.hooks[0].command;
|
||||
managedGroup.hooks.push({ type: 'command', command: 'user-command' });
|
||||
root.PreToolUse.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: command.replace('guard.js', 'guard.js.backup'), timeout: 10 }],
|
||||
});
|
||||
root.PreToolUse.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: command.replace('agent-1', 'agent-10'), timeout: 10 }],
|
||||
});
|
||||
await writeFile(fixture.configPath, `${JSON.stringify(root, null, 2)}\n`);
|
||||
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
const remaining = parseJsonc(await readFile(fixture.configPath, 'utf-8'));
|
||||
assert(remaining.PreToolUse.some(
|
||||
(group) => group.hooks.some((handler) => handler.command === 'user-command'),
|
||||
));
|
||||
assert.match(JSON.stringify(remaining), /guard\.js\.backup/);
|
||||
assert.match(JSON.stringify(remaining), /agent-10/);
|
||||
assert.equal(remaining.PostToolUse, undefined);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Droid preserves every malformed source before the first write', async () => {
|
||||
const cases = [
|
||||
{ hooks: '{ malformed', target: 'configPath' },
|
||||
{ hooks: '[]', target: 'configPath' },
|
||||
{ hooks: '{ "PreToolUse": [], "PreToolUse": [] }', target: 'configPath' },
|
||||
{ hooks: { PreToolUse: null }, target: 'configPath' },
|
||||
{ hooks: { PreToolUse: [null] }, target: 'configPath' },
|
||||
{ hooks: { PreToolUse: [{ matcher: '[', hooks: [] }] }, target: 'configPath' },
|
||||
{ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 1 }] }] }, target: 'configPath' },
|
||||
{ legacyHooks: '{ malformed', target: 'legacyConfigPath' },
|
||||
{ hooks: { PreToolUse: [] }, settings: '{ malformed', target: 'settingsPath' },
|
||||
{ settings: { hooks: null }, target: 'settingsPath' },
|
||||
];
|
||||
for (const input of cases) {
|
||||
const fixture = await createFixture(input);
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 1, fixture.installResult.stderr);
|
||||
assert.equal(
|
||||
await readFile(fixture[input.target], 'utf-8'),
|
||||
serialize(input[input.target === 'legacyConfigPath' ? 'legacyHooks'
|
||||
: input.target === 'settingsPath' ? 'settings' : 'hooks']),
|
||||
);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Droid rejects missing runtimes before creating a hook source', async () => {
|
||||
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-droid-missing-'));
|
||||
const workspaceDir = path.join(homeDir, 'workspace');
|
||||
const fixture = { homeDir, workspaceDir };
|
||||
await mkdir(workspaceDir);
|
||||
try {
|
||||
const result = await runPlugin(fixture, 'install', {
|
||||
agentName: 'droid',
|
||||
agentId: 'agent-1',
|
||||
guardScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'guard.js'),
|
||||
hookScriptPath: path.join(homeDir, '.elydora', 'agent-1', 'hook.js'),
|
||||
});
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /runtime is missing/i);
|
||||
await assert.rejects(
|
||||
readFile(path.join(homeDir, '.factory', 'hooks.json')),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
} finally {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Droid atomic transactions leave no staging files', async () => {
|
||||
const fixture = await createFixture({
|
||||
hooks: { PreToolUse: [] },
|
||||
settings: { hooks: { PostToolUse: [] } },
|
||||
});
|
||||
try {
|
||||
assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr);
|
||||
const names = await readdir(fixture.factoryDir, { recursive: true });
|
||||
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user