create-composure-app 1.0.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 (45) hide show
  1. package/dist/commands/create.d.ts +8 -0
  2. package/dist/commands/create.js +89 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +17 -0
  5. package/dist/lib/logger.d.ts +6 -0
  6. package/dist/lib/logger.js +7 -0
  7. package/dist/lib/prompts.d.ts +9 -0
  8. package/dist/lib/prompts.js +85 -0
  9. package/dist/steps/download-template.d.ts +1 -0
  10. package/dist/steps/download-template.js +38 -0
  11. package/dist/steps/init-git.d.ts +1 -0
  12. package/dist/steps/init-git.js +12 -0
  13. package/dist/steps/install-deps.d.ts +1 -0
  14. package/dist/steps/install-deps.js +6 -0
  15. package/dist/steps/print-next-steps.d.ts +6 -0
  16. package/dist/steps/print-next-steps.js +39 -0
  17. package/dist/steps/reset-migrations.d.ts +1 -0
  18. package/dist/steps/reset-migrations.js +23 -0
  19. package/dist/steps/rewrite-package-names.d.ts +9 -0
  20. package/dist/steps/rewrite-package-names.js +51 -0
  21. package/dist/steps/rewrite-package-names.test.d.ts +1 -0
  22. package/dist/steps/rewrite-package-names.test.js +32 -0
  23. package/dist/steps/scaffold-tier.d.ts +13 -0
  24. package/dist/steps/scaffold-tier.js +141 -0
  25. package/dist/steps/validate-license.d.ts +17 -0
  26. package/dist/steps/validate-license.js +135 -0
  27. package/dist/steps/validate-target.d.ts +1 -0
  28. package/dist/steps/validate-target.js +14 -0
  29. package/package.json +33 -0
  30. package/src/commands/create.ts +124 -0
  31. package/src/index.ts +22 -0
  32. package/src/lib/logger.ts +8 -0
  33. package/src/lib/prompts.ts +114 -0
  34. package/src/steps/download-template.ts +55 -0
  35. package/src/steps/init-git.ts +18 -0
  36. package/src/steps/install-deps.ts +7 -0
  37. package/src/steps/print-next-steps.ts +60 -0
  38. package/src/steps/reset-migrations.ts +27 -0
  39. package/src/steps/rewrite-package-names.test.ts +35 -0
  40. package/src/steps/rewrite-package-names.ts +59 -0
  41. package/src/steps/scaffold-tier.ts +172 -0
  42. package/src/steps/validate-license.ts +181 -0
  43. package/src/steps/validate-target.ts +21 -0
  44. package/tests/e2e.test.ts +57 -0
  45. package/tsconfig.json +16 -0
