@clubedge/create-clubedge-app 0.2.1 → 0.3.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 (154) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +50 -35
  3. package/bin/create.mjs +2 -469
  4. package/dist/cli/args.js +49 -0
  5. package/dist/cli/ui.js +85 -0
  6. package/dist/core/customize.js +56 -0
  7. package/dist/core/execute.js +60 -0
  8. package/dist/core/manifest.js +81 -0
  9. package/dist/core/plan.js +67 -0
  10. package/dist/core/source.js +107 -0
  11. package/dist/index.js +125 -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 +97 -0
  16. package/dist/utils/names.js +14 -0
  17. package/dist/utils/process.js +49 -0
  18. package/package.json +18 -7
  19. package/template/.dockerignore +8 -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 +49 -0
  27. package/template/.prettierignore +1 -0
  28. package/template/.prettierrc.json +1 -0
  29. package/template/AGENTS.md +31 -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 +157 -0
  37. package/template/SECURITY.md +15 -0
  38. package/template/SETUP.md +174 -0
  39. package/template/SUPPORT.md +8 -0
  40. package/template/_gitignore +13 -0
  41. package/template/apps/web/AGENTS.md +9 -0
  42. package/template/apps/web/components.json +21 -0
  43. package/template/apps/web/e2e/auth.spec.ts +37 -0
  44. package/template/apps/web/e2e/home.spec.ts +15 -0
  45. package/template/apps/web/next-env.d.ts +7 -0
  46. package/template/apps/web/next.config.ts +32 -0
  47. package/template/apps/web/package.json +33 -0
  48. package/template/apps/web/postcss.config.mjs +3 -0
  49. package/template/apps/web/public/.gitkeep +0 -0
  50. package/template/apps/web/public/.well-known/logo.svg +1 -0
  51. package/template/apps/web/src/app/actions/auth.ts +85 -0
  52. package/template/apps/web/src/app/api/health/route.ts +11 -0
  53. package/template/apps/web/src/app/auth/callback/route.ts +21 -0
  54. package/template/apps/web/src/app/dashboard/_components/account-button.tsx +34 -0
  55. package/template/apps/web/src/app/dashboard/_components/app-sidebar.tsx +91 -0
  56. package/template/apps/web/src/app/dashboard/page.tsx +352 -0
  57. package/template/apps/web/src/app/favicon.ico +0 -0
  58. package/template/apps/web/src/app/layout.tsx +36 -0
  59. package/template/apps/web/src/app/login/_components/auth-form-controls.tsx +88 -0
  60. package/template/apps/web/src/app/login/page.tsx +149 -0
  61. package/template/apps/web/src/app/page.tsx +229 -0
  62. package/template/apps/web/src/app/theme-provider.tsx +43 -0
  63. package/template/apps/web/src/app/theme-toggle.tsx +23 -0
  64. package/template/apps/web/src/components/branding/clubedge-mark.tsx +25 -0
  65. package/template/apps/web/src/components/landing/copy-command.tsx +48 -0
  66. package/template/apps/web/src/config/site.json +11 -0
  67. package/template/apps/web/src/config/site.ts +20 -0
  68. package/template/apps/web/src/env/client.ts +22 -0
  69. package/template/apps/web/src/env/server.test.ts +35 -0
  70. package/template/apps/web/src/env/server.ts +45 -0
  71. package/template/apps/web/src/lib/login-url.test.ts +16 -0
  72. package/template/apps/web/src/lib/login-url.ts +16 -0
  73. package/template/apps/web/src/lib/utils.ts +1 -0
  74. package/template/apps/web/src/proxy.ts +31 -0
  75. package/template/apps/web/src/server/auth.ts +69 -0
  76. package/template/apps/web/src/server/cache.ts +38 -0
  77. package/template/apps/web/src/server/db.ts +16 -0
  78. package/template/apps/web/src/server/storage.ts +39 -0
  79. package/template/apps/web/src/server/users.ts +17 -0
  80. package/template/apps/web/tsconfig.json +36 -0
  81. package/template/clubedge.template.json +13 -0
  82. package/template/docker-compose.yml +8 -0
  83. package/template/eslint.config.mjs +63 -0
  84. package/template/package.json +47 -0
  85. package/template/packages/auth/package.json +23 -0
  86. package/template/packages/auth/src/index.ts +9 -0
  87. package/template/packages/auth/src/supabase.test.ts +126 -0
  88. package/template/packages/auth/src/supabase.ts +65 -0
  89. package/template/packages/auth/src/types.ts +59 -0
  90. package/template/packages/auth/tsconfig.json +4 -0
  91. package/template/packages/cache/package.json +20 -0
  92. package/template/packages/cache/src/index.test.ts +67 -0
  93. package/template/packages/cache/src/index.ts +19 -0
  94. package/template/packages/cache/src/rate-limit.test.ts +52 -0
  95. package/template/packages/cache/src/rate-limit.ts +74 -0
  96. package/template/packages/cache/src/redis.ts +106 -0
  97. package/template/packages/cache/tsconfig.json +4 -0
  98. package/template/packages/core/package.json +17 -0
  99. package/template/packages/core/src/client-ip.test.ts +19 -0
  100. package/template/packages/core/src/client-ip.ts +11 -0
  101. package/template/packages/core/src/errors.test.ts +20 -0
  102. package/template/packages/core/src/errors.ts +20 -0
  103. package/template/packages/core/src/index.ts +4 -0
  104. package/template/packages/core/src/redirect.test.ts +27 -0
  105. package/template/packages/core/src/redirect.ts +20 -0
  106. package/template/packages/core/src/result.ts +10 -0
  107. package/template/packages/core/tsconfig.json +4 -0
  108. package/template/packages/db/drizzle/0000_initial.sql +10 -0
  109. package/template/packages/db/drizzle/meta/_journal.json +13 -0
  110. package/template/packages/db/drizzle.config.ts +9 -0
  111. package/template/packages/db/package.json +30 -0
  112. package/template/packages/db/scripts/load-env.ts +14 -0
  113. package/template/packages/db/scripts/migrate.ts +11 -0
  114. package/template/packages/db/scripts/seed.ts +14 -0
  115. package/template/packages/db/src/client.ts +22 -0
  116. package/template/packages/db/src/index.ts +4 -0
  117. package/template/packages/db/src/schema/index.ts +1 -0
  118. package/template/packages/db/src/schema/users.ts +14 -0
  119. package/template/packages/db/src/users.ts +16 -0
  120. package/template/packages/db/tsconfig.json +4 -0
  121. package/template/packages/storage/package.json +24 -0
  122. package/template/packages/storage/src/index.ts +1 -0
  123. package/template/packages/storage/src/s3.test.ts +86 -0
  124. package/template/packages/storage/src/s3.ts +86 -0
  125. package/template/packages/storage/src/supabase.ts +69 -0
  126. package/template/packages/storage/src/types.ts +18 -0
  127. package/template/packages/storage/tsconfig.json +4 -0
  128. package/template/packages/ui/components.json +21 -0
  129. package/template/packages/ui/package.json +32 -0
  130. package/template/packages/ui/src/components/badge.tsx +48 -0
  131. package/template/packages/ui/src/components/breadcrumb.tsx +102 -0
  132. package/template/packages/ui/src/components/button.tsx +57 -0
  133. package/template/packages/ui/src/components/card.tsx +87 -0
  134. package/template/packages/ui/src/components/input.tsx +19 -0
  135. package/template/packages/ui/src/components/label.tsx +19 -0
  136. package/template/packages/ui/src/components/separator.tsx +20 -0
  137. package/template/packages/ui/src/components/sheet.tsx +125 -0
  138. package/template/packages/ui/src/components/sidebar.tsx +692 -0
  139. package/template/packages/ui/src/components/skeleton.tsx +13 -0
  140. package/template/packages/ui/src/components/switch.tsx +31 -0
  141. package/template/packages/ui/src/components/table.tsx +88 -0
  142. package/template/packages/ui/src/components/tooltip.tsx +53 -0
  143. package/template/packages/ui/src/hooks/use-mobile.ts +19 -0
  144. package/template/packages/ui/src/lib/utils.ts +1 -0
  145. package/template/packages/ui/src/styles/globals.css +126 -0
  146. package/template/packages/ui/tsconfig.json +19 -0
  147. package/template/playwright.config.ts +19 -0
  148. package/template/pnpm-lock.yaml +3624 -0
  149. package/template/pnpm-workspace.yaml +3 -0
  150. package/template/scripts/start-standalone.mjs +37 -0
  151. package/template/tsconfig.base.json +16 -0
  152. package/template/turbo.json +17 -0
  153. package/template/vitest.config.ts +8 -0
  154. package/template.lock.json +142 -0
