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,89 @@
|
|
|
1
|
+
import kleur from "kleur";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { validateTarget } from "../steps/validate-target.js";
|
|
4
|
+
import { validateLicense } from "../steps/validate-license.js";
|
|
5
|
+
import { downloadTemplate } from "../steps/download-template.js";
|
|
6
|
+
import { rewritePackageNames } from "../steps/rewrite-package-names.js";
|
|
7
|
+
import { resetMigrations } from "../steps/reset-migrations.js";
|
|
8
|
+
import { scaffoldTier } from "../steps/scaffold-tier.js";
|
|
9
|
+
import { initGit } from "../steps/init-git.js";
|
|
10
|
+
import { installDeps } from "../steps/install-deps.js";
|
|
11
|
+
import { printNextSteps } from "../steps/print-next-steps.js";
|
|
12
|
+
import { promptProjectName, promptInstallTier } from "../lib/prompts.js";
|
|
13
|
+
import { logger } from "../lib/logger.js";
|
|
14
|
+
export async function createCommand(name, options) {
|
|
15
|
+
logger.info(kleur.bold().blue("\n create-composure-app\n"));
|
|
16
|
+
try {
|
|
17
|
+
const projectName = name ?? (await promptProjectName());
|
|
18
|
+
await validateTarget(projectName);
|
|
19
|
+
// ── License validation ──────────────────────────────────
|
|
20
|
+
let tier;
|
|
21
|
+
if (options.dev) {
|
|
22
|
+
logger.warn("Dev mode — skipping license validation");
|
|
23
|
+
// In dev mode, allow tier flag or prompt
|
|
24
|
+
tier = options.tier ?? (await promptInstallTier("enterprise"));
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const spinner = ora("Validating license...").start();
|
|
28
|
+
const license = await validateLicense(options.token);
|
|
29
|
+
spinner.succeed(`License validated — ${kleur.green(license.plan)} plan (${license.email})`);
|
|
30
|
+
// ── Interactive tier selection ──────────────────────────
|
|
31
|
+
logger.info("");
|
|
32
|
+
tier = options.tier ?? (await promptInstallTier(license.plan));
|
|
33
|
+
}
|
|
34
|
+
const includeMobile = tier === "starter-mobile" || tier === "pro-mobile";
|
|
35
|
+
const includePro = tier === "pro" || tier === "pro-mobile";
|
|
36
|
+
logger.info(`\n ${kleur.bold("Selected:")} ${tierLabel(tier)}\n`);
|
|
37
|
+
// ── Download ──────────────────────────────────────────────
|
|
38
|
+
const downloadSpinner = ora("Downloading template...").start();
|
|
39
|
+
await downloadTemplate(projectName, options.token, options.dev);
|
|
40
|
+
downloadSpinner.succeed("Template downloaded");
|
|
41
|
+
// ── Scaffold tier (remove unused files) ──────────────────
|
|
42
|
+
const scaffoldSpinner = ora("Scaffolding for selected tier...").start();
|
|
43
|
+
const { removed } = await scaffoldTier(projectName, tier);
|
|
44
|
+
if (removed.length > 0) {
|
|
45
|
+
scaffoldSpinner.succeed(`Scaffolded — removed ${removed.length} items not in ${tierLabel(tier)}`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
scaffoldSpinner.succeed("Full template installed");
|
|
49
|
+
}
|
|
50
|
+
// ── Personalize ──────────────────────────────────────────
|
|
51
|
+
const rewriteSpinner = ora("Personalizing project...").start();
|
|
52
|
+
await rewritePackageNames(projectName);
|
|
53
|
+
rewriteSpinner.succeed("Project personalized");
|
|
54
|
+
// ── Migrations ───────────────────────────────────────────
|
|
55
|
+
if (options.cleanMigrations) {
|
|
56
|
+
const migSpinner = ora("Resetting migrations...").start();
|
|
57
|
+
await resetMigrations(projectName);
|
|
58
|
+
migSpinner.succeed("Migrations reset");
|
|
59
|
+
}
|
|
60
|
+
// ── Git ──────────────────────────────────────────────────
|
|
61
|
+
const gitSpinner = ora("Initializing git...").start();
|
|
62
|
+
await initGit(projectName);
|
|
63
|
+
gitSpinner.succeed("Git initialized");
|
|
64
|
+
// ── Dependencies ─────────────────────────────────────────
|
|
65
|
+
if (!options.skipInstall) {
|
|
66
|
+
const installSpinner = ora("Installing dependencies (this may take a minute)...").start();
|
|
67
|
+
await installDeps(projectName);
|
|
68
|
+
installSpinner.succeed("Dependencies installed");
|
|
69
|
+
}
|
|
70
|
+
// ── Summary ──────────────────────────────────────────────
|
|
71
|
+
printNextSteps(projectName, { includeMobile, includePro });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
logger.error(`\nFailed: ${error instanceof Error ? error.message : String(error)}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function tierLabel(tier) {
|
|
79
|
+
switch (tier) {
|
|
80
|
+
case "starter":
|
|
81
|
+
return "Starter (Web)";
|
|
82
|
+
case "starter-mobile":
|
|
83
|
+
return "Starter + Mobile";
|
|
84
|
+
case "pro":
|
|
85
|
+
return "Pro (Web)";
|
|
86
|
+
case "pro-mobile":
|
|
87
|
+
return "Pro + Mobile";
|
|
88
|
+
}
|
|
89
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { createCommand } from "./commands/create.js";
|
|
4
|
+
const program = new Command();
|
|
5
|
+
program
|
|
6
|
+
.name("create-composure-app")
|
|
7
|
+
.description("Scaffold a new Composure Template project — multi-tenant Next.js + Supabase + Expo")
|
|
8
|
+
.version("1.0.0");
|
|
9
|
+
program
|
|
10
|
+
.argument("[name]", "Project name")
|
|
11
|
+
.option("-t, --token <token>", "OAuth license token (or use Composure login)")
|
|
12
|
+
.option("--tier <tier>", "Install tier: starter, starter-mobile, pro, pro-mobile")
|
|
13
|
+
.option("--clean-migrations", "Reset migrations to a single baseline", false)
|
|
14
|
+
.option("--skip-install", "Skip pnpm install step", false)
|
|
15
|
+
.option("--dev", "Development mode — skips license validation", false)
|
|
16
|
+
.action(createCommand);
|
|
17
|
+
program.parse();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TemplatePlan } from "../steps/validate-license.js";
|
|
2
|
+
export declare function promptProjectName(): Promise<string>;
|
|
3
|
+
export declare function promptConfirm(message: string): Promise<boolean>;
|
|
4
|
+
export type InstallTier = "starter" | "starter-mobile" | "pro" | "pro-mobile";
|
|
5
|
+
/**
|
|
6
|
+
* Prompt the user to select their install tier.
|
|
7
|
+
* Enterprise users see all options. Free users see starter options only.
|
|
8
|
+
*/
|
|
9
|
+
export declare function promptInstallTier(plan: TemplatePlan): Promise<InstallTier>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
export async function promptProjectName() {
|
|
3
|
+
const { name } = await prompts({
|
|
4
|
+
type: "text",
|
|
5
|
+
name: "name",
|
|
6
|
+
message: "Project name",
|
|
7
|
+
validate: (value) => /^[a-z0-9-_]+$/i.test(value) || "Use letters, numbers, dashes, and underscores only",
|
|
8
|
+
});
|
|
9
|
+
if (!name) {
|
|
10
|
+
throw new Error("Project name is required.");
|
|
11
|
+
}
|
|
12
|
+
return name;
|
|
13
|
+
}
|
|
14
|
+
export async function promptConfirm(message) {
|
|
15
|
+
const { confirmed } = await prompts({
|
|
16
|
+
type: "confirm",
|
|
17
|
+
name: "confirmed",
|
|
18
|
+
message,
|
|
19
|
+
initial: true,
|
|
20
|
+
});
|
|
21
|
+
return confirmed ?? false;
|
|
22
|
+
}
|
|
23
|
+
const ALL_TIERS = [
|
|
24
|
+
{
|
|
25
|
+
title: "Starter (Web only)",
|
|
26
|
+
value: "starter",
|
|
27
|
+
description: "Auth, accounts, users, contacts, settings, team",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
title: "Starter + Mobile",
|
|
31
|
+
value: "starter-mobile",
|
|
32
|
+
description: "Starter + Expo app with 3 tenant tab layouts",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
title: "Pro (Web only)",
|
|
36
|
+
value: "pro",
|
|
37
|
+
description: "All 30 tables, all screens, CRM, inbox, workflows, AI, commerce",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
title: "Pro + Mobile",
|
|
41
|
+
value: "pro-mobile",
|
|
42
|
+
description: "Everything + Expo mobile app",
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Get available tiers based on license plan.
|
|
47
|
+
*
|
|
48
|
+
* - enterprise: all tiers
|
|
49
|
+
* - pro: all tiers
|
|
50
|
+
* - free: starter tiers only
|
|
51
|
+
*/
|
|
52
|
+
function getAvailableTiers(plan) {
|
|
53
|
+
switch (plan) {
|
|
54
|
+
case "enterprise":
|
|
55
|
+
case "pro":
|
|
56
|
+
return ALL_TIERS;
|
|
57
|
+
case "free":
|
|
58
|
+
default:
|
|
59
|
+
return ALL_TIERS.filter((t) => t.value === "starter" || t.value === "starter-mobile");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Prompt the user to select their install tier.
|
|
64
|
+
* Enterprise users see all options. Free users see starter options only.
|
|
65
|
+
*/
|
|
66
|
+
export async function promptInstallTier(plan) {
|
|
67
|
+
const available = getAvailableTiers(plan);
|
|
68
|
+
// If enterprise with only one sensible choice, still show the prompt
|
|
69
|
+
// so internal users can choose web-only vs web+mobile
|
|
70
|
+
const { tier } = await prompts({
|
|
71
|
+
type: "select",
|
|
72
|
+
name: "tier",
|
|
73
|
+
message: "What would you like to install?",
|
|
74
|
+
choices: available.map((t) => ({
|
|
75
|
+
title: t.title,
|
|
76
|
+
value: t.value,
|
|
77
|
+
description: t.description,
|
|
78
|
+
})),
|
|
79
|
+
initial: available.length - 1, // Default to the highest tier available
|
|
80
|
+
});
|
|
81
|
+
if (!tier) {
|
|
82
|
+
throw new Error("Installation cancelled.");
|
|
83
|
+
}
|
|
84
|
+
return tier;
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function downloadTemplate(projectName: string, token?: string, devMode?: boolean): Promise<void>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { resolve, basename } from "node:path";
|
|
3
|
+
const TEMPLATE_REPO = process.env.TEMPLATE_REPO_URL ?? "https://github.com/hrconsultnj/template-monorepo.git";
|
|
4
|
+
export async function downloadTemplate(projectName, token, devMode) {
|
|
5
|
+
// Defense-in-depth: ensure projectName is just a directory name, no path traversal
|
|
6
|
+
if (basename(projectName) !== projectName) {
|
|
7
|
+
throw new Error(`Invalid project name: "${projectName}" contains path separators.`);
|
|
8
|
+
}
|
|
9
|
+
const targetPath = resolve(process.cwd(), projectName);
|
|
10
|
+
if (devMode) {
|
|
11
|
+
// execFileSync bypasses the shell — no injection possible
|
|
12
|
+
execFileSync("git", [
|
|
13
|
+
"clone", "--depth=1", "--single-branch", TEMPLATE_REPO, targetPath,
|
|
14
|
+
], { stdio: "pipe" });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
// Production mode: download tarball from license server
|
|
18
|
+
const LICENSE_SERVER = process.env.TEMPLATE_LICENSE_SERVER ?? "https://composure-pro.com";
|
|
19
|
+
const response = await fetch(`${LICENSE_SERVER}/download`, {
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
throw new Error(`Download failed: ${response.statusText}`);
|
|
26
|
+
}
|
|
27
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
28
|
+
mkdirSync(targetPath, { recursive: true });
|
|
29
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
30
|
+
const tarballPath = resolve(targetPath, ".template.tar.gz");
|
|
31
|
+
writeFileSync(tarballPath, buffer);
|
|
32
|
+
// --no-absolute-names prevents path traversal in tarball entries
|
|
33
|
+
execFileSync("tar", [
|
|
34
|
+
"xzf", ".template.tar.gz", "--strip-components=1", "--no-absolute-names",
|
|
35
|
+
], { cwd: targetPath, stdio: "pipe" });
|
|
36
|
+
const { unlinkSync } = await import("node:fs");
|
|
37
|
+
unlinkSync(tarballPath);
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function initGit(projectName: string): Promise<void>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
export async function initGit(projectName) {
|
|
5
|
+
const targetPath = resolve(process.cwd(), projectName);
|
|
6
|
+
// Remove the cloned .git directory
|
|
7
|
+
rmSync(resolve(targetPath, ".git"), { recursive: true, force: true });
|
|
8
|
+
// Initialize fresh git repo
|
|
9
|
+
execSync("git init", { cwd: targetPath, stdio: "pipe" });
|
|
10
|
+
execSync("git add -A", { cwd: targetPath, stdio: "pipe" });
|
|
11
|
+
execSync('git commit -m "chore: initial commit from create-composure-app"', { cwd: targetPath, stdio: "pipe" });
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function installDeps(projectName: string): Promise<void>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import kleur from "kleur";
|
|
2
|
+
export function printNextSteps(projectName, options = {}) {
|
|
3
|
+
const { includeMobile = false, includePro = false } = options;
|
|
4
|
+
const name = kleur.cyan(projectName);
|
|
5
|
+
const features = [
|
|
6
|
+
`- ${kleur.blue("Web app")} — Next.js 16 + Tailwind + shadcn`,
|
|
7
|
+
`- ${kleur.blue("Database")} — Supabase + typed queries + RLS`,
|
|
8
|
+
`- ${kleur.blue("3 tenant types")} — Admin / Workspace / Portal`,
|
|
9
|
+
`- ${kleur.blue("Auth")} — Contact-first signup + multi-account`,
|
|
10
|
+
];
|
|
11
|
+
if (includeMobile) {
|
|
12
|
+
features.push(`- ${kleur.blue("Mobile app")} — Expo SDK 55 + React Native`);
|
|
13
|
+
}
|
|
14
|
+
if (includePro) {
|
|
15
|
+
features.push(`- ${kleur.blue("CRM")} — Tasks, deals, pipelines`, `- ${kleur.blue("Inbox")} — Threads, compose, reply`, `- ${kleur.blue("AI")} — Agents, memory graph, thinking`, `- ${kleur.blue("Commerce")} — Invoices, quotes, payments`, `- ${kleur.blue("Workflows")} — Visual automation engine`);
|
|
16
|
+
}
|
|
17
|
+
console.log(`
|
|
18
|
+
${kleur.green().bold("Your project is ready!")}
|
|
19
|
+
|
|
20
|
+
${kleur.bold("Next steps:")}
|
|
21
|
+
${kleur.gray("$")} cd ${name}
|
|
22
|
+
${kleur.gray("$")} pnpm supabase start
|
|
23
|
+
${kleur.gray("$")} pnpm dev
|
|
24
|
+
|
|
25
|
+
${kleur.bold("First-time setup:")}
|
|
26
|
+
1. Copy ${kleur.yellow(".env.example")} to ${kleur.yellow(".env")}
|
|
27
|
+
2. Fill in your Supabase keys
|
|
28
|
+
3. Run ${kleur.yellow(`pnpm --filter @${projectName}/database db:types`)} after migrations apply
|
|
29
|
+
4. Visit ${kleur.underline("http://localhost:3000")} to sign up
|
|
30
|
+
|
|
31
|
+
${kleur.bold("What's included:")}
|
|
32
|
+
${features.map((f) => ` ${f}`).join("\n")}
|
|
33
|
+
${!includePro ? `\n${kleur.yellow("Upgrade to Pro for CRM, inbox, workflows, AI, and commerce.")}` : ""}
|
|
34
|
+
${!includeMobile ? `\n${kleur.yellow("Add mobile with the + Mobile tier for Expo SDK 55 support.")}` : ""}
|
|
35
|
+
|
|
36
|
+
${kleur.gray("Docs: https://composure-pro.com/template/docs")}
|
|
37
|
+
${kleur.gray("Support: support@composure-pro.com")}
|
|
38
|
+
`);
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function resetMigrations(projectName: string): Promise<void>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
export async function resetMigrations(projectName) {
|
|
4
|
+
const migrationsDir = resolve(process.cwd(), projectName, "supabase/migrations");
|
|
5
|
+
const files = readdirSync(migrationsDir)
|
|
6
|
+
.filter((f) => f.endsWith(".sql"))
|
|
7
|
+
.sort();
|
|
8
|
+
if (files.length <= 1)
|
|
9
|
+
return; // Already a single migration
|
|
10
|
+
// Concatenate all migrations into one baseline
|
|
11
|
+
let combined = "-- Baseline migration generated by create-composure-app --clean-migrations\n\n";
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
combined += `-- Source: ${file}\n`;
|
|
14
|
+
combined += readFileSync(join(migrationsDir, file), "utf-8");
|
|
15
|
+
combined += "\n\n";
|
|
16
|
+
}
|
|
17
|
+
// Delete all existing migrations
|
|
18
|
+
for (const file of files) {
|
|
19
|
+
unlinkSync(join(migrationsDir, file));
|
|
20
|
+
}
|
|
21
|
+
// Write the combined baseline
|
|
22
|
+
writeFileSync(join(migrationsDir, "20260101000001_init.sql"), combined, "utf-8");
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure function: rewrite @template/* references in a package.json string.
|
|
3
|
+
* Exported separately for unit testing.
|
|
4
|
+
*/
|
|
5
|
+
export declare function rewritePackageJsonContent(content: string, projectName: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Rewrite all @template/* references to @<projectName>/* across the project.
|
|
8
|
+
*/
|
|
9
|
+
export declare function rewritePackageNames(projectName: string): Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import { readdirSync, statSync } from "node:fs";
|
|
4
|
+
/**
|
|
5
|
+
* Pure function: rewrite @template/* references in a package.json string.
|
|
6
|
+
* Exported separately for unit testing.
|
|
7
|
+
*/
|
|
8
|
+
export function rewritePackageJsonContent(content, projectName) {
|
|
9
|
+
return content.replace(/@template\//g, `@${projectName}/`);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Walk a directory tree and find all package.json files.
|
|
13
|
+
*/
|
|
14
|
+
function findPackageJsonFiles(dir) {
|
|
15
|
+
const results = [];
|
|
16
|
+
for (const entry of readdirSync(dir)) {
|
|
17
|
+
if (entry === "node_modules" || entry === ".git")
|
|
18
|
+
continue;
|
|
19
|
+
const fullPath = join(dir, entry);
|
|
20
|
+
const stat = statSync(fullPath);
|
|
21
|
+
if (stat.isDirectory()) {
|
|
22
|
+
results.push(...findPackageJsonFiles(fullPath));
|
|
23
|
+
}
|
|
24
|
+
else if (entry === "package.json") {
|
|
25
|
+
results.push(fullPath);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Rewrite all @template/* references to @<projectName>/* across the project.
|
|
32
|
+
*/
|
|
33
|
+
export async function rewritePackageNames(projectName) {
|
|
34
|
+
const targetPath = resolve(process.cwd(), projectName);
|
|
35
|
+
const packageFiles = findPackageJsonFiles(targetPath);
|
|
36
|
+
for (const file of packageFiles) {
|
|
37
|
+
const content = readFileSync(file, "utf-8");
|
|
38
|
+
if (content.includes("@template/")) {
|
|
39
|
+
const rewritten = rewritePackageJsonContent(content, projectName);
|
|
40
|
+
writeFileSync(file, rewritten, "utf-8");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Also rewrite the root package.json name
|
|
44
|
+
const rootPkg = resolve(targetPath, "package.json");
|
|
45
|
+
const rootContent = readFileSync(rootPkg, "utf-8");
|
|
46
|
+
const parsed = JSON.parse(rootContent);
|
|
47
|
+
if (parsed.name === "template-monorepo") {
|
|
48
|
+
parsed.name = projectName;
|
|
49
|
+
writeFileSync(rootPkg, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { rewritePackageJsonContent } from "./rewrite-package-names.js";
|
|
3
|
+
describe("rewritePackageJsonContent", () => {
|
|
4
|
+
it("replaces @template/* references with project name", () => {
|
|
5
|
+
const input = JSON.stringify({
|
|
6
|
+
name: "@template/web",
|
|
7
|
+
dependencies: {
|
|
8
|
+
"@template/database": "workspace:*",
|
|
9
|
+
"@template/shared": "workspace:*",
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
const output = rewritePackageJsonContent(input, "my-app");
|
|
13
|
+
const parsed = JSON.parse(output);
|
|
14
|
+
expect(parsed.name).toBe("@my-app/web");
|
|
15
|
+
expect(parsed.dependencies["@my-app/database"]).toBe("workspace:*");
|
|
16
|
+
expect(parsed.dependencies["@my-app/shared"]).toBe("workspace:*");
|
|
17
|
+
});
|
|
18
|
+
it("preserves non-template dependencies", () => {
|
|
19
|
+
const input = JSON.stringify({
|
|
20
|
+
dependencies: { react: "^19.0.0", "@template/database": "workspace:*" },
|
|
21
|
+
});
|
|
22
|
+
const output = rewritePackageJsonContent(input, "acme");
|
|
23
|
+
const parsed = JSON.parse(output);
|
|
24
|
+
expect(parsed.dependencies.react).toBe("^19.0.0");
|
|
25
|
+
expect(parsed.dependencies["@acme/database"]).toBe("workspace:*");
|
|
26
|
+
});
|
|
27
|
+
it("handles content with no @template/ references", () => {
|
|
28
|
+
const input = JSON.stringify({ name: "plain-package", version: "1.0.0" });
|
|
29
|
+
const output = rewritePackageJsonContent(input, "my-app");
|
|
30
|
+
expect(output).toBe(input);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { InstallTier } from "../lib/prompts.js";
|
|
2
|
+
/**
|
|
3
|
+
* Remove files and folders that don't belong in the selected tier.
|
|
4
|
+
*
|
|
5
|
+
* Tier hierarchy:
|
|
6
|
+
* starter → core web only
|
|
7
|
+
* starter-mobile → core web + mobile
|
|
8
|
+
* pro → all web features
|
|
9
|
+
* pro-mobile → all web features + mobile
|
|
10
|
+
*/
|
|
11
|
+
export declare function scaffoldTier(projectName: string, tier: InstallTier): Promise<{
|
|
12
|
+
removed: string[];
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { rmSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
/**
|
|
4
|
+
* Remove files and folders that don't belong in the selected tier.
|
|
5
|
+
*
|
|
6
|
+
* Tier hierarchy:
|
|
7
|
+
* starter → core web only
|
|
8
|
+
* starter-mobile → core web + mobile
|
|
9
|
+
* pro → all web features
|
|
10
|
+
* pro-mobile → all web features + mobile
|
|
11
|
+
*/
|
|
12
|
+
export async function scaffoldTier(projectName, tier) {
|
|
13
|
+
const root = resolve(process.cwd(), projectName);
|
|
14
|
+
const removed = [];
|
|
15
|
+
const includeMobile = tier === "starter-mobile" || tier === "pro-mobile";
|
|
16
|
+
const includePro = tier === "pro" || tier === "pro-mobile";
|
|
17
|
+
// ── Remove mobile app if not included ───────────────────────
|
|
18
|
+
if (!includeMobile) {
|
|
19
|
+
const mobilePath = resolve(root, "apps/mobile");
|
|
20
|
+
if (existsSync(mobilePath)) {
|
|
21
|
+
rmSync(mobilePath, { recursive: true, force: true });
|
|
22
|
+
removed.push("apps/mobile");
|
|
23
|
+
}
|
|
24
|
+
// Remove mobile from pnpm-workspace.yaml
|
|
25
|
+
updateWorkspaceYaml(root, false);
|
|
26
|
+
}
|
|
27
|
+
// ── Remove pro features if starter tier ─────────────────────
|
|
28
|
+
if (!includePro) {
|
|
29
|
+
// Pro-only route pages (workspace)
|
|
30
|
+
const proWorkspaceRoutes = [
|
|
31
|
+
"inbox",
|
|
32
|
+
"events",
|
|
33
|
+
"timecards",
|
|
34
|
+
"forms",
|
|
35
|
+
"templates",
|
|
36
|
+
"commercial",
|
|
37
|
+
"connected-services",
|
|
38
|
+
"documents",
|
|
39
|
+
"tags",
|
|
40
|
+
];
|
|
41
|
+
for (const route of proWorkspaceRoutes) {
|
|
42
|
+
const routePath = resolve(root, `apps/web/app/(protected)/(tenant)/workspace/[id_prefix]/${route}`);
|
|
43
|
+
if (existsSync(routePath)) {
|
|
44
|
+
rmSync(routePath, { recursive: true, force: true });
|
|
45
|
+
removed.push(`workspace/${route}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Pro-only route pages (admin)
|
|
49
|
+
const proAdminRoutes = [
|
|
50
|
+
"broadcasts",
|
|
51
|
+
"ai-agents",
|
|
52
|
+
"integrations",
|
|
53
|
+
];
|
|
54
|
+
for (const route of proAdminRoutes) {
|
|
55
|
+
const routePath = resolve(root, `apps/web/app/(protected)/(internal)/admin/[id_prefix]/${route}`);
|
|
56
|
+
if (existsSync(routePath)) {
|
|
57
|
+
rmSync(routePath, { recursive: true, force: true });
|
|
58
|
+
removed.push(`admin/${route}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Pro-only route pages (portal)
|
|
62
|
+
const proPortalRoutes = [
|
|
63
|
+
"documents",
|
|
64
|
+
"messages",
|
|
65
|
+
"invoices",
|
|
66
|
+
];
|
|
67
|
+
for (const route of proPortalRoutes) {
|
|
68
|
+
const routePath = resolve(root, `apps/web/app/(protected)/(external)/portal/[id_prefix]/${route}`);
|
|
69
|
+
if (existsSync(routePath)) {
|
|
70
|
+
rmSync(routePath, { recursive: true, force: true });
|
|
71
|
+
removed.push(`portal/${route}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Pro-only components
|
|
75
|
+
const proComponents = [
|
|
76
|
+
"inbox",
|
|
77
|
+
"events",
|
|
78
|
+
"timecards",
|
|
79
|
+
"forms",
|
|
80
|
+
"templates",
|
|
81
|
+
"commercial",
|
|
82
|
+
"connected-services",
|
|
83
|
+
"documents",
|
|
84
|
+
"tags",
|
|
85
|
+
];
|
|
86
|
+
for (const component of proComponents) {
|
|
87
|
+
const componentPath = resolve(root, `apps/web/components/${component}`);
|
|
88
|
+
if (existsSync(componentPath)) {
|
|
89
|
+
rmSync(componentPath, { recursive: true, force: true });
|
|
90
|
+
removed.push(`components/${component}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Pro-only migrations
|
|
94
|
+
const proMigrations = [
|
|
95
|
+
"20260101000012_docs.sql",
|
|
96
|
+
"20260101000013_inboxes.sql",
|
|
97
|
+
"20260101000014_inbox_threads.sql",
|
|
98
|
+
"20260101000015_inbox_messages.sql",
|
|
99
|
+
"20260101000016_events.sql",
|
|
100
|
+
"20260101000017_timecards.sql",
|
|
101
|
+
"20260101000020_docs_versions.sql",
|
|
102
|
+
"20260127000001_broadcasts.sql",
|
|
103
|
+
"20260127000002_ai_system.sql",
|
|
104
|
+
"20260127000003_inbox_seed.sql",
|
|
105
|
+
"20260128000001_document_payment_enums.sql",
|
|
106
|
+
"20260128000002_tags.sql",
|
|
107
|
+
"20260128000003_entity_documents.sql",
|
|
108
|
+
"20260128000004_entity_document_storage.sql",
|
|
109
|
+
"20260128000005_commercial_documents_enums.sql",
|
|
110
|
+
"20260128000006_commercial_documents.sql",
|
|
111
|
+
"20260128000007_payments.sql",
|
|
112
|
+
"20260403000001_ai_thinking_system.sql",
|
|
113
|
+
"20260409000001_connected_services.sql",
|
|
114
|
+
];
|
|
115
|
+
for (const migration of proMigrations) {
|
|
116
|
+
const migrationPath = resolve(root, `supabase/migrations/${migration}`);
|
|
117
|
+
if (existsSync(migrationPath)) {
|
|
118
|
+
rmSync(migrationPath, { force: true });
|
|
119
|
+
removed.push(`migrations/${migration}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { removed };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Update pnpm-workspace.yaml to include/exclude mobile
|
|
127
|
+
*/
|
|
128
|
+
function updateWorkspaceYaml(root, includeMobile) {
|
|
129
|
+
const workspacePath = resolve(root, "pnpm-workspace.yaml");
|
|
130
|
+
if (!existsSync(workspacePath))
|
|
131
|
+
return;
|
|
132
|
+
let content = readFileSync(workspacePath, "utf8");
|
|
133
|
+
if (!includeMobile) {
|
|
134
|
+
// Remove apps/mobile line
|
|
135
|
+
content = content
|
|
136
|
+
.split("\n")
|
|
137
|
+
.filter((line) => !line.includes("apps/mobile"))
|
|
138
|
+
.join("\n");
|
|
139
|
+
writeFileSync(workspacePath, content, "utf8");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type TemplatePlan = "free" | "pro" | "enterprise";
|
|
2
|
+
export interface LicenseInfo {
|
|
3
|
+
valid: boolean;
|
|
4
|
+
plan: TemplatePlan;
|
|
5
|
+
email: string;
|
|
6
|
+
features: string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Validate license via Composure's auth system.
|
|
10
|
+
*
|
|
11
|
+
* Priority:
|
|
12
|
+
* 1. --token flag (legacy, still supported)
|
|
13
|
+
* 2. ~/.composure/credentials.json (Composure login)
|
|
14
|
+
*
|
|
15
|
+
* Returns plan info used to determine available tiers.
|
|
16
|
+
*/
|
|
17
|
+
export declare function validateLicense(token: string | undefined): Promise<LicenseInfo>;
|