diff --git a/.cursor/skills/runly/SKILL.md b/.cursor/skills/runly/SKILL.md index e4c575a..ca7ed67 100644 --- a/.cursor/skills/runly/SKILL.md +++ b/.cursor/skills/runly/SKILL.md @@ -3,8 +3,9 @@ name: runly description: >- Runs the same Node.js command under multiple Node versions from one config file (runly.config.mjs/js/cjs), resolving each runtime via npx and the npm `node` - package. Use when the user mentions Runly, @hamdymohamedak/runly, multi-version - Node testing, Node matrix in CI, runly.config, or running tests across Node 18/20/22. + package. Use `runly init` to scaffold config and npm script; use `loadConfig()` in code. + Use when the user mentions Runly, @hamdymohamedak/runly, multi-version Node testing, + Node matrix in CI, runly.config, or running tests across Node 18/20/22. --- # Runly @@ -12,7 +13,7 @@ description: >- ## What it is - **npm package**: `@hamdymohamedak/runly` (scoped; unscoped name `runly` is blocked on npm as too similar to `runjs`). -- **CLI binary name**: `runly` (after `npm install`, use `npx runly` from the project root). +- **CLI binary name**: `runly` — use **`npx runly init`** once to create config + **`npm run runly`**, or **`npx runly`** for one-off runs. - **Purpose**: For each entry in `versions`, resolve a real `node` binary for that spec, prepend its directory to `PATH`, then spawn the configured command so that command’s default `node` is that matrix version—without requiring nvm/fnm/asdf on the machine. ## Requirements @@ -30,8 +31,25 @@ One-off without saving to `package.json`: ```bash npx @hamdymohamedak/runly +npx @hamdymohamedak/runly init ``` +## `runly init` (scaffold) + +From the **project root** (after **`npm install -D @hamdymohamedak/runly`**): + +```bash +npx runly init +``` + +- Creates **`runly.config.js`** only if none of **`runly.config.mjs`**, **`runly.config.js`**, or **`runly.config.cjs`** exists (otherwise prints a message and exits **0**). +- Writes **`SKILL.md`** in the project root when missing (Cursor agent skill template from the package); you may move it to **`.cursor/skills/runly/SKILL.md`**. +- Uses **`export default`** when **`package.json`** has **`"type": "module"`**, else **`module.exports`**. +- Adds **`"runly": "runly"`** under **`scripts`** in **`package.json`** when the file exists and **`scripts.runly`** is not already set. +- Default **`run`** in the scaffold is a small **`node -e`** smoke command; edit to **`node --test`**, **`npm test`**, etc. + +Programmatic: **`initRunlyProject(cwd)`** from **`@hamdymohamedak/runly`**. + ## Config file discovery From **current working directory**, first file that exists: @@ -40,7 +58,7 @@ From **current working directory**, first file that exists: 2. `runly.config.js` 3. `runly.config.cjs` -Override: `runly -c /path/to/config.mjs` or `runly --config /path/to/config.mjs`. +Override: `runly -c /path/to/config.mjs` or `runly --config /path/to/runly.config.mjs`. Config must be **JavaScript** (ESM or CJS). Runly does **not** load `.ts` configs unless the user wires a loader themselves. Export **`default`** as the config object (or the module’s default export after dynamic `import()`). @@ -95,15 +113,16 @@ export default defineConfig({ }); ``` -Exported types: **`RunlyConfig`**, **`RunlyRun`**. The matrix runner **`runMatrix`** lives in source but is **not** part of the published `exports`—treat **`defineConfig` + types** as the library surface for dependents. +Exported types: **`RunlyConfig`**, **`RunlyRun`**. **`loadConfig(cwd?)`** loads the default config or throws with a hint to run **`npx runly init`**. The matrix runner **`runMatrix`** is not exported—use the CLI or compose from source. ## CLI -| Flag | Meaning | -|------|---------| +| Command / flag | Meaning | +|----------------|---------| +| `runly init` | Scaffold **`runly.config.js`**, **`SKILL.md`** (if missing), and **`scripts.runly`** (see above). | +| `runly` | Run matrix using config in cwd. | | `-c`, `--config` | Path to config file. | - -No other flags in the current CLI. +| `runly help` | Usage. | ## Exit codes @@ -118,7 +137,7 @@ No other flags in the current CLI. ## CI -Install deps, `cd` to repo root (or set `cwd` in config), run `npx runly`. No global version manager required if `npx` can fetch the `node` package. +Install deps, `cd` to repo root, run **`npx runly init`** once if the repo has no config, then **`npm run runly`** or **`npx runly`**. No global version manager required if `npx` can fetch the `node` package. ## Limitations @@ -129,7 +148,7 @@ Install deps, `cd` to repo root (or set `cwd` in config), run `npx runly`. No gl ## Reference in this repo - User-facing docs: [README.md](../../../README.md) -- Working demo: [matrix-demo/](../../../matrix-demo/) (`npm install` + `npm run matrix` from that folder; uses `file:..` to depend on the parent package). +- Example config: [examples/all-versions-pass/runly.config.mjs](../../../examples/all-versions-pass/runly.config.mjs) ## Links diff --git a/.cursor/skills/runly/skill/SKILL.md b/.cursor/skills/runly/skill/SKILL.md index e4c575a..573f132 100644 --- a/.cursor/skills/runly/skill/SKILL.md +++ b/.cursor/skills/runly/skill/SKILL.md @@ -3,8 +3,9 @@ name: runly description: >- Runs the same Node.js command under multiple Node versions from one config file (runly.config.mjs/js/cjs), resolving each runtime via npx and the npm `node` - package. Use when the user mentions Runly, @hamdymohamedak/runly, multi-version - Node testing, Node matrix in CI, runly.config, or running tests across Node 18/20/22. + package. Use `runly init` to scaffold config and npm script; use `loadConfig()` in code. + Use when the user mentions Runly, @hamdymohamedak/runly, multi-version Node testing, + Node matrix in CI, runly.config, or running tests across Node 18/20/22. --- # Runly @@ -12,7 +13,7 @@ description: >- ## What it is - **npm package**: `@hamdymohamedak/runly` (scoped; unscoped name `runly` is blocked on npm as too similar to `runjs`). -- **CLI binary name**: `runly` (after `npm install`, use `npx runly` from the project root). +- **CLI binary name**: `runly` — use **`npx runly init`** once to create config + **`npm run runly`**, or **`npx runly`** for one-off runs. - **Purpose**: For each entry in `versions`, resolve a real `node` binary for that spec, prepend its directory to `PATH`, then spawn the configured command so that command’s default `node` is that matrix version—without requiring nvm/fnm/asdf on the machine. ## Requirements @@ -30,8 +31,25 @@ One-off without saving to `package.json`: ```bash npx @hamdymohamedak/runly +npx @hamdymohamedak/runly init ``` +## `runly init` (scaffold) + +From the **project root** (after **`npm install -D @hamdymohamedak/runly`**): + +```bash +npx runly init +``` + +- Creates **`runly.config.js`** only if none of **`runly.config.mjs`**, **`runly.config.js`**, or **`runly.config.cjs`** exists (otherwise prints a message and exits **0**). +- Writes **`SKILL.md`** in the project root when missing (Cursor agent skill template from the package); you may move it to **`.cursor/skills/runly/SKILL.md`**. +- Uses **`export default`** when **`package.json`** has **`"type": "module"`**, else **`module.exports`**. +- Adds **`"runly": "runly"`** under **`scripts`** in **`package.json`** when the file exists and **`scripts.runly`** is not already set. +- Default **`run`** in the scaffold is a small **`node -e`** smoke command; edit to **`node --test`**, **`npm test`**, etc. + +Programmatic: **`initRunlyProject(cwd)`** from **`@hamdymohamedak/runly`**. + ## Config file discovery From **current working directory**, first file that exists: @@ -40,7 +58,7 @@ From **current working directory**, first file that exists: 2. `runly.config.js` 3. `runly.config.cjs` -Override: `runly -c /path/to/config.mjs` or `runly --config /path/to/config.mjs`. +Override: `runly -c /path/to/config.mjs` or `runly --config /path/to/runly.config.mjs`. Config must be **JavaScript** (ESM or CJS). Runly does **not** load `.ts` configs unless the user wires a loader themselves. Export **`default`** as the config object (or the module’s default export after dynamic `import()`). @@ -95,15 +113,16 @@ export default defineConfig({ }); ``` -Exported types: **`RunlyConfig`**, **`RunlyRun`**. The matrix runner **`runMatrix`** lives in source but is **not** part of the published `exports`—treat **`defineConfig` + types** as the library surface for dependents. +Exported types: **`RunlyConfig`**, **`RunlyRun`**. **`loadConfig(cwd?)`** loads the default config or throws with a hint to run **`npx runly init`**. The matrix runner **`runMatrix`** is not exported—use the CLI or compose from source. ## CLI -| Flag | Meaning | -|------|---------| +| Command / flag | Meaning | +|----------------|---------| +| `runly init` | Scaffold **`runly.config.js`**, **`SKILL.md`** (if missing), and **`scripts.runly`** (see above). | +| `runly` | Run matrix using config in cwd. | | `-c`, `--config` | Path to config file. | - -No other flags in the current CLI. +| `runly help` | Usage. | ## Exit codes @@ -118,7 +137,7 @@ No other flags in the current CLI. ## CI -Install deps, `cd` to repo root (or set `cwd` in config), run `npx runly`. No global version manager required if `npx` can fetch the `node` package. +Install deps, `cd` to repo root, run **`npx runly init`** once if the repo has no config, then **`npm run runly`** or **`npx runly`**. No global version manager required if `npx` can fetch the `node` package. ## Limitations @@ -128,8 +147,8 @@ Install deps, `cd` to repo root (or set `cwd` in config), run `npx runly`. No gl ## Reference in this repo -- User-facing docs: [README.md](../../../README.md) -- Working demo: [matrix-demo/](../../../matrix-demo/) (`npm install` + `npm run matrix` from that folder; uses `file:..` to depend on the parent package). +- User-facing docs: [README.md](../../../../README.md) +- Example config: [examples/all-versions-pass/runly.config.mjs](../../../../examples/all-versions-pass/runly.config.mjs) ## Links diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82c0626..25ce341 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,6 @@ -# Cross-OS × host Node matrix: build Runly, run smoke tests, execute Runly against -# multiple Node runtimes via npx (no nvm on the runner). +# Cross-OS × host Node matrix: build Runly, verify `runly init` scaffolds a consumer +# (runly.config.js + scripts.runly + npm run runly) from the packed tarball, smoke tests, +# then Runly e2e against multiple Node runtimes via npx (no nvm on the runner). name: CI on: @@ -39,6 +40,9 @@ jobs: - name: Typecheck / build run: npm run build + - name: runly init scaffold (pack tarball → CJS & ESM consumers → npm run runly) + run: npm run ci:verify-init + - name: Unit smoke (repo test/) run: node --test test/smoke.test.js diff --git a/README.md b/README.md index 85135b8..df3e968 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,35 @@ npm install -D @hamdymohamedak/runly The executable name is **`runly`** (see `bin` in `package.json`). +### First-time setup (`init`) + +From your project root, create a default `runly.config.js`, a **`SKILL.md`** agent-skill template (when `SKILL.md` is not already present), and add an npm script **`runly`** when `package.json` exists: + +```bash +npx runly init +``` + +If any of `runly.config.mjs`, `runly.config.js`, or `runly.config.cjs` already exists, `init` does nothing for the config (idempotent). Existing **`SKILL.md`** is never overwritten. + +### Run the matrix + From the directory that contains your config: ```bash npx runly ``` +Or, after `init`: + +```bash +npm run runly +``` + Without adding a dev dependency: ```bash npx @hamdymohamedak/runly +npx @hamdymohamedak/runly init # scaffold config in cwd ``` --- @@ -114,15 +133,18 @@ export default defineConfig({ }); ``` -Exported types include `RunlyConfig` and `RunlyRun` for use in your own tooling. +Exported types include `RunlyConfig` and `RunlyRun`. **`loadConfig(cwd?)`** loads the first config file in `cwd` (same discovery as the CLI) or throws with a hint to run **`npx runly init`**. **`initRunlyProject(cwd?)`** is the programmatic equivalent of **`runly init`**. --- ## CLI -| Flag | Description | -|------|-------------| +| Command / flag | Description | +|----------------|-------------| +| `runly init` | Create `runly.config.js` with defaults, copy **`SKILL.md`** when missing, and add `"runly": "runly"` to `package.json` when present. No-op for config if a config file already exists. | +| `runly`, `runly -c ` | Run the matrix (search for config in cwd, or use `-c` / `--config`). | | `-c`, `--config` | Path to a config file. If omitted, Runly searches for `runly.config.mjs`, then `.js`, then `.cjs` in the current working directory. | +| `runly help` | Print usage. | --- @@ -147,7 +169,7 @@ On Windows, `npx.cmd` is used for the resolution step. ## Continuous integration -Install dependencies as usual, ensure Node and npm are available, then invoke `npx runly` (or `npx @hamdymohamedak/runly`) from the repository root where the config lives. No extra global Node switcher is required on the runner image as long as `npx` can fetch the `node` package. +Install dependencies as usual, ensure Node and npm are available. If the repo has no Runly config yet, run **`npx runly init`** once at the root (or commit a config file). Then invoke **`npx runly`** (or **`npm run runly`**) from the repository root. No extra global Node switcher is required on the runner image as long as `npx` can fetch the `node` package. --- diff --git a/dist/cli.js b/dist/cli.js index 74a9771..bfb9f40 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -1,19 +1,9 @@ #!/usr/bin/env node -import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { resolveRunlyConfigPathOrThrow } from "./config-paths.js"; +import { initRunlyProject } from "./init.js"; import { runMatrix } from "./run-matrix.js"; -const CANDIDATES = ["runly.config.mjs", "runly.config.js", "runly.config.cjs"]; -function resolveConfigPath(explicit) { - if (explicit) - return resolve(explicit); - for (const name of CANDIDATES) { - const p = resolve(process.cwd(), name); - if (existsSync(p)) - return p; - } - throw new Error(`No config found. Create ${CANDIDATES.join(" or ")} or pass -c /path/to/runly.config.mjs`); -} async function loadConfig(configPath) { const abs = resolve(configPath); const mod = (await import(pathToFileURL(abs).href)); @@ -26,14 +16,41 @@ async function loadConfig(configPath) { } return c; } +function printHelp() { + console.error(`Runly — run one command per Node version (matrix) + +Usage: + runly Load config from cwd and run the matrix + runly init Create runly.config.js and add npm script "runly" + runly -c Use a specific config file + +Examples: + npx runly init + npm run runly + +Docs: https://github.com/hamdymohamedak/Runly`); +} async function main() { const args = process.argv.slice(2); + if (args[0] === "help" || args[0] === "--help" || args[0] === "-h") { + printHelp(); + process.exit(0); + } + if (args[0] === "init") { + const r = initRunlyProject(process.cwd()); + if (r === "exists") { + console.error("A Runly config already exists (runly.config.mjs, .js, or .cjs). Nothing to do."); + process.exit(0); + } + console.error("Created runly.config.js with default matrix, SKILL.md (if missing), and npm script \"runly\" if package.json is present."); + process.exit(0); + } const configFlag = args.findIndex((a) => a === "-c" || a === "--config"); let explicit; if (configFlag >= 0 && args[configFlag + 1]) { explicit = args[configFlag + 1]; } - const configPath = resolveConfigPath(explicit); + const configPath = resolveRunlyConfigPathOrThrow(process.cwd(), explicit); const config = await loadConfig(configPath); const results = await runMatrix(config); const failed = results.filter((r) => !r.ok); diff --git a/dist/cli.js.map b/dist/cli.js.map index a149cfd..78544d7 100644 --- a/dist/cli.js.map +++ b/dist/cli.js.map @@ -1 +1 @@ -{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/config-paths.d.ts b/dist/config-paths.d.ts new file mode 100644 index 0000000..c3e5fa2 --- /dev/null +++ b/dist/config-paths.d.ts @@ -0,0 +1,6 @@ +export declare const RUNLY_CONFIG_CANDIDATES: readonly ["runly.config.mjs", "runly.config.js", "runly.config.cjs"]; +export declare function hasAnyRunlyConfig(cwd: string): boolean; +/** Resolved path to an existing config, or `null`. */ +export declare function findRunlyConfigPath(cwd: string): string | null; +export declare function resolveRunlyConfigPathOrThrow(cwd: string, explicit?: string): string; +//# sourceMappingURL=config-paths.d.ts.map \ No newline at end of file diff --git a/dist/config-paths.d.ts.map b/dist/config-paths.d.ts.map new file mode 100644 index 0000000..1d0a558 --- /dev/null +++ b/dist/config-paths.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"config-paths.d.ts","sourceRoot":"","sources":["../src/config-paths.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,uBAAuB,sEAAuE,CAAC;AAE5G,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAKtD;AAED,sDAAsD;AACtD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM9D;AAED,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAOpF"} \ No newline at end of file diff --git a/dist/config-paths.js b/dist/config-paths.js new file mode 100644 index 0000000..7433725 --- /dev/null +++ b/dist/config-paths.js @@ -0,0 +1,28 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +export const RUNLY_CONFIG_CANDIDATES = ["runly.config.mjs", "runly.config.js", "runly.config.cjs"]; +export function hasAnyRunlyConfig(cwd) { + for (const name of RUNLY_CONFIG_CANDIDATES) { + if (existsSync(resolve(cwd, name))) + return true; + } + return false; +} +/** Resolved path to an existing config, or `null`. */ +export function findRunlyConfigPath(cwd) { + for (const name of RUNLY_CONFIG_CANDIDATES) { + const p = resolve(cwd, name); + if (existsSync(p)) + return p; + } + return null; +} +export function resolveRunlyConfigPathOrThrow(cwd, explicit) { + if (explicit) + return resolve(explicit); + const found = findRunlyConfigPath(cwd); + if (found) + return found; + throw new Error(`No Runly config found. Run: npx runly init (or create ${RUNLY_CONFIG_CANDIDATES.join(", ")})`); +} +//# sourceMappingURL=config-paths.js.map \ No newline at end of file diff --git a/dist/config-paths.js.map b/dist/config-paths.js.map new file mode 100644 index 0000000..91becae --- /dev/null +++ b/dist/config-paths.js.map @@ -0,0 +1 @@ +{"version":3,"file":"config-paths.js","sourceRoot":"","sources":["../src/config-paths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,kBAAkB,EAAE,iBAAiB,EAAE,kBAAkB,CAAU,CAAC;AAE5G,MAAM,UAAU,iBAAiB,CAAC,GAAW;IAC3C,KAAK,MAAM,IAAI,IAAI,uBAAuB,EAAE,CAAC;QAC3C,IAAI,UAAU,CAAC,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;IAClD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,sDAAsD;AACtD,MAAM,UAAU,mBAAmB,CAAC,GAAW;IAC7C,KAAK,MAAM,IAAI,IAAI,uBAAuB,EAAE,CAAC;QAC3C,MAAM,CAAC,GAAG,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QAC7B,IAAI,UAAU,CAAC,CAAC,CAAC;YAAE,OAAO,CAAC,CAAC;IAC9B,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,6BAA6B,CAAC,GAAW,EAAE,QAAiB;IAC1E,IAAI,QAAQ;QAAE,OAAO,OAAO,CAAC,QAAQ,CAAC,CAAC;IACvC,MAAM,KAAK,GAAG,mBAAmB,CAAC,GAAG,CAAC,CAAC;IACvC,IAAI,KAAK;QAAE,OAAO,KAAK,CAAC;IACxB,MAAM,IAAI,KAAK,CACb,yDAAyD,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAC/F,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index 146c32b..3b092eb 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,4 +1,12 @@ +import { findRunlyConfigPath, hasAnyRunlyConfig, RUNLY_CONFIG_CANDIDATES } from "./config-paths.js"; +import { initRunlyProject } from "./init.js"; import type { RunlyConfig } from "./types.js"; export type { RunlyConfig, RunlyRun } from "./types.js"; +export { RUNLY_CONFIG_CANDIDATES, findRunlyConfigPath, hasAnyRunlyConfig, initRunlyProject }; export declare function defineConfig(config: RunlyConfig): RunlyConfig; +/** + * Load the first existing Runly config in `cwd` (same discovery order as the CLI). + * @throws If no config file exists — run `npx runly init` or create a config manually. + */ +export declare function loadConfig(cwd?: string): Promise; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map index 387ea2e..2d961d8 100644 --- a/dist/index.d.ts.map +++ b/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAExD,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,WAAW,CAE7D"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AACpG,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAExD,OAAO,EAAE,uBAAuB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,CAAC;AAE7F,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,WAAW,CAE7D;AAED;;;GAGG;AACH,wBAAsB,UAAU,CAAC,GAAG,GAAE,MAAsB,GAAG,OAAO,CAAC,WAAW,CAAC,CAclF"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index e48e8c0..6407b3b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,4 +1,27 @@ +import { pathToFileURL } from "node:url"; +import { findRunlyConfigPath, hasAnyRunlyConfig, RUNLY_CONFIG_CANDIDATES } from "./config-paths.js"; +import { initRunlyProject } from "./init.js"; +export { RUNLY_CONFIG_CANDIDATES, findRunlyConfigPath, hasAnyRunlyConfig, initRunlyProject }; export function defineConfig(config) { return config; } +/** + * Load the first existing Runly config in `cwd` (same discovery order as the CLI). + * @throws If no config file exists — run `npx runly init` or create a config manually. + */ +export async function loadConfig(cwd = process.cwd()) { + const path = findRunlyConfigPath(cwd); + if (!path) { + throw new Error(`No Runly config found in ${cwd}. Run: npx runly init`); + } + const mod = (await import(pathToFileURL(path).href)); + const c = mod.default ?? mod; + if (!c?.versions?.length) { + throw new Error("Config must export `versions` (non-empty array)"); + } + if (!c.run) { + throw new Error("Config must export `run` (command to execute per version)"); + } + return c; +} //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map index f76654f..c9f2aa9 100644 --- a/dist/index.js.map +++ b/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,MAAM,UAAU,YAAY,CAAC,MAAmB;IAC9C,OAAO,MAAM,CAAC;AAChB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AACpG,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAK7C,OAAO,EAAE,uBAAuB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,CAAC;AAE7F,MAAM,UAAU,YAAY,CAAC,MAAmB;IAC9C,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,MAAc,OAAO,CAAC,GAAG,EAAE;IAC1D,MAAM,IAAI,GAAG,mBAAmB,CAAC,GAAG,CAAC,CAAC;IACtC,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CAAC,4BAA4B,GAAG,uBAAuB,CAAC,CAAC;IAC1E,CAAC;IACD,MAAM,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAA8B,CAAC;IAClF,MAAM,CAAC,GAAG,GAAG,CAAC,OAAO,IAAK,GAA8B,CAAC;IACzD,IAAI,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CAAC,iDAAiD,CAAC,CAAC;IACrE,CAAC;IACD,IAAI,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CAAC,2DAA2D,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,CAAC,CAAC;AACX,CAAC"} \ No newline at end of file diff --git a/dist/init.d.ts b/dist/init.d.ts new file mode 100644 index 0000000..6f1cb82 --- /dev/null +++ b/dist/init.d.ts @@ -0,0 +1,6 @@ +/** + * Create `runly.config.js` with defaults, copy `SKILL.md` when absent, and add `"runly": "runly"` to package.json when possible. + * @returns `"exists"` if any runly config file is already present, otherwise `"created"`. + */ +export declare function initRunlyProject(cwd: string): "exists" | "created"; +//# sourceMappingURL=init.d.ts.map \ No newline at end of file diff --git a/dist/init.d.ts.map b/dist/init.d.ts.map new file mode 100644 index 0000000..e37a919 --- /dev/null +++ b/dist/init.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"AAuEA;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAclE"} \ No newline at end of file diff --git a/dist/init.js b/dist/init.js new file mode 100644 index 0000000..8d7859c --- /dev/null +++ b/dist/init.js @@ -0,0 +1,90 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { hasAnyRunlyConfig } from "./config-paths.js"; +const PKG = "@hamdymohamedak/runly"; +const DEFAULT_FILE = "runly.config.js"; +const SKILL_FILE = "SKILL.md"; +function readPkgJson(cwd) { + const p = join(cwd, "package.json"); + if (!existsSync(p)) + return null; + try { + return JSON.parse(readFileSync(p, "utf8")); + } + catch { + return null; + } +} +function defaultConfigBody() { + return `{ + versions: ["18", "20", "22"], + bail: false, + run: { + argv: ["node", "-e", "console.log('runly ok', process.version)"], + shell: false, + }, +}`; +} +function tryAddRunlyScriptToDisk(cwd) { + const pkgPath = join(cwd, "package.json"); + if (!existsSync(pkgPath)) + return; + let pkg; + try { + pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + } + catch { + return; + } + const prev = pkg.scripts; + const scripts = prev != null && typeof prev === "object" && !Array.isArray(prev) + ? { ...prev } + : {}; + if (Object.hasOwn(scripts, "runly")) + return; + scripts.runly = "runly"; + pkg.scripts = scripts; + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); +} +/** Path to bundled `templates/SKILL.md` inside the installed package (next to `dist/`). */ +function bundledSkillTemplatePath() { + const here = dirname(fileURLToPath(import.meta.url)); + const packageRoot = join(here, ".."); + const p = join(packageRoot, "templates", SKILL_FILE); + return existsSync(p) ? p : null; +} +/** Copy agent skill template next to config; skip if `SKILL.md` already exists. */ +function tryWriteSkillTemplate(cwd) { + const target = join(cwd, SKILL_FILE); + if (existsSync(target)) + return; + const src = bundledSkillTemplatePath(); + if (!src) + return; + try { + writeFileSync(target, readFileSync(src, "utf8")); + } + catch { + /* ignore missing or unreadable template */ + } +} +/** + * Create `runly.config.js` with defaults, copy `SKILL.md` when absent, and add `"runly": "runly"` to package.json when possible. + * @returns `"exists"` if any runly config file is already present, otherwise `"created"`. + */ +export function initRunlyProject(cwd) { + if (hasAnyRunlyConfig(cwd)) + return "exists"; + const pkg = readPkgJson(cwd); + const isEsm = pkg?.type === "module"; + const body = defaultConfigBody(); + const content = isEsm + ? `/** @type {import("${PKG}").RunlyConfig} */\nexport default ${body};\n` + : `/** @type {import("${PKG}").RunlyConfig} */\nmodule.exports = ${body};\n`; + writeFileSync(join(cwd, DEFAULT_FILE), content, "utf8"); + tryWriteSkillTemplate(cwd); + tryAddRunlyScriptToDisk(cwd); + return "created"; +} +//# sourceMappingURL=init.js.map \ No newline at end of file diff --git a/dist/init.js.map b/dist/init.js.map new file mode 100644 index 0000000..ab06574 --- /dev/null +++ b/dist/init.js.map @@ -0,0 +1 @@ +{"version":3,"file":"init.js","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/resolve-node.d.ts.map b/dist/resolve-node.d.ts.map index a992b03..4f324a6 100644 --- a/dist/resolve-node.d.ts.map +++ b/dist/resolve-node.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"resolve-node.d.ts","sourceRoot":"","sources":["../src/resolve-node.ts"],"names":[],"mappings":"AAUA,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAqCrF;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAIjF;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEnD"} \ No newline at end of file +{"version":3,"file":"resolve-node.d.ts","sourceRoot":"","sources":["../src/resolve-node.ts"],"names":[],"mappings":"AAUA,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAwCrF;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAIjF;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEnD"} \ No newline at end of file diff --git a/dist/resolve-node.js b/dist/resolve-node.js index 20fe411..495f972 100644 --- a/dist/resolve-node.js +++ b/dist/resolve-node.js @@ -8,6 +8,8 @@ function npxCmd() { } export function resolveNodeExecPath(versionSpec, cwd) { return new Promise((resolve, reject) => { + // Windows: spawning `.cmd` without a shell often yields `spawn EINVAL` (Node + libuv). + // Use `shell: true` so `npx.cmd` runs under the default shell (cmd.exe). const win32 = process.platform === "win32"; const child = spawn(npxCmd(), [ "--yes", diff --git a/dist/resolve-node.js.map b/dist/resolve-node.js.map index d199a3a..7a1fe0c 100644 --- a/dist/resolve-node.js.map +++ b/dist/resolve-node.js.map @@ -1 +1 @@ -{"version":3,"file":"resolve-node.js","sourceRoot":"","sources":["../src/resolve-node.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC;;GAEG;AACH,SAAS,MAAM;IACb,OAAO,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC;AAC1D,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,WAAmB,EAAE,GAAW;IAClE,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,KAAK,GAAG,KAAK,CACjB,MAAM,EAAE,EACR;YACE,OAAO;YACP,WAAW;YACX,QAAQ,WAAW,EAAE;YACrB,IAAI;YACJ,MAAM;YACN,IAAI;YACJ,wCAAwC;SACzC,EACD,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAC3C,CAAC;QACF,IAAI,GAAG,GAAG,EAAE,CAAC;QACb,IAAI,GAAG,GAAG,EAAE,CAAC;QACb,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE;YAC7B,GAAG,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC;QACnB,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE;YAC7B,GAAG,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC;QACnB,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC1B,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YACzB,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC;gBACf,MAAM,CAAC,IAAI,KAAK,CAAC,0BAA0B,WAAW,KAAK,GAAG,IAAI,QAAQ,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC;gBACrF,OAAO;YACT,CAAC;YACD,MAAM,CAAC,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;YACrB,IAAI,CAAC,CAAC,EAAE,CAAC;gBACP,MAAM,CAAC,IAAI,KAAK,CAAC,wBAAwB,WAAW,EAAE,CAAC,CAAC,CAAC;gBACzD,OAAO;YACT,CAAC;YACD,OAAO,CAAC,CAAC,CAAC,CAAC;QACb,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,GAAW,EAAE,OAA2B;IACvE,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;IACrD,MAAM,IAAI,GAAG,OAAO,IAAI,EAAE,CAAC;IAC3B,OAAO,IAAI,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,QAAgB;IACzC,OAAO,OAAO,CAAC,QAAQ,CAAC,CAAC;AAC3B,CAAC"} \ No newline at end of file +{"version":3,"file":"resolve-node.js","sourceRoot":"","sources":["../src/resolve-node.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC;;GAEG;AACH,SAAS,MAAM;IACb,OAAO,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC;AAC1D,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,WAAmB,EAAE,GAAW;IAClE,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,uFAAuF;QACvF,yEAAyE;QACzE,MAAM,KAAK,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC;QAC3C,MAAM,KAAK,GAAG,KAAK,CACjB,MAAM,EAAE,EACR;YACE,OAAO;YACP,WAAW;YACX,QAAQ,WAAW,EAAE;YACrB,IAAI;YACJ,MAAM;YACN,IAAI;YACJ,wCAAwC;SACzC,EACD,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CACzD,CAAC;QACF,IAAI,GAAG,GAAG,EAAE,CAAC;QACb,IAAI,GAAG,GAAG,EAAE,CAAC;QACb,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE;YAC7B,GAAG,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC;QACnB,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE;YAC7B,GAAG,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC;QACnB,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC1B,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YACzB,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC;gBACf,MAAM,CAAC,IAAI,KAAK,CAAC,0BAA0B,WAAW,KAAK,GAAG,IAAI,QAAQ,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC;gBACrF,OAAO;YACT,CAAC;YACD,MAAM,CAAC,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;YACrB,IAAI,CAAC,CAAC,EAAE,CAAC;gBACP,MAAM,CAAC,IAAI,KAAK,CAAC,wBAAwB,WAAW,EAAE,CAAC,CAAC,CAAC;gBACzD,OAAO;YACT,CAAC;YACD,OAAO,CAAC,CAAC,CAAC,CAAC;QACb,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,GAAW,EAAE,OAA2B;IACvE,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;IACrD,MAAM,IAAI,GAAG,OAAO,IAAI,EAAE,CAAC;IAC3B,OAAO,IAAI,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,QAAgB;IACzC,OAAO,OAAO,CAAC,QAAQ,CAAC,CAAC;AAC3B,CAAC"} \ No newline at end of file diff --git a/package.json b/package.json index e1acd18..0551959 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hamdymohamedak/runly", - "version": "0.1.0", + "version": "0.2.1", "publishConfig": { "access": "public" }, @@ -19,11 +19,13 @@ } }, "files": [ - "dist" + "dist", + "templates/SKILL.md" ], "scripts": { "test": "node --test test/smoke.test.js", "ci:runly-smoke": "node dist/cli.js -c examples/all-versions-pass/runly.config.mjs", + "ci:verify-init": "node scripts/ci-verify-init.mjs", "build": "tsc", "dev": "tsc --watch", "prepublishOnly": "npm run build", diff --git a/scripts/ci-verify-init.mjs b/scripts/ci-verify-init.mjs new file mode 100644 index 0000000..5ed395a --- /dev/null +++ b/scripts/ci-verify-init.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * CI: pack this repo, install tarball into fresh consumers, run `runly init`, assert + * runly.config.js + SKILL.md + scripts.runly, then `npm run runly`. + */ +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PKG = "@hamdymohamedak/runly"; +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +process.chdir(repoRoot); + +function run(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { stdio: "inherit", shell: process.platform === "win32", ...opts }); + if (r.error) throw r.error; + if (r.status !== 0) process.exit(r.status ?? 1); +} + +const version = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version; +const packDir = mkdtempSync(join(tmpdir(), "runly-pack-")); +run("npm", ["pack", "--pack-destination", packDir, "--ignore-scripts"]); +const tgzName = `hamdymohamedak-runly-${version}.tgz`; +const tgz = join(packDir, tgzName); +if (!existsSync(tgz)) { + console.error("ci-verify-init: tarball missing:", tgz); + process.exit(1); +} + +function verifyConsumer(label, pkgJson, assertConfig) { + const consumer = mkdtempSync(join(tmpdir(), `runly-consumer-${label}-`)); + // Copy tarball into the consumer tree and use file:./… so npm on Windows does not choke on + // absolute file:// URLs (8.3 paths like RUNNER~1, %7E encoding, integrity null). + copyFileSync(tgz, join(consumer, tgzName)); + const body = { + ...pkgJson, + dependencies: { + ...(pkgJson.dependencies && typeof pkgJson.dependencies === "object" ? pkgJson.dependencies : {}), + [PKG]: `file:./${tgzName}`, + }, + }; + writeFileSync(join(consumer, "package.json"), `${JSON.stringify(body, null, 2)}\n`); + run("npm", ["install", "--no-fund", "--no-audit"], { cwd: consumer }); + + run("npx", ["runly", "init"], { cwd: consumer }); + + const cfg = join(consumer, "runly.config.js"); + if (!existsSync(cfg)) { + console.error(`[${label}] missing runly.config.js after runly init`); + process.exit(1); + } + assertConfig(readFileSync(cfg, "utf8")); + + const skill = join(consumer, "SKILL.md"); + if (!existsSync(skill)) { + console.error(`[${label}] missing SKILL.md after runly init`); + process.exit(1); + } + if (!readFileSync(skill, "utf8").includes("name: runly")) { + console.error(`[${label}] SKILL.md looks invalid`); + process.exit(1); + } + + const installed = JSON.parse(readFileSync(join(consumer, "package.json"), "utf8")); + if (installed.scripts?.runly !== "runly") { + console.error(`[${label}] expected scripts.runly === "runly", got:`, installed.scripts); + process.exit(1); + } + + run("npm", ["run", "runly"], { cwd: consumer }); + console.error(`[${label}] runly init + matrix: OK`); +} + +verifyConsumer( + "cjs", + { + name: "init-ci-cjs", + version: "1.0.0", + private: true, + type: "commonjs", + }, + (text) => { + if (!text.includes("module.exports")) { + console.error("[cjs] expected module.exports in runly.config.js"); + process.exit(1); + } + }, +); + +verifyConsumer( + "esm", + { + name: "init-ci-esm", + version: "1.0.0", + private: true, + type: "module", + }, + (text) => { + if (!text.includes("export default")) { + console.error("[esm] expected export default in runly.config.js"); + process.exit(1); + } + }, +); + +console.error("ci-verify-init: all checks passed"); diff --git a/src/cli.ts b/src/cli.ts index 3dbd1ff..5c097ac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,23 +1,11 @@ #!/usr/bin/env node -import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { resolveRunlyConfigPathOrThrow } from "./config-paths.js"; +import { initRunlyProject } from "./init.js"; import { runMatrix } from "./run-matrix.js"; import type { RunlyConfig } from "./types.js"; -const CANDIDATES = ["runly.config.mjs", "runly.config.js", "runly.config.cjs"]; - -function resolveConfigPath(explicit?: string): string { - if (explicit) return resolve(explicit); - for (const name of CANDIDATES) { - const p = resolve(process.cwd(), name); - if (existsSync(p)) return p; - } - throw new Error( - `No config found. Create ${CANDIDATES.join(" or ")} or pass -c /path/to/runly.config.mjs`, - ); -} - async function loadConfig(configPath: string): Promise { const abs = resolve(configPath); const mod = (await import(pathToFileURL(abs).href)) as { default?: RunlyConfig }; @@ -31,15 +19,48 @@ async function loadConfig(configPath: string): Promise { return c; } +function printHelp(): void { + console.error(`Runly — run one command per Node version (matrix) + +Usage: + runly Load config from cwd and run the matrix + runly init Create runly.config.js and add npm script "runly" + runly -c Use a specific config file + +Examples: + npx runly init + npm run runly + +Docs: https://github.com/hamdymohamedak/Runly`); +} + async function main() { const args = process.argv.slice(2); + + if (args[0] === "help" || args[0] === "--help" || args[0] === "-h") { + printHelp(); + process.exit(0); + } + + if (args[0] === "init") { + const r = initRunlyProject(process.cwd()); + if (r === "exists") { + console.error("A Runly config already exists (runly.config.mjs, .js, or .cjs). Nothing to do."); + process.exit(0); + } + console.error( + "Created runly.config.js with default matrix, SKILL.md (if missing), and npm script \"runly\" if package.json is present.", + ); + process.exit(0); + } + const configFlag = args.findIndex((a) => a === "-c" || a === "--config"); let explicit: string | undefined; if (configFlag >= 0 && args[configFlag + 1]) { explicit = args[configFlag + 1]; } - const configPath = resolveConfigPath(explicit); + const configPath = resolveRunlyConfigPathOrThrow(process.cwd(), explicit); const config = await loadConfig(configPath); const results = await runMatrix(config); diff --git a/src/config-paths.ts b/src/config-paths.ts new file mode 100644 index 0000000..8d9debd --- /dev/null +++ b/src/config-paths.ts @@ -0,0 +1,29 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +export const RUNLY_CONFIG_CANDIDATES = ["runly.config.mjs", "runly.config.js", "runly.config.cjs"] as const; + +export function hasAnyRunlyConfig(cwd: string): boolean { + for (const name of RUNLY_CONFIG_CANDIDATES) { + if (existsSync(resolve(cwd, name))) return true; + } + return false; +} + +/** Resolved path to an existing config, or `null`. */ +export function findRunlyConfigPath(cwd: string): string | null { + for (const name of RUNLY_CONFIG_CANDIDATES) { + const p = resolve(cwd, name); + if (existsSync(p)) return p; + } + return null; +} + +export function resolveRunlyConfigPathOrThrow(cwd: string, explicit?: string): string { + if (explicit) return resolve(explicit); + const found = findRunlyConfigPath(cwd); + if (found) return found; + throw new Error( + `No Runly config found. Run: npx runly init (or create ${RUNLY_CONFIG_CANDIDATES.join(", ")})`, + ); +} diff --git a/src/index.ts b/src/index.ts index cf064f8..4a22888 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,32 @@ +import { pathToFileURL } from "node:url"; +import { findRunlyConfigPath, hasAnyRunlyConfig, RUNLY_CONFIG_CANDIDATES } from "./config-paths.js"; +import { initRunlyProject } from "./init.js"; import type { RunlyConfig } from "./types.js"; export type { RunlyConfig, RunlyRun } from "./types.js"; +export { RUNLY_CONFIG_CANDIDATES, findRunlyConfigPath, hasAnyRunlyConfig, initRunlyProject }; + export function defineConfig(config: RunlyConfig): RunlyConfig { return config; } + +/** + * Load the first existing Runly config in `cwd` (same discovery order as the CLI). + * @throws If no config file exists — run `npx runly init` or create a config manually. + */ +export async function loadConfig(cwd: string = process.cwd()): Promise { + const path = findRunlyConfigPath(cwd); + if (!path) { + throw new Error(`No Runly config found in ${cwd}. Run: npx runly init`); + } + const mod = (await import(pathToFileURL(path).href)) as { default?: RunlyConfig }; + const c = mod.default ?? (mod as unknown as RunlyConfig); + if (!c?.versions?.length) { + throw new Error("Config must export `versions` (non-empty array)"); + } + if (!c.run) { + throw new Error("Config must export `run` (command to execute per version)"); + } + return c; +} diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..c056e75 --- /dev/null +++ b/src/init.ts @@ -0,0 +1,90 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { hasAnyRunlyConfig } from "./config-paths.js"; + +const PKG = "@hamdymohamedak/runly"; +const DEFAULT_FILE = "runly.config.js"; +const SKILL_FILE = "SKILL.md"; + +function readPkgJson(cwd: string) { + const p = join(cwd, "package.json"); + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, "utf8")) as Record; + } catch { + return null; + } +} + +function defaultConfigBody(): string { + return `{ + versions: ["18", "20", "22"], + bail: false, + run: { + argv: ["node", "-e", "console.log('runly ok', process.version)"], + shell: false, + }, +}`; +} + +function tryAddRunlyScriptToDisk(cwd: string): void { + const pkgPath = join(cwd, "package.json"); + if (!existsSync(pkgPath)) return; + let pkg: Record; + try { + pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + } catch { + return; + } + const prev = pkg.scripts; + const scripts = + prev != null && typeof prev === "object" && !Array.isArray(prev) + ? { ...(prev as Record) } + : {}; + if (Object.hasOwn(scripts, "runly")) return; + scripts.runly = "runly"; + pkg.scripts = scripts; + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); +} + +/** Path to bundled `templates/SKILL.md` inside the installed package (next to `dist/`). */ +function bundledSkillTemplatePath(): string | null { + const here = dirname(fileURLToPath(import.meta.url)); + const packageRoot = join(here, ".."); + const p = join(packageRoot, "templates", SKILL_FILE); + return existsSync(p) ? p : null; +} + +/** Copy agent skill template next to config; skip if `SKILL.md` already exists. */ +function tryWriteSkillTemplate(cwd: string): void { + const target = join(cwd, SKILL_FILE); + if (existsSync(target)) return; + const src = bundledSkillTemplatePath(); + if (!src) return; + try { + writeFileSync(target, readFileSync(src, "utf8")); + } catch { + /* ignore missing or unreadable template */ + } +} + +/** + * Create `runly.config.js` with defaults, copy `SKILL.md` when absent, and add `"runly": "runly"` to package.json when possible. + * @returns `"exists"` if any runly config file is already present, otherwise `"created"`. + */ +export function initRunlyProject(cwd: string): "exists" | "created" { + if (hasAnyRunlyConfig(cwd)) return "exists"; + + const pkg = readPkgJson(cwd); + const isEsm = pkg?.type === "module"; + const body = defaultConfigBody(); + const content = isEsm + ? `/** @type {import("${PKG}").RunlyConfig} */\nexport default ${body};\n` + : `/** @type {import("${PKG}").RunlyConfig} */\nmodule.exports = ${body};\n`; + + writeFileSync(join(cwd, DEFAULT_FILE), content, "utf8"); + tryWriteSkillTemplate(cwd); + tryAddRunlyScriptToDisk(cwd); + return "created"; +} diff --git a/templates/SKILL.md b/templates/SKILL.md new file mode 100644 index 0000000..f83dfc5 --- /dev/null +++ b/templates/SKILL.md @@ -0,0 +1,156 @@ +--- +name: runly +description: >- + Runs the same Node.js command under multiple Node versions from one config file + (runly.config.mjs/js/cjs), resolving each runtime via npx and the npm `node` + package. Use `runly init` to scaffold config and npm script; use `loadConfig()` in code. + Use when the user mentions Runly, @hamdymohamedak/runly, multi-version Node testing, + Node matrix in CI, runly.config, or running tests across Node 18/20/22. +--- + +# Runly + +## What it is + +- **npm package**: `@hamdymohamedak/runly` (scoped; unscoped name `runly` is blocked on npm as too similar to `runjs`). +- **CLI binary name**: `runly` — use **`npx runly init`** once to create config + **`npm run runly`**, or **`npx runly`** for one-off runs. +- **Purpose**: For each entry in `versions`, resolve a real `node` binary for that spec, prepend its directory to `PATH`, then spawn the configured command so that command’s default `node` is that matrix version—without requiring nvm/fnm/asdf on the machine. + +## Requirements + +- Node **≥ 18** for the Runly CLI process. +- **npm** with **npx** (first-time version resolution may hit the network). + +## Install + +```bash +npm install -D @hamdymohamedak/runly +``` + +One-off without saving to `package.json`: + +```bash +npx @hamdymohamedak/runly +npx @hamdymohamedak/runly init +``` + +## `runly init` (scaffold) + +From the **project root** (after **`npm install -D @hamdymohamedak/runly`**): + +```bash +npx runly init +``` + +- Creates **`runly.config.js`** only if none of **`runly.config.mjs`**, **`runly.config.js`**, or **`runly.config.cjs`** exists (otherwise prints a message and exits **0**). +- Writes **`SKILL.md`** in the project root (from the package template) when **`SKILL.md`** does not already exist—useful for Cursor agent skills (you can move it to **`.cursor/skills/runly/SKILL.md`** if you prefer). +- Uses **`export default`** when **`package.json`** has **`"type": "module"`**, else **`module.exports`**. +- Adds **`"runly": "runly"`** under **`scripts`** in **`package.json`** when the file exists and **`scripts.runly`** is not already set. +- Default **`run`** in the scaffold is a small **`node -e`** smoke command; edit to **`node --test`**, **`npm test`**, etc. + +Programmatic: **`initRunlyProject(cwd)`** from **`@hamdymohamedak/runly`**. + +## Config file discovery + +From **current working directory**, first file that exists: + +1. `runly.config.mjs` +2. `runly.config.js` +3. `runly.config.cjs` + +Override: `runly -c /path/to/config.mjs` or `runly --config /path/to/runly.config.mjs`. + +Config must be **JavaScript** (ESM or CJS). Runly does **not** load `.ts` configs unless the user wires a loader themselves. Export **`default`** as the config object (or the module’s default export after dynamic `import()`). + +## Config shape (`RunlyConfig`) + +| Field | Type | Required | Notes | +|-------|------|----------|--------| +| `versions` | `string[]` | Yes | Non-empty. Each string is an npm **`node`** package spec (e.g. `"20"`, `"20.10.0"`, `"lts/*"`). Passed as `node@` when resolving. | +| `run` | `RunlyRun` | Yes | See below. | +| `cwd` | `string` | No | Child `cwd`; default Runly’s `process.cwd()`. | +| `bail` | `boolean` | No | If `true`, stop after first failing version. Default `false`. | +| `env` | `NodeJS.ProcessEnv` | No | Merged into child env; `PATH` is overridden per row so matrix `node` is first. | + +### `RunlyRun` + +- **String**: treated as one shell command → `spawn` with **`shell: true`** (single string in argv). +- **`{ argv: string[]; shell?: boolean }`**: `spawn(argv[0], argv.slice(1), { shell, stdio: 'inherit' })`. Default **`shell: false`** for array form. + +**Guidance**: For predictable use of the matrix Node, prefer **`argv`** whose first token is **`node`** (e.g. `["node", "--test", "test/"]`). String form like `"npm test"` depends on how npm/scripts resolve `node`. + +## Minimal examples + +```javascript +// runly.config.mjs +export default { + versions: ["18", "20", "22"], + bail: false, + run: { + argv: ["node", "--test", "test/"], + shell: false, + }, +}; +``` + +```javascript +export default { + versions: ["20", "22"], + run: "npm test", +}; +``` + +## TypeScript / IDE helper + +From the same package (public export `"."` only): + +```javascript +import { defineConfig } from "@hamdymohamedak/runly"; + +export default defineConfig({ + versions: ["18", "20", "22"], + run: { argv: ["node", "--test", "test/"], shell: false }, +}); +``` + +Exported types: **`RunlyConfig`**, **`RunlyRun`**. **`loadConfig(cwd?)`** loads the default config or throws with a hint to run **`npx runly init`**. The matrix runner **`runMatrix`** is not exported—use the CLI or compose from source. + +## CLI + +| Command / flag | Meaning | +|----------------|---------| +| `runly init` | Scaffold **`runly.config.js`**, **`SKILL.md`**, and **`scripts.runly`** (see above). | +| `runly` | Run matrix using config in cwd. | +| `-c`, `--config` | Path to config file. | +| `runly help` | Usage. | + +## Exit codes + +- **0**: every version’s child exited with code `0`. +- **1**: any child non-zero, resolution failure, or config error. stderr includes `Failed: ` when some matrix rows failed. + +## Internals (for debugging) + +1. Resolve binary: `npx` with `--yes --package node@ -- node -e "process.stdout.write(process.execPath)"` (on Windows: **`npx.cmd`**). +2. `PATH`: prepend `dirname(process.execPath)` for that Node for the child only. +3. Output: banner `━━━ Node ━━━` per row; child **stdio inherited**. + +## CI + +Install deps, `cd` to repo root, run **`npx runly init`** once if the repo has no config, then **`npm run runly`** or **`npx runly`**. No global version manager required if `npx` can fetch the `node` package. + +## Limitations + +- Network may be needed for `npx` / registry on cold caches. +- Windows: `npx.cmd` for resolution; usual Windows spawn/shell rules apply. +- Package **`bin`** in `package.json` must use paths like `dist/cli.js` (no `./` prefix)—npm may strip invalid `bin` entries on publish. + +## Reference + +- README: [https://github.com/hamdymohamedak/Runly#readme](https://github.com/hamdymohamedak/Runly#readme) +- npm: [https://www.npmjs.com/package/@hamdymohamedak/runly](https://www.npmjs.com/package/@hamdymohamedak/runly) + +## Links + +- npm: `https://www.npmjs.com/package/@hamdymohamedak/runly` +- Source: `https://github.com/hamdymohamedak/Runly`