@chanom/cli 0.4.4 → 0.5.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 +21 -9
  2. package/dist/index.js +211 -38
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @chanom/cli
2
2
 
3
- Interactive CLI that brews dev tooling config into your project - oxlint, oxfmt, knip, VS Code settings, agent coding standards, and commit hooks, picked with a couple of prompts.
3
+ Interactive CLI that brews dev tooling config into your project - oxlint, oxfmt, knip, VS Code settings, agent coding standards, a GitHub Actions CI workflow, and commit hooks, picked with a couple of prompts.
4
4
 
5
5
  ## Install
6
6
 
@@ -32,7 +32,7 @@ chanom brew --debug
32
32
 
33
33
  Debug mode logs each step (detected package manager, planned packages, every command it spawns and its exit code) to stderr, and on failure prints the full error trace including which step failed.
34
34
 
35
- `brew` walks you through two prompts:
35
+ `brew` walks you through two prompts (plus a third when you pick `github-actions`):
36
36
 
37
37
  1. **Toppings** - which tools to add, any combination (or none):
38
38
  - `oxlint` - fast Rust-based linter
@@ -40,6 +40,7 @@ Debug mode logs each step (detected package manager, planned packages, every com
40
40
  - `knip` - dead code remover
41
41
  - `vscode` - editor settings + extension recommendations
42
42
  - `skills` - coding standards for AI agents (`.claude/skills`)
43
+ - `github-actions` - CI workflow (`.github/workflows/ci.yml`), then pick its jobs: any of `lint`, `build`, `typecheck`, `test`
43
44
  2. **Sweetness** - how strict to be:
44
45
  - `light` - warn only, no blocking
45
46
  - `medium` - block on commit via husky + lint-staged + commitlint
@@ -62,13 +63,14 @@ installing anything. `light` sweetness works anywhere in the repo.
62
63
 
63
64
  ## What gets added
64
65
 
65
- | Topping | Config file | Scripts added |
66
- | -------- | ------------------------------------------------------------------------------- | ------------------------ |
67
- | `oxlint` | `oxlint.config.ts`\* | `lint`, `lint:fix` |
68
- | `oxfmt` | `oxfmt.config.ts`\* | `format`, `format:check` |
69
- | `knip` | `knip.config.ts`\* | `knip` |
70
- | `vscode` | `.vscode/settings.json`, `.vscode/extensions.json` | none |
71
- | `skills` | `.agents/skills/coding-standards/`, `.claude/skills/coding-standards` (symlink) | none |
66
+ | Topping | Config file | Scripts added |
67
+ | ---------------- | ------------------------------------------------------------------------------- | ------------------------ |
68
+ | `oxlint` | `oxlint.config.ts`\* | `lint`, `lint:fix` |
69
+ | `oxfmt` | `oxfmt.config.ts`\* | `format`, `format:check` |
70
+ | `knip` | `knip.config.ts`\* | `knip` |
71
+ | `vscode` | `.vscode/settings.json`, `.vscode/extensions.json` | none |
72
+ | `skills` | `.agents/skills/coding-standards/`, `.claude/skills/coding-standards` (symlink) | none |
73
+ | `github-actions` | `.github/workflows/ci.yml` | none |
72
74
 
73
75
  \* `.mts` instead of `.ts` when your project is CommonJS (no `"type": "module"` in `package.json`).
74
76
 
@@ -95,6 +97,15 @@ tool can read it, and links `.claude/skills/coding-standards` at that directory
95
97
  with a relative symlink rather than keeping a second copy. The standards target
96
98
  the stack chanom scaffolds (Vite + React + TypeScript, oxlint, oxfmt, vitest).
97
99
 
100
+ The `github-actions` topping installs no packages and adds no scripts. It
101
+ writes a CI workflow that runs on every pull request, with one independent job
102
+ per check you picked. Each job checks out the repo, sets up your package
103
+ manager and Node (from `.nvmrc` when present, otherwise the latest LTS),
104
+ installs dependencies from the lockfile, and runs `<pm> run <job>`. The job
105
+ calls the matching `package.json` script, so `brew` warns about each selected
106
+ job that has no script yet (the `lint` script from the `oxlint` topping
107
+ counts).
108
+
98
109
  `medium` sweetness additionally adds:
99
110
 
100
111
  - `husky` - installed via `husky init`
@@ -120,6 +131,7 @@ Existing setup is never overwritten:
120
131
  setting you already have wins (and is warned about), and existing extension
121
132
  recommendations are kept. A `.vscode` file that can't be parsed as JSON (e.g. it
122
133
  has comments) is left untouched with a warning.