@@ -0,0 +1,49 @@
1
+ import { parseArgs } from "node:util";
2
+ export class UsageError extends Error {
3
+ name = "UsageError";
4
+ }
5
+ export function parseCliArgs(argv) {
6
+ let parsed;
7
+ try {
8
+ parsed = parseArgs({
9
+ args: argv,
10
+ allowPositionals: true,
11
+ allowNegative: true,
12
+ strict: true,
13
+ options: {
14
+ ref: { type: "string" },
15
+ "template-dir": { type: "string" },
16
+ install: { type: "boolean", default: true },
17
+ git: { type: "boolean", default: true },
18
+ yes: { type: "boolean", short: "y", default: false },
19
+ "dry-run": { type: "boolean", default: false },
20
+ help: { type: "boolean", short: "h", default: false },
21
+ version: { type: "boolean", short: "v", default: false },
22
+ },
23
+ });
24
+ }
25
+ catch (error) {
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ throw new UsageError(`${message.split("\n")[0]} Run with --help to see available options.`);
28
+ }
29
+ const { values, positionals } = parsed;
30
+ if (positionals.length > 1)
31
+ throw new UsageError("Only one project directory can be provided.");
32
+ if (values.ref !== undefined && !values.ref.trim()) {
33
+ throw new UsageError("--ref requires a tag, branch, or commit.");
34
+ }
35
+ if (values.ref && values["template-dir"]) {
36
+ throw new UsageError("Use either --ref or --template-dir, not both.");
37
+ }
38
+ return {
39
+ projectDirectory: positionals[0],
40
+ ref: values.ref,
41
+ templateDir: values["template-dir"],
42
+ install: values.install,
43
+ git: values.git,
44
+ yes: values.yes,
45
+ dryRun: values["dry-run"],
46
+ help: values.help,
47
+ version: values.version,
48
+ };
49
+ }
package/dist/cli/ui.js ADDED
@@ -0,0 +1,85 @@
1
+ import process from "node:process";
2
+ import { cliUrl, cliVersion, siteUrl, starterPin } from "../package-info.js";
3
+ // Each glyph is 6 rows tall. Rows are padded to a common width when rendered.
4
+ const glyphs = {
5
+ C: [" ██████╗", "██╔════╝", "██║ ", "██║ ", "╚██████╗", " ╚═════╝"],
6
+ L: ["██╗ ", "██║ ", "██║ ", "██║ ", "███████╗", "╚══════╝"],
7
+ U: ["██╗ ██╗", "██║ ██║", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
8
+ B: ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██████╔╝", "╚═════╝ "],
9
+ E: ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "███████╗", "╚══════╝"],
10
+ D: ["██████╗ ", "██╔══██╗", "██║ ██║", "██║ ██║", "██████╔╝", "╚═════╝ "],
11
+ G: [" ██████╗ ", "██╔════╝ ", "██║ ███╗", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
12
+ S: ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
13
+ T: ["████████╗", "╚══██╔══╝", " ██║ ", " ██║ ", " ██║ ", " ╚═╝ "],
14
+ A: [" █████╗ ", "██╔══██╗", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
15
+ R: ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██║ ██║", "╚═╝ ╚═╝"],
16
+ };
17
+ function renderWord(word) {
18
+ const letters = [...word].map((letter) => {
19
+ const rows = glyphs[letter];
20
+ const width = Math.max(...rows.map((row) => row.length));
21
+ return rows.map((row) => row.padEnd(width, " "));
22
+ });
23
+ return letters[0].map((_, rowIndex) => letters.map((letter) => letter[rowIndex]).join(""));
24
+ }
25
+ const colorEnabled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb";
26
+ const paint = (rgb, value) => colorEnabled ? `\x1b[38;2;${rgb.join(";")}m${value}\x1b[0m` : value;
27
+ export const dim = (value) => (colorEnabled ? `\x1b[2m${value}\x1b[0m` : value);
28
+ export const bold = (value) => (colorEnabled ? `\x1b[1m${value}\x1b[0m` : value);
29
+ // Light violet -> Clubedge brand violet, top to bottom across the banner.
30
+ const gradientFrom = [165, 140, 255];
31
+ const gradientTo = [91, 61, 245];
32
+ const mix = (from, to, t) => from.map((channel, index) => Math.round(channel + (to[index] - channel) * t));
33
+ export function showBanner() {
34
+ // Stay quiet when output is piped (CI, logs, scripts).
35
+ if (!process.stdout.isTTY)
36
+ return;
37
+ const columns = process.stdout.columns ?? 80;
38
+ const lines = [...renderWord("CLUBEDGE"), ...renderWord("STARTER")];
39
+ const widest = Math.max(...lines.map((line) => line.length));
40
+ console.log("");
41
+ if (columns >= widest + 2) {
42
+ lines.forEach((line, index) => {
43
+ console.log(paint(mix(gradientFrom, gradientTo, index / (lines.length - 1)), line));
44
+ });
45
+ }
46
+ else {
47
+ // Narrow terminal: compact wordmark instead of wrapping the big letters.
48
+ console.log(paint(gradientTo, bold("CLUBEDGE STARTER")));
49
+ }
50
+ console.log("");
51
+ console.log(` ${bold("create-clubedge-app")} ${dim(`v${cliVersion}`)} ${dim("·")} ${paint(gradientFrom, cliUrl)}`);
52
+ console.log(` ${dim("By Clubedge")} ${dim("·")} ${paint(gradientFrom, siteUrl)}`);
53
+ console.log("");
54
+ }
55
+ export function helpText() {
56
+ return `
57
+ create-clubedge-app ${cliVersion}
58
+
59
+ Create a project from the Clubedge Starter reference repository.
60
+ This CLI release scaffolds Starter ${starterPin.starterRef} (commit ${starterPin.starterCommit}).
61
+
62
+ Usage:
63
+ pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
64
+
65
+ Options:
66
+ --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
67
+ --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
68
+ --dry-run Show what would be created without writing anything
69
+ -y, --yes Accept defaults and never prompt (default directory: my-app)
70
+ --no-install Skip dependency installation
71
+ --no-git Skip Git repository initialization
72
+ -h, --help Show this help
73
+ -v, --version Show the CLI version
74
+
75
+ Examples:
76
+ pnpm dlx @clubedge/create-clubedge-app my-app
77
+ pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
78
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref main
79
+ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
80
+
81
+ Learn more:
82
+ CLI ${cliUrl}
83
+ Clubedge ${siteUrl}
84
+ `;
85
+ }
@@ -0,0 +1,56 @@
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
+ };
13
+ for (const script of ["docker:build", "docker:start"]) {
14
+ if (typeof manifest.scripts?.[script] === "string") {
15
+ manifest.scripts[script] = manifest.scripts[script].replaceAll(dockerImage, packageName);
16
+ }
17
+ }
18
+ return json(manifest);
19
+ }
20
+ export function customizeReadme(source, { displayName }, provenance) {
21
+ const commitLine = provenance.starterCommit
22
+ ? `\n- Starter commit: \`${provenance.starterCommit}\``
23
+ : "";
24
+ const origin = `## Generated from\n\n` +
25
+ `- Starter repository: \`${provenance.starterRepository}\`\n` +
26
+ `- Starter ref: \`${provenance.starterRef}\`${commitLine}\n` +
27
+ `- CLI version: \`${provenance.cliVersion}\``;
28
+ // Only the first heading is the project title; leave every other line as written.
29
+ return source.replace(/^# .+$/m, `# ${displayName}\n\n${origin}`);
30
+ }
31
+ export function customizeSiteConfig(source, { packageName, displayName }) {
32
+ const siteConfig = JSON.parse(source);
33
+ siteConfig.name = displayName;
34
+ siteConfig.shortName = displayName;
35
+ siteConfig.description = `${displayName} application foundation.`;
36
+ siteConfig.serviceId = packageName;
37
+ siteConfig.workspaceLabel = "Application workspace";
38
+ return json(siteConfig);
39
+ }
40
+ /** Starter v0.1.x has no site config; these files carried the project name instead. */
41
+ export const legacyBrandedFiles = [
42
+ "apps/web/src/app/layout.tsx",
43
+ "apps/web/src/app/page.tsx",
44
+ "apps/web/src/app/dashboard/_components/app-sidebar.tsx",
45
+ "apps/web/src/app/dashboard/page.tsx",
46
+ "apps/web/src/app/login/page.tsx",
47
+ ];
48
+ export function customizeLegacySource(source, { displayName }) {
49
+ return source
50
+ .replaceAll('"Clubedge Starter"', JSON.stringify(displayName))
51
+ .replace("%s · Clubedge Starter", `%s · ${displayName}`)
52
+ .replace("A production-minded foundation for Clubedge applications.", `${displayName} application foundation.`)
53
+ .replaceAll("Clubedge Starter", displayName)
54
+ .replaceAll(">Clubedge<", `>${displayName}<`)
55
+ .replaceAll("Starter workspace", "Application workspace");
56
+ }
@@ -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,81 @@
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 = 1;
5
+ /** Starter releases before v0.3.0 have no manifest; this describes their layout. */
6
+ export const legacyManifest = {
7
+ schemaVersion: 1,
8
+ name: "clubedge-starter",
9
+ packageManager: "pnpm",
10
+ app: "apps/web",
11
+ siteConfig: "apps/web/src/config/site.json",
12
+ env: { example: ".env.example", target: "apps/web/.env.local" },
13
+ dockerImage: "clubedge-starter",
14
+ exclude: [],
15
+ };
16
+ export class ManifestError extends Error {
17
+ name = "ManifestError";
18
+ }
19
+ export function parseTemplateManifest(source) {
20
+ let data;
21
+ try {
22
+ data = JSON.parse(source);
23
+ }
24
+ catch {
25
+ throw new ManifestError(`${MANIFEST_FILE} is not valid JSON.`);
26
+ }
27
+ if (!isRecord(data))
28
+ throw new ManifestError(`${MANIFEST_FILE} must contain an object.`);
29
+ const { schemaVersion } = data;
30
+ if (typeof schemaVersion !== "number" || !Number.isInteger(schemaVersion)) {
31
+ throw new ManifestError(`${MANIFEST_FILE} is missing a numeric schemaVersion.`);
32
+ }
33
+ if (schemaVersion > SUPPORTED_SCHEMA_VERSION) {
34
+ throw new ManifestError(`This Starter uses template schema ${schemaVersion}, but this CLI supports up to ${SUPPORTED_SCHEMA_VERSION}. ` +
35
+ "Run the latest CLI: pnpm dlx @clubedge/create-clubedge-app@latest");
36
+ }
37
+ const env = data.env;
38
+ if (data.packageManager !== "pnpm") {
39
+ throw new ManifestError(`${MANIFEST_FILE}: packageManager must be "pnpm".`);
40
+ }
41
+ if (!isRecord(env))
42
+ throw new ManifestError(`${MANIFEST_FILE}: env must be an object.`);
43
+ if (data.exclude !== undefined && !isStringArray(data.exclude)) {
44
+ throw new ManifestError(`${MANIFEST_FILE}: exclude must be an array of paths.`);
45
+ }
46
+ return {
47
+ schemaVersion,
48
+ name: requireString(data, "name"),
49
+ packageManager: "pnpm",
50
+ app: requireString(data, "app"),
51
+ siteConfig: requireString(data, "siteConfig"),
52
+ env: { example: requireString(env, "example", "env."), target: requireString(env, "target", "env.") },
53
+ dockerImage: requireString(data, "dockerImage"),
54
+ exclude: data.exclude ?? [],
55
+ };
56
+ }
57
+ export async function readTemplateManifest(templateRoot) {
58
+ let source;
59
+ try {
60
+ source = await readFile(join(templateRoot, MANIFEST_FILE), "utf8");
61
+ }
62
+ catch (error) {
63
+ if (isRecord(error) && error.code === "ENOENT")
64
+ return legacyManifest;
65
+ throw error;
66
+ }
67
+ return parseTemplateManifest(source);
68
+ }
69
+ function requireString(record, key, prefix = "") {
70
+ const value = record[key];
71
+ if (typeof value !== "string" || !value) {
72
+ throw new ManifestError(`${MANIFEST_FILE}: ${prefix}${key} must be a non-empty string.`);
73
+ }
74
+ return value;
75
+ }
76
+ function isRecord(value) {
77
+ return typeof value === "object" && value !== null && !Array.isArray(value);
78
+ }
79
+ function isStringArray(value) {
80
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
81
+ }
@@ -0,0 +1,67 @@
1
+ import { customizeLegacySource, customizeReadme, customizeRootManifest, customizeSiteConfig, legacyBrandedFiles, } from "./customize.js";
2
+ function isExcluded(path, exclude) {
3
+ return exclude.some((entry) => path === entry || path.startsWith(`${entry.replace(/\/$/, "")}/`));
4
+ }
5
+ export function createPlan(input) {
6
+ const { manifest, identity, source } = input;
7
+ const provenance = {
8
+ cliVersion: input.cliVersion,
9
+ starterRepository: source.repository,
10
+ starterRef: source.ref,
11
+ ...(source.commit ? { starterCommit: source.commit } : {}),
12
+ };
13
+ const files = source.files.filter(({ to }) => !isExcluded(to, manifest.exclude) && to !== manifest.env.target);
14
+ const has = (path) => files.some(({ to }) => to === path);
15
+ const edits = [];
16
+ if (has("package.json")) {
17
+ edits.push({
18
+ path: "package.json",
19
+ description: `Name the package "${identity.packageName}" and record its origin`,
20
+ apply: (text) => customizeRootManifest(text, identity, provenance, manifest.dockerImage),
21
+ });
22
+ }
23
+ if (has("README.md")) {
24
+ edits.push({
25
+ path: "README.md",
26
+ description: `Title the README "${identity.displayName}" and record its origin`,
27
+ apply: (text) => customizeReadme(text, identity, provenance),
28
+ });
29
+ }
30
+ if (has(manifest.siteConfig)) {
31
+ edits.push({
32
+ path: manifest.siteConfig,
33
+ description: `Set the project name to "${identity.displayName}"`,
34
+ apply: (text) => customizeSiteConfig(text, identity),
35
+ });
36
+ }
37
+ else {
38
+ for (const path of legacyBrandedFiles.filter(has)) {
39
+ edits.push({
40
+ path,
41
+ description: `Replace the Starter name with "${identity.displayName}"`,
42
+ apply: (text) => customizeLegacySource(text, identity),
43
+ });
44
+ }
45
+ }
46
+ return {
47
+ targetDirectory: input.targetDirectory,
48
+ identity,
49
+ provenance,
50
+ files,
51
+ edits,
52
+ envFile: has(manifest.env.example) ? { from: manifest.env.example, to: manifest.env.target } : null,
53
+ git: input.git,
54
+ install: input.install,
55
+ };
56
+ }
57
+ export function describePlan(plan) {
58
+ return [
59
+ `Create ${plan.targetDirectory}`,
60
+ `Copy ${plan.files.length} files from ${plan.provenance.starterRepository}@${plan.provenance.starterRef}` +
61
+ (plan.provenance.starterCommit ? ` (${plan.provenance.starterCommit.slice(0, 8)})` : ""),
62
+ ...plan.edits.map((edit) => `Edit ${edit.path}: ${edit.description}`),
63
+ ...(plan.envFile ? [`Create ${plan.envFile.to} from ${plan.envFile.from}`] : []),
64
+ plan.git ? "Initialize a Git repository on main" : "Skip Git initialization",
65
+ plan.install ? "Install dependencies with pnpm" : "Skip dependency installation",
66
+ ];
67
+ }
@@ -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,125 @@
1
+ import { cancel, intro, isCancel, log, outro, 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 } 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
+ async function createProject(args) {
34
+ const interactive = Boolean(process.stdin.isTTY) && !args.yes;
35
+ const requested = args.projectDirectory ?? (interactive ? await askProjectDirectory() : "my-app");
36
+ const targetDirectory = resolve(process.cwd(), requested);
37
+ await assertTargetIsUsable(targetDirectory);
38
+ const packageName = packageNameFromDirectory(targetDirectory);
39
+ const identity = { packageName, displayName: displayNameFromPackageName(packageName) };
40
+ const progress = spinner();
41
+ progress.start("Preparing the Starter");
42
+ const source = await resolveTemplateSource({ ref: args.ref, templateDir: args.templateDir }).catch((error) => {
43
+ progress.stop("Could not prepare the Starter");
44
+ throw error;
45
+ });
46
+ try {
47
+ const manifest = await readTemplateManifest(source.root);
48
+ const plan = createPlan({
49
+ targetDirectory,
50
+ identity,
51
+ source,
52
+ manifest,
53
+ cliVersion,
54
+ git: args.git,
55
+ install: args.install,
56
+ });
57
+ if (args.dryRun) {
58
+ progress.stop("Dry run: nothing was written");
59
+ log.message(describePlan(plan).map((line) => `• ${line}`).join("\n"));
60
+ outro("Run again without --dry-run to create the project.");
61
+ return;
62
+ }
63
+ progress.message(`Writing ${plan.files.length} files`);
64
+ await executePlan(plan, source);
65
+ progress.stop(`Created ${identity.packageName} from Starter ${source.ref}`);
66
+ }
67
+ catch (error) {
68
+ progress.stop("Project scaffold failed; nothing was left behind");
69
+ throw error;
70
+ }
71
+ finally {
72
+ await source.cleanup();
73
+ }
74
+ if (args.git) {
75
+ await initializeGit(targetDirectory);
76
+ log.success("Git repository initialized on main");
77
+ }
78
+ let pnpmCommand = "pnpm";
79
+ if (args.install) {
80
+ const launcher = detectLauncher();
81
+ if (launcher && launcher !== "pnpm") {
82
+ log.info(`This project is a pnpm workspace, so dependencies are installed with pnpm (not ${launcher}).`);
83
+ }
84
+ const pnpm = await resolvePnpm();
85
+ if (!pnpm) {
86
+ log.warn("pnpm was not found. Enable it with `corepack enable`, then run `pnpm install` in the project.");
87
+ }
88
+ else {
89
+ pnpmCommand = pnpm.join(" ");
90
+ try {
91
+ await installDependencies(targetDirectory, pnpm);
92
+ log.success("Dependencies installed");
93
+ }
94
+ catch (error) {
95
+ log.error(`Project created; dependency installation did not complete. ${error instanceof Error ? error.message : ""}`);
96
+ log.info(`Run \`${pnpmCommand} install\` in the project to retry.`);
97
+ }
98
+ }
99
+ }
100
+ const directory = relative(process.cwd(), targetDirectory) || ".";
101
+ outro(`Next steps:\n cd ${directory}\n ${pnpmCommand} dev\n\n` +
102
+ `See SETUP.md for database and provider configuration.\n\n` +
103
+ `${dim("Docs")} ${cliUrl}\n${dim("Clubedge")} ${siteUrl}`);
104
+ }
105
+ export async function main(argv = process.argv.slice(2)) {
106
+ try {
107
+ const args = parseCliArgs(argv);
108
+ if (args.version) {
109
+ console.log(cliVersion);
110
+ return;
111
+ }
112
+ showBanner();
113
+ if (args.help) {
114
+ console.log(helpText());
115
+ return;
116
+ }
117
+ assertSupportedNode();
118
+ intro("Create a project with Clubedge Starter");
119
+ await createProject(args);
120
+ }
121
+ catch (error) {
122
+ console.error(`\n${error instanceof Error ? error.message : String(error)}`);
123
+ process.exitCode = 1;
124
+ }
125
+ }
@@ -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";
@@ -0,0 +1,11 @@
1
+ import { run } from "../utils/process.js";
2
+ export async function initializeGit(directory) {
3
+ try {
4
+ await run("git", ["init", "-b", "main"], directory);
5
+ }
6
+ catch {
7
+ // Git before 2.28 has no -b option.
8
+ await run("git", ["init"], directory);
9
+ await run("git", ["branch", "-M", "main"], directory);
10
+ }
11
+ }