@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.
- package/NOTICE +6 -0
- package/README.md +63 -0
- package/dist/bin.js +85 -37
- package/dist/bin.js.map +1 -1
- package/dist/commands/add.d.ts +22 -1
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +61 -4
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/create-flow.d.ts +34 -0
- package/dist/commands/create-flow.d.ts.map +1 -0
- package/dist/commands/create-flow.js +180 -0
- package/dist/commands/create-flow.js.map +1 -0
- package/dist/commands/create.d.ts +23 -6
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +45 -13
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.d.ts +14 -3
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +293 -37
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/migrate-check.d.ts +54 -1
- package/dist/commands/migrate-check.d.ts.map +1 -1
- package/dist/commands/migrate-check.js +101 -5
- package/dist/commands/migrate-check.js.map +1 -1
- package/dist/commands/migrate.d.ts +97 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +147 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/upgrade-check.d.ts +5 -5
- package/dist/commands/upgrade-check.d.ts.map +1 -1
- package/dist/commands/upgrade-check.js +19 -6
- package/dist/commands/upgrade-check.js.map +1 -1
- package/dist/env-files.d.ts +12 -0
- package/dist/env-files.d.ts.map +1 -0
- package/dist/env-files.js +29 -0
- package/dist/env-files.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +9 -15
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +5 -16
- package/dist/manifest.js.map +1 -1
- package/dist/migrations.d.ts +3 -6
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +3 -6
- package/dist/migrations.js.map +1 -1
- package/dist/model-catalogue.d.ts +14 -0
- package/dist/model-catalogue.d.ts.map +1 -0
- package/dist/model-catalogue.js +34 -0
- package/dist/model-catalogue.js.map +1 -0
- package/dist/registry-items.d.ts +56 -0
- package/dist/registry-items.d.ts.map +1 -0
- package/dist/registry-items.js +103 -0
- package/dist/registry-items.js.map +1 -0
- package/package.json +36 -10
- package/src/bin.ts +264 -0
- package/src/commands/add.ts +244 -0
- package/src/commands/create-flow.ts +228 -0
- package/src/commands/create.ts +139 -0
- package/src/commands/doctor.ts +487 -0
- package/src/commands/migrate-check.ts +257 -0
- package/src/commands/migrate.ts +234 -0
- package/src/commands/upgrade-check.ts +157 -0
- package/src/env-files.ts +31 -0
- package/src/index.ts +39 -0
- package/src/manifest.ts +93 -0
- package/src/migrations-dir.ts +25 -0
- package/src/migrations.ts +133 -0
- package/src/model-catalogue.ts +35 -0
- package/src/registry-items.ts +132 -0
- package/templates/admin-page/admin-page.tsx.tpl +2 -3
- package/templates/app-scaffold/assistant-route.ts.tpl +2 -2
- package/templates/app-scaffold/auth-route.ts.tpl +10 -0
- package/templates/app-scaffold/components.json.tpl +6 -2
- package/templates/app-scaffold/db-schema.ts.tpl +29 -0
- package/templates/app-scaffold/drizzle-journal.json.tpl +5 -0
- package/templates/app-scaffold/drizzle.config.ts.tpl +45 -0
- package/templates/app-scaffold/env.example.tpl +65 -1
- package/templates/app-scaffold/globals.css.tpl +172 -281
- package/templates/app-scaffold/i18n-request.ts.tpl +25 -0
- package/templates/app-scaffold/instrumentation.ts.tpl +21 -0
- package/templates/app-scaffold/intelligo.ts.tpl +104 -9
- package/templates/app-scaffold/layout.tsx.tpl +18 -1
- package/templates/app-scaffold/lib-utils.ts.tpl +1 -6
- package/templates/app-scaffold/next.config.mjs.tpl +2 -0
- package/templates/app-scaffold/package.json.tpl +31 -17
- package/templates/app-scaffold/page.tsx.tpl +16 -0
- package/templates/app-scaffold/plans.ts.tpl +32 -13
- package/templates/app-scaffold/{middleware.ts.tpl → proxy.ts.tpl} +6 -0
- package/templates/app-scaffold/sonner.tsx.tpl +42 -20
- package/templates/app-scaffold/stripe-webhook-route.ts.tpl +24 -0
- package/templates/app-scaffold/theme-provider.tsx.tpl +6 -4
- package/templates/app-scaffold/tsconfig.json.tpl +20 -4
- package/templates/app-scaffold/use-mobile.ts.tpl +19 -0
- package/templates/app-scaffold/workspace-bootstrap.ts.tpl +30 -0
- package/templates/maintenance/maintenance-route.ts.tpl +119 -0
- package/templates/manifest.json +57 -18
- package/templates/registry-items.json +116 -0
- package/templates/registry-requires.json +156 -0
- package/templates/usage-page/usage-page.tsx.tpl +23 -8
- 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
|
+
}
|