134
+ - An existing `.github/workflows/ci.yml` is left untouched with a warning.
123
135
  - Skill files already on disk are warned about and skipped, and an existing
124
136
  `.claude/skills/coding-standards` (a link of your own, or a real directory you
125
137
  maintain by hand) is left alone - so re-running never overwrites standards you
package/dist/index.js CHANGED
@@ -1,9 +1,133 @@
1
1
  #!/usr/bin/env node
2
+ import { Document, isMap, isNode, isScalar, isSeq } from "yaml";
2
3
  import { Command, CommandExecutor, FileSystem, Path } from "@effect/platform";
3
4
  import { Cause, Chunk, Data, Effect, Layer, LogLevel, Logger, Option, Stream } from "effect";
4
5
  import * as clack from "@clack/prompts";
5
6
  import pc from "picocolors";
6
7
  import { NodeContext, NodeRuntime } from "@effect/platform-node";
8
+ // ../internal/src/domain/yaml.ts
9
+ /** Stringify options shared by every YAML file chanom generates, so they all read alike. */
10
+ const YAML_OPTIONS = {
11
+ singleQuote: true,
12
+ lineWidth: 0,
13
+ flowCollectionPadding: false
14
+ };
15
+ /**
16
+ * Puts a blank line before every entry of a map or sequence node except the
17
+ * first. Mutates the node, so only call it on a `Document` built for output.
18
+ */
19
+ function spaceItems(node) {
20
+ if (isMap(node)) {
21
+ for (const pair of node.items.slice(1)) if (isScalar(pair.key)) pair.key.spaceBefore = true;
22
+ } else if (isSeq(node)) {
23
+ for (const item of node.items.slice(1)) if (isNode(item)) item.spaceBefore = true;
24
+ }
25
+ }
26
+ // ../internal/src/domain/github-actions.ts
27
+ /** Every job the workflow can contain, in the order they appear in the file. */
28
+ const CI_JOBS = [
29
+ "lint",
30
+ "build",
31
+ "typecheck",
32
+ "test"
33
+ ];
34
+ const WORKFLOW_PATH = ".github/workflows/ci.yml";
35
+ const INSTALL_COMMAND = {
36
+ pnpm: "pnpm install --frozen-lockfile",
37
+ npm: "npm ci",
38
+ yarn: "yarn install --frozen-lockfile",
39
+ bun: "bun install --frozen-lockfile"
40
+ };
41
+ const JOB_STEP_NAME = {
42
+ lint: "Lint",
43
+ build: "Build",
44
+ typecheck: "Typecheck",
45
+ test: "Test"
46
+ };
47
+ function setupNode(cache, hasNvmrc) {
48
+ return {
49
+ uses: "actions/setup-node@v6",
50
+ with: {
51
+ ...hasNvmrc ? { "node-version-file": ".nvmrc" } : { "node-version": "lts/*" },
52
+ cache
53
+ }
54
+ };
55
+ }
56
+ /** Steps that bring a job up to "dependencies installed". */
57
+ function setupSteps(options) {
58
+ const checkout = { uses: "actions/checkout@v7" };
59
+ const install = {
60
+ name: "Install dependencies",
61
+ run: INSTALL_COMMAND[options.pm]
62
+ };
63
+ switch (options.pm) {
64
+ case "pnpm": return [
65
+ checkout,
66
+ options.hasPackageManagerField ? { uses: "pnpm/action-setup@v6" } : {
67
+ uses: "pnpm/action-setup@v6",
68
+ with: { version: "latest" }
69
+ },
70
+ setupNode("pnpm", options.hasNvmrc),
71
+ install
72
+ ];
73
+ case "npm": return [
74
+ checkout,
75
+ setupNode("npm", options.hasNvmrc),
76
+ install
77
+ ];
78
+ case "yarn": return [
79
+ checkout,
80
+ { run: "corepack enable" },
81
+ setupNode("yarn", options.hasNvmrc),
82
+ install
83
+ ];
84
+ case "bun": return [
85
+ checkout,
86
+ { uses: "oven-sh/setup-bun@v2" },
87
+ install
88
+ ];
89
+ }
90
+ }
91
+ /** Selected jobs deduplicated and put in workflow order. */
92
+ function orderJobs(jobs) {
93
+ return CI_JOBS.filter((job) => jobs.includes(job));
94
+ }
95
+ /**
96
+ * `.github/workflows/ci.yml` contents: one independent job per selected check,
97
+ * each running `<pm> run <job>`. Shared so `chanom brew` and
98
+ * `create-chanom-app` emit byte-identical workflows.
99
+ */
100
+ function workflowFile(options) {
101
+ const jobs = Object.fromEntries(orderJobs(options.jobs).map((job) => [job, {
102
+ "runs-on": "ubuntu-latest",
103
+ steps: [...setupSteps(options), {
104
+ name: JOB_STEP_NAME[job],
105
+ run: `${options.pm} run ${job}`
106
+ }]
107
+ }]));
108
+ const doc = new Document({
109
+ name: "CI",
110
+ on: { pull_request: { branches: ["*"] } },
111
+ concurrency: {
112
+ group: "${{ github.workflow }}-${{ github.ref }}",
113
+ "cancel-in-progress": true
114
+ },
115
+ jobs
116
+ });
117
+ spaceItems(doc.contents);
118
+ const branches = doc.getIn([
119
+ "on",
120
+ "pull_request",
121
+ "branches"
122
+ ], true);
123
+ if (isSeq(branches)) branches.flow = true;
124
+ const jobNodes = doc.get("jobs", true);
125
+ spaceItems(jobNodes);
126
+ if (isMap(jobNodes)) {
127
+ for (const pair of jobNodes.items) if (isMap(pair.value)) spaceItems(pair.value.get("steps", true));
128
+ }
129
+ return doc.toString(YAML_OPTIONS);
130
+ }
7
131
  // ../internal/src/domain/package-manager.ts
