+322
-13
| #!/usr/bin/env node | ||
| import { existsSync } from "node:fs"; | ||
| import { resolve } from "node:path"; | ||
| import { loadConfig } from "c12"; | ||
| import { execSync } from "node:child_process"; | ||
| import { cancel, confirm, isCancel } from "@clack/prompts"; | ||
| import { existsSync, readFileSync, writeFileSync } from "node:fs"; | ||
| import { basename, join, resolve } from "node:path"; | ||
| import { pathToFileURL } from "node:url"; | ||
| import { createJiti } from "jiti"; | ||
| import { exec, execSync } from "node:child_process"; | ||
| import { promisify } from "node:util"; | ||
| import { cancel, confirm, intro, isCancel, log, multiselect, outro, select, spinner, text } from "@clack/prompts"; | ||
| //#region package.json | ||
| var version = "1.0.1"; | ||
| var version = "1.1.0"; | ||
| //#endregion | ||
@@ -43,4 +45,3 @@ //#region src/lib/args.ts | ||
| //#endregion | ||
| //#region src/lib/config.ts | ||
| const CONFIG_BASENAME = "greenly.config"; | ||
| //#region src/lib/constants.ts | ||
| const CONFIG_EXTENSIONS = [ | ||
@@ -55,2 +56,5 @@ "ts", | ||
| ]; | ||
| //#endregion | ||
| //#region src/lib/config.ts | ||
| const CONFIG_BASENAME = "greenly.config"; | ||
| var ConfigNotFoundError = class extends Error { | ||
@@ -81,6 +85,3 @@ cwd; | ||
| if (!configFile) throw new ConfigNotFoundError(cwd); | ||
| const { config } = await loadConfig({ | ||
| cwd, | ||
| configFile | ||
| }); | ||
| const config = await createJiti(pathToFileURL(resolve(cwd, "greenly.config")).href).import(configFile, { default: true }); | ||
| if (!config || typeof config !== "object") throw new ConfigInvalidError(configFile, "config must export an object"); | ||
@@ -98,2 +99,304 @@ if (!Array.isArray(config.checks) || config.checks.length === 0) throw new ConfigInvalidError(configFile, `"checks" must be a non-empty array`); | ||
| //#endregion | ||
| //#region src/lib/init.ts | ||
| const execAsync = promisify(exec); | ||
| function isRecord(value) { | ||
| return typeof value === "object" && value !== null; | ||
| } | ||
| function scriptCommand(ctx, candidates) { | ||
| const found = candidates.find((s) => ctx.scripts.has(s)); | ||
| return found ? `${ctx.run} ${found}` : null; | ||
| } | ||
| const CHECK_PRESETS = [ | ||
| { | ||
| value: "typescript", | ||
| label: "TypeScript (tsc)", | ||
| build: (c) => ({ | ||
| name: "TypeScript", | ||
| command: scriptCommand(c, ["typecheck", "type-check"]) ?? `${c.exec} tsc --noEmit${c.isNext ? " --incremental false" : ""}` | ||
| }) | ||
| }, | ||
| { | ||
| value: "oxfmt", | ||
| label: "Oxfmt", | ||
| build: (c) => ({ | ||
| name: "Oxfmt", | ||
| command: scriptCommand(c, ["fmt:check", "format:check"]) ?? `${c.exec} oxfmt --check`, | ||
| onFail: scriptCommand(c, ["fmt", "format"]) ?? `${c.exec} oxfmt` | ||
| }) | ||
| }, | ||
| { | ||
| value: "prettier", | ||
| label: "Prettier", | ||
| build: (c) => ({ | ||
| name: "Prettier", | ||
| command: scriptCommand(c, ["fmt:check", "format:check"]) ?? `${c.exec} prettier --check .`, | ||
| onFail: scriptCommand(c, ["fmt", "format"]) ?? `${c.exec} prettier --write .` | ||
| }) | ||
| }, | ||
| { | ||
| value: "oxlint", | ||
| label: "Oxlint", | ||
| build: (c) => ({ | ||
| name: "Oxlint", | ||
| command: scriptCommand(c, ["lint"]) ?? `${c.exec} oxlint` | ||
| }) | ||
| }, | ||
| { | ||
| value: "eslint", | ||
| label: "ESLint", | ||
| build: (c) => ({ | ||
| name: "ESLint", | ||
| command: scriptCommand(c, ["lint"]) ?? `${c.exec} eslint .` | ||
| }) | ||
| }, | ||
| { | ||
| value: "vitest", | ||
| label: "Tests (Vitest)", | ||
| build: (c) => ({ | ||
| name: "Tests", | ||
| command: scriptCommand(c, ["test"]) ?? `${c.exec} vitest run` | ||
| }) | ||
| }, | ||
| { | ||
| value: "build", | ||
| label: "Build", | ||
| build: (c) => ({ | ||
| name: "Build", | ||
| command: scriptCommand(c, ["build"]) ?? `${c.run} build` | ||
| }) | ||
| } | ||
| ]; | ||
| function pmContext(pm) { | ||
| switch (pm) { | ||
| case "pnpm": return { | ||
| exec: "pnpm", | ||
| run: "pnpm" | ||
| }; | ||
| case "yarn": return { | ||
| exec: "yarn", | ||
| run: "yarn" | ||
| }; | ||
| case "bun": return { | ||
| exec: "bunx", | ||
| run: "bun run" | ||
| }; | ||
| default: return { | ||
| exec: "npx", | ||
| run: "npm run" | ||
| }; | ||
| } | ||
| } | ||
| function installCommand(pm) { | ||
| switch (pm) { | ||
| case "pnpm": return "pnpm add -D greenly@latest"; | ||
| case "yarn": return "yarn add -D greenly@latest"; | ||
| case "bun": return "bun add -d greenly@latest"; | ||
| default: return "npm install -D greenly@latest"; | ||
| } | ||
| } | ||
| function detectPackageManager(userAgent, lockfiles) { | ||
| const ua = userAgent ?? ""; | ||
| if (ua.startsWith("pnpm")) return "pnpm"; | ||
| if (ua.startsWith("yarn")) return "yarn"; | ||
| if (ua.startsWith("bun")) return "bun"; | ||
| if (ua.startsWith("npm")) return "npm"; | ||
| if (lockfiles.includes("pnpm-lock.yaml")) return "pnpm"; | ||
| if (lockfiles.includes("yarn.lock")) return "yarn"; | ||
| if (lockfiles.includes("bun.lockb") || lockfiles.includes("bun.lock")) return "bun"; | ||
| return "npm"; | ||
| } | ||
| function buildChecks(selected, pm, opts = {}) { | ||
| const ctx = { | ||
| ...pmContext(pm), | ||
| isNext: opts.isNext ?? false, | ||
| scripts: opts.scripts ?? new Set() | ||
| }; | ||
| return CHECK_PRESETS.filter((p) => selected.includes(p.value)).map((p) => p.build(ctx)); | ||
| } | ||
| function isNextProject(pkg, hasNextConfig) { | ||
| if (hasNextConfig) return true; | ||
| const hasNextDep = (field) => { | ||
| const deps = pkg?.[field]; | ||
| return isRecord(deps) && "next" in deps; | ||
| }; | ||
| return hasNextDep("dependencies") || hasNextDep("devDependencies"); | ||
| } | ||
| function packageScripts(pkg) { | ||
| const scripts = pkg?.scripts; | ||
| return isRecord(scripts) ? new Set(Object.keys(scripts)) : new Set(); | ||
| } | ||
| function installedDependencies(pkg) { | ||
| const names = new Set(); | ||
| for (const field of ["dependencies", "devDependencies"]) { | ||
| const deps = pkg?.[field]; | ||
| if (isRecord(deps)) for (const key of Object.keys(deps)) names.add(key); | ||
| } | ||
| return names; | ||
| } | ||
| function availablePresets(installed) { | ||
| const hidden = new Set(); | ||
| const prefer = (a, b) => { | ||
| if (installed.has(a) && !installed.has(b)) hidden.add(b); | ||
| if (installed.has(b) && !installed.has(a)) hidden.add(a); | ||
| }; | ||
| prefer("oxlint", "eslint"); | ||
| prefer("oxfmt", "prettier"); | ||
| return CHECK_PRESETS.filter((p) => !hidden.has(p.value)); | ||
| } | ||
| function configFileName(ext) { | ||
| return `greenly.config.${ext}`; | ||
| } | ||
| function renderCheck(check) { | ||
| const parts = [`name: ${JSON.stringify(check.name)}`, `command: ${JSON.stringify(check.command)}`]; | ||
| if (check.onFail) parts.push(`onFail: ${JSON.stringify(check.onFail)}`); | ||
| return `{ ${parts.join(", ")} }`; | ||
| } | ||
| function isEsm(ext, isModule) { | ||
| if (ext === "ts" || ext === "mts" || ext === "mjs") return true; | ||
| if (ext === "cts" || ext === "cjs") return false; | ||
| return isModule; | ||
| } | ||
| function renderConfig(opts) { | ||
| const { name, checks, ext, isModule } = opts; | ||
| if (ext === "json") return `${JSON.stringify({ | ||
| name, | ||
| checks | ||
| }, null, 2)}\n`; | ||
| const body = checks.map((c) => ` ${renderCheck(c)},`).join("\n"); | ||
| const object = `{\n name: ${JSON.stringify(name)},\n checks: [\n${body}\n ],\n}`; | ||
| if (isEsm(ext, isModule)) return `import { defineConfig } from "greenly";\n\nexport default defineConfig(${object});\n`; | ||
| return `const { defineConfig } = require("greenly");\n\nmodule.exports = defineConfig(${object});\n`; | ||
| } | ||
| function withCheckScript(pkg, scriptName) { | ||
| const scripts = isRecord(pkg.scripts) ? pkg.scripts : {}; | ||
| return { | ||
| ...pkg, | ||
| scripts: { | ||
| ...scripts, | ||
| [scriptName]: "greenly" | ||
| } | ||
| }; | ||
| } | ||
| function readPackageJson(path) { | ||
| if (!existsSync(path)) return null; | ||
| try { | ||
| const parsed = JSON.parse(readFileSync(path, "utf8")); | ||
| if (isRecord(parsed)) return parsed; | ||
| } catch {} | ||
| return null; | ||
| } | ||
| function detectLockfiles(cwd) { | ||
| return [ | ||
| "pnpm-lock.yaml", | ||
| "yarn.lock", | ||
| "package-lock.json", | ||
| "bun.lockb", | ||
| "bun.lock" | ||
| ].filter((f) => existsSync(join(cwd, f))); | ||
| } | ||
| function hasNextConfigFile(cwd) { | ||
| return [ | ||
| "js", | ||
| "mjs", | ||
| "cjs", | ||
| "ts", | ||
| "mts", | ||
| "cts" | ||
| ].some((e) => existsSync(join(cwd, `next.config.${e}`))); | ||
| } | ||
| function ensure(value) { | ||
| if (isCancel(value)) { | ||
| cancel("init cancelled."); | ||
| process.exit(0); | ||
| } | ||
| return value; | ||
| } | ||
| async function runInit(cwd = process.cwd()) { | ||
| intro(colors.inverse(" greenly ") + colors.dim(" init ")); | ||
| const pkgPath = join(cwd, "package.json"); | ||
| const pkg = readPackageJson(pkgPath); | ||
| const defaultName = typeof pkg?.name === "string" ? pkg.name : basename(cwd); | ||
| const pm = detectPackageManager(process.env.npm_config_user_agent, detectLockfiles(cwd)); | ||
| const name = ensure(await text({ | ||
| message: "Project name (shown in the banner)", | ||
| initialValue: defaultName, | ||
| validate: (value) => value?.trim() ? void 0 : "Please enter a project name" | ||
| })); | ||
| const ext = ensure(await select({ | ||
| message: "Config file format", | ||
| options: CONFIG_EXTENSIONS.map((e) => ({ | ||
| value: e, | ||
| label: configFileName(e) | ||
| })), | ||
| initialValue: "ts" | ||
| })); | ||
| const scriptName = ensure(await text({ | ||
| message: "Script name to add to package.json", | ||
| initialValue: "check", | ||
| validate: (value) => value?.trim() ? void 0 : "Please enter a script name" | ||
| })); | ||
| const presets = availablePresets(installedDependencies(pkg)); | ||
| const selected = ensure(await multiselect({ | ||
| message: "Select the checks to include", | ||
| options: presets.map((p) => ({ | ||
| value: p.value, | ||
| label: p.label | ||
| })), | ||
| required: true | ||
| })); | ||
| const doInstall = ensure(await confirm({ | ||
| message: `Install greenly now with ${pm}?`, | ||
| initialValue: true | ||
| })); | ||
| const fileName = configFileName(ext); | ||
| const filePath = join(cwd, fileName); | ||
| if (existsSync(filePath)) { | ||
| if (!ensure(await confirm({ | ||
| message: `${fileName} already exists. Overwrite it?`, | ||
| initialValue: false | ||
| }))) { | ||
| cancel("Kept the existing config. Nothing changed."); | ||
| process.exit(0); | ||
| } | ||
| } | ||
| const isNext = isNextProject(pkg, hasNextConfigFile(cwd)); | ||
| if (isNext && selected.includes("typescript")) log.info(colors.dim("Detected Next.js, using tsc --incremental false")); | ||
| const content = renderConfig({ | ||
| name, | ||
| checks: buildChecks(selected, pm, { | ||
| isNext, | ||
| scripts: packageScripts(pkg) | ||
| }), | ||
| ext, | ||
| isModule: pkg?.type === "module" | ||
| }); | ||
| writeFileSync(filePath, content); | ||
| log.success(`Created ${colors.bold(fileName)}`); | ||
| if (pkg) { | ||
| const current = (isRecord(pkg.scripts) ? pkg.scripts : {})[scriptName]; | ||
| let write = true; | ||
| if (typeof current === "string" && current !== "greenly") write = ensure(await confirm({ | ||
| message: `Script "${scriptName}" already runs "${current}". Overwrite with "greenly"?`, | ||
| initialValue: false | ||
| })); | ||
| if (write) { | ||
| writeFileSync(pkgPath, `${JSON.stringify(withCheckScript(pkg, scriptName), null, 2)}\n`); | ||
| log.success(`Added ${colors.bold(`"${scriptName}": "greenly"`)} to package.json`); | ||
| } | ||
| } else log.warn("No package.json found, skipped adding the script."); | ||
| if (doInstall) { | ||
| const s = spinner(); | ||
| s.start(`Installing greenly with ${pm}`); | ||
| try { | ||
| await execAsync(installCommand(pm), { cwd }); | ||
| s.stop("Installed greenly"); | ||
| } catch { | ||
| s.stop("Could not install greenly automatically"); | ||
| log.warn(`Run "${installCommand(pm)}" yourself.`); | ||
| } | ||
| } else log.info(`Skipped install. Run "${installCommand(pm)}" when ready.`); | ||
| const runCmd = `${pmContext(pm).run} ${scriptName}`; | ||
| outro(`Done. Run ${colors.bold(runCmd)} to run your checks.`); | ||
| } | ||
| //#endregion | ||
| //#region src/lib/runner.ts | ||
@@ -288,2 +591,3 @@ const MIN_WIDTH = 60; | ||
| greenly [options] | ||
| greenly init Scaffold a greenly.config file interactively | ||
@@ -311,3 +615,8 @@ ${colors.bold("Options")} | ||
| async function main() { | ||
| const parsed = parseArgs(process.argv.slice(2)); | ||
| const argv = process.argv.slice(2); | ||
| if (argv[0] === "init") { | ||
| await runInit(); | ||
| return; | ||
| } | ||
| const parsed = parseArgs(argv); | ||
| if (parsed.help) { | ||
@@ -314,0 +623,0 @@ console.log(HELP); |
+2
-2
| { | ||
| "name": "greenly", | ||
| "version": "1.0.1", | ||
| "version": "1.1.0", | ||
| "description": "Config-driven project check runner. Define your lint/format/typecheck/test steps in greenly.config.ts and run them with one command.", | ||
@@ -78,3 +78,3 @@ "keywords": [ | ||
| "@clack/prompts": "1.7.0", | ||
| "c12": "3.3.4" | ||
| "jiti": "2.7.0" | ||
| }, | ||
@@ -81,0 +81,0 @@ "devDependencies": { |
+31
-9
| # greenly | ||
| [](https://www.npmjs.com/package/greenly) | ||
| [](https://www.npmjs.com/package/greenly) | ||
| [](https://www.npmjs.com/package/greenly) | ||
| [](https://socket.dev/npm/package/greenly) | ||
| [](https://github.com/yusifaliyevpro/greenly/actions/workflows/pr-checks.yml) | ||
| [](https://github.com/yusifaliyevpro/greenly/blob/main/LICENSE) | ||
| > Config-driven project check runner. Define your lint / format / typecheck / test steps once in `greenly.config.ts` and run them with a single command. | ||
@@ -7,4 +14,19 @@ | ||
| ## Quick start | ||
| The fastest way to get going is the interactive scaffolder. It asks for a project | ||
| name, config format, script name, and which checks to include, then writes the config, | ||
| adds a `"check": "greenly"` script, and installs greenly. It adapts to your project: | ||
| it reuses matching `package.json` scripts (e.g. `fmt:check`, `lint`) when they exist, | ||
| only offers the lint/format tools you already use, and detects Next.js: | ||
| ```bash | ||
| pnpx greenly init | ||
| # or: npx greenly init | ||
| ``` | ||
| ## Install | ||
| Or set it up manually: | ||
| ```bash | ||
@@ -14,4 +36,2 @@ pnpm add -D greenly | ||
| ## Quick start | ||
| Create a `greenly.config.ts` at your project root: | ||
@@ -109,10 +129,12 @@ | ||
| ## CLI flags | ||
| ## CLI | ||
| | Flag | Description | | ||
| | ---------------------- | ------------------------------------------------------------------------ | | ||
| | `-y`, `--yes`, `--fix` | Auto-run every `onFail` fixer without prompting (great for CI / agents). | | ||
| | `--no-fix` | Run all checks, never prompt or fix, just report. | | ||
| | `-v`, `--version` | Print the version. | | ||
| | `-h`, `--help` | Show help. | | ||
| | Command / Flag | Description | | ||
| | ---------------------- | ---------------------------------------------------------------------------------- | | ||
| | `greenly` | Run the checks from `greenly.config.*`. | | ||
| | `greenly init` | Scaffold a config interactively (format, script name, checks) and install greenly. | | ||
| | `-y`, `--yes`, `--fix` | Auto-run every `onFail` fixer without prompting (great for CI / agents). | | ||
| | `--no-fix` | Run all checks, never prompt or fix, just report. | | ||
| | `-v`, `--version` | Print the version. | | ||
| | `-h`, `--help` | Show help. | | ||
@@ -119,0 +141,0 @@ When stdout is **not a TTY** (CI, piped output), greenly is non-interactive by default, so it never prompts and nothing hangs. Use `--yes` there to auto-apply fixes. |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
35181
44.76%684
82.4%143
18.18%4
33.33%+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed