@intelligo-dev/cli 1.0.0-beta.1 → 1.0.0-beta.13

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 (103) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +63 -0
  3. package/dist/bin.js +85 -37
  4. package/dist/bin.js.map +1 -1
  5. package/dist/commands/add.d.ts +22 -1
  6. package/dist/commands/add.d.ts.map +1 -1
  7. package/dist/commands/add.js +61 -4
  8. package/dist/commands/add.js.map +1 -1
  9. package/dist/commands/create-flow.d.ts +34 -0
  10. package/dist/commands/create-flow.d.ts.map +1 -0
  11. package/dist/commands/create-flow.js +180 -0
  12. package/dist/commands/create-flow.js.map +1 -0
  13. package/dist/commands/create.d.ts +23 -6
  14. package/dist/commands/create.d.ts.map +1 -1
  15. package/dist/commands/create.js +45 -13
  16. package/dist/commands/create.js.map +1 -1
  17. package/dist/commands/doctor.d.ts +14 -3
  18. package/dist/commands/doctor.d.ts.map +1 -1
  19. package/dist/commands/doctor.js +293 -37
  20. package/dist/commands/doctor.js.map +1 -1
  21. package/dist/commands/migrate-check.d.ts +54 -1
  22. package/dist/commands/migrate-check.d.ts.map +1 -1
  23. package/dist/commands/migrate-check.js +101 -5
  24. package/dist/commands/migrate-check.js.map +1 -1
  25. package/dist/commands/migrate.d.ts +97 -0
  26. package/dist/commands/migrate.d.ts.map +1 -0
  27. package/dist/commands/migrate.js +147 -0
  28. package/dist/commands/migrate.js.map +1 -0
  29. package/dist/commands/upgrade-check.d.ts +5 -5
  30. package/dist/commands/upgrade-check.d.ts.map +1 -1
  31. package/dist/commands/upgrade-check.js +19 -6
  32. package/dist/commands/upgrade-check.js.map +1 -1
  33. package/dist/env-files.d.ts +12 -0
  34. package/dist/env-files.d.ts.map +1 -0
  35. package/dist/env-files.js +29 -0
  36. package/dist/env-files.js.map +1 -0
  37. package/dist/index.d.ts +9 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +9 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/manifest.d.ts +9 -15
  42. package/dist/manifest.d.ts.map +1 -1
  43. package/dist/manifest.js +5 -16
  44. package/dist/manifest.js.map +1 -1
  45. package/dist/migrations.d.ts +3 -6
  46. package/dist/migrations.d.ts.map +1 -1
  47. package/dist/migrations.js +3 -6
  48. package/dist/migrations.js.map +1 -1
  49. package/dist/model-catalogue.d.ts +14 -0
  50. package/dist/model-catalogue.d.ts.map +1 -0
  51. package/dist/model-catalogue.js +34 -0
  52. package/dist/model-catalogue.js.map +1 -0
  53. package/dist/registry-items.d.ts +56 -0
  54. package/dist/registry-items.d.ts.map +1 -0
  55. package/dist/registry-items.js +103 -0
  56. package/dist/registry-items.js.map +1 -0
  57. package/package.json +36 -10
  58. package/src/bin.ts +264 -0
  59. package/src/commands/add.ts +244 -0
  60. package/src/commands/create-flow.ts +228 -0
  61. package/src/commands/create.ts +139 -0
  62. package/src/commands/doctor.ts +487 -0
  63. package/src/commands/migrate-check.ts +257 -0
  64. package/src/commands/migrate.ts +234 -0
  65. package/src/commands/upgrade-check.ts +157 -0
  66. package/src/env-files.ts +31 -0
  67. package/src/index.ts +39 -0
  68. package/src/manifest.ts +93 -0
  69. package/src/migrations-dir.ts +25 -0
  70. package/src/migrations.ts +133 -0
  71. package/src/model-catalogue.ts +35 -0
  72. package/src/registry-items.ts +132 -0
  73. package/templates/admin-page/admin-page.tsx.tpl +2 -3
  74. package/templates/app-scaffold/assistant-route.ts.tpl +2 -2
  75. package/templates/app-scaffold/auth-route.ts.tpl +10 -0
  76. package/templates/app-scaffold/components.json.tpl +6 -2
  77. package/templates/app-scaffold/db-schema.ts.tpl +29 -0
  78. package/templates/app-scaffold/drizzle-journal.json.tpl +5 -0
  79. package/templates/app-scaffold/drizzle.config.ts.tpl +45 -0
  80. package/templates/app-scaffold/env.example.tpl +65 -1
  81. package/templates/app-scaffold/globals.css.tpl +172 -281
  82. package/templates/app-scaffold/i18n-request.ts.tpl +25 -0
  83. package/templates/app-scaffold/instrumentation.ts.tpl +21 -0
  84. package/templates/app-scaffold/intelligo.ts.tpl +104 -9
  85. package/templates/app-scaffold/layout.tsx.tpl +18 -1
  86. package/templates/app-scaffold/lib-utils.ts.tpl +1 -6
  87. package/templates/app-scaffold/next.config.mjs.tpl +2 -0
  88. package/templates/app-scaffold/package.json.tpl +31 -17
  89. package/templates/app-scaffold/page.tsx.tpl +16 -0
  90. package/templates/app-scaffold/plans.ts.tpl +32 -13
  91. package/templates/app-scaffold/{middleware.ts.tpl → proxy.ts.tpl} +6 -0
  92. package/templates/app-scaffold/sonner.tsx.tpl +42 -20
  93. package/templates/app-scaffold/stripe-webhook-route.ts.tpl +24 -0
  94. package/templates/app-scaffold/theme-provider.tsx.tpl +6 -4
  95. package/templates/app-scaffold/tsconfig.json.tpl +20 -4
  96. package/templates/app-scaffold/use-mobile.ts.tpl +19 -0
  97. package/templates/app-scaffold/workspace-bootstrap.ts.tpl +30 -0
  98. package/templates/maintenance/maintenance-route.ts.tpl +119 -0
  99. package/templates/manifest.json +57 -18
  100. package/templates/registry-items.json +116 -0
  101. package/templates/registry-requires.json +156 -0
  102. package/templates/usage-page/usage-page.tsx.tpl +23 -8
  103. package/templates/billing-page/billing-page.tsx.tpl +0 -72
