@clubedge/create-clubedge-app 0.2.1 → 0.4.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 (199) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +67 -36
  3. package/bin/create.mjs +2 -469
  4. package/dist/cli/args.js +54 -0
  5. package/dist/cli/ui.js +87 -0
  6. package/dist/core/customize.js +95 -0
  7. package/dist/core/execute.js +60 -0
  8. package/dist/core/manifest.js +119 -0
  9. package/dist/core/plan.js +132 -0
  10. package/dist/core/source.js +107 -0
  11. package/dist/index.js +160 -0
  12. package/dist/package-info.js +8 -0
  13. package/dist/steps/git.js +11 -0
  14. package/dist/steps/install.js +19 -0
  15. package/dist/utils/fs.js +100 -0
  16. package/dist/utils/names.js +14 -0
  17. package/dist/utils/process.js +49 -0
  18. package/package.json +19 -7
  19. package/template/.dockerignore +11 -0
  20. package/template/.env.example +25 -0
  21. package/template/.gitattributes +6 -0
  22. package/template/.github/ISSUE_TEMPLATE/bug_report.yml +35 -0
  23. package/template/.github/ISSUE_TEMPLATE/config.yml +8 -0
  24. package/template/.github/ISSUE_TEMPLATE/feature_request.yml +24 -0
  25. package/template/.github/pull_request_template.md +20 -0
  26. package/template/.github/workflows/ci.yml +96 -0
  27. package/template/.prettierignore +2 -0
  28. package/template/.prettierrc.json +1 -0
  29. package/template/AGENTS.md +32 -0
  30. package/template/CODE_OF_CONDUCT.md +22 -0
  31. package/template/CONTRIBUTING.md +53 -0
  32. package/template/Dockerfile +32 -0
  33. package/template/LICENSE +201 -0
  34. package/template/NOTICE +6 -0
  35. package/template/PUBLISHING.md +27 -0
  36. package/template/README.md +170 -0
  37. package/template/SECURITY.md +15 -0
  38. package/template/SETUP.md +196 -0
  39. package/template/SUPPORT.md +8 -0
  40. package/template/_gitignore +17 -0
  41. package/template/apps/start/.env.example +27 -0
  42. package/template/apps/start/AGENTS.md +11 -0
  43. package/template/apps/start/Dockerfile +38 -0
  44. package/template/apps/start/package.json +37 -0
  45. package/template/apps/start/public/.well-known/logo.svg +1 -0
  46. package/template/apps/start/public/favicon.ico +0 -0
  47. package/template/apps/start/src/components/auth/auth-form-controls.tsx +108 -0
  48. package/template/apps/start/src/components/branding/clubedge-mark.tsx +24 -0
  49. package/template/apps/start/src/components/dashboard/account-button.tsx +33 -0
  50. package/template/apps/start/src/components/dashboard/app-sidebar.tsx +90 -0
  51. package/template/apps/start/src/components/landing/copy-command.tsx +46 -0
  52. package/template/apps/start/src/components/theme/theme-provider.tsx +41 -0
  53. package/template/apps/start/src/components/theme/theme-toggle.tsx +21 -0
  54. package/template/apps/start/src/config/site.json +11 -0
  55. package/template/apps/start/src/config/site.ts +20 -0
  56. package/template/apps/start/src/env/server.test.ts +48 -0
  57. package/template/apps/start/src/env/server.ts +67 -0
  58. package/template/apps/start/src/lib/login-url.test.ts +16 -0
  59. package/template/apps/start/src/lib/login-url.ts +16 -0
  60. package/template/apps/start/src/routeTree.gen.ts +220 -0
  61. package/template/apps/start/src/router.tsx +27 -0
  62. package/template/apps/start/src/routes/__root.tsx +59 -0
  63. package/template/apps/start/src/routes/api/health.ts +15 -0
  64. package/template/apps/start/src/routes/auth/callback.ts +10 -0
  65. package/template/apps/start/src/routes/auth/sign-in.ts +11 -0
  66. package/template/apps/start/src/routes/auth/sign-out.ts +11 -0
  67. package/template/apps/start/src/routes/auth/sign-up.ts +11 -0
  68. package/template/apps/start/src/routes/dashboard.tsx +351 -0
  69. package/template/apps/start/src/routes/index.tsx +233 -0
  70. package/template/apps/start/src/routes/login.tsx +164 -0
  71. package/template/apps/start/src/server/auth-forms.test.ts +169 -0
  72. package/template/apps/start/src/server/auth-forms.ts +118 -0
  73. package/template/apps/start/src/server/auth.functions.ts +27 -0
  74. package/template/apps/start/src/server/auth.ts +51 -0
  75. package/template/apps/start/src/server/cache.ts +38 -0
  76. package/template/apps/start/src/server/cookies.test.ts +41 -0
  77. package/template/apps/start/src/server/cookies.ts +31 -0
  78. package/template/apps/start/src/server/db.ts +16 -0
  79. package/template/apps/start/src/server/storage.ts +39 -0
  80. package/template/apps/start/src/server/users.ts +17 -0
  81. package/template/apps/start/src/start.ts +40 -0
  82. package/template/apps/start/src/styles.css +2 -0
  83. package/template/apps/start/tsconfig.json +19 -0
  84. package/template/apps/start/vite.config.ts +27 -0
  85. package/template/apps/web/AGENTS.md +9 -0
  86. package/template/apps/web/components.json +21 -0
  87. package/template/apps/web/next-env.d.ts +7 -0
  88. package/template/apps/web/next.config.ts +32 -0
  89. package/template/apps/web/package.json +33 -0
  90. package/template/apps/web/postcss.config.mjs +3 -0
  91. package/template/apps/web/public/.gitkeep +0 -0
  92. package/template/apps/web/public/.well-known/logo.svg +1 -0
  93. package/template/apps/web/scripts/start-standalone.mjs +36 -0
  94. package/template/apps/web/src/app/actions/auth.ts +85 -0
  95. package/template/apps/web/src/app/api/health/route.ts +11 -0
  96. package/template/apps/web/src/app/auth/callback/route.ts +21 -0
  97. package/template/apps/web/src/app/dashboard/_components/account-button.tsx +34 -0
  98. package/template/apps/web/src/app/dashboard/_components/app-sidebar.tsx +91 -0
  99. package/template/apps/web/src/app/dashboard/page.tsx +352 -0
  100. package/template/apps/web/src/app/favicon.ico +0 -0
  101. package/template/apps/web/src/app/layout.tsx +36 -0
  102. package/template/apps/web/src/app/login/_components/auth-form-controls.tsx +88 -0
  103. package/template/apps/web/src/app/login/page.tsx +149 -0
  104. package/template/apps/web/src/app/page.tsx +229 -0
  105. package/template/apps/web/src/app/theme-provider.tsx +43 -0
  106. package/template/apps/web/src/app/theme-toggle.tsx +23 -0
  107. package/template/apps/web/src/components/branding/clubedge-mark.tsx +25 -0
  108. package/template/apps/web/src/components/landing/copy-command.tsx +48 -0
  109. package/template/apps/web/src/config/site.json +11 -0
  110. package/template/apps/web/src/config/site.ts +20 -0
  111. package/template/apps/web/src/env/client.ts +22 -0
  112. package/template/apps/web/src/env/server.test.ts +35 -0
  113. package/template/apps/web/src/env/server.ts +45 -0
  114. package/template/apps/web/src/lib/login-url.test.ts +16 -0
  115. package/template/apps/web/src/lib/login-url.ts +16 -0
  116. package/template/apps/web/src/lib/utils.ts +1 -0
  117. package/template/apps/web/src/proxy.ts +31 -0
  118. package/template/apps/web/src/server/auth.ts +69 -0
  119. package/template/apps/web/src/server/cache.ts +38 -0
  120. package/template/apps/web/src/server/db.ts +16 -0
  121. package/template/apps/web/src/server/storage.ts +39 -0
  122. package/template/apps/web/src/server/users.ts +17 -0
  123. package/template/apps/web/tsconfig.json +36 -0
  124. package/template/clubedge.template.json +29 -0
  125. package/template/docker-compose.yml +8 -0
  126. package/template/e2e/app.ts +12 -0
  127. package/template/e2e/auth.spec.ts +37 -0
  128. package/template/e2e/home.spec.ts +15 -0
  129. package/template/eslint.config.mjs +67 -0
  130. package/template/package.json +47 -0
  131. package/template/packages/auth/package.json +23 -0
  132. package/template/packages/auth/src/index.ts +9 -0
  133. package/template/packages/auth/src/supabase.test.ts +126 -0
  134. package/template/packages/auth/src/supabase.ts +65 -0
  135. package/template/packages/auth/src/types.ts +59 -0
  136. package/template/packages/auth/tsconfig.json +4 -0
  137. package/template/packages/cache/package.json +20 -0
  138. package/template/packages/cache/src/index.test.ts +67 -0
  139. package/template/packages/cache/src/index.ts +19 -0
  140. package/template/packages/cache/src/rate-limit.test.ts +52 -0
  141. package/template/packages/cache/src/rate-limit.ts +74 -0
  142. package/template/packages/cache/src/redis.ts +106 -0
  143. package/template/packages/cache/tsconfig.json +4 -0
  144. package/template/packages/core/package.json +17 -0
  145. package/template/packages/core/src/client-ip.test.ts +19 -0
  146. package/template/packages/core/src/client-ip.ts +11 -0
  147. package/template/packages/core/src/errors.test.ts +20 -0
  148. package/template/packages/core/src/errors.ts +20 -0
  149. package/template/packages/core/src/index.ts +4 -0
  150. package/template/packages/core/src/redirect.test.ts +27 -0
  151. package/template/packages/core/src/redirect.ts +20 -0
  152. package/template/packages/core/src/result.ts +10 -0
  153. package/template/packages/core/tsconfig.json +4 -0
  154. package/template/packages/db/drizzle/0000_initial.sql +10 -0
  155. package/template/packages/db/drizzle/meta/_journal.json +13 -0
  156. package/template/packages/db/drizzle.config.ts +9 -0
  157. package/template/packages/db/package.json +30 -0
  158. package/template/packages/db/scripts/load-env.ts +14 -0
  159. package/template/packages/db/scripts/migrate.ts +11 -0
  160. package/template/packages/db/scripts/seed.ts +14 -0
  161. package/template/packages/db/src/client.ts +22 -0
  162. package/template/packages/db/src/index.ts +4 -0
  163. package/template/packages/db/src/schema/index.ts +1 -0
  164. package/template/packages/db/src/schema/users.ts +14 -0
  165. package/template/packages/db/src/users.ts +16 -0
  166. package/template/packages/db/tsconfig.json +4 -0
  167. package/template/packages/storage/package.json +24 -0
  168. package/template/packages/storage/src/index.ts +1 -0
  169. package/template/packages/storage/src/s3.test.ts +86 -0
  170. package/template/packages/storage/src/s3.ts +86 -0
  171. package/template/packages/storage/src/supabase.ts +69 -0
  172. package/template/packages/storage/src/types.ts +18 -0
  173. package/template/packages/storage/tsconfig.json +4 -0
  174. package/template/packages/ui/components.json +21 -0
  175. package/template/packages/ui/package.json +32 -0
  176. package/template/packages/ui/src/components/badge.tsx +48 -0
  177. package/template/packages/ui/src/components/breadcrumb.tsx +102 -0
  178. package/template/packages/ui/src/components/button.tsx +57 -0
  179. package/template/packages/ui/src/components/card.tsx +87 -0
  180. package/template/packages/ui/src/components/input.tsx +19 -0
  181. package/template/packages/ui/src/components/label.tsx +19 -0
  182. package/template/packages/ui/src/components/separator.tsx +20 -0
  183. package/template/packages/ui/src/components/sheet.tsx +125 -0
  184. package/template/packages/ui/src/components/sidebar.tsx +692 -0
  185. package/template/packages/ui/src/components/skeleton.tsx +13 -0
  186. package/template/packages/ui/src/components/switch.tsx +31 -0
  187. package/template/packages/ui/src/components/table.tsx +88 -0
  188. package/template/packages/ui/src/components/tooltip.tsx +53 -0
  189. package/template/packages/ui/src/hooks/use-mobile.ts +19 -0
  190. package/template/packages/ui/src/lib/utils.ts +1 -0
  191. package/template/packages/ui/src/styles/globals.css +126 -0
  192. package/template/packages/ui/tsconfig.json +19 -0
  193. package/template/playwright.config.ts +23 -0
  194. package/template/pnpm-lock.yaml +4987 -0
  195. package/template/pnpm-workspace.yaml +3 -0
  196. package/template/tsconfig.base.json +16 -0
  197. package/template/turbo.json +17 -0
  198. package/template/vitest.config.ts +10 -0
  199. package/template.lock.json +187 -0
