@pragma-sh/automations 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/package.json +45 -0
- package/src/cli.ts +315 -0
- package/src/find.ts +168 -0
- package/src/index.ts +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @pragma-sh/automations
|
|
2
|
+
|
|
3
|
+
Write a Pragma automation. Part of [Pragma](https://github.com/pragma-sh/pragma) — a desktop workspace for
|
|
4
|
+
running persistent, worktree-scoped coding agents.
|
|
5
|
+
|
|
6
|
+
Authoring API for trusted TypeScript tasks a Pragma host runs on a cron
|
|
7
|
+
schedule, on a workspace event, or on demand.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun add @pragma-sh/automations
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { defineAutomation } from "@pragma-sh/automations";
|
|
15
|
+
|
|
16
|
+
export default defineAutomation({
|
|
17
|
+
name: "Nightly cleanup",
|
|
18
|
+
trigger: { kind: "cron", expression: "0 3 * * *" },
|
|
19
|
+
async run({ client }) {
|
|
20
|
+
/* … */
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Docs: <https://pragma-app.sh/docs/automations>
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
AGPL-3.0-only. See [LICENSE](https://github.com/pragma-sh/pragma/blob/main/LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pragma-sh/automations",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pragma automation authoring API and host sidecar runtime.",
|
|
5
|
+
"homepage": "https://github.com/pragma-sh/pragma#readme",
|
|
6
|
+
"bugs": "https://github.com/pragma-sh/pragma/issues",
|
|
7
|
+
"license": "AGPL-3.0-only",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/pragma-sh/pragma.git",
|
|
11
|
+
"directory": "packages/automations"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"pragma-automations": "src/cli.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"!src/**/*.test.ts"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "./src/index.ts",
|
|
22
|
+
"module": "./src/index.ts",
|
|
23
|
+
"types": "./src/index.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.ts",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "bun --bun vitest run",
|
|
34
|
+
"lint": "oxlint .",
|
|
35
|
+
"build:sidecar": "bun build src/cli.ts --compile --minify --outfile dist/pragma-automations"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@pragma-sh/sidecar-kit": "^0.1.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^24.10.1",
|
|
42
|
+
"typescript": "^6.0.3",
|
|
43
|
+
"vitest": "^4.1.8"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { builtinModules } from "node:module";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { readStdinLines } from "@pragma-sh/sidecar-kit";
|
|
7
|
+
|
|
8
|
+
import { findFiles } from "./find.ts";
|
|
9
|
+
import { defineAutomation, type AutomationContext, type AutomationDefinition } from "./index.ts";
|
|
10
|
+
|
|
11
|
+
declare const Bun: {
|
|
12
|
+
spawn(
|
|
13
|
+
command: string[],
|
|
14
|
+
options: { cwd: string; stdout: "pipe"; stderr: "pipe" },
|
|
15
|
+
): {
|
|
16
|
+
exited: Promise<number>;
|
|
17
|
+
stderr: ReadableStream<Uint8Array>;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type Scope = "global" | "local";
|
|
22
|
+
|
|
23
|
+
interface LoadCommand {
|
|
24
|
+
type: "load";
|
|
25
|
+
id: string;
|
|
26
|
+
path: string;
|
|
27
|
+
sourceVersion?: string;
|
|
28
|
+
root: string;
|
|
29
|
+
scope: Scope;
|
|
30
|
+
projectId?: string | null;
|
|
31
|
+
worktreeId?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface UnloadCommand {
|
|
35
|
+
type: "unload" | "runNow" | "reload";
|
|
36
|
+
id: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Command = LoadCommand | UnloadCommand;
|
|
40
|
+
|
|
41
|
+
interface LoadedAutomation {
|
|
42
|
+
command: LoadCommand;
|
|
43
|
+
definition: AutomationDefinition;
|
|
44
|
+
dispose?: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const loaded = new Map<string, LoadedAutomation>();
|
|
48
|
+
const builtins = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)]);
|
|
49
|
+
|
|
50
|
+
function emit(event: Record<string, unknown>): void {
|
|
51
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function emitError(id: string | undefined, error: unknown): void {
|
|
55
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
56
|
+
emit({ type: "error", id, error: message });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cacheDir(): string {
|
|
60
|
+
if (process.env.PRAGMA_AUTOMATIONS_CACHE) return process.env.PRAGMA_AUTOMATIONS_CACHE;
|
|
61
|
+
const home = process.env.HOME ?? process.cwd();
|
|
62
|
+
return join(home, ".pragma", "automation-cache");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function packageName(specifier: string): string {
|
|
66
|
+
if (specifier.startsWith("@")) return specifier.split("/").slice(0, 2).join("/");
|
|
67
|
+
return specifier.split("/")[0] ?? specifier;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const IMPORT_PATTERNS = [
|
|
71
|
+
/import\s+(?:[^"']+\s+from\s+)?["']([^"']+)["']/g,
|
|
72
|
+
/export\s+[^"']+\s+from\s+["']([^"']+)["']/g,
|
|
73
|
+
/import\(\s*["']([^"']+)["']\s*\)/g,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function isExternalSpecifier(specifier: string | undefined): specifier is string {
|
|
77
|
+
if (!specifier || specifier === "@pragma-sh/automations") return false;
|
|
78
|
+
if (/^[./]/.test(specifier)) return false;
|
|
79
|
+
return !builtins.has(specifier);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function bareImports(source: string): string[] {
|
|
83
|
+
const imports = new Set<string>();
|
|
84
|
+
for (const pattern of IMPORT_PATTERNS) {
|
|
85
|
+
for (const match of source.matchAll(pattern)) {
|
|
86
|
+
if (isExternalSpecifier(match[1])) imports.add(packageName(match[1]));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [...imports];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function ensureRuntimePackage(root: string): Promise<void> {
|
|
93
|
+
const pkgRoot = join(root, "node_modules", "@pragma", "automations");
|
|
94
|
+
await mkdir(pkgRoot, { recursive: true });
|
|
95
|
+
await writeFile(
|
|
96
|
+
join(pkgRoot, "package.json"),
|
|
97
|
+
JSON.stringify(
|
|
98
|
+
{ name: "@pragma-sh/automations", type: "module", exports: { ".": "./index.ts" } },
|
|
99
|
+
null,
|
|
100
|
+
2,
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
await writeFile(
|
|
104
|
+
join(pkgRoot, "index.ts"),
|
|
105
|
+
`export { defineAutomation };\n${defineAutomation.toString()}\n`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function ensurePackages(root: string, specifiers: string[]): Promise<void> {
|
|
110
|
+
await mkdir(root, { recursive: true });
|
|
111
|
+
await ensureRuntimePackage(root);
|
|
112
|
+
await writeFile(
|
|
113
|
+
join(root, "package.json"),
|
|
114
|
+
JSON.stringify({ type: "module", dependencies: {} }, null, 2),
|
|
115
|
+
);
|
|
116
|
+
const missing: string[] = [];
|
|
117
|
+
const checks = await Promise.all(
|
|
118
|
+
specifiers.map(async (specifier) => {
|
|
119
|
+
try {
|
|
120
|
+
await stat(join(root, "node_modules", specifier));
|
|
121
|
+
return null;
|
|
122
|
+
} catch {
|
|
123
|
+
return specifier;
|
|
124
|
+
}
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
missing.push(...checks.filter((specifier): specifier is string => specifier !== null));
|
|
128
|
+
if (missing.length === 0) return;
|
|
129
|
+
const proc = Bun.spawn(["bun", "add", ...missing], {
|
|
130
|
+
cwd: root,
|
|
131
|
+
stdout: "pipe",
|
|
132
|
+
stderr: "pipe",
|
|
133
|
+
});
|
|
134
|
+
const code = await proc.exited;
|
|
135
|
+
if (code !== 0) {
|
|
136
|
+
const stderr = await new Response(proc.stderr).text();
|
|
137
|
+
throw new Error(`install failed for ${missing.join(", ")}: ${stderr.trim()}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hasValidTrigger(definition: AutomationDefinition): boolean {
|
|
142
|
+
const trigger = definition.trigger;
|
|
143
|
+
return Boolean(trigger) && (trigger.type === "cron" || trigger.type === "event");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Returns the first failing validation message for a definition, or null when valid. */
|
|
147
|
+
function automationProblem(definition: AutomationDefinition): string | null {
|
|
148
|
+
const checks: Array<[boolean, string]> = [
|
|
149
|
+
[definition.pragmaAutomation === true, "default export must use defineAutomation"],
|
|
150
|
+
[Boolean(definition.name.trim()), "automation name is required"],
|
|
151
|
+
[Boolean(definition.description.trim()), "automation description is required"],
|
|
152
|
+
[hasValidTrigger(definition), "automation trigger must be cron or event"],
|
|
153
|
+
[typeof definition.run === "function", "automation run must be a function"],
|
|
154
|
+
];
|
|
155
|
+
return checks.find(([ok]) => !ok)?.[1] ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validateAutomation(value: unknown): AutomationDefinition {
|
|
159
|
+
if (!value || typeof value !== "object") throw new Error("default export is not an automation");
|
|
160
|
+
const definition = value as AutomationDefinition;
|
|
161
|
+
const problem = automationProblem(definition);
|
|
162
|
+
if (problem) throw new Error(problem);
|
|
163
|
+
return definition;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function copyEntry(command: LoadCommand, source: string, root: string): Promise<string> {
|
|
167
|
+
const entryRoot = join(root, "entries", command.id, command.sourceVersion ?? "current");
|
|
168
|
+
await mkdir(entryRoot, { recursive: true });
|
|
169
|
+
const extension = command.path.endsWith(".js") ? "js" : "ts";
|
|
170
|
+
const rewritten = source.replaceAll(
|
|
171
|
+
"@pragma-sh/automations",
|
|
172
|
+
pathToFileURL(join(root, "node_modules", "@pragma", "automations", "index.ts")).href,
|
|
173
|
+
);
|
|
174
|
+
const entry = join(entryRoot, `automation.${extension}`);
|
|
175
|
+
await writeFile(entry, rewritten);
|
|
176
|
+
return entry;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function contextFor(command: LoadCommand): AutomationContext {
|
|
180
|
+
const log = (level: "info" | "warn" | "error") => (message: string, data?: unknown) => {
|
|
181
|
+
emit({ type: "log", id: command.id, level, message, data });
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
log: {
|
|
185
|
+
info: log("info"),
|
|
186
|
+
warn: log("warn"),
|
|
187
|
+
error: log("error"),
|
|
188
|
+
},
|
|
189
|
+
paths: {
|
|
190
|
+
project: command.root,
|
|
191
|
+
worktree: command.root,
|
|
192
|
+
global: command.scope === "global",
|
|
193
|
+
},
|
|
194
|
+
fs: {
|
|
195
|
+
find: (path, options) => findFiles(command.root, path, options),
|
|
196
|
+
},
|
|
197
|
+
git: {},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function runAutomation(id: string, payload?: unknown): Promise<void> {
|
|
202
|
+
const automation = loaded.get(id);
|
|
203
|
+
if (!automation) throw new Error(`automation not loaded: ${id}`);
|
|
204
|
+
const ctx = contextFor(automation.command);
|
|
205
|
+
emit({ type: "status", id, status: "running" });
|
|
206
|
+
try {
|
|
207
|
+
await automation.definition.run(ctx, payload);
|
|
208
|
+
emit({ type: "status", id, status: "idle" });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
emitError(id, error);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function unload(id: string): Promise<void> {
|
|
215
|
+
const automation = loaded.get(id);
|
|
216
|
+
if (!automation) return;
|
|
217
|
+
automation.dispose?.();
|
|
218
|
+
loaded.delete(id);
|
|
219
|
+
emit({ type: "unloaded", id });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function load(command: LoadCommand): Promise<void> {
|
|
223
|
+
await unload(command.id);
|
|
224
|
+
const source = await readFile(command.path, "utf8");
|
|
225
|
+
const root = cacheDir();
|
|
226
|
+
await ensurePackages(root, bareImports(source));
|
|
227
|
+
const entry = await copyEntry(command, source, root);
|
|
228
|
+
const imported = (await import(`${pathToFileURL(entry).href}?v=${Date.now()}`)) as {
|
|
229
|
+
default?: unknown;
|
|
230
|
+
};
|
|
231
|
+
const definition = validateAutomation(imported.default);
|
|
232
|
+
const loadedAutomation: LoadedAutomation = { command, definition };
|
|
233
|
+
loaded.set(command.id, loadedAutomation);
|
|
234
|
+
emit({
|
|
235
|
+
type: "loaded",
|
|
236
|
+
id: command.id,
|
|
237
|
+
name: definition.name,
|
|
238
|
+
description: definition.description,
|
|
239
|
+
triggerKind: definition.trigger.type,
|
|
240
|
+
schedule: definition.trigger.type === "cron" ? definition.trigger.schedule : null,
|
|
241
|
+
});
|
|
242
|
+
if (definition.trigger.type === "event") {
|
|
243
|
+
const ctx = contextFor(command);
|
|
244
|
+
const dispose = await definition.trigger.listen(ctx, (payload?: unknown) => {
|
|
245
|
+
void runAutomation(command.id, payload);
|
|
246
|
+
});
|
|
247
|
+
if (typeof dispose === "function") loadedAutomation.dispose = dispose;
|
|
248
|
+
}
|
|
249
|
+
emit({ type: "status", id: command.id, status: "idle" });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function reload(id: string): Promise<void> {
|
|
253
|
+
const existing = loaded.get(id);
|
|
254
|
+
if (!existing) throw new Error(`automation not loaded: ${id}`);
|
|
255
|
+
await load(existing.command);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function handle(command: Command): Promise<void> {
|
|
259
|
+
switch (command.type) {
|
|
260
|
+
case "load":
|
|
261
|
+
return load(command);
|
|
262
|
+
case "unload":
|
|
263
|
+
return unload(command.id);
|
|
264
|
+
case "reload":
|
|
265
|
+
return reload(command.id);
|
|
266
|
+
default:
|
|
267
|
+
return runAutomation(command.id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
class StdinLines {
|
|
272
|
+
constructor() {
|
|
273
|
+
readStdinLines(
|
|
274
|
+
(line) => void this.dispatch(line),
|
|
275
|
+
() => this.shutdown(),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private shutdown(): void {
|
|
280
|
+
// The server owns this sidecar through its stdin pipe. Parent death closes
|
|
281
|
+
// the pipe even after SIGKILL, so release automation-created listeners and
|
|
282
|
+
// timers before forcing the Bun process to leave no orphan behind.
|
|
283
|
+
for (const automation of loaded.values()) {
|
|
284
|
+
try {
|
|
285
|
+
automation.dispose?.();
|
|
286
|
+
} catch {
|
|
287
|
+
// stdout commonly closes with stdin; cleanup remains best-effort and
|
|
288
|
+
// must not turn parent death into another uncaught-error write.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
loaded.clear();
|
|
292
|
+
process.exit(0);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async dispatch(line: string): Promise<void> {
|
|
296
|
+
let command: Command | undefined;
|
|
297
|
+
try {
|
|
298
|
+
command = JSON.parse(line) as Command;
|
|
299
|
+
await handle(command);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
emitError(command?.id, error);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Automation callbacks (e.g. timers inside event listeners) can reject outside
|
|
307
|
+
// runAutomation's try/catch; report instead of letting the sidecar die.
|
|
308
|
+
process.on("unhandledRejection", (error) => emitError(undefined, error));
|
|
309
|
+
process.on("uncaughtException", (error) => emitError(undefined, error));
|
|
310
|
+
|
|
311
|
+
await mkdir(dirname(cacheDir()), { recursive: true });
|
|
312
|
+
const stdinLines = new StdinLines();
|
|
313
|
+
void stdinLines;
|
|
314
|
+
emit({ type: "ready" });
|
|
315
|
+
process.stdin.resume();
|
package/src/find.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Options accepted by `ctx.fs.find`. */
|
|
6
|
+
export interface FindOptions {
|
|
7
|
+
name?: string;
|
|
8
|
+
minBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Missing or unreadable paths (ENOENT, EPERM, …) are skipped, not fatal —
|
|
12
|
+
// a watcher polling for a file that does not exist yet must not throw.
|
|
13
|
+
async function statSafe(path: string): Promise<Awaited<ReturnType<typeof stat>> | null> {
|
|
14
|
+
try {
|
|
15
|
+
return await stat(path);
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readdirSafe(path: string): Promise<Dirent[]> {
|
|
22
|
+
try {
|
|
23
|
+
return await readdir(path, { withFileTypes: true });
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Directories a project-scoped search must never descend into. They are
|
|
31
|
+
* machine-generated, enormous, and never what an automation is watching for —
|
|
32
|
+
* `.pragma` in particular holds `worktrees/`, which multiplies the whole tree
|
|
33
|
+
* by the number of worktrees.
|
|
34
|
+
*/
|
|
35
|
+
const FIND_SKIP_DIRS = new Set([
|
|
36
|
+
".git",
|
|
37
|
+
".pragma",
|
|
38
|
+
"node_modules",
|
|
39
|
+
"target",
|
|
40
|
+
"dist",
|
|
41
|
+
"build",
|
|
42
|
+
".next",
|
|
43
|
+
".turbo",
|
|
44
|
+
".cache",
|
|
45
|
+
".venv",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Directories walked at once. The walk used to recurse with an unbounded
|
|
50
|
+
* `Promise.all` per directory, so a large tree opened thousands of concurrent
|
|
51
|
+
* handles and the sidecar died with `EMFILE: too many open files`.
|
|
52
|
+
*/
|
|
53
|
+
const FIND_CONCURRENCY = 8;
|
|
54
|
+
|
|
55
|
+
/** Depth cap, so a pathological tree cannot turn a poll into an endless walk. */
|
|
56
|
+
const FIND_MAX_DEPTH = 12;
|
|
57
|
+
|
|
58
|
+
function fileMatches(path: string, size: number | bigint, options: FindOptions): boolean {
|
|
59
|
+
if (options.name !== undefined && basename(path) !== options.name) return false;
|
|
60
|
+
return options.minBytes === undefined || size >= options.minBytes;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when `candidate` stays inside `root`. */
|
|
64
|
+
function within(root: string, candidate: string): boolean {
|
|
65
|
+
const rel = relative(resolve(root), resolve(candidate));
|
|
66
|
+
return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface FindWaveResult {
|
|
70
|
+
files: string[];
|
|
71
|
+
directories: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function inspectDirectory(path: string, entry: Dirent): FindWaveResult {
|
|
75
|
+
return {
|
|
76
|
+
files: [],
|
|
77
|
+
directories: FIND_SKIP_DIRS.has(entry.name) ? [] : [path],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function inspectFile(
|
|
82
|
+
root: string,
|
|
83
|
+
path: string,
|
|
84
|
+
entry: Dirent,
|
|
85
|
+
options: FindOptions,
|
|
86
|
+
): Promise<FindWaveResult> {
|
|
87
|
+
if (!entry.isFile() || (options.name !== undefined && entry.name !== options.name)) {
|
|
88
|
+
return { files: [], directories: [] };
|
|
89
|
+
}
|
|
90
|
+
if (options.minBytes === undefined) {
|
|
91
|
+
return { files: [relative(root, path)], directories: [] };
|
|
92
|
+
}
|
|
93
|
+
const info = await statSafe(path);
|
|
94
|
+
return {
|
|
95
|
+
files: info && fileMatches(path, info.size, options) ? [relative(root, path)] : [],
|
|
96
|
+
directories: [],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function inspectEntry(
|
|
101
|
+
root: string,
|
|
102
|
+
directory: string,
|
|
103
|
+
entry: Dirent,
|
|
104
|
+
options: FindOptions,
|
|
105
|
+
): Promise<FindWaveResult> {
|
|
106
|
+
const path = join(directory, entry.name);
|
|
107
|
+
if (entry.isSymbolicLink()) return { files: [], directories: [] };
|
|
108
|
+
return entry.isDirectory()
|
|
109
|
+
? inspectDirectory(path, entry)
|
|
110
|
+
: inspectFile(root, path, entry, options);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function inspectWave(
|
|
114
|
+
root: string,
|
|
115
|
+
wave: string[],
|
|
116
|
+
options: FindOptions,
|
|
117
|
+
): Promise<FindWaveResult> {
|
|
118
|
+
const result: FindWaveResult = { files: [], directories: [] };
|
|
119
|
+
const listings = await Promise.all(wave.map((directory) => readdirSafe(directory)));
|
|
120
|
+
for (const [index, entries] of listings.entries()) {
|
|
121
|
+
const directory = wave[index];
|
|
122
|
+
if (directory === undefined) continue;
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
// Serial stats keep descriptor use flat even inside a concurrent directory wave.
|
|
125
|
+
// eslint-disable-next-line no-await-in-loop
|
|
126
|
+
const inspected = await inspectEntry(root, directory, entry, options);
|
|
127
|
+
result.files.push(...inspected.files);
|
|
128
|
+
result.directories.push(...inspected.directories);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Lists files under `start`, relative to the automation `root`.
|
|
136
|
+
*/
|
|
137
|
+
export async function findFiles(
|
|
138
|
+
root: string,
|
|
139
|
+
start: string,
|
|
140
|
+
options: FindOptions = {},
|
|
141
|
+
): Promise<string[]> {
|
|
142
|
+
const base = resolve(root, start);
|
|
143
|
+
if (!within(root, base)) throw new Error("path escapes automation root");
|
|
144
|
+
const result: string[] = [];
|
|
145
|
+
const baseInfo = await statSafe(base);
|
|
146
|
+
if (!baseInfo) return result;
|
|
147
|
+
if (!baseInfo.isDirectory()) {
|
|
148
|
+
if (fileMatches(base, baseInfo.size, options)) result.push(relative(root, base));
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Breadth-first with a fixed-width wave rather than recursive `Promise.all`:
|
|
153
|
+
// the number of directories open at once is bounded, so a big tree costs
|
|
154
|
+
// time instead of file descriptors.
|
|
155
|
+
let level: string[] = [base];
|
|
156
|
+
for (let depth = 0; depth < FIND_MAX_DEPTH && level.length > 0; depth += 1) {
|
|
157
|
+
const next: string[] = [];
|
|
158
|
+
for (let index = 0; index < level.length; index += FIND_CONCURRENCY) {
|
|
159
|
+
const wave = level.slice(index, index + FIND_CONCURRENCY);
|
|
160
|
+
// eslint-disable-next-line no-await-in-loop -- serialising the waves is the point: it is what bounds open file descriptors
|
|
161
|
+
const inspected = await inspectWave(root, wave, options);
|
|
162
|
+
result.push(...inspected.files);
|
|
163
|
+
next.push(...inspected.directories);
|
|
164
|
+
}
|
|
165
|
+
level = next;
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface AutomationLogger {
|
|
2
|
+
info(message: string, data?: unknown): void;
|
|
3
|
+
warn(message: string, data?: unknown): void;
|
|
4
|
+
error(message: string, data?: unknown): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface AutomationFsClient {
|
|
8
|
+
find(path: string, options?: { name?: string; minBytes?: number }): Promise<string[]>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AutomationContext {
|
|
12
|
+
log: AutomationLogger;
|
|
13
|
+
paths: {
|
|
14
|
+
project: string;
|
|
15
|
+
worktree: string;
|
|
16
|
+
global: boolean;
|
|
17
|
+
};
|
|
18
|
+
fs: AutomationFsClient;
|
|
19
|
+
git: Record<string, never>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type AutomationTrigger =
|
|
23
|
+
| { type: "cron"; schedule: string }
|
|
24
|
+
| {
|
|
25
|
+
type: "event";
|
|
26
|
+
listen: (
|
|
27
|
+
ctx: AutomationContext,
|
|
28
|
+
fire: (payload?: unknown) => void,
|
|
29
|
+
) => (() => void) | void | Promise<(() => void) | void>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface AutomationDefinition {
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
trigger: AutomationTrigger;
|
|
36
|
+
run: (ctx: AutomationContext, payload?: unknown) => void | Promise<void>;
|
|
37
|
+
readonly pragmaAutomation: true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DefineAutomationInput {
|
|
41
|
+
name: string;
|
|
42
|
+
description: string;
|
|
43
|
+
trigger: AutomationTrigger;
|
|
44
|
+
run: (ctx: AutomationContext, payload?: unknown) => void | Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Defines one Pragma automation. Export the returned value as the file default. */
|
|
48
|
+
export function defineAutomation(definition: DefineAutomationInput): AutomationDefinition {
|
|
49
|
+
return { ...definition, pragmaAutomation: true };
|
|
50
|
+
}
|