@@ -0,0 +1,244 @@
1
+ /**
2
+ * `intelligo add <feature>` — generate consumer-owned source.
3
+ *
4
+ * "Owned" is the operative word: the files land in the
5
+ * consumer's repository and Intelligo stops deciding what is in them.
6
+ * The manifest records what was written and its hash so a later
7
+ * upgrade can tell an untouched file from one the consumer has made
8
+ * their own, and refuse to overwrite the latter.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import path from "node:path";
13
+
14
+ import {
15
+ emptyManifest,
16
+ hashContents,
17
+ readManifest,
18
+ recordFeature,
19
+ writeManifest,
20
+ type GeneratedFile,
21
+ } from "../manifest.js";
22
+
23
+ export type TemplateFile = { template: string; target: string };
24
+ /** A route the feature needs called on a schedule (five-field cron). */
25
+ export type TemplateCron = { path: string; schedule: string };
26
+ export type TemplateFeature = {
27
+ templateVersion: string;
28
+ description: string;
29
+ /** Set when a registry item supersedes this template feature. */
30
+ deprecated?: string;
31
+ files: TemplateFile[];
32
+ cron?: TemplateCron;
33
+ };
34
+ export type TemplateCatalogue = Record<string, TemplateFeature>;
35
+
36
+ export function readCatalogue(templatesDir: string): TemplateCatalogue {
37
+ return JSON.parse(
38
+ readFileSync(path.join(templatesDir, "manifest.json"), "utf8")
39
+ ) as TemplateCatalogue;
40
+ }
41
+
42
+ export type AddResult = {
43
+ feature: string;
44
+ /** The catalogue's deprecation notice, when the feature has one. */
45
+ deprecated?: string;
46
+ written: string[];
47
+ /** Present and modified by the consumer — left untouched. */
48
+ skippedCustomized: string[];
49
+ /** Present but not ours (no manifest record) — left untouched. */
50
+ skippedUnknown: string[];
51
+ /**
52
+ * The feature's schedule and what became of it: `written` to a new
53
+ * vercel.json, already `present` in the app's own, or `manual` — the
54
+ * app has a vercel.json without it, which is the consumer's to edit.
55
+ */
56
+ cron?: TemplateCron & { status: "written" | "present" | "manual" };
57
+ };
58
+
59
+ export type AddOptions = {
60
+ appRoot: string;
61
+ templatesDir: string;
62
+ frameworkVersion: string;
63
+ /** Re-write files the consumer has customized. Off by default. */
64
+ force?: boolean;
65
+ /**
66
+ * Placeholder substitutions applied to template contents, e.g.
67
+ * `{ __APP_NAME__: "acme" }`. Applied before hashing, so the
68
+ * recorded hash describes what actually landed on disk.
69
+ */
70
+ variables?: Record<string, string>;
71
+ };
72
+
73
+ export function substitute(
74
+ contents: string,
75
+ variables: Record<string, string> | undefined
76
+ ): string {
77
+ if (!variables) return contents;
78
+ return Object.entries(variables).reduce(
79
+ (out, [key, value]) => out.split(key).join(value),
80
+ contents
81
+ );
82
+ }
83
+
84
+ const VERCEL_CONFIG = "vercel.json";
85
+
86
+ /**
87
+ * Schedule the cron in vercel.json when the app has none. An existing
88
+ * one is deployment configuration the consumer wrote: it is read to see
89
+ * whether the path is already scheduled, never written. The file is not
90
+ * recorded in the manifest either — it is the consumer's from the start.
91
+ */
92
+ function scheduleCron(
93
+ appRoot: string,
94
+ cron: TemplateCron
95
+ ): "written" | "present" | "manual" {
96
+ const file = path.join(appRoot, VERCEL_CONFIG);
97
+ if (!existsSync(file)) {
98
+ const config = {
99
+ $schema: "https://openapi.vercel.sh/vercel.json",
100
+ crons: [cron],
101
+ };
102
+ writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
103
+ return "written";
104
+ }
105
+ try {
106
+ const config = JSON.parse(readFileSync(file, "utf8")) as {
107
+ crons?: Array<{ path?: string }>;
108
+ };
109
+ return config.crons?.some((c) => c.path === cron.path)
110
+ ? "present"
111
+ : "manual";
112
+ } catch {
113
+ return "manual";
114
+ }
115
+ }
116
+
117
+ export function addFeature(feature: string, options: AddOptions): AddResult {
118
+ const catalogue = readCatalogue(options.templatesDir);
119
+ const spec = catalogue[feature];
120
+ if (!spec) {
121
+ throw new Error(
122
+ `Unknown feature "${feature}". Available: ${Object.keys(catalogue).join(", ")}`
123
+ );
124
+ }
125
+
126
+ const manifest =
127
+ readManifest(options.appRoot) ?? emptyManifest(options.frameworkVersion);
128
+ const previous = manifest.features[feature];
129
+ const previousByPath = new Map(
130
+ (previous?.files ?? []).map((f) => [f.path, f])
131
+ );
132
+
133
+ const result: AddResult = {
134
+ feature,
135
+ written: [],
136
+ skippedCustomized: [],
137
+ skippedUnknown: [],
138
+ };
139
+ if (spec.deprecated) result.deprecated = spec.deprecated;
140
+ const recorded: GeneratedFile[] = [];
141
+
142
+ for (const file of spec.files) {
143
+ const source = path.join(options.templatesDir, file.template);
144
+ const target = path.join(options.appRoot, file.target);
145
+ const contents = substitute(
146
+ readFileSync(source, "utf8"),
147
+ options.variables
148
+ );
149
+
150
+ if (existsSync(target) && !options.force) {
151
+ const current = hashContents(readFileSync(target, "utf8"));
152
+ const known = previousByPath.get(file.target);
153
+
154
+ if (!known) {
155
+ // Not ours: a file we never generated already occupies the
156
+ // path. Overwriting it would destroy work we cannot attribute.
157
+ result.skippedUnknown.push(file.target);
158
+ continue;
159
+ }
160
+ if (current !== known.hash) {
161
+ result.skippedCustomized.push(file.target);
162
+ // Keep the ORIGINAL hash: it is the baseline that says "this is
163
+ // customized", and replacing it with the current hash would
164
+ // silently re-adopt the consumer's file as ours.
165
+ recorded.push(known);
166
+ continue;
167
+ }
168
+ }
169
+
170
+ mkdirSync(path.dirname(target), { recursive: true });
171
+ writeFileSync(target, contents);
172
+ recorded.push({ path: file.target, hash: hashContents(contents) });
173
+ result.written.push(file.target);
174
+ }
175
+
176
+ if (spec.cron) {
177
+ result.cron = {
178
+ ...spec.cron,
179
+ status: scheduleCron(options.appRoot, spec.cron),
180
+ };
181
+ }
182
+
183
+ writeManifest(
184
+ options.appRoot,
185
+ recordFeature(
186
+ manifest,
187
+ feature,
188
+ spec.templateVersion,
189
+ recorded,
190
+ options.variables
191
+ )
192
+ );
193
+
194
+ return result;
195
+ }
196
+
197
+ export function formatAddResult(r: AddResult): string {
198
+ const lines = [`${r.feature}:`];
199
+ if (r.deprecated) lines.push(` ⚠ deprecated: ${r.deprecated}`);
200
+ for (const f of r.written) lines.push(` + ${f}`);
201
+ for (const f of r.skippedCustomized)
202
+ lines.push(` = ${f} (yours — left alone; use --force to overwrite)`);
203
+ for (const f of r.skippedUnknown)
204
+ lines.push(` ! ${f} (already exists and was not generated by Intelligo)`);
205
+ if (r.cron?.status === "written")
206
+ lines.push(` + ${VERCEL_CONFIG} (${r.cron.path} on "${r.cron.schedule}")`);
207
+ if (
208
+ r.written.length === 0 &&
209
+ r.skippedCustomized.length === 0 &&
210
+ r.skippedUnknown.length === 0 &&
211
+ r.cron?.status !== "written"
212
+ )
213
+ lines.push(" nothing to do");
214
+ if (r.cron) lines.push(...cronNextSteps(r.cron));
215
+ return lines.join("\n");
216
+ }
217
+
218
+ /**
219
+ * Non-zero when a file the feature needs is still missing: one that
220
+ * exists but was not generated by Intelligo was left alone, so the
221
+ * feature is not installed as the catalogue describes it.
222
+ */
223
+ export function addExitCode(r: AddResult): number {
224
+ return r.skippedUnknown.length > 0 ? 1 : 0;
225
+ }
226
+
227
+ function cronNextSteps(cron: NonNullable<AddResult["cron"]>): string[] {
228
+ const entry = JSON.stringify({ path: cron.path, schedule: cron.schedule });
229
+ const lines = ["", " Next:"];
230
+ if (cron.status === "manual") {
231
+ lines.push(
232
+ ` - ${VERCEL_CONFIG} is yours and was left alone. Add to its "crons":`,
233
+ ` ${entry}`
234
+ );
235
+ }
236
+ lines.push(
237
+ " - Set CRON_SECRET (32+ characters). Vercel sends it as the Bearer token itself.",
238
+ " - Vercel Hobby runs a cron at most once a day and refuses a deployment that",
239
+ ` asks for more. On Hobby, or on any other host, remove the ${VERCEL_CONFIG} entry`,
240
+ " and call the route from your own scheduler at the same cadence:",
241
+ ` curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://<your-app>${cron.path}`
242
+ );
243
+ return lines;
244
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The conversation around `createApp`: ask what the flags did not say,
3
+ * scaffold, and install the chosen registry items once the developer
4
+ * has approved the commands that do it.
5
+ *
6
+ * Nothing runs on the developer's machine without their say-so. In a
7
+ * terminal the commands are shown and confirmed; without one (CI, a
8
+ * pipe) they run only under `--yes`, and are otherwise printed.
9
+ */
10
+
11
+ import { spawnSync } from "node:child_process";
12
+ import path from "node:path";
13
+
14
+ import * as p from "@clack/prompts";
15
+
16
+ import {
17
+ assertNotOccupied,
18
+ createApp,
19
+ deriveNames,
20
+ formatCreateResult,
21
+ formatNextSteps,
22
+ isOccupied,
23
+ } from "./create.js";
24
+ import {
25
+ detectPackageManager,
26
+ formatCommand,
27
+ hasLocalShadcn,
28
+ installPlan,
29
+ readRegistryCatalogue,
30
+ shortDescription,
31
+ withDependencies,
32
+ type Command,
33
+ } from "../registry-items.js";
34
+
35
+ export type CreateFlags = {
36
+ target?: string;
37
+ /** `--items a,b` — install these without asking. */
38
+ items?: string[];
39
+ /** `--all` — every page item. */
40
+ all: boolean;
41
+ /** `--yes` — approve the install commands without asking. */
42
+ yes: boolean;
43
+ /** `--no-install` — scaffold only; print the install commands. */
44
+ install: boolean;
45
+ /**
46
+ * `--link-workspace` — for scaffolding inside the Intelligo monorepo,
47
+ * where the packages are workspace members. The default is a version
48
+ * range, because that is what works everywhere else.
49
+ */
50
+ linkWorkspace: boolean;
51
+ };
52
+
53
+ export function parseCreateFlags(args: readonly string[]): CreateFlags {
54
+ let target: string | undefined;
55
+ let items: string[] | undefined;
56
+ for (let i = 0; i < args.length; i++) {
57
+ const arg = args[i]!;
58
+ if (arg === "--items" || arg.startsWith("--items=")) {
59
+ const list = arg === "--items" ? (args[++i] ?? "") : arg.slice(8);
60
+ items = list
61
+ .split(",")
62
+ .map((s) => s.trim())
63
+ .filter(Boolean);
64
+ } else if (!arg.startsWith("-") && target === undefined) {
65
+ target = arg;
66
+ }
67
+ }
68
+ return {
69
+ target,
70
+ items,
71
+ all: args.includes("--all"),
72
+ yes: args.includes("--yes") || args.includes("-y"),
73
+ install: !args.includes("--no-install"),
74
+ linkWorkspace: args.includes("--link-workspace"),
75
+ };
76
+ }
77
+
78
+ /** The pages a first app most likely wants; the picker starts with them ticked. */
79
+ const SUGGESTED = ["app-shell", "auth-login", "auth-signup", "dashboard"];
80
+
81
+ function bail(
82
+ value: unknown,
83
+ message = "Nothing was created."
84
+ ): value is symbol {
85
+ if (!p.isCancel(value)) return false;
86
+ p.cancel(message);
87
+ return true;
88
+ }
89
+
90
+ function run(c: Command, cwd: string): boolean {
91
+ const result = spawnSync(c.command, c.args, {
92
+ cwd,
93
+ stdio: "inherit",
94
+ // pnpm, npx and friends are .cmd shims on Windows.
95
+ shell: process.platform === "win32",
96
+ });
97
+ return result.status === 0;
98
+ }
99
+
100
+ export async function runCreate(
101
+ flags: CreateFlags,
102
+ context: {
103
+ templatesDir: string;
104
+ frameworkVersion: string;
105
+ /** stdin and stdout are a terminal: prompts are allowed. */
106
+ interactive: boolean;
107
+ }
108
+ ): Promise<number> {
109
+ const { interactive } = context;
110
+ const catalogue = readRegistryCatalogue(context.templatesDir);
111
+ const packageManager = detectPackageManager();
112
+
113
+ if (interactive) p.intro("intelligo create");
114
+
115
+ // 1. Where.
116
+ let target = flags.target;
117
+ if (!target) {
118
+ if (!interactive) {
119
+ console.error(
120
+ "Usage: intelligo create <directory> [--items a,b | --all] [--yes] [--no-install]"
121
+ );
122
+ return 1;
123
+ }
124
+ const answer = await p.text({
125
+ message: "Project name",
126
+ placeholder: "my-app",
127
+ validate: (v) => {
128
+ if (!v) return "A name is required.";
129
+ try {
130
+ deriveNames(v);
131
+ } catch (error) {
132
+ return (error as Error).message;
133
+ }
134
+ return isOccupied(v) ? `${v} is not empty.` : undefined;
135
+ },
136
+ });
137
+ if (bail(answer)) return 1;
138
+ target = answer;
139
+ }
140
+ // Before any question about items, so nobody chooses pages for an
141
+ // app that is then refused.
142
+ assertNotOccupied(target);
143
+
144
+ // 2. What.
145
+ let selected: string[] = [];
146
+ if (flags.all) selected = Object.keys(catalogue.items);
147
+ else if (flags.items) selected = flags.items;
148
+ else if (interactive) {
149
+ const answer = await p.multiselect({
150
+ message: "Which pages should be installed? (space to toggle)",
151
+ options: Object.entries(catalogue.items).map(([name, item]) => ({
152
+ value: name,
153
+ label: item.title,
154
+ hint: shortDescription(item.description),
155
+ })),
156
+ initialValues: SUGGESTED.filter((n) => n in catalogue.items),
157
+ required: false,
158
+ });
159
+ if (bail(answer)) return 1;
160
+ selected = answer;
161
+ }
162
+ const items = withDependencies(selected, catalogue.requires);
163
+ const added = items.filter((n) => !selected.includes(n));
164
+ if (interactive && added.length > 0) {
165
+ p.log.info(`Also installing what they build on: ${added.join(", ")}`);
166
+ }
167
+
168
+ // 3. The scaffold.
169
+ const result = createApp({
170
+ target,
171
+ templatesDir: context.templatesDir,
172
+ frameworkVersion: context.frameworkVersion,
173
+ linkWorkspace: flags.linkWorkspace,
174
+ });
175
+ const appRoot = path.resolve(target);
176
+ if (interactive) {
177
+ p.log.success(`Scaffolded ${result.written.length} files in ${target}`);
178
+ }
179
+
180
+ // 4. The pages — only with approval.
181
+ const plan = installPlan(items, { appRoot, packageManager });
182
+ let approved = plan.length > 0 && flags.install && flags.yes;
183
+ if (plan.length > 0 && flags.install && !flags.yes && interactive) {
184
+ const needsInstall = !hasLocalShadcn(appRoot);
185
+ p.note(plan.map(formatCommand).join("\n"), "Commands to run in " + target);
186
+ const answer = await p.confirm({
187
+ message: needsInstall
188
+ ? `shadcn is not installed yet — install the dependencies (shadcn included) with ${packageManager} and add ${items.length} page(s)?`
189
+ : `Add ${items.length} page(s) with shadcn?`,
190
+ });
191
+ if (bail(answer, `The scaffold is in ${target}; nothing was installed.`))
192
+ return 1;
193
+ approved = answer;
194
+ }
195
+
196
+ let pending: Command[] = [];
197
+ if (approved) {
198
+ for (const [i, command] of plan.entries()) {
199
+ if (interactive) p.log.step(formatCommand(command));
200
+ else console.log(`› ${formatCommand(command)}`);
201
+ if (!run(command, appRoot)) {
202
+ pending = plan.slice(i);
203
+ const message = `\`${formatCommand(command)}\` failed — the scaffold is in place; run the rest by hand.`;
204
+ if (interactive) p.log.error(message);
205
+ else console.error(message);
206
+ break;
207
+ }
208
+ }
209
+ } else {
210
+ pending = plan;
211
+ }
212
+
213
+ const next = {
214
+ packageManager,
215
+ installed: approved && pending.length === 0,
216
+ pending,
217
+ };
218
+ if (interactive) {
219
+ p.note(formatNextSteps(target, next), "Next");
220
+ p.outro(
221
+ next.installed ? `${items.length} page(s) installed.` : "Scaffold ready."
222
+ );
223
+ } else {
224
+ console.log(formatCreateResult(target, result, next));
225
+ }
226
+ // A failed install is a failure; a declined one is a choice.
227
+ return approved && pending.length > 0 ? 1 : 0;
228
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `intelligo create <name>` — a new application on the framework.
3
+ *
4
+ * This is onboarding, not the product boundary: what it writes is the
5
+ * consumer's from the moment it lands, and the enduring relationship is the versioned packages, not this
6
+ * scaffold. So it generates through the same manifest machinery as
7
+ * `add`, which means the very first upgrade already knows which files
8
+ * you have since edited.
9
+ *
10
+ * In a terminal it asks for the project name when none is given, then
11
+ * which registry pages to install (`--items a,b` or `--all` answer that
12
+ * without asking). The pages are installed by the shadcn CLI the scaffold
13
+ * declares, after the dependencies — and only once you approve the exact
14
+ * commands, or pass `--yes`. `--no-install` stops after the scaffold and
15
+ * prints them instead.
16
+ */
17
+
18
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
19
+ import path from "node:path";
20
+
21
+ import { addFeature, formatAddResult, type AddResult } from "./add.js";
22
+ import {
23
+ formatCommand,
24
+ type Command,
25
+ type PackageManager,
26
+ } from "../registry-items.js";
27
+
28
+ export type CreateOptions = {
29
+ /** Directory to create the app in; created if absent. */
30
+ target: string;
31
+ templatesDir: string;
32
+ frameworkVersion: string;
33
+ /**
34
+ * Emit `workspace:*` for the @intelligo-dev/* dependencies instead of a
35
+ * version range. Only meaningful when scaffolding inside this
36
+ * monorepo — outside it, pnpm fails the install with "workspace
37
+ * protocol used outside a workspace".
38
+ */
39
+ linkWorkspace?: boolean;
40
+ };
41
+
42
+ /** A package name and product slug derived from the directory name. */
43
+ export function deriveNames(target: string): {
44
+ appName: string;
45
+ appSlug: string;
46
+ } {
47
+ const base = path.basename(path.resolve(target));
48
+ const slug = base
49
+ .toLowerCase()
50
+ .replace(/[^a-z0-9]+/g, "-")
51
+ .replace(/^-+|-+$/g, "");
52
+ if (!slug) {
53
+ throw new Error(`Cannot derive a project name from "${target}".`);
54
+ }
55
+ return { appName: slug, appSlug: slug };
56
+ }
57
+
58
+ export function isOccupied(target: string): boolean {
59
+ const dir = path.resolve(target);
60
+ return existsSync(dir) && readdirSync(dir).length > 0;
61
+ }
62
+
63
+ /** Scaffolding into an occupied directory is how people lose work. */
64
+ export function assertNotOccupied(target: string): void {
65
+ if (isOccupied(target)) {
66
+ throw new Error(
67
+ `${path.resolve(target)} is not empty. Create the app in a new directory, or use ` +
68
+ `\`intelligo add app-scaffold\` inside an existing one.`
69
+ );
70
+ }
71
+ }
72
+
73
+ export function createApp(options: CreateOptions): AddResult {
74
+ const target = path.resolve(options.target);
75
+
76
+ assertNotOccupied(target);
77
+ mkdirSync(target, { recursive: true });
78
+
79
+ const { appName, appSlug } = deriveNames(target);
80
+
81
+ return addFeature("app-scaffold", {
82
+ appRoot: target,
83
+ templatesDir: options.templatesDir,
84
+ frameworkVersion: options.frameworkVersion,
85
+ variables: {
86
+ __APP_NAME__: appName,
87
+ __APP_SLUG__: appSlug,
88
+ __INTELLIGO_DEP__: options.linkWorkspace
89
+ ? "workspace:*"
90
+ : `^${options.frameworkVersion}`,
91
+ },
92
+ });
93
+ }
94
+
95
+ export type NextSteps = {
96
+ packageManager: PackageManager;
97
+ /** Whether `create` already installed the dependencies. */
98
+ installed: boolean;
99
+ /** Install commands the developer declined, or that did not get to run. */
100
+ pending?: Command[];
101
+ };
102
+
103
+ /** A path the reader can paste into a shell, spaces and quotes included. */
104
+ function shellQuote(value: string): string {
105
+ return /^[\w@%+=:,./-]+$/.test(value)
106
+ ? value
107
+ : `'${value.replace(/'/g, "'\\''")}'`;
108
+ }
109
+
110
+ export function formatNextSteps(target: string, next: NextSteps): string {
111
+ const pm = next.packageManager;
112
+ return [
113
+ `cd ${shellQuote(path.relative(process.cwd(), path.resolve(target)) || ".")}`,
114
+ "cp .env.example .env.local # then fill it in",
115
+ ...(next.pending?.length
116
+ ? next.pending.map(formatCommand)
117
+ : next.installed
118
+ ? []
119
+ : [`${pm} install`]),
120
+ `${pm === "npm" ? "npm run" : pm} dev`,
121
+ ].join("\n");
122
+ }
123
+
124
+ export function formatCreateResult(
125
+ target: string,
126
+ r: AddResult,
127
+ next: NextSteps = { packageManager: "pnpm", installed: false }
128
+ ): string {
129
+ return [
130
+ formatAddResult(r),
131
+ "",
132
+ "Next:",
133
+ ...formatNextSteps(target, next)
134
+ .split("\n")
135
+ .map((line) => ` ${line}`),
136
+ "",
137
+ "`intelligo doctor` will tell you what is still missing.",
138
+ ].join("\n");
139
+ }