@@ -0,0 +1,95 @@
1
+ // Pure text transforms applied to template files. Keeping them free of I/O makes each one easy
2
+ // to test and lets --dry-run describe them without touching the disk.
3
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
4
+ export function customizeRootManifest(source, { packageName }, provenance, dockerImage) {
5
+ const manifest = JSON.parse(source);
6
+ manifest.name = packageName;
7
+ manifest.clubedge = {
8
+ cliVersion: provenance.cliVersion,
9
+ starterRepository: provenance.starterRepository,
10
+ starterRef: provenance.starterRef,
11
+ ...(provenance.starterCommit ? { starterCommit: provenance.starterCommit } : {}),
12
+ ...(provenance.framework ? { framework: provenance.framework } : {}),
13
+ };
14
+ for (const script of ["docker:build", "docker:start"]) {
15
+ if (typeof manifest.scripts?.[script] === "string") {
16
+ manifest.scripts[script] = manifest.scripts[script].replaceAll(dockerImage, packageName);
17
+ }
18
+ }
19
+ return json(manifest);
20
+ }
21
+ export function customizeReadme(source, { displayName }, provenance) {
22
+ const commitLine = provenance.starterCommit
23
+ ? `\n- Starter commit: \`${provenance.starterCommit}\``
24
+ : "";
25
+ const frameworkLine = provenance.frameworkName ? `\n- Framework: ${provenance.frameworkName}` : "";
26
+ const origin = `## Generated from\n\n` +
27
+ `- Starter repository: \`${provenance.starterRepository}\`\n` +
28
+ `- Starter ref: \`${provenance.starterRef}\`${commitLine}${frameworkLine}\n` +
29
+ `- CLI version: \`${provenance.cliVersion}\``;
30
+ // Only the first heading is the project title; leave every other line as written.
31
+ return source.replace(/^# .+$/m, `# ${displayName}\n\n${origin}`);
32
+ }
33
+ export function customizeSiteConfig(source, { packageName, displayName }) {
34
+ const siteConfig = JSON.parse(source);
35
+ siteConfig.name = displayName;
36
+ siteConfig.shortName = displayName;
37
+ siteConfig.description = `${displayName} application foundation.`;
38
+ siteConfig.serviceId = packageName;
39
+ siteConfig.workspaceLabel = "Application workspace";
40
+ return json(siteConfig);
41
+ }
42
+ /** Starter v0.1.x has no site config; these files carried the project name instead. */
43
+ export const legacyBrandedFiles = [
44
+ "apps/web/src/app/layout.tsx",
45
+ "apps/web/src/app/page.tsx",
46
+ "apps/web/src/app/dashboard/_components/app-sidebar.tsx",
47
+ "apps/web/src/app/dashboard/page.tsx",
48
+ "apps/web/src/app/login/page.tsx",
49
+ ];
50
+ export function customizeLegacySource(source, { displayName }) {
51
+ return source
52
+ .replaceAll('"Clubedge Starter"', JSON.stringify(displayName))
53
+ .replace("%s · Clubedge Starter", `%s · ${displayName}`)
54
+ .replace("A production-minded foundation for Clubedge applications.", `${displayName} application foundation.`)
55
+ .replaceAll("Clubedge Starter", displayName)
56
+ .replaceAll(">Clubedge<", `>${displayName}<`)
57
+ .replaceAll("Starter workspace", "Application workspace");
58
+ }
59
+ /** Gives the selected framework's app the package name generated projects use. */
60
+ export function customizeAppManifest(source, appPackage) {
61
+ const manifest = JSON.parse(source);
62
+ manifest.name = appPackage;
63
+ return json(manifest);
64
+ }
65
+ /**
66
+ * Keeps pnpm-lock.yaml in step with the selected framework so `pnpm install --frozen-lockfile`
67
+ * works. Importers are keyed by workspace path at two-space indentation; package entries that
68
+ * are no longer used are harmless, and pnpm prunes them on the next lockfile update.
69
+ */
70
+ export function customizeLockfile(source, { remove, rename }) {
71
+ const output = [];
72
+ let inImporters = false;
73
+ let skipping = false;
74
+ for (const line of source.split("\n")) {
75
+ if (/^\S/.test(line)) {
76
+ inImporters = line === "importers:";
77
+ skipping = false;
78
+ }
79
+ else if (inImporters) {
80
+ // An importer key, with its value on the next lines or inline ("packages/core: {}").
81
+ const key = /^ {2}([^\s:][^:]*):(?: |$)/.exec(line)?.[1];
82
+ if (key !== undefined) {
83
+ skipping = remove.includes(key);
84
+ if (!skipping && key === rename?.from) {
85
+ output.push(` ${rename.to}:`);
86
+ continue;
87
+ }
88
+ }
89
+ if (skipping)
90
+ continue;
91
+ }
92
+ output.push(line);
93
+ }
94
+ return output.join("\n");
95
+ }
@@ -0,0 +1,60 @@
1
+ import { constants } from "node:fs";
2
+ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join, parse } from "node:path";
4
+ import { copyTemplateFiles, emptyDirectory } from "../utils/fs.js";
5
+ export class TargetError extends Error {
6
+ name = "TargetError";
7
+ }
8
+ /** Refuses filesystem roots and non-empty directories. Never deletes or overwrites anything. */
9
+ export async function assertTargetIsUsable(directory) {
10
+ if (parse(directory).root === directory) {
11
+ throw new TargetError("Choose a project subdirectory rather than a filesystem root.");
12
+ }
13
+ try {
14
+ const entries = await readdir(directory);
15
+ if (entries.length > 0)
16
+ throw new TargetError(`The target directory is not empty: ${directory}`);
17
+ }
18
+ catch (error) {
19
+ if (error instanceof TargetError)
20
+ throw error;
21
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
22
+ return;
23
+ throw error;
24
+ }
25
+ }
26
+ /**
27
+ * Writes the project described by the plan. If any step fails, everything written so far is
28
+ * removed: a directory the CLI created is deleted, and a pre-existing empty one is emptied.
29
+ */
30
+ export async function executePlan(plan, source) {
31
+ const target = plan.targetDirectory;
32
+ await assertTargetIsUsable(target);
33
+ await mkdir(dirname(target), { recursive: true });
34
+ let created = false;
35
+ try {
36
+ await mkdir(target);
37
+ created = true;
38
+ }
39
+ catch (error) {
40
+ if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST"))
41
+ throw error;
42
+ }
43
+ try {
44
+ await copyTemplateFiles(source.root, target, plan.files);
45
+ for (const edit of plan.edits) {
46
+ const path = join(target, edit.path);
47
+ await writeFile(path, edit.apply(await readFile(path, "utf8")));
48
+ }
49
+ if (plan.envFile) {
50
+ await copyFile(join(target, plan.envFile.from), join(target, plan.envFile.to), constants.COPYFILE_EXCL);
51
+ }
52
+ }
53
+ catch (error) {
54
+ if (created)
55
+ await rm(target, { recursive: true, force: true });
56
+ else
57
+ await emptyDirectory(target);
58
+ throw error;
59
+ }
60
+ }
@@ -0,0 +1,119 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export const MANIFEST_FILE = "clubedge.template.json";
4
+ export const SUPPORTED_SCHEMA_VERSION = 2;
5
+ /** Schema 1 Starters ship a single Next.js app at the generated location. */
6
+ function singleNextFramework(app, envExample) {
7
+ return {
8
+ defaultFramework: "next",
9
+ frameworks: { next: { name: "Next.js", app, envExample, dockerfile: "Dockerfile" } },
10
+ };
11
+ }
12
+ /** Starter releases before v0.3.0 have no manifest; this describes their layout. */
13
+ export const legacyManifest = {
14
+ schemaVersion: 1,
15
+ name: "clubedge-starter",
16
+ packageManager: "pnpm",
17
+ app: "apps/web",
18
+ siteConfig: "apps/web/src/config/site.json",
19
+ env: { example: ".env.example", target: "apps/web/.env.local" },
20
+ dockerImage: "clubedge-starter",
21
+ ...singleNextFramework("apps/web", ".env.example"),
22
+ exclude: [],
23
+ };
24
+ export class ManifestError extends Error {
25
+ name = "ManifestError";
26
+ }
27
+ export function parseTemplateManifest(source) {
28
+ let data;
29
+ try {
30
+ data = JSON.parse(source);
31
+ }
32
+ catch {
33
+ throw new ManifestError(`${MANIFEST_FILE} is not valid JSON.`);
34
+ }
35
+ if (!isRecord(data))
36
+ throw new ManifestError(`${MANIFEST_FILE} must contain an object.`);
37
+ const { schemaVersion } = data;
38
+ if (typeof schemaVersion !== "number" || !Number.isInteger(schemaVersion)) {
39
+ throw new ManifestError(`${MANIFEST_FILE} is missing a numeric schemaVersion.`);
40
+ }
41
+ if (schemaVersion > SUPPORTED_SCHEMA_VERSION) {
42
+ throw new ManifestError(`This Starter uses template schema ${schemaVersion}, but this CLI supports up to ${SUPPORTED_SCHEMA_VERSION}. ` +
43
+ "Run the latest CLI: pnpm dlx @clubedge/create-clubedge-app@latest");
44
+ }
45
+ const env = data.env;
46
+ if (data.packageManager !== "pnpm") {
47
+ throw new ManifestError(`${MANIFEST_FILE}: packageManager must be "pnpm".`);
48
+ }
49
+ if (!isRecord(env))
50
+ throw new ManifestError(`${MANIFEST_FILE}: env must be an object.`);
51
+ if (data.exclude !== undefined && !isStringArray(data.exclude)) {
52
+ throw new ManifestError(`${MANIFEST_FILE}: exclude must be an array of paths.`);
53
+ }
54
+ const app = requireString(data, "app");
55
+ const envExample = requireString(env, "example", "env.");
56
+ return {
57
+ schemaVersion,
58
+ name: requireString(data, "name"),
59
+ packageManager: "pnpm",
60
+ app,
61
+ ...(schemaVersion >= 2 ? { appPackage: requireString(data, "appPackage") } : {}),
62
+ siteConfig: requireString(data, "siteConfig"),
63
+ env: { example: envExample, target: requireString(env, "target", "env.") },
64
+ dockerImage: requireString(data, "dockerImage"),
65
+ ...(schemaVersion >= 2 ? parseFrameworks(data) : singleNextFramework(app, envExample)),
66
+ exclude: data.exclude ?? [],
67
+ };
68
+ }
69
+ function parseFrameworks(data) {
70
+ const { frameworks } = data;
71
+ if (!isRecord(frameworks) || Object.keys(frameworks).length === 0) {
72
+ throw new ManifestError(`${MANIFEST_FILE}: frameworks must describe at least one framework.`);
73
+ }
74
+ const parsed = {};
75
+ for (const [id, entry] of Object.entries(frameworks)) {
76
+ if (!/^[a-z][a-z0-9-]*$/.test(id)) {
77
+ throw new ManifestError(`${MANIFEST_FILE}: "${id}" is not a valid framework id.`);
78
+ }
79
+ if (!isRecord(entry))
80
+ throw new ManifestError(`${MANIFEST_FILE}: frameworks.${id} must be an object.`);
81
+ const prefix = `frameworks.${id}.`;
82
+ parsed[id] = {
83
+ name: requireString(entry, "name", prefix),
84
+ app: requireString(entry, "app", prefix),
85
+ envExample: requireString(entry, "envExample", prefix),
86
+ dockerfile: requireString(entry, "dockerfile", prefix),
87
+ };
88
+ }
89
+ const defaultFramework = requireString(data, "defaultFramework");
90
+ if (!parsed[defaultFramework]) {
91
+ throw new ManifestError(`${MANIFEST_FILE}: defaultFramework "${defaultFramework}" is not in frameworks.`);
92
+ }
93
+ return { defaultFramework, frameworks: parsed };
94
+ }
95
+ export async function readTemplateManifest(templateRoot) {
96
+ let source;
97
+ try {
98
+ source = await readFile(join(templateRoot, MANIFEST_FILE), "utf8");
99
+ }
100
+ catch (error) {
101
+ if (isRecord(error) && error.code === "ENOENT")
102
+ return legacyManifest;
103
+ throw error;
104
+ }
105
+ return parseTemplateManifest(source);
106
+ }
107
+ function requireString(record, key, prefix = "") {
108
+ const value = record[key];
109
+ if (typeof value !== "string" || !value) {
110
+ throw new ManifestError(`${MANIFEST_FILE}: ${prefix}${key} must be a non-empty string.`);
111
+ }
112
+ return value;
113
+ }
114
+ function isRecord(value) {
115
+ return typeof value === "object" && value !== null && !Array.isArray(value);
116
+ }
117
+ function isStringArray(value) {
118
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
119
+ }
@@ -0,0 +1,132 @@
1
+ import { customizeAppManifest, customizeLegacySource, customizeLockfile, customizeReadme, customizeRootManifest, customizeSiteConfig, legacyBrandedFiles, } from "./customize.js";
2
+ export class FrameworkError extends Error {
3
+ name = "FrameworkError";
4
+ }
5
+ const LOCKFILE = "pnpm-lock.yaml";
6
+ const isWithin = (path, directory) => path === directory || path.startsWith(`${directory.replace(/\/$/, "")}/`);
7
+ function isExcluded(path, exclude) {
8
+ return exclude.some((entry) => isWithin(path, entry));
9
+ }
10
+ /** Resolves the requested framework, or the default, with a helpful error for unknown ids. */
11
+ export function selectFramework(manifest, requested) {
12
+ const id = requested ?? manifest.defaultFramework;
13
+ const framework = manifest.frameworks[id];
14
+ if (!framework) {
15
+ const choices = Object.keys(manifest.frameworks).join(", ");
16
+ throw new FrameworkError(`Unknown framework "${id}". This Starter offers: ${choices}.`);
17
+ }
18
+ return { id, ...framework };
19
+ }
20
+ /**
21
+ * Where a Starter file lands in the generated project, or null when it belongs to a framework
22
+ * that was not selected. The selected app moves to `manifest.app`, and its environment example
23
+ * and Dockerfile move to the generated project's locations.
24
+ */
25
+ function relocate(path, manifest, selected, others) {
26
+ if (path === selected.envExample)
27
+ return manifest.env.example;
28
+ if (path === selected.dockerfile)
29
+ return "Dockerfile";
30
+ if (isWithin(path, selected.app))
31
+ return manifest.app + path.slice(selected.app.length);
32
+ const belongsToOther = others.some((other) => isWithin(path, other.app) || path === other.envExample || path === other.dockerfile);
33
+ return belongsToOther ? null : path;
34
+ }
35
+ export function createPlan(input) {
36
+ const { manifest, identity, source } = input;
37
+ const framework = selectFramework(manifest, input.framework);
38
+ const others = Object.entries(manifest.frameworks)
39
+ .filter(([id]) => id !== framework.id)
40
+ .map(([, other]) => other);
41
+ const multipleFrameworks = others.length > 0;
42
+ const provenance = {
43
+ cliVersion: input.cliVersion,
44
+ starterRepository: source.repository,
45
+ starterRef: source.ref,
46
+ ...(source.commit ? { starterCommit: source.commit } : {}),
47
+ ...(multipleFrameworks ? { framework: framework.id, frameworkName: framework.name } : {}),
48
+ };
49
+ const files = [];
50
+ for (const file of source.files) {
51
+ if (isExcluded(file.to, manifest.exclude))
52
+ continue;
53
+ const to = relocate(file.to, manifest, framework, others);
54
+ if (to !== null && to !== manifest.env.target)
55
+ files.push({ from: file.from, to });
56
+ }
57
+ const has = (path) => files.some(({ to }) => to === path);
58
+ const edits = [];
59
+ if (has("package.json")) {
60
+ edits.push({
61
+ path: "package.json",
62
+ description: `Name the package "${identity.packageName}" and record its origin`,
63
+ apply: (text) => customizeRootManifest(text, identity, provenance, manifest.dockerImage),
64
+ });
65
+ }
66
+ const appManifest = `${manifest.app}/package.json`;
67
+ if (manifest.appPackage && framework.app !== manifest.app && has(appManifest)) {
68
+ const appPackage = manifest.appPackage;
69
+ edits.push({
70
+ path: appManifest,
71
+ description: `Name the ${framework.name} app "${appPackage}"`,
72
+ apply: (text) => customizeAppManifest(text, appPackage),
73
+ });
74
+ }
75
+ if (multipleFrameworks && has(LOCKFILE)) {
76
+ const changes = {
77
+ remove: others.map((other) => other.app).filter((app) => app !== framework.app),
78
+ ...(framework.app !== manifest.app ? { rename: { from: framework.app, to: manifest.app } } : {}),
79
+ };
80
+ edits.push({
81
+ path: LOCKFILE,
82
+ description: `Keep only the ${framework.name} app in the lockfile`,
83
+ apply: (text) => customizeLockfile(text, changes),
84
+ });
85
+ }
86
+ if (has("README.md")) {
87
+ edits.push({
88
+ path: "README.md",
89
+ description: `Title the README "${identity.displayName}" and record its origin`,
90
+ apply: (text) => customizeReadme(text, identity, provenance),
91
+ });
92
+ }
93
+ if (has(manifest.siteConfig)) {
94
+ edits.push({
95
+ path: manifest.siteConfig,
96
+ description: `Set the project name to "${identity.displayName}"`,
97
+ apply: (text) => customizeSiteConfig(text, identity),
98
+ });
99
+ }
100
+ else {
101
+ for (const path of legacyBrandedFiles.filter(has)) {
102
+ edits.push({
103
+ path,
104
+ description: `Replace the Starter name with "${identity.displayName}"`,
105
+ apply: (text) => customizeLegacySource(text, identity),
106
+ });
107
+ }
108
+ }
109
+ return {
110
+ targetDirectory: input.targetDirectory,
111
+ identity,
112
+ provenance,
113
+ framework: { id: framework.id, name: framework.name },
114
+ files,
115
+ edits,
116
+ envFile: has(manifest.env.example) ? { from: manifest.env.example, to: manifest.env.target } : null,
117
+ git: input.git,
118
+ install: input.install,
119
+ };
120
+ }
121
+ export function describePlan(plan) {
122
+ return [
123
+ `Create ${plan.targetDirectory}`,
124
+ ...(plan.provenance.framework ? [`Use ${plan.framework.name}`] : []),
125
+ `Copy ${plan.files.length} files from ${plan.provenance.starterRepository}@${plan.provenance.starterRef}` +
126
+ (plan.provenance.starterCommit ? ` (${plan.provenance.starterCommit.slice(0, 8)})` : ""),
127
+ ...plan.edits.map((edit) => `Edit ${edit.path}: ${edit.description}`),
128
+ ...(plan.envFile ? [`Create ${plan.envFile.to} from ${plan.envFile.from}`] : []),
129
+ plan.git ? "Initialize a Git repository on main" : "Skip Git initialization",
130
+ plan.install ? "Install dependencies with pnpm" : "Skip dependency installation",
131
+ ];
132
+ }
@@ -0,0 +1,107 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { downloadTemplate } from "giget";
6
+ import { starterPin } from "../package-info.js";
7
+ import { fromPackedPath, listTemplateFiles, pathExists } from "../utils/fs.js";
8
+ import { runCapture } from "../utils/process.js";
9
+ // dist/core/source.js (or src/core/source.ts in tests) → the package root.
10
+ const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
11
+ export const bundledTemplateDirectory = join(packageRoot, "template");
12
+ export const bundledLockFile = join(packageRoot, "template.lock.json");
13
+ const noCleanup = async () => { };
14
+ export async function readBundledTemplate(lockFile = bundledLockFile, templateDirectory = bundledTemplateDirectory) {
15
+ if (!(await pathExists(lockFile)))
16
+ return null;
17
+ const lock = JSON.parse(await readFile(lockFile, "utf8"));
18
+ return {
19
+ kind: "bundled",
20
+ root: templateDirectory,
21
+ files: lock.files.map((from) => ({ from, to: fromPackedPath(from) })),
22
+ repository: lock.repository,
23
+ ref: lock.ref,
24
+ commit: lock.commit,
25
+ cleanup: noCleanup,
26
+ };
27
+ }
28
+ export async function readDirectoryTemplate(directory) {
29
+ const root = resolve(directory);
30
+ if (!(await pathExists(join(root, "package.json")))) {
31
+ throw new Error(`No Starter found at ${root}: package.json is missing.`);
32
+ }
33
+ const files = await listTemplateFiles(root);
34
+ let commit;
35
+ try {
36
+ commit = await runCapture("git", ["-C", root, "rev-parse", "HEAD"]);
37
+ }
38
+ catch {
39
+ commit = undefined;
40
+ }
41
+ return {
42
+ kind: "directory",
43
+ root,
44
+ files: files.map((file) => ({ from: file, to: file })),
45
+ repository: starterPin.starterRepository,
46
+ ref: "local",
47
+ commit,
48
+ cleanup: noCleanup,
49
+ };
50
+ }
51
+ /** Resolves a tag to its commit with `git ls-remote`, following annotated tags. */
52
+ export async function resolveTagCommit(repository, tag) {
53
+ const output = await runCapture("git", [
54
+ "ls-remote",
55
+ `https://github.com/${repository}.git`,
56
+ `refs/tags/${tag}^{}`,
57
+ `refs/tags/${tag}`,
58
+ ]);
59
+ const lines = output.split("\n").map((line) => line.split(/\s+/));
60
+ const peeled = lines.find(([, ref]) => ref?.endsWith("^{}"));
61
+ return (peeled ?? lines[0])?.[0] || undefined;
62
+ }
63
+ export async function downloadGitHubTemplate(ref, { repository = starterPin.starterRepository, expectedCommit } = {}) {
64
+ if (expectedCommit) {
65
+ const commit = await resolveTagCommit(repository, ref);
66
+ if (commit !== expectedCommit) {
67
+ throw new Error(`Starter tag ${ref} resolved to ${commit ?? "no commit"}, expected ${expectedCommit}.`);
68
+ }
69
+ }
70
+ const temporary = await mkdtemp(join(tmpdir(), "create-clubedge-app-"));
71
+ const cleanup = () => rm(temporary, { recursive: true, force: true });
72
+ try {
73
+ const root = join(temporary, "starter");
74
+ await downloadTemplate(`gh:${repository}#${ref}`, { dir: root, force: true });
75
+ const files = await listTemplateFiles(root);
76
+ return {
77
+ kind: "github",
78
+ root,
79
+ files: files.map((file) => ({ from: file, to: file })),
80
+ repository,
81
+ ref,
82
+ commit: expectedCommit,
83
+ cleanup,
84
+ };
85
+ }
86
+ catch (error) {
87
+ await cleanup();
88
+ throw error;
89
+ }
90
+ }
91
+ /**
92
+ * Picks where template files come from: a local directory, an explicit ref from GitHub, or
93
+ * the Starter bundled into this package. Without a bundle (a CLI checkout that has not run
94
+ * bundle-template), the pinned release is downloaded and its commit verified.
95
+ */
96
+ export async function resolveTemplateSource({ ref, templateDir }) {
97
+ if (templateDir)
98
+ return readDirectoryTemplate(templateDir);
99
+ const pinned = !ref || ref === starterPin.starterRef;
100
+ if (!pinned)
101
+ return downloadGitHubTemplate(ref);
102
+ const bundled = await readBundledTemplate();
103
+ if (bundled && bundled.ref === starterPin.starterRef && bundled.commit === starterPin.starterCommit) {
104
+ return bundled;
105
+ }
106
+ return downloadGitHubTemplate(starterPin.starterRef, { expectedCommit: starterPin.starterCommit });
107
+ }
package/dist/index.js ADDED
@@ -0,0 +1,160 @@
1
+ import { cancel, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
2
+ import { relative, resolve } from "node:path";
3
+ import process from "node:process";
4
+ import { parseCliArgs, UsageError } from "./cli/args.js";
5
+ import { dim, helpText, showBanner } from "./cli/ui.js";
6
+ import { assertTargetIsUsable, executePlan } from "./core/execute.js";
7
+ import { readTemplateManifest } from "./core/manifest.js";
8
+ import { createPlan, describePlan, selectFramework } from "./core/plan.js";
9
+ import { resolveTemplateSource } from "./core/source.js";
10
+ import { cliUrl, cliVersion, siteUrl } from "./package-info.js";
11
+ import { initializeGit } from "./steps/git.js";
12
+ import { detectLauncher, installDependencies, resolvePnpm } from "./steps/install.js";
13
+ import { displayNameFromPackageName, packageNameFromDirectory } from "./utils/names.js";
14
+ const minimumNode = [22, 12];
15
+ export function assertSupportedNode(version = process.versions.node) {
16
+ const [major = 0, minor = 0] = version.split(".").map(Number);
17
+ if (major < minimumNode[0] || (major === minimumNode[0] && minor < minimumNode[1])) {
18
+ throw new UsageError(`Node.js ${minimumNode.join(".")} or newer is required; this is ${version}.`);
19
+ }
20
+ }
21
+ async function askProjectDirectory() {
22
+ const value = await text({
23
+ message: "Where should the new project be created?",
24
+ placeholder: "my-app",
25
+ validate: (input) => (input?.trim() ? undefined : "Enter a project directory name."),
26
+ });
27
+ if (isCancel(value)) {
28
+ cancel("Operation cancelled.");
29
+ process.exit(0);
30
+ }
31
+ return value;
32
+ }
33
+ /**
34
+ * The framework from --framework, a prompt when the Starter offers several, or the default.
35
+ * Returns null when the prompt is cancelled.
36
+ */
37
+ async function chooseFramework(manifest, requested, interactive) {
38
+ if (requested)
39
+ return selectFramework(manifest, requested).id;
40
+ const ids = Object.keys(manifest.frameworks);
41
+ if (!interactive || ids.length < 2)
42
+ return manifest.defaultFramework;
43
+ const value = await select({
44
+ message: "Which framework should the app use?",
45
+ initialValue: manifest.defaultFramework,
46
+ options: ids.map((id) => ({
47
+ value: id,
48
+ label: manifest.frameworks[id].name,
49
+ ...(id === manifest.defaultFramework ? { hint: "default" } : {}),
50
+ })),
51
+ });
52
+ return isCancel(value) ? null : value;
53
+ }
54
+ async function createProject(args) {
55
+ const interactive = Boolean(process.stdin.isTTY) && !args.yes;
56
+ const requested = args.projectDirectory ?? (interactive ? await askProjectDirectory() : "my-app");
57
+ const targetDirectory = resolve(process.cwd(), requested);
58
+ await assertTargetIsUsable(targetDirectory);
59
+ const packageName = packageNameFromDirectory(targetDirectory);
60
+ const identity = { packageName, displayName: displayNameFromPackageName(packageName) };
61
+ const progress = spinner();
62
+ progress.start("Preparing the Starter");
63
+ const source = await resolveTemplateSource({ ref: args.ref, templateDir: args.templateDir }).catch((error) => {
64
+ progress.stop("Could not prepare the Starter");
65
+ throw error;
66
+ });
67
+ // The spinner runs while the manifest is read and again while files are written.
68
+ let spinning = "preparing";
69
+ try {
70
+ const manifest = await readTemplateManifest(source.root);
71
+ progress.stop(`Starter ${source.ref} ready`);
72
+ spinning = null;
73
+ const framework = await chooseFramework(manifest, args.framework, interactive);
74
+ if (framework === null) {
75
+ cancel("Operation cancelled.");
76
+ return;
77
+ }
78
+ const plan = createPlan({
79
+ targetDirectory,
80
+ identity,
81
+ source,
82
+ manifest,
83
+ framework,
84
+ cliVersion,
85
+ git: args.git,
86
+ install: args.install,
87
+ });
88
+ if (args.dryRun) {
89
+ log.message(describePlan(plan).map((line) => `• ${line}`).join("\n"));
90
+ outro("Dry run: nothing was written. Run again without --dry-run to create the project.");
91
+ return;
92
+ }
93
+ spinning = "writing";
94
+ progress.start(`Writing ${plan.files.length} files`);
95
+ await executePlan(plan, source);
96
+ progress.stop(`Created ${identity.packageName} with ${plan.framework.name} from Starter ${source.ref}`);
97
+ spinning = null;
98
+ }
99
+ catch (error) {
100
+ if (spinning === "preparing")
101
+ progress.stop("Could not read the Starter");
102
+ if (spinning === "writing")
103
+ progress.stop("Project scaffold failed; nothing was left behind");
104
+ throw error;
105
+ }
106
+ finally {
107
+ await source.cleanup();
108
+ }
109
+ if (args.git) {
110
+ await initializeGit(targetDirectory);
111
+ log.success("Git repository initialized on main");
112
+ }
113
+ let pnpmCommand = "pnpm";
114
+ if (args.install) {
115
+ const launcher = detectLauncher();
116
+ if (launcher && launcher !== "pnpm") {
117
+ log.info(`This project is a pnpm workspace, so dependencies are installed with pnpm (not ${launcher}).`);
118
+ }
119
+ const pnpm = await resolvePnpm();
120
+ if (!pnpm) {
121
+ log.warn("pnpm was not found. Enable it with `corepack enable`, then run `pnpm install` in the project.");
122
+ }
123
+ else {
124
+ pnpmCommand = pnpm.join(" ");
125
+ try {
126
+ await installDependencies(targetDirectory, pnpm);
127
+ log.success("Dependencies installed");
128
+ }
129
+ catch (error) {
130
+ log.error(`Project created; dependency installation did not complete. ${error instanceof Error ? error.message : ""}`);
131
+ log.info(`Run \`${pnpmCommand} install\` in the project to retry.`);
132
+ }
133
+ }
134
+ }
135
+ const directory = relative(process.cwd(), targetDirectory) || ".";
136
+ outro(`Next steps:\n cd ${directory}\n ${pnpmCommand} dev\n\n` +
137
+ `See SETUP.md for database and provider configuration.\n\n` +
138
+ `${dim("Docs")} ${cliUrl}\n${dim("Clubedge")} ${siteUrl}`);
139
+ }
140
+ export async function main(argv = process.argv.slice(2)) {
141
+ try {
142
+ const args = parseCliArgs(argv);
143
+ if (args.version) {
144
+ console.log(cliVersion);
145
+ return;
146
+ }
147
+ showBanner();
148
+ if (args.help) {
149
+ console.log(helpText());
150
+ return;
151
+ }
152
+ assertSupportedNode();
153
+ intro("Create a project with Clubedge Starter");
154
+ await createProject(args);
155
+ }
156
+ catch (error) {
157
+ console.error(`\n${error instanceof Error ? error.message : String(error)}`);
158
+ process.exitCode = 1;
159
+ }
160
+ }
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from "node:fs";
2
+ // package.json is the single source for the version and the tested Starter pin. It ships
3
+ // with the package, so the npm registry exposes exactly what each release scaffolds.
4
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
5
+ export const cliVersion = packageJson.version;
6
+ export const starterPin = packageJson.clubedge;
7
+ export const siteUrl = "https://clubedge.live";
8
+ export const cliUrl = "https://starter.clubedge.live";