@theholocron/astromech 3.65.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/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/config/index.d.mts +25 -0
- package/dist/config/index.mjs +48 -0
- package/dist/index.d.mts +136 -0
- package/dist/index.mjs +237 -0
- package/dist/schema-4kyr9ILV.d.mts +54 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Newton Koumantzelis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# `@theholocron/astromech`
|
|
2
|
+
|
|
3
|
+
The Holocron task runner. One **task manifest** per repo, and every
|
|
4
|
+
derived surface comes from it: `holocron run` (local), `holocron ci` (the
|
|
5
|
+
CI suite run locally), the generated GitHub Actions workflows, the
|
|
6
|
+
`package.json` scripts, the linter set, and the branch-protection
|
|
7
|
+
required-checks list.
|
|
8
|
+
|
|
9
|
+
> An astromech droid runs a starfighter's maintenance, diagnostics and
|
|
10
|
+
> system wiring while the pilot flies. This does that for a repo.
|
|
11
|
+
|
|
12
|
+
A plain library — **not** a capability plugin. `@theholocron/cli` depends
|
|
13
|
+
on it and instantiates it once.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pnpm add @theholocron/astromech
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createAstromech } from "@theholocron/astromech";
|
|
25
|
+
|
|
26
|
+
const astromech = createAstromech({ cwd });
|
|
27
|
+
|
|
28
|
+
const report = astromech.run("test", { passthrough: ["--watch"] });
|
|
29
|
+
// → { status: "ok" | "fail" | "skip" | "dry-run" | "unknown", command?, message? }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### `holocron run <task>` resolution
|
|
33
|
+
|
|
34
|
+
`holocron run test` runs your tests — you don't tell it turbo vs pnpm vs
|
|
35
|
+
npm, or which runner:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
1. turbo.json defines the task → turbo run <task>
|
|
39
|
+
2. package.json has a <task> script → <detected pm> run <task>
|
|
40
|
+
(a "holocron run …" thin caller is skipped — no recursion)
|
|
41
|
+
3. the registry has a local runner → <tool> <args> <org-flags> (e.g. --coverage)
|
|
42
|
+
4. known task, nothing to run → "no <task> task", exit 0 (exit 1 with --required)
|
|
43
|
+
5. unknown task → error, exit 1
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The registry (`TASKS`) covers `test` / `typecheck` / `lint` / `build` /
|
|
47
|
+
`sync` / `wiki`; `codeql` / `deploy` have no local equivalent. Adding a
|
|
48
|
+
task here gives every repo that task.
|
|
49
|
+
|
|
50
|
+
## Config — `@theholocron/astromech/config`
|
|
51
|
+
|
|
52
|
+
The config surface lives in a zero-runtime-dep subpath so
|
|
53
|
+
`astromech.config.ts` and `@theholocron/cli` import it without pulling
|
|
54
|
+
the runner.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// astromech.config.ts
|
|
58
|
+
import { defineConfig } from "@theholocron/astromech/config";
|
|
59
|
+
|
|
60
|
+
export default defineConfig({
|
|
61
|
+
tasks: [
|
|
62
|
+
"typecheck",
|
|
63
|
+
{ name: "test", required: true, with: { "run-coverage": true } },
|
|
64
|
+
{ name: "audit", ci: true, local: false }, // CI-only
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`loadTasksConfig(cwd)` resolves the manifest: the `tasks` key of
|
|
70
|
+
`holocron.config.*` (a bare item array), then a dedicated
|
|
71
|
+
`astromech.config.*` merged on top (dedicated wins; arrays concatenate).
|
|
72
|
+
|
|
73
|
+
| `TaskEntry` field | Effect |
|
|
74
|
+
| ------------------------ | ----------------------------------------------------------- |
|
|
75
|
+
| `ci` (default `true`) | emit the CI workflow; include in `holocron ci` |
|
|
76
|
+
| `local` (default `true`) | write the `package.json` script; `holocron run` resolves it |
|
|
77
|
+
| `local: false` | `holocron run <name>` → "CI-only task", exit 0 |
|
|
78
|
+
| `required` | the task's check context is a required status check |
|
|
79
|
+
| `with` | per-repo overrides on the reusable-workflow channel |
|
|
80
|
+
| `linters` (`lint` only) | explicit linter list; omitted → auto-detect |
|
|
81
|
+
|
|
82
|
+
Nothing in `holocron run` reads the config yet — the manifest drives
|
|
83
|
+
`holocron ci`, workflow generation, and script sync in later phases
|
|
84
|
+
(epic #581).
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
| Script | Description |
|
|
89
|
+
| -------------------- | ----------------------- |
|
|
90
|
+
| `pnpm build` | Bundle with tsdown |
|
|
91
|
+
| `pnpm test` | Run the vitest suite |
|
|
92
|
+
| `pnpm test:coverage` | Run tests with coverage |
|
|
93
|
+
| `pnpm typecheck` | `tsc --noEmit` |
|
|
94
|
+
| `pnpm lint` | ESLint |
|
|
95
|
+
|
|
96
|
+
## Releases
|
|
97
|
+
|
|
98
|
+
Automated via semantic-release. See [CHANGELOG.md](../../CHANGELOG.md).
|
|
99
|
+
|
|
100
|
+
## Documentation
|
|
101
|
+
|
|
102
|
+
<https://theholocron.github.io/holocron/>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { i as normalizeTaskEntry, n as TaskEntry, r as TasksConfig, t as TaskConfigItem } from "../schema-4kyr9ILV.mjs";
|
|
2
|
+
//#region src/config/define.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Typed identity helper for `astromech.config.ts`:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { defineConfig } from "@theholocron/astromech/config";
|
|
8
|
+
*
|
|
9
|
+
* export default defineConfig({
|
|
10
|
+
* tasks: ["typecheck", { name: "test", required: true }],
|
|
11
|
+
* });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
declare const defineConfig: <C extends TasksConfig>(config: C) => C;
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/config/load.d.ts
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the task manifest for a repo: the `tasks` key of
|
|
19
|
+
* `holocron.config.*`, then a dedicated `astromech.config.*` merged on
|
|
20
|
+
* top (dedicated wins; item arrays concatenate). Returns `{}` when
|
|
21
|
+
* neither source is present.
|
|
22
|
+
*/
|
|
23
|
+
declare function loadTasksConfig(cwd: string): Promise<TasksConfig>;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { type TaskConfigItem, type TaskEntry, type TasksConfig, defineConfig, loadTasksConfig, normalizeTaskEntry };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createDefineConfig, loadConfigFile, mergeConfig } from "@theholocron/datapad";
|
|
2
|
+
//#region src/config/define.ts
|
|
3
|
+
/**
|
|
4
|
+
* Typed identity helper for `astromech.config.ts`:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { defineConfig } from "@theholocron/astromech/config";
|
|
8
|
+
*
|
|
9
|
+
* export default defineConfig({
|
|
10
|
+
* tasks: ["typecheck", { name: "test", required: true }],
|
|
11
|
+
* });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
const defineConfig = createDefineConfig();
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/config/load.ts
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the task manifest for a repo: the `tasks` key of
|
|
19
|
+
* `holocron.config.*`, then a dedicated `astromech.config.*` merged on
|
|
20
|
+
* top (dedicated wins; item arrays concatenate). Returns `{}` when
|
|
21
|
+
* neither source is present.
|
|
22
|
+
*/
|
|
23
|
+
async function loadTasksConfig(cwd) {
|
|
24
|
+
const dedicated = await loadConfigFile({
|
|
25
|
+
cwd,
|
|
26
|
+
name: "astromech"
|
|
27
|
+
});
|
|
28
|
+
return [coerce((await loadConfigFile({
|
|
29
|
+
cwd,
|
|
30
|
+
name: "holocron"
|
|
31
|
+
}))?.config.tasks), coerce(dedicated?.config)].filter((layer) => layer !== void 0).reduce((acc, layer) => mergeConfig(acc, layer), {});
|
|
32
|
+
}
|
|
33
|
+
function coerce(value) {
|
|
34
|
+
if (value == null) return void 0;
|
|
35
|
+
return Array.isArray(value) ? { tasks: value } : value;
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/config/schema.ts
|
|
39
|
+
/** Normalise a `TaskConfigItem` to a full {@link TaskEntry} with defaults applied. */
|
|
40
|
+
function normalizeTaskEntry(item) {
|
|
41
|
+
return {
|
|
42
|
+
ci: true,
|
|
43
|
+
local: true,
|
|
44
|
+
...typeof item === "string" ? { name: item } : item
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
export { defineConfig, loadTasksConfig, normalizeTaskEntry };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { r as TasksConfig } from "./schema-4kyr9ILV.mjs";
|
|
2
|
+
//#region src/run.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* `holocron run <task> [-- <passthrough>]` — run a registry task locally.
|
|
5
|
+
*
|
|
6
|
+
* Resolution:
|
|
7
|
+
*
|
|
8
|
+
* 1. turbo.json defines the task → `turbo run <task>`
|
|
9
|
+
* 2. package.json has a `<task>` script → `<pm> run <task>`
|
|
10
|
+
* (unless it's the `holocron run …` thin caller — that recurses)
|
|
11
|
+
* 3. TASKS[task].local resolves → `<tool> <args> <org-flags> <passthrough>`
|
|
12
|
+
* 4. known task, nothing to run → "no <task> task" (exit 0, or 1 with --required)
|
|
13
|
+
* 5. unknown task → "unknown task" (exit 1)
|
|
14
|
+
*/
|
|
15
|
+
/** Minimal structural logger — `@theholocron/logger`'s `Logger` satisfies it. */
|
|
16
|
+
interface RunLogger {
|
|
17
|
+
debug(obj: Record<string, unknown>, msg?: string): void;
|
|
18
|
+
warn(obj: Record<string, unknown>, msg?: string): void;
|
|
19
|
+
}
|
|
20
|
+
type ExecFn = (cmd: string, args: string[], opts: {
|
|
21
|
+
cwd: string;
|
|
22
|
+
}) => {
|
|
23
|
+
exitCode: number;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Everything `runTask` needs from the outside world. `createAstromech`
|
|
27
|
+
* fills these with real implementations (or the caller's injected ones);
|
|
28
|
+
* `runTask` never reaches for a global itself, so it has no default
|
|
29
|
+
* branches to leave untested.
|
|
30
|
+
*/
|
|
31
|
+
interface RunDeps {
|
|
32
|
+
print: (line: string) => void;
|
|
33
|
+
logger: RunLogger;
|
|
34
|
+
exec: ExecFn;
|
|
35
|
+
readFile: (path: string) => string;
|
|
36
|
+
fileExists: (path: string) => boolean;
|
|
37
|
+
listDir: (path: string) => string[];
|
|
38
|
+
}
|
|
39
|
+
interface RunTaskInput extends RunDeps {
|
|
40
|
+
/** Registry task name, e.g. `"test"`. */
|
|
41
|
+
task: string;
|
|
42
|
+
/** Directory to run in. */
|
|
43
|
+
cwd: string;
|
|
44
|
+
/** Args after `--` on the command line, forwarded to the tool / turbo / script. */
|
|
45
|
+
passthrough?: string[];
|
|
46
|
+
/** Print the resolved command without running it. */
|
|
47
|
+
dryRun?: boolean;
|
|
48
|
+
/** Turn "no such task for this repo" (normally exit 0) into a failure. */
|
|
49
|
+
required?: boolean;
|
|
50
|
+
}
|
|
51
|
+
interface RunTaskReport {
|
|
52
|
+
status: "ok" | "fail" | "skip" | "dry-run" | "unknown";
|
|
53
|
+
/** Resolved command, for the caller / tests. */
|
|
54
|
+
command?: string;
|
|
55
|
+
message?: string;
|
|
56
|
+
}
|
|
57
|
+
declare function runTask(input: RunTaskInput): RunTaskReport;
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/astromech.d.ts
|
|
60
|
+
interface AstromechOptions {
|
|
61
|
+
/** Repo root. */
|
|
62
|
+
cwd: string;
|
|
63
|
+
/**
|
|
64
|
+
* The resolved task manifest. Optional for `run` (which is
|
|
65
|
+
* filesystem-driven); later methods (`ci`, workflow generation) need it.
|
|
66
|
+
* Load it with `loadTasksConfig` from `@theholocron/astromech/config`.
|
|
67
|
+
*/
|
|
68
|
+
config?: TasksConfig;
|
|
69
|
+
/** Structured-logging sink. Defaults to a no-op. */
|
|
70
|
+
logger?: RunLogger;
|
|
71
|
+
/** User-facing line printer. Defaults to `console.log`. */
|
|
72
|
+
print?: (line: string) => void;
|
|
73
|
+
/** Injectable subprocess runner (tests). Defaults to `spawnSync` (stdio inherit). */
|
|
74
|
+
exec?: ExecFn;
|
|
75
|
+
/** Injectable fs (tests). Default to `node:fs`. */
|
|
76
|
+
readFile?: (path: string) => string;
|
|
77
|
+
fileExists?: (path: string) => boolean;
|
|
78
|
+
listDir?: (path: string) => string[];
|
|
79
|
+
}
|
|
80
|
+
interface RunOptions {
|
|
81
|
+
/** Args after `--`, forwarded to the tool / turbo / script. */
|
|
82
|
+
passthrough?: string[];
|
|
83
|
+
/** Print the resolved command without running it. */
|
|
84
|
+
dryRun?: boolean;
|
|
85
|
+
/** Fail (exit 1) instead of skipping when the repo has no such task. */
|
|
86
|
+
required?: boolean;
|
|
87
|
+
}
|
|
88
|
+
interface Astromech {
|
|
89
|
+
/** Run one task locally. */
|
|
90
|
+
run(task: string, opts?: RunOptions): RunTaskReport;
|
|
91
|
+
}
|
|
92
|
+
declare function createAstromech(options: AstromechOptions): Astromech;
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/registry.d.ts
|
|
95
|
+
/**
|
|
96
|
+
* The task registry — how each task runs *locally*, without GitHub
|
|
97
|
+
* Actions. `holocron run <task>` and `holocron ci` resolve against this;
|
|
98
|
+
* adding a task here gives every repo that task.
|
|
99
|
+
*
|
|
100
|
+
* Keyed identically to the workflow templates — a task IS a workflow.
|
|
101
|
+
*
|
|
102
|
+
* Spec: `.notes/tech-astromech-task-runner.spec.md` (epic #581).
|
|
103
|
+
*/
|
|
104
|
+
/** How to run one task (or job) locally. */
|
|
105
|
+
interface LocalRunner {
|
|
106
|
+
/** Binary to invoke — resolved from `node_modules/.bin` then PATH. */
|
|
107
|
+
tool?: string;
|
|
108
|
+
/** Args appended after the tool. */
|
|
109
|
+
args?: string[];
|
|
110
|
+
/** First entry whose `when` filename matches a repo-root file wins. */
|
|
111
|
+
detect?: Array<{
|
|
112
|
+
when: RegExp;
|
|
113
|
+
tool: string;
|
|
114
|
+
args?: string[];
|
|
115
|
+
}>;
|
|
116
|
+
/** The task is already a holocron subcommand (`sync`, `sync-wiki`). */
|
|
117
|
+
command?: string;
|
|
118
|
+
}
|
|
119
|
+
interface TaskDef {
|
|
120
|
+
/**
|
|
121
|
+
* `null` — no local equivalent (CodeQL, deploys). `holocron ci` reports
|
|
122
|
+
* it as skipped; `holocron run` treats it as "nothing to do".
|
|
123
|
+
*/
|
|
124
|
+
local: LocalRunner | null;
|
|
125
|
+
/** Sub-jobs, keyed by slug — `holocron run audit performance`. */
|
|
126
|
+
jobs?: Record<string, {
|
|
127
|
+
local: LocalRunner | null;
|
|
128
|
+
}>;
|
|
129
|
+
/** Org-default flags injected by tool name. Removed by a repo override. */
|
|
130
|
+
flags?: Record<string, string[]>;
|
|
131
|
+
}
|
|
132
|
+
declare const TASKS: Record<string, TaskDef>;
|
|
133
|
+
/** Every task name the registry knows. */
|
|
134
|
+
declare const KNOWN_TASKS: Set<string>;
|
|
135
|
+
//#endregion
|
|
136
|
+
export { type Astromech, type AstromechOptions, type ExecFn, KNOWN_TASKS, type LocalRunner, type RunLogger, type RunOptions, type RunTaskInput, type RunTaskReport, TASKS, type TaskDef, createAstromech, runTask };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
//#region src/registry.ts
|
|
5
|
+
const TASKS = {
|
|
6
|
+
test: {
|
|
7
|
+
local: {
|
|
8
|
+
tool: "vitest",
|
|
9
|
+
args: ["run"]
|
|
10
|
+
},
|
|
11
|
+
flags: { vitest: ["--coverage"] }
|
|
12
|
+
},
|
|
13
|
+
typecheck: { local: {
|
|
14
|
+
tool: "tsc",
|
|
15
|
+
args: ["--noEmit"]
|
|
16
|
+
} },
|
|
17
|
+
lint: { local: {
|
|
18
|
+
tool: "eslint",
|
|
19
|
+
args: ["."]
|
|
20
|
+
} },
|
|
21
|
+
build: { local: { detect: [
|
|
22
|
+
{
|
|
23
|
+
when: /^tsdown\.config\.(ts|js|mjs|cjs)$/,
|
|
24
|
+
tool: "tsdown"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
when: /^vite\.config\.(ts|js|mjs|cjs)$/,
|
|
28
|
+
tool: "vite",
|
|
29
|
+
args: ["build"]
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
when: /^rollup\.config\.(ts|js|mjs|cjs)$/,
|
|
33
|
+
tool: "rollup",
|
|
34
|
+
args: ["-c"]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
when: /^tsconfig\.json$/,
|
|
38
|
+
tool: "tsc",
|
|
39
|
+
args: ["-b"]
|
|
40
|
+
}
|
|
41
|
+
] } },
|
|
42
|
+
sync: { local: { command: "sync" } },
|
|
43
|
+
wiki: { local: { command: "sync-wiki" } },
|
|
44
|
+
codeql: { local: null },
|
|
45
|
+
deploy: { local: null }
|
|
46
|
+
};
|
|
47
|
+
/** Every task name the registry knows. */
|
|
48
|
+
const KNOWN_TASKS = new Set(Object.keys(TASKS));
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/run.ts
|
|
51
|
+
/**
|
|
52
|
+
* `holocron run <task> [-- <passthrough>]` — run a registry task locally.
|
|
53
|
+
*
|
|
54
|
+
* Resolution:
|
|
55
|
+
*
|
|
56
|
+
* 1. turbo.json defines the task → `turbo run <task>`
|
|
57
|
+
* 2. package.json has a `<task>` script → `<pm> run <task>`
|
|
58
|
+
* (unless it's the `holocron run …` thin caller — that recurses)
|
|
59
|
+
* 3. TASKS[task].local resolves → `<tool> <args> <org-flags> <passthrough>`
|
|
60
|
+
* 4. known task, nothing to run → "no <task> task" (exit 0, or 1 with --required)
|
|
61
|
+
* 5. unknown task → "unknown task" (exit 1)
|
|
62
|
+
*/
|
|
63
|
+
function runTask(input) {
|
|
64
|
+
const { print, logger, exec, readFile, fileExists, listDir, task, cwd } = input;
|
|
65
|
+
const passthrough = input.passthrough ?? [];
|
|
66
|
+
const dryRun = input.dryRun ?? false;
|
|
67
|
+
const run = (cmd, args) => {
|
|
68
|
+
const command = [cmd, ...args].join(" ");
|
|
69
|
+
if (dryRun) {
|
|
70
|
+
print(`would run: ${command}`);
|
|
71
|
+
logger.debug({
|
|
72
|
+
task,
|
|
73
|
+
command,
|
|
74
|
+
status: "dry-run"
|
|
75
|
+
}, `run: ${task}`);
|
|
76
|
+
return {
|
|
77
|
+
status: "dry-run",
|
|
78
|
+
command
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
print(`→ ${command}`);
|
|
82
|
+
const { exitCode } = exec(cmd, args, { cwd });
|
|
83
|
+
const status = exitCode === 0 ? "ok" : "fail";
|
|
84
|
+
logger[status === "fail" ? "warn" : "debug"]({
|
|
85
|
+
task,
|
|
86
|
+
command,
|
|
87
|
+
exitCode,
|
|
88
|
+
status
|
|
89
|
+
}, `run: ${task}`);
|
|
90
|
+
return {
|
|
91
|
+
status,
|
|
92
|
+
command,
|
|
93
|
+
...status === "fail" ? { message: `\`${command}\` exited ${exitCode}` } : {}
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
if (turboDefinesTask(cwd, task, readFile, fileExists)) {
|
|
97
|
+
const args = [
|
|
98
|
+
"run",
|
|
99
|
+
task,
|
|
100
|
+
...passthrough.length ? ["--", ...passthrough] : []
|
|
101
|
+
];
|
|
102
|
+
return run(resolveBin(cwd, "turbo", fileExists), args);
|
|
103
|
+
}
|
|
104
|
+
const def = TASKS[task];
|
|
105
|
+
const script = packageJsonScript(cwd, task, readFile, fileExists);
|
|
106
|
+
if (script && !/^holocron run\b/.test(script.trim())) return run(packageManager(cwd, readFile, fileExists), [
|
|
107
|
+
"run",
|
|
108
|
+
task,
|
|
109
|
+
...passthrough.length ? ["--", ...passthrough] : []
|
|
110
|
+
]);
|
|
111
|
+
if (def?.local) {
|
|
112
|
+
if (def.local.command) return run(process.execPath, [
|
|
113
|
+
process.argv[1],
|
|
114
|
+
def.local.command,
|
|
115
|
+
...passthrough
|
|
116
|
+
]);
|
|
117
|
+
const runner = resolveRunner(def.local, cwd, listDir);
|
|
118
|
+
if (runner) {
|
|
119
|
+
const flags = def.flags?.[runner.tool] ?? [];
|
|
120
|
+
return run(resolveBin(cwd, runner.tool, fileExists), [
|
|
121
|
+
...runner.args,
|
|
122
|
+
...flags,
|
|
123
|
+
...passthrough
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (KNOWN_TASKS.has(task) || def?.local === null) {
|
|
128
|
+
const msg = `no ${task} task for this repo`;
|
|
129
|
+
print(input.required ? `✗ ${msg} (required)` : `· ${msg}`);
|
|
130
|
+
logger[input.required ? "warn" : "debug"]({
|
|
131
|
+
task,
|
|
132
|
+
status: input.required ? "fail" : "skip"
|
|
133
|
+
}, `run: ${task}`);
|
|
134
|
+
return {
|
|
135
|
+
status: input.required ? "fail" : "skip",
|
|
136
|
+
message: msg
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
print(`✗ unknown task "${task}"`);
|
|
140
|
+
logger.warn({
|
|
141
|
+
task,
|
|
142
|
+
status: "unknown"
|
|
143
|
+
}, `run: ${task}`);
|
|
144
|
+
return {
|
|
145
|
+
status: "unknown",
|
|
146
|
+
message: `unknown task "${task}"`
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const mkRunner = (tool, args = []) => ({
|
|
150
|
+
tool,
|
|
151
|
+
args
|
|
152
|
+
});
|
|
153
|
+
/**
|
|
154
|
+
* Resolve `{ tool, args }` for a runner, applying `detect[]` against repo
|
|
155
|
+
* files. Only called for `tool` / `detect` runners — the caller handles
|
|
156
|
+
* `command` runners itself, so `local.detect` is present whenever
|
|
157
|
+
* `local.tool` is not.
|
|
158
|
+
*/
|
|
159
|
+
function resolveRunner(local, cwd, listDir) {
|
|
160
|
+
if (local.tool) return mkRunner(local.tool, local.args);
|
|
161
|
+
let files;
|
|
162
|
+
try {
|
|
163
|
+
files = listDir(cwd);
|
|
164
|
+
} catch {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
for (const candidate of local.detect) if (files.some((f) => candidate.when.test(f))) return mkRunner(candidate.tool, candidate.args);
|
|
168
|
+
}
|
|
169
|
+
function resolveBin(cwd, tool, fileExists) {
|
|
170
|
+
const local = join(cwd, "node_modules", ".bin", tool);
|
|
171
|
+
return fileExists(local) ? local : tool;
|
|
172
|
+
}
|
|
173
|
+
function packageManager(cwd, readFile, fileExists) {
|
|
174
|
+
try {
|
|
175
|
+
const pkg = JSON.parse(readFile(join(cwd, "package.json")));
|
|
176
|
+
if (typeof pkg.packageManager === "string") return pkg.packageManager.split("@")[0];
|
|
177
|
+
} catch {}
|
|
178
|
+
if (fileExists(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
179
|
+
if (fileExists(join(cwd, "bun.lockb"))) return "bun";
|
|
180
|
+
if (fileExists(join(cwd, "yarn.lock"))) return "yarn";
|
|
181
|
+
if (fileExists(join(cwd, "package-lock.json"))) return "npm";
|
|
182
|
+
return "pnpm";
|
|
183
|
+
}
|
|
184
|
+
function turboDefinesTask(cwd, task, readFile, fileExists) {
|
|
185
|
+
if (!fileExists(join(cwd, "turbo.json"))) return false;
|
|
186
|
+
try {
|
|
187
|
+
const turbo = JSON.parse(readFile(join(cwd, "turbo.json")));
|
|
188
|
+
return Boolean(turbo.tasks?.[task] ?? turbo.pipeline?.[task]);
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function packageJsonScript(cwd, task, readFile, fileExists) {
|
|
194
|
+
if (!fileExists(join(cwd, "package.json"))) return void 0;
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(readFile(join(cwd, "package.json"))).scripts?.[task];
|
|
197
|
+
} catch {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/astromech.ts
|
|
203
|
+
/**
|
|
204
|
+
* `createAstromech(options)` — the self-contained task runner.
|
|
205
|
+
* `@theholocron/cli` instantiates it once and delegates the `run` /
|
|
206
|
+
* `ci` / workflow-generation commands to it (like `@theholocron/logger`).
|
|
207
|
+
*/
|
|
208
|
+
const noopLogger = {
|
|
209
|
+
debug() {},
|
|
210
|
+
warn() {}
|
|
211
|
+
};
|
|
212
|
+
const realExec = (cmd, args, opts) => {
|
|
213
|
+
return { exitCode: spawnSync(cmd, args, {
|
|
214
|
+
cwd: opts.cwd,
|
|
215
|
+
stdio: "inherit"
|
|
216
|
+
}).status ?? -1 };
|
|
217
|
+
};
|
|
218
|
+
function createAstromech(options) {
|
|
219
|
+
const deps = {
|
|
220
|
+
print: options.print ?? ((line) => console.log(line)),
|
|
221
|
+
logger: options.logger ?? noopLogger,
|
|
222
|
+
exec: options.exec ?? realExec,
|
|
223
|
+
readFile: options.readFile ?? ((path) => readFileSync(path, "utf8")),
|
|
224
|
+
fileExists: options.fileExists ?? ((path) => existsSync(path)),
|
|
225
|
+
listDir: options.listDir ?? ((path) => readdirSync(path))
|
|
226
|
+
};
|
|
227
|
+
return { run: (task, opts = {}) => runTask({
|
|
228
|
+
...deps,
|
|
229
|
+
task,
|
|
230
|
+
cwd: options.cwd,
|
|
231
|
+
passthrough: opts.passthrough ?? [],
|
|
232
|
+
dryRun: opts.dryRun ?? false,
|
|
233
|
+
required: opts.required ?? false
|
|
234
|
+
}) };
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
export { KNOWN_TASKS, TASKS, createAstromech, runTask };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region src/config/schema.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The `tasks` config shape — the single manifest of what a repo runs.
|
|
4
|
+
* Read from a dedicated `astromech.config.*` file or the `tasks` key of
|
|
5
|
+
* `holocron.config.*` (see {@link loadTasksConfig}).
|
|
6
|
+
*
|
|
7
|
+
* Runtime validation is the loader's job, not this type's — the type only
|
|
8
|
+
* describes what an author writes.
|
|
9
|
+
*/
|
|
10
|
+
interface TaskEntry {
|
|
11
|
+
/** Registry task name — `test`, `lint`, `build`, `audit`, … */
|
|
12
|
+
name: string;
|
|
13
|
+
/**
|
|
14
|
+
* Emit a `.github/workflows/<name>.yml` thin caller and include the task
|
|
15
|
+
* in `holocron ci`. Default `true`.
|
|
16
|
+
*/
|
|
17
|
+
ci?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Write a `"<name>": "holocron run <name>"` script to `package.json` and
|
|
20
|
+
* let `holocron run <name>` resolve the task. Default `true`.
|
|
21
|
+
* `false` → `holocron run <name>` prints "CI-only task" and exits 0.
|
|
22
|
+
*/
|
|
23
|
+
local?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The task's CI check context is a required status check (branch
|
|
26
|
+
* protection) and part of `holocron ci`'s default run.
|
|
27
|
+
*/
|
|
28
|
+
required?: boolean;
|
|
29
|
+
/** Per-repo overrides on the same channel the reusable workflow reads. */
|
|
30
|
+
with?: Record<string, unknown>;
|
|
31
|
+
/**
|
|
32
|
+
* `lint` only — the explicit linter list driving both super-linter's
|
|
33
|
+
* `VALIDATE_*` env (CI) and the native local run. Omitted → auto-detect
|
|
34
|
+
* from the config files present.
|
|
35
|
+
*/
|
|
36
|
+
linters?: string[];
|
|
37
|
+
}
|
|
38
|
+
/** A task is either its bare name (all defaults) or an entry object. */
|
|
39
|
+
type TaskConfigItem = string | TaskEntry;
|
|
40
|
+
interface TasksConfig {
|
|
41
|
+
/** The manifest. */
|
|
42
|
+
tasks?: TaskConfigItem[];
|
|
43
|
+
/** Opt out of the `package.json` script writes. Default `true`. */
|
|
44
|
+
syncScripts?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Required status-check contexts not backed by a task — DCO, semantic
|
|
47
|
+
* PR title, …
|
|
48
|
+
*/
|
|
49
|
+
extraRequiredChecks?: string[];
|
|
50
|
+
}
|
|
51
|
+
/** Normalise a `TaskConfigItem` to a full {@link TaskEntry} with defaults applied. */
|
|
52
|
+
declare function normalizeTaskEntry(item: TaskConfigItem): Required<Pick<TaskEntry, "name" | "ci" | "local">> & TaskEntry;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { normalizeTaskEntry as i, TaskEntry as n, TasksConfig as r, TaskConfigItem as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theholocron/astromech",
|
|
3
|
+
"version": "3.65.0",
|
|
4
|
+
"description": "The Holocron task runner — one task manifest drives `holocron run`, `holocron ci`, the CI workflows, package.json scripts, linters, and required checks.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ci",
|
|
7
|
+
"holocron",
|
|
8
|
+
"monorepo",
|
|
9
|
+
"task-runner",
|
|
10
|
+
"theholocron",
|
|
11
|
+
"turbo"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/theholocron/holocron/tree/main/packages/astromech#readme",
|
|
14
|
+
"bugs": "https://github.com/theholocron/holocron/issues",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/theholocron/holocron.git",
|
|
18
|
+
"directory": "packages/astromech"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Newton Koumantzelis",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"import": "./dist/index.mjs",
|
|
28
|
+
"default": "./dist/index.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./config": {
|
|
31
|
+
"types": "./dist/config/index.d.mts",
|
|
32
|
+
"import": "./dist/config/index.mjs",
|
|
33
|
+
"default": "./dist/config/index.mjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@theholocron/datapad": "3.65.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@theholocron/eslint-config": "^7.32.1",
|
|
44
|
+
"@theholocron/tsconfig": "^7.32.1",
|
|
45
|
+
"@theholocron/tsdown-config": "^7.32.1",
|
|
46
|
+
"@theholocron/vitest-config": "^7.32.1",
|
|
47
|
+
"@types/node": "^26",
|
|
48
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
49
|
+
"@vitest/eslint-plugin": "^1.6.27",
|
|
50
|
+
"eslint": "^10.8.1",
|
|
51
|
+
"eslint-plugin-n": "^18.3.0",
|
|
52
|
+
"globals": "^17.11.0",
|
|
53
|
+
"tsdown": "^0.22.14",
|
|
54
|
+
"typescript": "^5.9.3",
|
|
55
|
+
"vitest": "^4.1.11"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=22"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsdown",
|
|
65
|
+
"lint": "eslint .",
|
|
66
|
+
"typecheck": "tsc --noEmit",
|
|
67
|
+
"test": "vitest run",
|
|
68
|
+
"test:watch": "vitest",
|
|
69
|
+
"test:coverage": "vitest run --coverage"
|
|
70
|
+
}
|
|
71
|
+
}
|