create-chanom-app 0.4.2 → 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 +9 -3
  2. package/dist/index.js +223 -17
  3. package/package.json +7 -6
package/README.md CHANGED
@@ -29,10 +29,11 @@ pnpm create chanom-app .
29
29
  Run with no flags, the CLI walks you through:
30
30
 
31
31
  1. **Project name** - the target directory (kebab-cased). Defaults to `my-chanom-app`.
32
- 2. **Initialize a git repository?** - `git init` plus an initial commit.
32
+ 2. **Initialize a git repository?** - `git init` plus an initial commit (made after the install, so it includes the lockfile).
33
33
  3. **Add commit hooks?** - husky + lint-staged + commitlint (only asked when git is enabled).
34
34
  4. **Add agent coding standards?** - the `coding-standards` skill in `.agents/skills`, linked into `.claude/skills`.
35
- 5. **Install dependencies now?** - runs `<pm> install` after scaffolding.
35
+ 5. **Add a GitHub Actions CI workflow?** - `.github/workflows/ci.yml`; if yes, pick its jobs from `lint`, `build`, `typecheck`, `test` (all checked by default).
36
+ 6. **Install dependencies now?** - runs `<pm> install` after scaffolding.
36
37
 
37
38
  Every prompt has a matching flag so you can pre-answer any of them (or all, with `--yes`).
38
39
 