8
132
  /** How to run a locally installed binary with each package manager. */
9
133
  const PM_EXEC = {
@@ -227,7 +351,8 @@ var Prompter = class extends Effect.Service()("internal/Prompter", { succeed: {
227
351
  multiselect: (params) => guardCancel(() => clack.multiselect({
228
352
  message: params.message,
229
353
  options: [...params.options],
230
- required: params.required ?? false
354
+ required: params.required ?? false,
355
+ initialValues: params.initialValues && [...params.initialValues]
231
356
  })),
232
357
  spinner: (message) => Effect.sync(() => {
233
358
  const s = clack.spinner();
@@ -471,7 +596,7 @@ function commitMsgHook(pm) {
471
596
  ].join(" ") + "\n";
472
597
  }
473
598
  // src/commands/add-commitlint/index.ts
474
- const apply$7 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
599
+ const apply$8 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
475
600
  const fs = yield* FileSystem.FileSystem;
476
601
  const path = yield* Path.Path;
477
602
  const prompter = yield* Prompter;
@@ -485,6 +610,38 @@ const apply$7 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
485
610
  else yield* fs.writeFileString(hookPath, commitMsgHook(pm));
486
611
  }
487
612
  });
613
+ // src/commands/add-github-actions/logic.ts
614
+ /** Selected jobs whose `<pm> run <job>` has no matching script in package.json. */
615
+ function missingScripts(scripts, jobs) {
616
+ return orderJobs(jobs).filter((job) => scripts?.[job] === void 0);
617
+ }
618
+ // src/commands/add-github-actions/index.ts
619
+ /**
620
+ * Writes `.github/workflows/ci.yml` with one job per selected check. An
621
+ * existing workflow is left alone. `pkg` should already carry the scripts the
622
+ * other toppings added, so the missing-script warning only names scripts the
623
+ * user still has to write. Contributes no packages and no scripts, so - like
624
+ * `add-vscode` - it returns nothing for the caller to persist.
625
+ */
626
+ const apply$7 = Effect.fn("add-github-actions.apply")(function* (cwd, pm, pkg, jobs) {
627
+ const fs = yield* FileSystem.FileSystem;
628
+ const path = yield* Path.Path;
629
+ const prompter = yield* Prompter;
630
+ const filePath = path.join(cwd, WORKFLOW_PATH);
631
+ if (yield* fs.exists(filePath)) {
632
+ yield* prompter.warn(`\`${WORKFLOW_PATH}\` already exists - skipping GitHub Actions workflow`);
633
+ return;
634
+ }
635
+ const hasNvmrc = yield* fs.exists(path.join(cwd, ".nvmrc"));
636
+ yield* fs.makeDirectory(path.dirname(filePath), { recursive: true });
637
+ yield* fs.writeFileString(filePath, workflowFile({
638
+ pm,
639
+ jobs,
640
+ hasNvmrc,
641
+ hasPackageManagerField: pkg.packageManager !== void 0
642
+ }));
643
+ for (const job of missingScripts(pkg.scripts, jobs)) yield* prompter.warn(`No \`${job}\` script in package.json - the \`${job}\` CI job will fail until you add one`);
644
+ });
488
645
  // src/commands/add-husky/logic.ts
489
646
  function getPackages$4(pkg) {
490
647
  return isPackageInstalled(pkg, "husky") ? [] : ["husky"];
@@ -897,38 +1054,53 @@ const ensureGitIgnore = Effect.fn("brew.ensureGitIgnore")(function* (cwd) {
897
1054
  });
898
1055
  const askRecipe = Effect.fn("brew.askRecipe")(function* () {
899
1056
  const prompter = yield* Prompter;
1057
+ const toppings = yield* prompter.multiselect({
1058
+ message: "Which toppings would you like?",
1059
+ options: [
1060
+ {
1061
+ value: "oxlint",
1062
+ label: "oxlint",
1063
+ hint: "fast Rust-based linter"
1064
+ },
1065
+ {
1066
+ value: "oxfmt",
1067
+ label: "oxfmt",
1068
+ hint: "fast Rust-based formatter"
1069
+ },
1070
+ {
1071
+ value: "knip",
1072
+ label: "knip",
1073
+ hint: "dead code remover"
1074
+ },
1075
+ {
1076
+ value: "vscode",
1077
+ label: "vscode",
1078
+ hint: "editor settings + extension recommendations"
1079
+ },
1080
+ {
1081
+ value: "skills",
1082
+ label: "skills",
1083
+ hint: "coding standards for AI agents (.claude/skills)"
1084
+ },
1085
+ {
1086
+ value: "github-actions",
1087
+ label: "github-actions",
1088
+ hint: "CI workflow (.github/workflows)"
1089
+ }
1090
+ ],
1091
+ required: false
1092
+ });
900
1093
  return {
901
- toppings: yield* prompter.multiselect({
902
- message: "Which toppings would you like?",
903
- options: [
904
- {
905
- value: "oxlint",
906
- label: "oxlint",
907
- hint: "fast Rust-based linter"
908
- },
909
- {
910
- value: "oxfmt",
911
- label: "oxfmt",
912
- hint: "fast Rust-based formatter"
913
- },
914
- {
915
- value: "knip",
916
- label: "knip",
917
- hint: "dead code remover"
918
- },
919
- {
920
- value: "vscode",
921
- label: "vscode",
922
- hint: "editor settings + extension recommendations"
923
- },
924
- {
925
- value: "skills",
926
- label: "skills",
927
- hint: "coding standards for AI agents (.claude/skills)"
928
- }
929
- ],
930
- required: false
931
- }),
1094
+ toppings,
1095
+ ciJobs: toppings.includes("github-actions") ? yield* prompter.multiselect({
1096
+ message: "Which GitHub Actions jobs would you like?",
1097
+ options: CI_JOBS.map((job) => ({
1098
+ value: job,
1099
+ label: job
1100
+ })),
1101
+ initialValues: CI_JOBS,
1102
+ required: true
1103
+ }) : [],
932
1104
  sweetness: yield* prompter.select({
933
1105
  message: "How sweet would you like it?",
934
1106
  options: [{
@@ -954,13 +1126,14 @@ const installPackages = Effect.fn("brew.installPackages")(function* (pm, cwd, pa
954
1126
  yield* installer.installDev(pm, cwd, packages).pipe(Effect.tapError(() => s.stop(pc.red("Package installation failed"))));
955
1127
  yield* s.stop("Packages installed");
956
1128
  });
957
- const applyToppings = Effect.fn("brew.applyToppings")(function* (cwd, esm, pkg, toppings) {
1129
+ const applyToppings = Effect.fn("brew.applyToppings")(function* (cwd, pm, esm, pkg, toppings, ciJobs) {
958
1130
  let updated = pkg;
959
1131
  if (toppings.includes("oxlint")) updated = yield* apply$2(cwd, esm, updated);
960
1132
  if (toppings.includes("oxfmt")) updated = yield* apply$3(cwd, esm, updated);
961
1133
  if (toppings.includes("knip")) updated = yield* apply$5(cwd, esm, updated);
962
1134
  if (toppings.includes("vscode")) yield* apply(cwd, oxfmtConfigPath(esm));
963
1135
  if (toppings.includes("skills")) yield* apply$1(cwd);
1136
+ if (toppings.includes("github-actions")) yield* apply$7(cwd, pm, updated, ciJobs);
964
1137
  return updated;
965
1138
  });
966
1139
  const persistScripts = Effect.fn("brew.persistScripts")(function* (pkgPath, pkg, updated) {
@@ -999,7 +1172,7 @@ const setupCommitGate = Effect.fn("brew.setupCommitGate")(function* (cwd, pm, es
999
1172
  const seededHook = path.join(cwd, ".husky", "pre-commit");
1000
1173
  if (!huskyExisted && (yield* fs.exists(seededHook))) yield* fs.remove(seededHook);
1001
1174
  }
1002
- yield* apply$7(cwd, pm);
1175
+ yield* apply$8(cwd, pm);
1003
1176
  });
1004
1177
  const commitChanges = Effect.fn("brew.commitChanges")(function* (cwd) {
1005
1178
  const git = yield* Git;
@@ -1028,14 +1201,14 @@ const brew = (cwd = process.cwd()) => Effect.gen(function* () {
1028
1201
  const pm = yield* detectPm(cwd);
1029
1202
  yield* Effect.logDebug(`detected package manager: ${pm}`);
1030
1203
  const { pkg, pkgPath } = yield* readPkg(cwd);
1031
- const { toppings, sweetness } = yield* askRecipe();
1032
- yield* Effect.logDebug(`recipe: toppings=[${toppings.join(", ")}] sweetness=${sweetness} esm=${isEsm(pkg)}`);
1204
+ const { toppings, ciJobs, sweetness } = yield* askRecipe();
1205
+ yield* Effect.logDebug(`recipe: toppings=[${toppings.join(", ")}] ciJobs=[${ciJobs.join(", ")}] sweetness=${sweetness} esm=${isEsm(pkg)}`);
1033
1206
  yield* ensureRepoRootForHooks(cwd, sweetness);
1034
1207
  const packages = planPackages(pkg, toppings, sweetness, bundledVersions);
1035
1208
  yield* Effect.logDebug(`planned packages: ${packages.length > 0 ? packages.join(", ") : "(none)"}`);
1036
1209
  yield* installPackages(pm, cwd, packages);
1037
1210
  const { pkg: installedPkg } = yield* readPkg(cwd);
1038
- const updated = yield* applyToppings(cwd, isEsm(installedPkg), installedPkg, toppings);
1211
+ const updated = yield* applyToppings(cwd, pm, isEsm(installedPkg), installedPkg, toppings, ciJobs);
1039
1212
  yield* persistScripts(pkgPath, installedPkg, updated);
1040
1213
  yield* setupCommitGate(cwd, pm, isEsm(installedPkg), toppings, sweetness);
1041
1214
  yield* commitChanges(cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chanom/cli",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "repository": {
@@ -20,7 +20,8 @@
20
20
  "@effect/platform": "0.96.2",
21
21
  "@effect/platform-node": "0.107.0",
22
22
  "effect": "3.22.4",
23
- "picocolors": "1.1.1"
23
+ "picocolors": "1.1.1",
24
+ "yaml": "2.9.0"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@effect/language-service": "0.86.4",