greenly 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +31 -9
  2. package/dist/cli.js +330 -16
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -1,17 +1,37 @@
1
1
  # greenly
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/greenly?color=3fb950&label=npm)](https://www.npmjs.com/package/greenly)
4
+ [![npm downloads](https://img.shields.io/npm/dm/greenly?color=3fb950)](https://www.npmjs.com/package/greenly)
5
+ [![unpacked size](https://img.shields.io/npm/unpacked-size/greenly?color=3fb950)](https://www.npmjs.com/package/greenly)
6
+ [![Socket Badge](https://badge.socket.dev/npm/package/greenly/1.0.1)](https://socket.dev/npm/package/greenly)
7
+ [![PR Checks](https://github.com/yusifaliyevpro/greenly/actions/workflows/pr-checks.yml/badge.svg)](https://github.com/yusifaliyevpro/greenly/actions/workflows/pr-checks.yml)
8
+ [![license](https://img.shields.io/npm/l/greenly?color=3fb950)](https://github.com/yusifaliyevpro/greenly/blob/main/LICENSE)
9
+
3
10
  > Config-driven project check runner. Define your lint / format / typecheck / test steps once in `greenly.config.ts` and run them with a single command.
4
11
 
5
12
  Stop copy-pasting a `pr-checks` script into every repo. Describe your checks in a typed config, and `greenly` runs them in order, streams their output, and offers to auto-fix the ones that can be fixed.
6
13
 
14
+ ## Quick start
15
+
16
+ The fastest way to get going is the interactive scaffolder. It asks for a project
17
+ name, config format, script name, and which checks to include, then writes the config,
18
+ adds a `"check": "greenly"` script, and installs greenly. It adapts to your project:
19
+ it reuses matching `package.json` scripts (e.g. `fmt:check`, `lint`) when they exist,
20
+ only offers the lint/format tools you already use, and detects Next.js:
21
+
22
+ ```bash
23
+ pnpx greenly init
24
+ # or: npx greenly init
25
+ ```
26
+
7
27
  ## Install
8
28
 
29
+ Or set it up manually:
30
+
9
31
  ```bash
10
32
  pnpm add -D greenly
11
33
  ```
12
34
 
13
- ## Quick start
14
-
15
35
  Create a `greenly.config.ts` at your project root:
16
36
 
17
37
  ```ts
@@ -104,14 +124,16 @@ greenly.config.js greenly.config.mjs greenly.config.cjs
104
124
  greenly.config.json
105
125
  ```
106
126
 
107
- ## CLI flags
127
+ ## CLI
108
128
 
109
- | Flag | Description |
110
- | ---------------------- | ------------------------------------------------------------------------ |
111
- | `-y`, `--yes`, `--fix` | Auto-run every `onFail` fixer without prompting (great for CI / agents). |
112
- | `--no-fix` | Run all checks, never prompt or fix, just report. |
113
- | `-v`, `--version` | Print the version. |
114
- | `-h`, `--help` | Show help. |
129
+ | Command / Flag | Description |
130
+ | ---------------------- | ---------------------------------------------------------------------------------- |
131
+ | `greenly` | Run the checks from `greenly.config.*`. |
132
+ | `greenly init` | Scaffold a config interactively (format, script name, checks) and install greenly. |
133
+ | `-y`, `--yes`, `--fix` | Auto-run every `onFail` fixer without prompting (great for CI / agents). |
134
+ | `--no-fix` | Run all checks, never prompt or fix, just report. |
135
+ | `-v`, `--version` | Print the version. |
136
+ | `-h`, `--help` | Show help. |
115
137
 
116
138
  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.
117
139
 
package/dist/cli.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
3
- import { resolve } from "node:path";
4
- import { loadConfig } from "c12";
5
- import { execSync } from "node:child_process";
6
- import { cancel, confirm, isCancel } from "@clack/prompts";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { createJiti } from "jiti";
6
+ import { exec, execSync } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { cancel, confirm, intro, isCancel, log, multiselect, outro, select, spinner, text } from "@clack/prompts";
7
9
  //#region package.json
8
- var version = "1.0.0";
10
+ var version = "1.1.0";
9
11
  //#endregion
10
12
  //#region src/lib/args.ts
11
13
  function parseArgs(argv) {
@@ -40,8 +42,7 @@ const colors = {
40
42
  cyan: wrap(36, 39)
41
43
  };
42
44
  //#endregion
43
- //#region src/lib/config.ts
44
- const CONFIG_BASENAME = "greenly.config";
45
+ //#region src/lib/constants.ts
45
46
  const CONFIG_EXTENSIONS = [
46
47
  "ts",
47
48
  "mts",
@@ -51,6 +52,9 @@ const CONFIG_EXTENSIONS = [
51
52
  "cjs",
52
53
  "json"
53
54
  ];
55
+ //#endregion
56
+ //#region src/lib/config.ts
57
+ const CONFIG_BASENAME = "greenly.config";
54
58
  var ConfigNotFoundError = class extends Error {
55
59
  cwd;
56
60
  constructor(cwd) {
@@ -76,10 +80,7 @@ function findConfigFile(cwd = process.cwd()) {
76
80
  async function loadGreenlyConfig(cwd = process.cwd()) {
77
81
  const configFile = findConfigFile(cwd);
78
82
  if (!configFile) throw new ConfigNotFoundError(cwd);
79
- const { config } = await loadConfig({
80
- cwd,
81
- configFile
82
- });
83
+ const config = await createJiti(pathToFileURL(resolve(cwd, "greenly.config")).href).import(configFile, { default: true });
83
84
  if (!config || typeof config !== "object") throw new ConfigInvalidError(configFile, "config must export an object");
84
85
  if (!Array.isArray(config.checks) || config.checks.length === 0) throw new ConfigInvalidError(configFile, `"checks" must be a non-empty array`);
85
86
  for (const [i, check] of config.checks.entries()) {
@@ -92,6 +93,308 @@ async function loadGreenlyConfig(cwd = process.cwd()) {
92
93
  };
93
94
  }
94
95
  //#endregion
96
+ //#region src/lib/init.ts
97
+ const execAsync = promisify(exec);
98
+ function isRecord(value) {
99
+ return typeof value === "object" && value !== null;
100
+ }
101
+ function scriptCommand(ctx, candidates) {
102
+ const found = candidates.find((s) => ctx.scripts.has(s));
103
+ return found ? `${ctx.run} ${found}` : null;
104
+ }
105
+ const CHECK_PRESETS = [
106
+ {
107
+ value: "typescript",
108
+ label: "TypeScript (tsc)",
109
+ build: (c) => ({
110
+ name: "TypeScript",
111
+ command: scriptCommand(c, ["typecheck", "type-check"]) ?? `${c.exec} tsc --noEmit${c.isNext ? " --incremental false" : ""}`
112
+ })
113
+ },
114
+ {
115
+ value: "oxfmt",
116
+ label: "Oxfmt",
117
+ build: (c) => ({
118
+ name: "Oxfmt",
119
+ command: scriptCommand(c, ["fmt:check", "format:check"]) ?? `${c.exec} oxfmt --check`,
120
+ onFail: scriptCommand(c, ["fmt", "format"]) ?? `${c.exec} oxfmt`
121
+ })
122
+ },
123
+ {
124
+ value: "prettier",
125
+ label: "Prettier",
126
+ build: (c) => ({
127
+ name: "Prettier",
128
+ command: scriptCommand(c, ["fmt:check", "format:check"]) ?? `${c.exec} prettier --check .`,
129
+ onFail: scriptCommand(c, ["fmt", "format"]) ?? `${c.exec} prettier --write .`
130
+ })
131
+ },
132
+ {
133
+ value: "oxlint",
134
+ label: "Oxlint",
135
+ build: (c) => ({
136
+ name: "Oxlint",
137
+ command: scriptCommand(c, ["lint"]) ?? `${c.exec} oxlint`
138
+ })
139
+ },
140
+ {
141
+ value: "eslint",
142
+ label: "ESLint",
143
+ build: (c) => ({
144
+ name: "ESLint",
145
+ command: scriptCommand(c, ["lint"]) ?? `${c.exec} eslint .`
146
+ })
147
+ },
148
+ {
149
+ value: "vitest",
150
+ label: "Tests (Vitest)",
151
+ build: (c) => ({
152
+ name: "Tests",
153
+ command: scriptCommand(c, ["test"]) ?? `${c.exec} vitest run`
154
+ })
155
+ },
156
+ {
157
+ value: "build",
158
+ label: "Build",
159
+ build: (c) => ({
160
+ name: "Build",
161
+ command: scriptCommand(c, ["build"]) ?? `${c.run} build`
162
+ })
163
+ }
164
+ ];
165
+ function pmContext(pm) {
166
+ switch (pm) {
167
+ case "pnpm": return {
168
+ exec: "pnpm",
169
+ run: "pnpm"
170
+ };
171
+ case "yarn": return {
172
+ exec: "yarn",
173
+ run: "yarn"
174
+ };
175
+ case "bun": return {
176
+ exec: "bunx",
177
+ run: "bun run"
178
+ };
179
+ default: return {
180
+ exec: "npx",
181
+ run: "npm run"
182
+ };
183
+ }
184
+ }
185
+ function installCommand(pm) {
186
+ switch (pm) {
187
+ case "pnpm": return "pnpm add -D greenly@latest";
188
+ case "yarn": return "yarn add -D greenly@latest";
189
+ case "bun": return "bun add -d greenly@latest";
190
+ default: return "npm install -D greenly@latest";
191
+ }
192
+ }
193
+ function detectPackageManager(userAgent, lockfiles) {
194
+ const ua = userAgent ?? "";
195
+ if (ua.startsWith("pnpm")) return "pnpm";
196
+ if (ua.startsWith("yarn")) return "yarn";
197
+ if (ua.startsWith("bun")) return "bun";
198
+ if (ua.startsWith("npm")) return "npm";
199
+ if (lockfiles.includes("pnpm-lock.yaml")) return "pnpm";
200
+ if (lockfiles.includes("yarn.lock")) return "yarn";
201
+ if (lockfiles.includes("bun.lockb") || lockfiles.includes("bun.lock")) return "bun";
202
+ return "npm";
203
+ }
204
+ function buildChecks(selected, pm, opts = {}) {
205
+ const ctx = {
206
+ ...pmContext(pm),
207
+ isNext: opts.isNext ?? false,
208
+ scripts: opts.scripts ?? new Set()
209
+ };
210
+ return CHECK_PRESETS.filter((p) => selected.includes(p.value)).map((p) => p.build(ctx));
211
+ }
212
+ function isNextProject(pkg, hasNextConfig) {
213
+ if (hasNextConfig) return true;
214
+ const hasNextDep = (field) => {
215
+ const deps = pkg?.[field];
216
+ return isRecord(deps) && "next" in deps;
217
+ };
218
+ return hasNextDep("dependencies") || hasNextDep("devDependencies");
219
+ }
220
+ function packageScripts(pkg) {
221
+ const scripts = pkg?.scripts;
222
+ return isRecord(scripts) ? new Set(Object.keys(scripts)) : new Set();
223
+ }
224
+ function installedDependencies(pkg) {
225
+ const names = new Set();
226
+ for (const field of ["dependencies", "devDependencies"]) {
227
+ const deps = pkg?.[field];
228
+ if (isRecord(deps)) for (const key of Object.keys(deps)) names.add(key);
229
+ }
230
+ return names;
231
+ }
232
+ function availablePresets(installed) {
233
+ const hidden = new Set();
234
+ const prefer = (a, b) => {
235
+ if (installed.has(a) && !installed.has(b)) hidden.add(b);
236
+ if (installed.has(b) && !installed.has(a)) hidden.add(a);
237
+ };
238
+ prefer("oxlint", "eslint");
239
+ prefer("oxfmt", "prettier");
240
+ return CHECK_PRESETS.filter((p) => !hidden.has(p.value));
241
+ }
242
+ function configFileName(ext) {
243
+ return `greenly.config.${ext}`;
244
+ }
245
+ function renderCheck(check) {
246
+ const parts = [`name: ${JSON.stringify(check.name)}`, `command: ${JSON.stringify(check.command)}`];
247
+ if (check.onFail) parts.push(`onFail: ${JSON.stringify(check.onFail)}`);
248
+ return `{ ${parts.join(", ")} }`;
249
+ }
250
+ function isEsm(ext, isModule) {
251
+ if (ext === "ts" || ext === "mts" || ext === "mjs") return true;
252
+ if (ext === "cts" || ext === "cjs") return false;
253
+ return isModule;
254
+ }
255
+ function renderConfig(opts) {
256
+ const { name, checks, ext, isModule } = opts;
257
+ if (ext === "json") return `${JSON.stringify({
258
+ name,
259
+ checks
260
+ }, null, 2)}\n`;
261
+ const body = checks.map((c) => ` ${renderCheck(c)},`).join("\n");
262
+ const object = `{\n name: ${JSON.stringify(name)},\n checks: [\n${body}\n ],\n}`;
263
+ if (isEsm(ext, isModule)) return `import { defineConfig } from "greenly";\n\nexport default defineConfig(${object});\n`;
264
+ return `const { defineConfig } = require("greenly");\n\nmodule.exports = defineConfig(${object});\n`;
265
+ }
266
+ function withCheckScript(pkg, scriptName) {
267
+ const scripts = isRecord(pkg.scripts) ? pkg.scripts : {};
268
+ return {
269
+ ...pkg,
270
+ scripts: {
271
+ ...scripts,
272
+ [scriptName]: "greenly"
273
+ }
274
+ };
275
+ }
276
+ function readPackageJson(path) {
277
+ if (!existsSync(path)) return null;
278
+ try {
279
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
280
+ if (isRecord(parsed)) return parsed;
281
+ } catch {}
282
+ return null;
283
+ }
284
+ function detectLockfiles(cwd) {
285
+ return [
286
+ "pnpm-lock.yaml",
287
+ "yarn.lock",
288
+ "package-lock.json",
289
+ "bun.lockb",
290
+ "bun.lock"
291
+ ].filter((f) => existsSync(join(cwd, f)));
292
+ }
293
+ function hasNextConfigFile(cwd) {
294
+ return [
295
+ "js",
296
+ "mjs",
297
+ "cjs",
298
+ "ts",
299
+ "mts",
300
+ "cts"
301
+ ].some((e) => existsSync(join(cwd, `next.config.${e}`)));
302
+ }
303
+ function ensure(value) {
304
+ if (isCancel(value)) {
305
+ cancel("init cancelled.");
306
+ process.exit(0);
307
+ }
308
+ return value;
309
+ }
310
+ async function runInit(cwd = process.cwd()) {
311
+ intro(colors.inverse(" greenly ") + colors.dim(" init "));
312
+ const pkgPath = join(cwd, "package.json");
313
+ const pkg = readPackageJson(pkgPath);
314
+ const defaultName = typeof pkg?.name === "string" ? pkg.name : basename(cwd);
315
+ const pm = detectPackageManager(process.env.npm_config_user_agent, detectLockfiles(cwd));
316
+ const name = ensure(await text({
317
+ message: "Project name (shown in the banner)",
318
+ initialValue: defaultName,
319
+ validate: (value) => value?.trim() ? void 0 : "Please enter a project name"
320
+ }));
321
+ const ext = ensure(await select({
322
+ message: "Config file format",
323
+ options: CONFIG_EXTENSIONS.map((e) => ({
324
+ value: e,
325
+ label: configFileName(e)
326
+ })),
327
+ initialValue: "ts"
328
+ }));
329
+ const scriptName = ensure(await text({
330
+ message: "Script name to add to package.json",
331
+ initialValue: "check",
332
+ validate: (value) => value?.trim() ? void 0 : "Please enter a script name"
333
+ }));
334
+ const presets = availablePresets(installedDependencies(pkg));
335
+ const selected = ensure(await multiselect({
336
+ message: "Select the checks to include",
337
+ options: presets.map((p) => ({
338
+ value: p.value,
339
+ label: p.label
340
+ })),
341
+ required: true
342
+ }));
343
+ const doInstall = ensure(await confirm({
344
+ message: `Install greenly now with ${pm}?`,
345
+ initialValue: true
346
+ }));
347
+ const fileName = configFileName(ext);
348
+ const filePath = join(cwd, fileName);
349
+ if (existsSync(filePath)) {
350
+ if (!ensure(await confirm({
351
+ message: `${fileName} already exists. Overwrite it?`,
352
+ initialValue: false
353
+ }))) {
354
+ cancel("Kept the existing config. Nothing changed.");
355
+ process.exit(0);
356
+ }
357
+ }
358
+ const isNext = isNextProject(pkg, hasNextConfigFile(cwd));
359
+ if (isNext && selected.includes("typescript")) log.info(colors.dim("Detected Next.js, using tsc --incremental false"));
360
+ const content = renderConfig({
361
+ name,
362
+ checks: buildChecks(selected, pm, {
363
+ isNext,
364
+ scripts: packageScripts(pkg)
365
+ }),
366
+ ext,
367
+ isModule: pkg?.type === "module"
368
+ });
369
+ writeFileSync(filePath, content);
370
+ log.success(`Created ${colors.bold(fileName)}`);
371
+ if (pkg) {
372
+ const current = (isRecord(pkg.scripts) ? pkg.scripts : {})[scriptName];
373
+ let write = true;
374
+ if (typeof current === "string" && current !== "greenly") write = ensure(await confirm({
375
+ message: `Script "${scriptName}" already runs "${current}". Overwrite with "greenly"?`,
376
+ initialValue: false
377
+ }));
378
+ if (write) {
379
+ writeFileSync(pkgPath, `${JSON.stringify(withCheckScript(pkg, scriptName), null, 2)}\n`);
380
+ log.success(`Added ${colors.bold(`"${scriptName}": "greenly"`)} to package.json`);
381
+ }
382
+ } else log.warn("No package.json found, skipped adding the script.");
383
+ if (doInstall) {
384
+ const s = spinner();
385
+ s.start(`Installing greenly with ${pm}`);
386
+ try {
387
+ await execAsync(installCommand(pm), { cwd });
388
+ s.stop("Installed greenly");
389
+ } catch {
390
+ s.stop("Could not install greenly automatically");
391
+ log.warn(`Run "${installCommand(pm)}" yourself.`);
392
+ }
393
+ } else log.info(`Skipped install. Run "${installCommand(pm)}" when ready.`);
394
+ const runCmd = `${pmContext(pm).run} ${scriptName}`;
395
+ outro(`Done. Run ${colors.bold(runCmd)} to run your checks.`);
396
+ }
397
+ //#endregion
95
398
  //#region src/lib/runner.ts
96
399
  const MIN_WIDTH = 60;
97
400
  function bannerWidth(name) {
@@ -222,7 +525,11 @@ async function runChecks(config, options = {}) {
222
525
  });
223
526
  if (isCancel(answer)) {
224
527
  cancel("Aborted.");
225
- process.exit(1);
528
+ return {
529
+ results,
530
+ failed: results.filter((r) => r.status === "failed").length,
531
+ exitCode: 1
532
+ };
226
533
  }
227
534
  shouldFix = answer;
228
535
  }
@@ -277,6 +584,7 @@ const HELP = `${colors.bold("greenly")} - config-driven project check runner
277
584
 
278
585
  ${colors.bold("Usage")}
279
586
  greenly [options]
587
+ greenly init Scaffold a greenly.config file interactively
280
588
 
281
589
  ${colors.bold("Options")}
282
590
  -y, --yes, --fix Auto-run every onFail fixer without prompting (CI / agents)
@@ -299,7 +607,12 @@ ${colors.bold("Config")}
299
607
  });
300
608
  `;
301
609
  async function main() {
302
- const parsed = parseArgs(process.argv.slice(2));
610
+ const argv = process.argv.slice(2);
611
+ if (argv[0] === "init") {
612
+ await runInit();
613
+ return;
614
+ }
615
+ const parsed = parseArgs(argv);
303
616
  if (parsed.help) {
304
617
  console.log(HELP);
305
618
  return;
@@ -312,11 +625,12 @@ async function main() {
312
625
  try {
313
626
  const { config } = await loadGreenlyConfig();
314
627
  const { exitCode } = await runChecks(config, mode);
315
- process.exit(exitCode);
628
+ process.exitCode = exitCode;
316
629
  } catch (error) {
317
630
  if (error instanceof ConfigNotFoundError || error instanceof ConfigInvalidError) {
318
631
  console.error(colors.red(error.message));
319
- process.exit(1);
632
+ process.exitCode = 1;
633
+ return;
320
634
  }
321
635
  throw error;
322
636
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "greenly",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Config-driven project check runner. Define your lint/format/typecheck/test steps in greenly.config.ts and run them with one command.",
5
5
  "keywords": [
6
6
  "check",
@@ -75,13 +75,13 @@
75
75
  },
76
76
  "dependencies": {
77
77
  "@clack/prompts": "1.7.0",
78
- "c12": "3.3.4"
78
+ "jiti": "2.7.0"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@types/node": "24.13.3",
82
82
  "oxfmt": "0.61.0",
83
83
  "oxlint": "1.76.0",
84
- "oxlint-tsgolint": "^7.0.2001",
84
+ "oxlint-tsgolint": "7.0.2001",
85
85
  "tsdown": "0.22.14",
86
86
  "typescript": "7.0.2",
87
87
  "vitest": "4.1.10"