@@ -0,0 +1,59 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve, join } from "node:path";
3
+ import { readdirSync, statSync } from "node:fs";
4
+
5
+ /**
6
+ * Pure function: rewrite @template/* references in a package.json string.
7
+ * Exported separately for unit testing.
8
+ */
9
+ export function rewritePackageJsonContent(
10
+ content: string,
11
+ projectName: string,
12
+ ): string {
13
+ return content.replace(/@template\//g, `@${projectName}/`);
14
+ }
15
+
16
+ /**
17
+ * Walk a directory tree and find all package.json files.
18
+ */
19
+ function findPackageJsonFiles(dir: string): string[] {
20
+ const results: string[] = [];
21
+
22
+ for (const entry of readdirSync(dir)) {
23
+ if (entry === "node_modules" || entry === ".git") continue;
24
+ const fullPath = join(dir, entry);
25
+ const stat = statSync(fullPath);
26
+ if (stat.isDirectory()) {
27
+ results.push(...findPackageJsonFiles(fullPath));
28
+ } else if (entry === "package.json") {
29
+ results.push(fullPath);
30
+ }
31
+ }
32
+
33
+ return results;
34
+ }
35
+
36
+ /**
37
+ * Rewrite all @template/* references to @<projectName>/* across the project.
38
+ */
39
+ export async function rewritePackageNames(projectName: string): Promise<void> {
40
+ const targetPath = resolve(process.cwd(), projectName);
41
+ const packageFiles = findPackageJsonFiles(targetPath);
42
+
43
+ for (const file of packageFiles) {
44
+ const content = readFileSync(file, "utf-8");
45
+ if (content.includes("@template/")) {
46
+ const rewritten = rewritePackageJsonContent(content, projectName);
47
+ writeFileSync(file, rewritten, "utf-8");
48
+ }
49
+ }
50
+
51
+ // Also rewrite the root package.json name
52
+ const rootPkg = resolve(targetPath, "package.json");
53
+ const rootContent = readFileSync(rootPkg, "utf-8");
54
+ const parsed = JSON.parse(rootContent);
55
+ if (parsed.name === "template-monorepo") {
56
+ parsed.name = projectName;
57
+ writeFileSync(rootPkg, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
58
+ }
59
+ }
@@ -0,0 +1,172 @@
1
+ import { resolve } from "node:path";
2
+ import { rmSync, existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import type { InstallTier } from "../lib/prompts.js";
4
+
5
+ /**
6
+ * Remove files and folders that don't belong in the selected tier.
7
+ *
8
+ * Tier hierarchy:
9
+ * starter → core web only
10
+ * starter-mobile → core web + mobile
11
+ * pro → all web features
12
+ * pro-mobile → all web features + mobile
13
+ */
14
+ export async function scaffoldTier(
15
+ projectName: string,
16
+ tier: InstallTier,
17
+ ): Promise<{ removed: string[] }> {
18
+ const root = resolve(process.cwd(), projectName);
19
+ const removed: string[] = [];
20
+
21
+ const includeMobile = tier === "starter-mobile" || tier === "pro-mobile";
22
+ const includePro = tier === "pro" || tier === "pro-mobile";
23
+
24
+ // ── Remove mobile app if not included ───────────────────────
25
+ if (!includeMobile) {
26
+ const mobilePath = resolve(root, "apps/mobile");
27
+ if (existsSync(mobilePath)) {
28
+ rmSync(mobilePath, { recursive: true, force: true });
29
+ removed.push("apps/mobile");
30
+ }
31
+
32
+ // Remove mobile from pnpm-workspace.yaml
33
+ updateWorkspaceYaml(root, false);
34
+ }
35
+
36
+ // ── Remove pro features if starter tier ─────────────────────
37
+ if (!includePro) {
38
+ // Pro-only route pages (workspace)
39
+ const proWorkspaceRoutes = [
40
+ "inbox",
41
+ "events",
42
+ "timecards",
43
+ "forms",
44
+ "templates",
45
+ "commercial",
46
+ "connected-services",
47
+ "documents",
48
+ "tags",
49
+ ];
50
+
51
+ for (const route of proWorkspaceRoutes) {
52
+ const routePath = resolve(
53
+ root,
54
+ `apps/web/app/(protected)/(tenant)/workspace/[id_prefix]/${route}`,
55
+ );
56
+ if (existsSync(routePath)) {
57
+ rmSync(routePath, { recursive: true, force: true });
58
+ removed.push(`workspace/${route}`);
59
+ }
60
+ }
61
+
62
+ // Pro-only route pages (admin)
63
+ const proAdminRoutes = [
64
+ "broadcasts",
65
+ "ai-agents",
66
+ "integrations",
67
+ ];
68
+
69
+ for (const route of proAdminRoutes) {
70
+ const routePath = resolve(
71
+ root,
72
+ `apps/web/app/(protected)/(internal)/admin/[id_prefix]/${route}`,
73
+ );
74
+ if (existsSync(routePath)) {
75
+ rmSync(routePath, { recursive: true, force: true });
76
+ removed.push(`admin/${route}`);
77
+ }
78
+ }
79
+
80
+ // Pro-only route pages (portal)
81
+ const proPortalRoutes = [
82
+ "documents",
83
+ "messages",
84
+ "invoices",
85
+ ];
86
+
87
+ for (const route of proPortalRoutes) {
88
+ const routePath = resolve(
89
+ root,
90
+ `apps/web/app/(protected)/(external)/portal/[id_prefix]/${route}`,
91
+ );
92
+ if (existsSync(routePath)) {
93
+ rmSync(routePath, { recursive: true, force: true });
94
+ removed.push(`portal/${route}`);
95
+ }
96
+ }
97
+
98
+ // Pro-only components
99
+ const proComponents = [
100
+ "inbox",
101
+ "events",
102
+ "timecards",
103
+ "forms",
104
+ "templates",
105
+ "commercial",
106
+ "connected-services",
107
+ "documents",
108
+ "tags",
109
+ ];
110
+
111
+ for (const component of proComponents) {
112
+ const componentPath = resolve(root, `apps/web/components/${component}`);
113
+ if (existsSync(componentPath)) {
114
+ rmSync(componentPath, { recursive: true, force: true });
115
+ removed.push(`components/${component}`);
116
+ }
117
+ }
118
+
119
+ // Pro-only migrations
120
+ const proMigrations = [
121
+ "20260101000012_docs.sql",
122
+ "20260101000013_inboxes.sql",
123
+ "20260101000014_inbox_threads.sql",
124
+ "20260101000015_inbox_messages.sql",
125
+ "20260101000016_events.sql",
126
+ "20260101000017_timecards.sql",
127
+ "20260101000020_docs_versions.sql",
128
+ "20260127000001_broadcasts.sql",
129
+ "20260127000002_ai_system.sql",
130
+ "20260127000003_inbox_seed.sql",
131
+ "20260128000001_document_payment_enums.sql",
132
+ "20260128000002_tags.sql",
133
+ "20260128000003_entity_documents.sql",
134
+ "20260128000004_entity_document_storage.sql",
135
+ "20260128000005_commercial_documents_enums.sql",
136
+ "20260128000006_commercial_documents.sql",
137
+ "20260128000007_payments.sql",
138
+ "20260403000001_ai_thinking_system.sql",
139
+ "20260409000001_connected_services.sql",
140
+ ];
141
+
142
+ for (const migration of proMigrations) {
143
+ const migrationPath = resolve(root, `supabase/migrations/${migration}`);
144
+ if (existsSync(migrationPath)) {
145
+ rmSync(migrationPath, { force: true });
146
+ removed.push(`migrations/${migration}`);
147
+ }
148
+ }
149
+ }
150
+
151
+ return { removed };
152
+ }
153
+
154
+ /**
155
+ * Update pnpm-workspace.yaml to include/exclude mobile
156
+ */
157
+ function updateWorkspaceYaml(root: string, includeMobile: boolean): void {
158
+ const workspacePath = resolve(root, "pnpm-workspace.yaml");
159
+ if (!existsSync(workspacePath)) return;
160
+
161
+ let content = readFileSync(workspacePath, "utf8");
162
+
163
+ if (!includeMobile) {
164
+ // Remove apps/mobile line
165
+ content = content
166
+ .split("\n")
167
+ .filter((line) => !line.includes("apps/mobile"))
168
+ .join("\n");
169
+
170
+ writeFileSync(workspacePath, content, "utf8");
171
+ }
172
+ }
@@ -0,0 +1,181 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+
6
+ export type TemplatePlan = "free" | "pro" | "enterprise";
7
+
8
+ export interface LicenseInfo {
9
+ valid: boolean;
10
+ plan: TemplatePlan;
11
+ email: string;
12
+ features: string[];
13
+ }
14
+
15
+ const COMPOSURE_DIR = join(homedir(), ".composure");
16
+ const CREDENTIALS_PATH = join(COMPOSURE_DIR, "credentials.json");
17
+ const TOKEN_SCRIPT = join(COMPOSURE_DIR, "bin", "composure-token.mjs");
18
+
19
+ /**
20
+ * Validate license via Composure's auth system.
21
+ *
22
+ * Priority:
23
+ * 1. --token flag (legacy, still supported)
24
+ * 2. ~/.composure/credentials.json (Composure login)
25
+ *
26
+ * Returns plan info used to determine available tiers.
27
+ */
28
+ export async function validateLicense(
29
+ token: string | undefined,
30
+ ): Promise<LicenseInfo> {
31
+ // Method 1: Composure credentials (preferred)
32
+ if (existsSync(CREDENTIALS_PATH)) {
33
+ return validateViaComposure();
34
+ }
35
+
36
+ // Method 2: Legacy token flag
37
+ if (token) {
38
+ return validateViaToken(token);
39
+ }
40
+
41
+ throw new Error(
42
+ "Not authenticated. Log in with Composure first:\n" +
43
+ " Run: /composure:auth login\n\n" +
44
+ "Or use a license token:\n" +
45
+ " npx create-composure-app --token=<your-token>\n\n" +
46
+ "Get a license at https://composure-pro.com/template",
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Validate using Composure's composure-token.mjs CLI
52
+ */
53
+ async function validateViaComposure(): Promise<LicenseInfo> {
54
+ // Check if composure-token.mjs exists
55
+ if (!existsSync(TOKEN_SCRIPT)) {
56
+ throw new Error(
57
+ "Composure CLI not found. Install Composure first:\n" +
58
+ " claude plugin install composure@composure-suite\n" +
59
+ " Then: /composure:auth login",
60
+ );
61
+ }
62
+
63
+ try {
64
+ // Step 1: Validate token is current (handles refresh automatically)
65
+ const validateOutput = execFileSync("node", [TOKEN_SCRIPT, "validate"], {
66
+ encoding: "utf8",
67
+ timeout: 15000,
68
+ }).trim();
69
+
70
+ // Output format: "valid:{plan}:{email}" or "expired" or "not-authenticated"
71
+ if (validateOutput === "not-authenticated") {
72
+ throw new Error("Not authenticated. Run: /composure:auth login");
73
+ }
74
+ if (validateOutput === "expired") {
75
+ throw new Error(
76
+ "Session expired. Run: /composure:auth login to refresh.",
77
+ );
78
+ }
79
+
80
+ const parts = validateOutput.split(":");
81
+ if (parts[0] !== "valid" || parts.length < 3) {
82
+ throw new Error(`Unexpected validation response: ${validateOutput}`);
83
+ }
84
+
85
+ const plan = normalizePlan(parts[1]);
86
+ const email = parts.slice(2).join(":"); // email may contain colons (unlikely but safe)
87
+
88
+ // Step 2: Get full license info (features, etc.)
89
+ let features: string[] = [];
90
+ try {
91
+ const licenseOutput = execFileSync(
92
+ "node",
93
+ [TOKEN_SCRIPT, "license"],
94
+ { encoding: "utf8", timeout: 15000 },
95
+ ).trim();
96
+ const licenseData = JSON.parse(licenseOutput);
97
+ features = licenseData.features ?? [];
98
+ } catch {
99
+ // License endpoint unavailable — proceed with plan-based features
100
+ features = getDefaultFeatures(plan);
101
+ }
102
+
103
+ return { valid: true, plan, email, features };
104
+ } catch (error) {
105
+ if (error instanceof Error && error.message.includes("Not authenticated")) {
106
+ throw error;
107
+ }
108
+ if (error instanceof Error && error.message.includes("Session expired")) {
109
+ throw error;
110
+ }
111
+ throw new Error(
112
+ `License validation failed: ${error instanceof Error ? error.message : String(error)}`,
113
+ );
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Validate using legacy token (standalone license server)
119
+ */
120
+ async function validateViaToken(token: string): Promise<LicenseInfo> {
121
+ const LICENSE_SERVER =
122
+ process.env.TEMPLATE_LICENSE_SERVER ?? "https://composure-pro.com";
123
+
124
+ const response = await fetch(`${LICENSE_SERVER}/api/v1/license/validate`, {
125
+ headers: {
126
+ Authorization: `Bearer ${token}`,
127
+ "Content-Type": "application/json",
128
+ },
129
+ });
130
+
131
+ if (!response.ok) {
132
+ const body = await response
133
+ .json()
134
+ .catch(() => ({ message: "Unknown error" }));
135
+ throw new Error(
136
+ `License validation failed: ${(body as { message?: string }).message ?? response.statusText}`,
137
+ );
138
+ }
139
+
140
+ const data = (await response.json()) as {
141
+ plan?: string;
142
+ email?: string;
143
+ features?: string[];
144
+ };
145
+
146
+ return {
147
+ valid: true,
148
+ plan: normalizePlan(data.plan),
149
+ email: data.email ?? "unknown",
150
+ features: data.features ?? getDefaultFeatures(normalizePlan(data.plan)),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Normalize plan string to TemplatePlan
156
+ */
157
+ function normalizePlan(plan: string | undefined): TemplatePlan {
158
+ switch (plan?.toLowerCase()) {
159
+ case "enterprise":
160
+ return "enterprise";
161
+ case "pro":
162
+ return "pro";
163
+ default:
164
+ return "free";
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Default features by plan when license endpoint doesn't return them
170
+ */
171
+ function getDefaultFeatures(plan: TemplatePlan): string[] {
172
+ switch (plan) {
173
+ case "enterprise":
174
+ return ["starter", "pro", "mobile", "support"];
175
+ case "pro":
176
+ return ["starter", "pro", "mobile"];
177
+ case "free":
178
+ default:
179
+ return ["starter", "mobile"];
180
+ }
181
+ }
@@ -0,0 +1,21 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ export async function validateTarget(projectName: string): Promise<void> {
5
+ if (!/^[a-z0-9-_]+$/i.test(projectName)) {
6
+ throw new Error(
7
+ `Invalid project name "${projectName}". Use letters, numbers, dashes, and underscores only.`,
8
+ );
9
+ }
10
+
11
+ const targetPath = resolve(process.cwd(), projectName);
12
+
13
+ if (existsSync(targetPath)) {
14
+ const contents = readdirSync(targetPath);
15
+ if (contents.length > 0) {
16
+ throw new Error(
17
+ `Target directory "${projectName}" exists and is not empty. Refusing to overwrite.`,
18
+ );
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { execSync } from "node:child_process";
3
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+
7
+ const CLI_PATH = resolve(__dirname, "../dist/index.js");
8
+
9
+ describe("create-composure-app E2E", () => {
10
+ let tempDir: string;
11
+
12
+ afterEach(() => {
13
+ if (tempDir) rmSync(tempDir, { recursive: true, force: true });
14
+ });
15
+
16
+ it("creates a project in dev mode with --skip-install", async () => {
17
+ tempDir = mkdtempSync(join(tmpdir(), "cta-e2e-"));
18
+ const projectName = "test-project";
19
+
20
+ execSync(
21
+ `node "${CLI_PATH}" ${projectName} --dev --skip-install`,
22
+ { cwd: tempDir, timeout: 120_000 },
23
+ );
24
+
25
+ const projectPath = join(tempDir, projectName);
26
+ expect(existsSync(projectPath)).toBe(true);
27
+
28
+ // Verify package.json was rewritten
29
+ const pkg = JSON.parse(
30
+ readFileSync(join(projectPath, "package.json"), "utf-8"),
31
+ );
32
+ expect(pkg.name).toBe(projectName);
33
+
34
+ // Verify @template/* was replaced
35
+ const content = readFileSync(join(projectPath, "package.json"), "utf-8");
36
+ expect(content).not.toContain("@template/");
37
+
38
+ // Verify git was initialized
39
+ expect(existsSync(join(projectPath, ".git"))).toBe(true);
40
+ }, 120_000);
41
+
42
+ it("refuses to overwrite a non-empty directory", () => {
43
+ tempDir = mkdtempSync(join(tmpdir(), "cta-e2e-"));
44
+ const projectName = "existing";
45
+ const projectPath = join(tempDir, projectName);
46
+
47
+ // Create non-empty directory
48
+ execSync(`mkdir -p "${projectPath}" && touch "${projectPath}/README.md"`);
49
+
50
+ expect(() =>
51
+ execSync(
52
+ `node "${CLI_PATH}" ${projectName} --dev --skip-install`,
53
+ { cwd: tempDir, stdio: "pipe" },
54
+ ),
55
+ ).toThrow();
56
+ });
57
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true,
12
+ "resolveJsonModule": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist", "tests"]
16
+ }