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.
- package/dist/commands/create.d.ts +8 -0
- package/dist/commands/create.js +89 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +17 -0
- package/dist/lib/logger.d.ts +6 -0
- package/dist/lib/logger.js +7 -0
- package/dist/lib/prompts.d.ts +9 -0
- package/dist/lib/prompts.js +85 -0
- package/dist/steps/download-template.d.ts +1 -0
- package/dist/steps/download-template.js +38 -0
- package/dist/steps/init-git.d.ts +1 -0
- package/dist/steps/init-git.js +12 -0
- package/dist/steps/install-deps.d.ts +1 -0
- package/dist/steps/install-deps.js +6 -0
- package/dist/steps/print-next-steps.d.ts +6 -0
- package/dist/steps/print-next-steps.js +39 -0
- package/dist/steps/reset-migrations.d.ts +1 -0
- package/dist/steps/reset-migrations.js +23 -0
- package/dist/steps/rewrite-package-names.d.ts +9 -0
- package/dist/steps/rewrite-package-names.js +51 -0
- package/dist/steps/rewrite-package-names.test.d.ts +1 -0
- package/dist/steps/rewrite-package-names.test.js +32 -0
- package/dist/steps/scaffold-tier.d.ts +13 -0
- package/dist/steps/scaffold-tier.js +141 -0
- package/dist/steps/validate-license.d.ts +17 -0
- package/dist/steps/validate-license.js +135 -0
- package/dist/steps/validate-target.d.ts +1 -0
- package/dist/steps/validate-target.js +14 -0
- package/package.json +33 -0
- package/src/commands/create.ts +124 -0
- package/src/index.ts +22 -0
- package/src/lib/logger.ts +8 -0
- package/src/lib/prompts.ts +114 -0
- package/src/steps/download-template.ts +55 -0
- package/src/steps/init-git.ts +18 -0
- package/src/steps/install-deps.ts +7 -0
- package/src/steps/print-next-steps.ts +60 -0
- package/src/steps/reset-migrations.ts +27 -0
- package/src/steps/rewrite-package-names.test.ts +35 -0
- package/src/steps/rewrite-package-names.ts +59 -0
- package/src/steps/scaffold-tier.ts +172 -0
- package/src/steps/validate-license.ts +181 -0
- package/src/steps/validate-target.ts +21 -0
- package/tests/e2e.test.ts +57 -0
- 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
|
+
}
|