@@ -46,13 +47,16 @@ Every prompt has a matching flag so you can pre-answer any of them (or all, with
46
47
  | `--no-commit-hooks` | Skip the commit-hooks topping |
47
48
  | `--agent-skills` | Add the coding-standards skill (default: yes) |
48
49
  | `--no-agent-skills` | Skip the agent-skills topping |
50
+ | `--github-actions` | Add a GitHub Actions CI workflow (default: yes) |
51
+ | `--no-github-actions` | Skip the github-actions topping |
52
+ | `--ci-jobs <jobs>` | Comma-separated CI jobs, e.g. `lint,test` (default: all) |
49
53
  | `--install` / `--no-install` | Install dependencies after scaffolding (default: install) |
50
54
  | `--pm <pnpm\|npm\|yarn\|bun>` | Package manager to record (default: detected) |
51
55
  | `-y`, `--yes` | Accept all defaults, no prompts (CI-friendly) |
52
56
  | `--help` | Show help |
53
57
  | `--version` | Show the version |
54
58
 
55
- `--no-git --commit-hooks` is rejected: husky needs a repo to install into.
59
+ `--no-git --commit-hooks` is rejected: husky needs a repo to install into. `--no-github-actions --ci-jobs` is rejected too. `--ci-jobs` on its own turns the topping on.
56
60
 
57
61
  The package manager is detected from the invoking user agent (falling back to `pnpm`); `--pm` overrides it and is recorded in the generated `package.json` `packageManager` field.
58
62
 
@@ -81,6 +85,8 @@ With the **commit-hooks** topping, it also adds husky, lint-staged, and commitli
81
85
 
82
86
  With the **agent-skills** topping, it adds the `coding-standards` agent skill - a router `SKILL.md` plus TypeScript, React, and Vitest references written for exactly the stack scaffolded here. The real files land in `.agents/skills/coding-standards/` so any agent tool can read them, and `.claude/skills/coding-standards` is a relative symlink pointing at that directory rather than a second copy. It contributes no packages and no scripts, so skipping it leaves both directories out entirely.
83
87
 
88
+ With the **github-actions** topping, it adds `.github/workflows/ci.yml` - the same workflow `chanom brew` writes. It runs on every pull request with one independent job per check you picked. Each job sets up your package manager and the latest LTS Node, runs `<pm> install` with a frozen lockfile, then `<pm> run <job>`. The generated app ships all four scripts, so every job works as generated. When dependencies are installed during scaffolding, the lockfile lands in the initial commit; with `--no-install`, commit it after your first install - the frozen install fails without it.
89
+
84
90
  ## Development
85
91
 
86
92
  ```sh
package/dist/index.js CHANGED
@@ -1,10 +1,134 @@
1
1
  #!/usr/bin/env node
2
+ import { Document, isMap, isNode, isScalar, isSeq, stringify } from "yaml";
2
3
  import { Command, CommandExecutor, FileSystem, Path } from "@effect/platform";
3
4
  import { 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";
7
8
  import { join } from "node:path";
9
+ // ../internal/src/domain/yaml.ts
10
+ /** Stringify options shared by every YAML file chanom generates, so they all read alike. */
11
+ const YAML_OPTIONS = {
12
+ singleQuote: true,
13
+ lineWidth: 0,
14
+ flowCollectionPadding: false
15
+ };
16
+ /**
17
+ * Puts a blank line before every entry of a map or sequence node except the
18
+ * first. Mutates the node, so only call it on a `Document` built for output.
19
+ */
20
+ function spaceItems(node) {
21
+ if (isMap(node)) {
22
+ for (const pair of node.items.slice(1)) if (isScalar(pair.key)) pair.key.spaceBefore = true;
23
+ } else if (isSeq(node)) {
24
+ for (const item of node.items.slice(1)) if (isNode(item)) item.spaceBefore = true;
25
+ }
26
+ }
27
+ // ../internal/src/domain/github-actions.ts
28
+ /** Every job the workflow can contain, in the order they appear in the file. */
29
+ const CI_JOBS = [
30
+ "lint",
31
+ "build",
32
+ "typecheck",
33
+ "test"
34
+ ];
35
+ const WORKFLOW_PATH = ".github/workflows/ci.yml";
36
+ const INSTALL_COMMAND = {
37
+ pnpm: "pnpm install --frozen-lockfile",
38
+ npm: "npm ci",
39
+ yarn: "yarn install --frozen-lockfile",
40
+ bun: "bun install --frozen-lockfile"
41
+ };
42
+ const JOB_STEP_NAME = {
43
+ lint: "Lint",
44
+ build: "Build",
45
+ typecheck: "Typecheck",
46
+ test: "Test"
47
+ };
48
+ function setupNode(cache, hasNvmrc) {
49
+ return {
50
+ uses: "actions/setup-node@v6",
51
+ with: {
52
+ ...hasNvmrc ? { "node-version-file": ".nvmrc" } : { "node-version": "lts/*" },
53
+ cache
54
+ }
55
+ };
56
+ }
57
+ /** Steps that bring a job up to "dependencies installed". */
58
+ function setupSteps(options) {
59
+ const checkout = { uses: "actions/checkout@v7" };
60
+ const install = {
61
+ name: "Install dependencies",
62
+ run: INSTALL_COMMAND[options.pm]
63
+ };
64
+ switch (options.pm) {
65
+ case "pnpm": return [
66
+ checkout,
67
+ options.hasPackageManagerField ? { uses: "pnpm/action-setup@v6" } : {
68
+ uses: "pnpm/action-setup@v6",
69
+ with: { version: "latest" }
70
+ },
71
+ setupNode("pnpm", options.hasNvmrc),
72
+ install
73
+ ];
74
+ case "npm": return [
75
+ checkout,
76
+ setupNode("npm", options.hasNvmrc),
77
+ install
78
+ ];
79
+ case "yarn": return [
80
+ checkout,
81
+ { run: "corepack enable" },
82
+ setupNode("yarn", options.hasNvmrc),
83
+ install
84
+ ];
85
+ case "bun": return [
86
+ checkout,
87
+ { uses: "oven-sh/setup-bun@v2" },
88
+ install
89
+ ];
90
+ }
91
+ }
92
+ /** Selected jobs deduplicated and put in workflow order. */
93
+ function orderJobs(jobs) {
94
+ return CI_JOBS.filter((job) => jobs.includes(job));
95
+ }
96
+ /**
97
+ * `.github/workflows/ci.yml` contents: one independent job per selected check,
98
+ * each running `<pm> run <job>`. Shared so `chanom brew` and
99
+ * `create-chanom-app` emit byte-identical workflows.
100
+ */
101
+ function workflowFile(options) {
102
+ const jobs = Object.fromEntries(orderJobs(options.jobs).map((job) => [job, {
103
+ "runs-on": "ubuntu-latest",
104
+ steps: [...setupSteps(options), {
105
+ name: JOB_STEP_NAME[job],
106
+ run: `${options.pm} run ${job}`
107
+ }]
108
+ }]));
109
+ const doc = new Document({
110
+ name: "CI",
111
+ on: { pull_request: { branches: ["*"] } },
112
+ concurrency: {
113
+ group: "${{ github.workflow }}-${{ github.ref }}",
114
+ "cancel-in-progress": true
115
+ },
116
+ jobs
117
+ });
118
+ spaceItems(doc.contents);
119
+ const branches = doc.getIn([
120
+ "on",
121
+ "pull_request",
122
+ "branches"
123
+ ], true);
124
+ if (isSeq(branches)) branches.flow = true;
125
+ const jobNodes = doc.get("jobs", true);
126
+ spaceItems(jobNodes);
127
+ if (isMap(jobNodes)) {
128
+ for (const pair of jobNodes.items) if (isMap(pair.value)) spaceItems(pair.value.get("steps", true));
129
+ }
130
+ return doc.toString(YAML_OPTIONS);
131
+ }
8
132
  // ../internal/src/domain/package-manager.ts
9
133
  /** How to run a locally installed binary with each package manager. */
10
134
  const PM_EXEC = {
@@ -193,7 +317,8 @@ var Prompter = class extends Effect.Service()("internal/Prompter", { succeed: {
193
317
  multiselect: (params) => guardCancel(() => clack.multiselect({
194
318
  message: params.message,
195
319
  options: [...params.options],
196
- required: params.required ?? false
320
+ required: params.required ?? false,
321
+ initialValues: params.initialValues && [...params.initialValues]
197
322
  })),
198
323
  spinner: (message) => Effect.sync(() => {
199
324
  const s = clack.spinner();
@@ -262,8 +387,8 @@ const depVersions = {
262
387
  "@types/react-dom": "19.2.3",
263
388
  "typescript": "7.0.2",
264
389
  "vite": "8.1.0",
265
- "vitest": "3.2.6",
266
- "@vitest/coverage-v8": "3.2.6",
390
+ "vitest": "5.0.5",
391
+ "@vitest/coverage-v8": "5.0.5",
267
392
  "jsdom": "30.0.0",
268
393
  "@testing-library/react": "16.3.2",
269
394
  "@testing-library/jest-dom": "7.0.0",
@@ -275,10 +400,10 @@ const depVersions = {
275
400
  "@commitlint/cli": "21.2.0",
276
401
  "@commitlint/config-conventional": "21.2.0",
277
402
  "@chanom/dev-config": "0.0.5",
278
- "@chanom/vite-config": "0.1.1"
403
+ "@chanom/vite-config": "0.1.2"
279
404
  };
280
405
  /** This package's own version, for `--version`. */
281
- const pkgVersion = "0.4.2";
406
+ const pkgVersion = "0.5.0";
282
407
  // src/domain/args.ts
283
408
  const PACKAGE_MANAGERS = [
284
409
  "pnpm",
@@ -287,11 +412,12 @@ const PACKAGE_MANAGERS = [
287
412
  "bun"
288
413
  ];
289
414
  const isPackageManager = (value) => PACKAGE_MANAGERS.includes(value);
415
+ const isCiJob = (value) => CI_JOBS.includes(value);
290
416
  /**
291
417
  * Parses the CLI arguments, returning either the resolved flags or a
292
418
  * user-facing error message. Every flag mirrors an interactive prompt; the
293
- * contradictory `--no-git --commit-hooks` pair is rejected here rather than
294
- * silently reconciled (husky needs a repo to install into).
419
+ * contradictory `--no-git --commit-hooks` and `--no-github-actions --ci-jobs`
420
+ * pairs are rejected here rather than silently reconciled.
295
421
  */
296
422
  function parseArgs(argv) {
297
423
  const args = {
@@ -331,6 +457,27 @@ function parseArgs(argv) {
331
457
  case "--no-agent-skills":
332
458
  args.agentSkills = false;
333
459
  break;
460
+ case "--github-actions":
461
+ args.githubActions = true;
462
+ break;
463
+ case "--no-github-actions":
464
+ args.githubActions = false;
465
+ break;
466
+ case "--ci-jobs": {
467
+ const value = argv[++i];
468
+ if (value === void 0) return {
469
+ ok: false,
470
+ message: "--ci-jobs requires a value."
471
+ };
472
+ const jobs = value.split(",").map((job) => job.trim());
473
+ const unknown = jobs.find((job) => !isCiJob(job));
474
+ if (unknown !== void 0) return {
475
+ ok: false,
476
+ message: `Unknown CI job "${unknown}". Use any of: ${CI_JOBS.join(", ")}.`
477
+ };
478
+ args.ciJobs = jobs.filter(isCiJob);
479
+ break;
480
+ }
334
481
  case "--install":
335
482
  args.install = true;
336
483
  break;
@@ -366,6 +513,10 @@ function parseArgs(argv) {
366
513
  ok: false,
367
514
  message: "--commit-hooks requires a git repo. Remove --no-git or --commit-hooks."
368
515
  };
516
+ if (args.githubActions === false && args.ciJobs !== void 0) return {
517
+ ok: false,
518
+ message: "--ci-jobs needs the github-actions topping. Remove --no-github-actions or --ci-jobs."
519
+ };
369
520
  return {
370
521
  ok: true,
371
522
  args
@@ -511,12 +662,10 @@ const onlyBuiltDependencies = ["esbuild"];
511
662
  const allowBuilds = ["esbuild"];
512
663
  /** Renders the generated `pnpm-workspace.yaml` as a string. */
513
664
  function buildPnpmWorkspace() {
514
- return [
515
- "onlyBuiltDependencies:",
516
- ...onlyBuiltDependencies.map((name) => ` - ${name}`),
517
- "allowBuilds:",
518
- ...allowBuilds.map((name) => ` ${name}: true`)
519
- ].join("\n") + "\n";
665
+ return stringify({
666
+ onlyBuiltDependencies,
667
+ allowBuilds: Object.fromEntries(allowBuilds.map((name) => [name, true]))
668
+ }, YAML_OPTIONS);
520
669
  }
521
670
  // src/generate/readme.ts
522
671
  /**
@@ -713,11 +862,32 @@ const agentSkills = {
713
862
  files: skillFiles(codingStandardsSkill),
714
863
  links: [skillLink(codingStandardsSkill)]
715
864
  };
865
+ /**
866
+ * The `github-actions` topping: a CI workflow with one job per chosen check,
867
+ * identical to what `chanom brew` writes. The baseline ships every script the
868
+ * jobs run, so each job works out of the box. The generated app has no
869
+ * `.nvmrc`, so setup-node uses the latest LTS.
870
+ */
871
+ function githubActions(pm, jobs, hasPackageManagerField) {
872
+ return {
873
+ ...emptyContribution,
874
+ files: [{
875
+ path: WORKFLOW_PATH,
876
+ contents: workflowFile({
877
+ pm,
878
+ jobs,
879
+ hasNvmrc: false,
880
+ hasPackageManagerField
881
+ })
882
+ }]
883
+ };
884
+ }
716
885
  /** The baseline plus whichever toppings the resolved options select, in fold order. */
717
886
  function selectContributions(selection) {
718
887
  const contributions = [baseline];
719
888
  if (selection.commitHooks) contributions.push(commitHooks(selection.pm));
720
889
  if (selection.agentSkills) contributions.push(agentSkills);
890
+ if (selection.ciJobs.length > 0) contributions.push(githubActions(selection.pm, selection.ciJobs, selection.hasPackageManagerField));
721
891
  return contributions;
722
892
  }
723
893
  // src/scaffold.ts
@@ -777,10 +947,15 @@ const initGit = Effect.fn("scaffold.initGit")(function* (plan) {
777
947
  const git = yield* Git;
778
948
  const s = yield* (yield* Prompter).spinner("Initializing git repository...");
779
949
  yield* git.init(plan.targetDir);
950
+ yield* s.stop("Git repository initialized");
951
+ });
952
+ const commitScaffold = Effect.fn("scaffold.commitScaffold")(function* (plan) {
953
+ const git = yield* Git;
954
+ const s = yield* (yield* Prompter).spinner("Creating the initial commit...");
780
955
  if (!(yield* git.hasIdentity(plan.targetDir))) yield* git.setLocalIdentity(plan.targetDir, "create-chanom-app", "create-chanom-app@users.noreply.github.com");
781
956
  yield* git.stageAll(plan.targetDir);
782
957
  const result = yield* git.commit(plan.targetDir, "chore: scaffold with create-chanom-app");
783
- yield* s.stop(result.ok ? "Git repository initialized" : pc.yellow("Could not create the initial commit"));
958
+ yield* s.stop(result.ok ? "Initial commit created" : pc.yellow("Could not create the initial commit"));
784
959
  });
785
960
  const installDependencies = Effect.fn("scaffold.installDependencies")(function* (plan) {
786
961
  const prompter = yield* Prompter;
@@ -796,19 +971,22 @@ const installDependencies = Effect.fn("scaffold.installDependencies")(function*
796
971
  }
797
972
  yield* s.stop("Dependencies installed");
798
973
  });
799
- /** Writes the generated app, then optionally initializes git and installs deps. */
974
+ /** Writes the generated app, then optionally initializes git, installs deps, and commits. */
800
975
  const scaffold = Effect.fn("scaffold")(function* (plan) {
801
976
  const fs = yield* FileSystem.FileSystem;
802
977
  const folded = mergeContributions(selectContributions({
803
978
  pm: plan.pm,
804
979
  commitHooks: plan.commitHooks,
805
- agentSkills: plan.agentSkills
980
+ agentSkills: plan.agentSkills,
981
+ ciJobs: plan.ciJobs,
982
+ hasPackageManagerField: plan.packageManagerField !== void 0
806
983
  }));
807
984
  yield* fs.makeDirectory(plan.targetDir, { recursive: true });
808
985
  yield* copyTemplates(plan);
809
986
  yield* writeGeneratedFiles(plan, folded);
810
987
  if (plan.git) yield* initGit(plan);
811
- if (plan.install) yield* installDependencies(plan);
988
+ if (plan.install) yield* installDependencies(plan).pipe(Effect.tapError(() => plan.git ? commitScaffold(plan) : Effect.void));
989
+ if (plan.git) yield* commitScaffold(plan);
812
990
  });
813
991
  // src/cli.ts
814
992
  const DEFAULT_NAME = "my-chanom-app";
@@ -825,6 +1003,9 @@ ${pc.bold("Options")}
825
1003
  ${pc.cyan("--no-commit-hooks")} Skip the commit-hooks topping
826
1004
  ${pc.cyan("--agent-skills")} Add the coding-standards skill to .claude/skills
827
1005
  ${pc.cyan("--no-agent-skills")} Skip the agent-skills topping
1006
+ ${pc.cyan("--github-actions")} Add a GitHub Actions CI workflow
1007
+ ${pc.cyan("--no-github-actions")} Skip the github-actions topping
1008
+ ${pc.cyan("--ci-jobs <jobs>")} Comma-separated CI jobs: ${CI_JOBS.join(", ")} (default: all)
828
1009
  ${pc.cyan("--install / --no-install")} Install dependencies after scaffolding (default: install)
829
1010
  ${pc.cyan("--pm <pnpm|npm|yarn|bun>")} Package manager to record (default: detected)
830
1011
  ${pc.cyan("-y, --yes")} Accept all defaults, no prompts (CI-friendly)
@@ -845,6 +1026,28 @@ function resolveConfirm(flag, yes, ask) {
845
1026
  if (flag !== void 0) return Effect.succeed(flag);
846
1027
  return yes ? Effect.succeed(true) : ask;
847
1028
  }
1029
+ /**
1030
+ * `--ci-jobs` implies the topping and wins over the jobs prompt; `--yes` takes
1031
+ * every job. Returns no jobs when the topping is declined.
1032
+ */
1033
+ const resolveCiJobs = Effect.fn("cli.resolveCiJobs")(function* (flag, jobs, yes) {
1034
+ const prompter = yield* Prompter;
1035
+ if (!(yield* resolveConfirm(jobs === void 0 ? flag : true, yes, prompter.confirm({
1036
+ message: "Add a GitHub Actions CI workflow?",
1037
+ initialValue: true
1038
+ })))) return [];
1039
+ if (jobs !== void 0) return jobs;
1040
+ if (yes) return CI_JOBS;
1041
+ return yield* prompter.multiselect({
1042
+ message: "Which GitHub Actions jobs would you like?",
1043
+ options: CI_JOBS.map((job) => ({
1044
+ value: job,
1045
+ label: job
1046
+ })),
1047
+ initialValues: CI_JOBS,
1048
+ required: true
1049
+ });
1050
+ });
848
1051
  const isEmptyDir = Effect.fn("cli.isEmptyDir")(function* (dir) {
849
1052
  const fs = yield* FileSystem.FileSystem;
850
1053
  if (!(yield* fs.exists(dir))) return true;
@@ -900,6 +1103,7 @@ const program$1 = (argv, cwd, options) => Effect.gen(function* () {
900
1103
  message: "Add agent coding standards (.agents/skills)?",
901
1104
  initialValue: true
902
1105
  }));
1106
+ const ciJobs = yield* resolveCiJobs(args.githubActions, args.ciJobs, args.yes);
903
1107
  const install = yield* resolveConfirm(args.install, args.yes, prompter.confirm({
904
1108
  message: "Install dependencies now?",
905
1109
  initialValue: true
@@ -914,6 +1118,7 @@ const program$1 = (argv, cwd, options) => Effect.gen(function* () {
914
1118
  `${pc.dim("git")} ${git ? "yes" : "no"}`,
915
1119
  `${pc.dim("commit hooks")} ${git ? commitHooks ? "yes" : "no" : pc.dim("n/a")}`,
916
1120
  `${pc.dim("agent skills")} ${agentSkills ? "yes" : "no"}`,
1121
+ `${pc.dim("ci jobs")} ${ciJobs.length > 0 ? ciJobs.join(", ") : "no"}`,
917
1122
  `${pc.dim("install")} ${install ? "yes" : "no"}`
918
1123
  ].join("\n"));
919
1124
  yield* scaffold({
@@ -924,6 +1129,7 @@ const program$1 = (argv, cwd, options) => Effect.gen(function* () {
924
1129
  git,
925
1130
  commitHooks,
926
1131
  agentSkills,
1132
+ ciJobs,
927
1133
  install,
928
1134
  templatesRoot: options.templatesRoot
929
1135
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-chanom-app",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Scaffold a Vite + React + TypeScript app, wired to the chanom shared configs.",
5
5
  "keywords": [
6
6
  "create",
@@ -29,19 +29,20 @@
29
29
  "@clack/prompts": "1.6.0",
30
30
  "@effect/platform": "0.96.2",
31
31
  "@effect/platform-node": "0.107.0",
32
- "effect": "3.21.4",
33
- "picocolors": "1.1.1"
32
+ "effect": "3.22.4",
33
+ "picocolors": "1.1.1",
34
+ "yaml": "2.9.0"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@effect/language-service": "0.86.4",
37
- "@effect/vitest": "0.29.0",
38
+ "@effect/vitest": "0.30.1",
38
39
  "@tsconfig/node-lts": "24.0.0",
39
40
  "@tsconfig/node-ts": "23.6.4",
40
41
  "@types/node": "26.0.1",
41
- "@vitest/coverage-v8": "3.2.6",
42
+ "@vitest/coverage-v8": "5.0.5",
42
43
  "tsdown": "0.23.1",
43
44
  "typescript": "7.0.2",
44
- "vitest": "3.2.6",
45
+ "vitest": "5.0.5",
45
46
  "@chanom/dev-config": "0.0.5",
46
47
  "@chanom/internal": "0.0.0"
47
48
  },