create-aphex 0.3.6 → 0.4.0-next.20261109083516

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/README.md CHANGED
@@ -1,8 +1,40 @@
1
1
  # create-aphex
2
2
 
3
- Scaffold a new Aphex CMS project from an official standalone template.
3
+ Create a new Aphex CMS project, or add Aphex to the SvelteKit app you already have.
4
4
 
5
- ## Usage
5
+ ## Add Aphex to an existing app
6
+
7
+ Run it inside your SvelteKit 2 app, with no project name:
8
+
9
+ ```bash
10
+ pnpm create aphex
11
+ ```
12
+
13
+ It asks which database, upload storage and email to use, shows every file it will create or change, and asks before writing anything. It creates the config, the admin route and a sample schema, adds `aphex()` (and Tailwind, if you don't have it) to `vite.config.ts`, puts Aphex first in your `handle`, adds Aphex's `locals` to `app.d.ts`, fills in `.env` with a generated secret, and installs the packages. It never overwrites a file you already have. When a file has a shape it can't edit safely, it prints the lines to add by hand.
14
+
15
+ ```bash
16
+ pnpm create aphex --dry-run # show the changes, make none
17
+ pnpm create aphex --db postgres --storage s3 --yes # no questions
18
+ ```
19
+
20
+ | Flag | Values | Default |
21
+ | -------------- | ----------------------------------------------------------------- | -------- |
22
+ | `--db` | `sqlite`, `postgres` | `sqlite` |
23
+ | `--storage` | `local`, `s3` | `local` |
24
+ | `--email` | `none`, `resend` | `none` |
25
+ | `-y`, `--yes` | Use the defaults for anything not given, and don't ask to confirm | |
26
+ | `--dry-run` | Show the changes without making them | |
27
+ | `--no-install` | Write the files but don't install packages | |
28
+
29
+ With Postgres it also runs `drizzle-kit generate`, so commit the `drizzle` folder it creates. It stops without changing anything on SvelteKit 3, Tailwind 3, an app without TypeScript, or an app that already has `src/routes/admin`.
30
+
31
+ ## Upgrade an older Aphex app
32
+
33
+ Run the same command in an app that already uses Aphex, and it upgrades the app to the embedded admin (`@aphexcms/cms-core` 12) instead: it deletes the admin, account, `/api` and `/media` route files that core now serves, adds the admin route and `aphex.client.ts`, fixes the config, and moves the `@aphexcms/*` packages to the latest version. `--yes`, `--dry-run` and `--no-install` work the same way.
34
+
35
+ It deletes a file only if it's exactly a version an Aphex template shipped, and keeps a route folder whole if you changed any file in it. It recognises versions by their git blob ids, listed in `src/upgrade/known-files.ts`. When a template changes one of those files, regenerate the list with `pnpm -F create-aphex known-files`, which reads the monorepo's git history. See [Upgrading to the embedded admin](https://docs.getaphex.com/upgrading-to-embedded-admin).
36
+
37
+ ## Create a new app
6
38
 
7
39
  ```bash
8
40
  pnpm create aphex my-app
package/dist/index.js CHANGED
@@ -4,8 +4,11 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
4
4
  import { join, resolve } from 'path';
5
5
  import pc from 'picocolors';
6
6
  import { downloadTemplate } from 'giget';
7
- import { parseArgs, templates } from './options.js';
7
+ import { parseArgs, releaseTag, templates } from './options.js';
8
8
  import { withGeneratedAuthSecret } from './env.js';
9
+ import { isSvelteKitApp, readPackageJson } from './init/detect.js';
10
+ import { runInit } from './init/run.js';
11
+ import { runUpgrade } from './upgrade/run.js';
9
12
  /** Shown as the prompt's placeholder and used when the answer is left empty. */
10
13
  const DEFAULT_PROJECT_NAME = 'my-aphex-project';
11
14
  async function main() {
@@ -14,6 +17,29 @@ async function main() {
14
17
  printHelp();
15
18
  return;
16
19
  }
20
+ // Run inside a SvelteKit app with no project name, and this adds Aphex to that app
21
+ // instead of creating a new one beside it.
22
+ // An app that already has Aphex is upgraded to the embedded admin instead.
23
+ if (!cliOptions.projectName && !cliOptions.template && isSvelteKitApp(process.cwd())) {
24
+ const pkg = readPackageJson(process.cwd());
25
+ const hasAphex = Boolean(pkg?.dependencies?.['@aphexcms/cms-core'] ?? pkg?.devDependencies?.['@aphexcms/cms-core']);
26
+ if (!hasAphex) {
27
+ await runInit(process.cwd(), cliOptions);
28
+ return;
29
+ }
30
+ if (cliOptions.database || cliOptions.storage || cliOptions.email) {
31
+ throw new Error('This app already uses Aphex, so this upgrades it. --db, --storage and --email are only for adding Aphex.');
32
+ }
33
+ await runUpgrade(process.cwd(), cliOptions);
34
+ return;
35
+ }
36
+ if (cliOptions.database || cliOptions.storage || cliOptions.email || cliOptions.dryRun) {
37
+ throw new Error('--db, --storage, --email and --dry-run are for adding Aphex to an existing app. Run them inside a SvelteKit app, without a project name.');
38
+ }
39
+ // Templates are tagged per stable release only, so a snapshot has none to fetch.
40
+ if (releaseTag !== 'latest' && !process.env.APHEX_TEMPLATE) {
41
+ throw new Error(`create-aphex@${releaseTag} adds Aphex to an existing app or upgrades one, but can't start a new project. For that, run \`pnpm create aphex@latest\`.`);
42
+ }
17
43
  console.clear();
18
44
  p.intro(pc.bgCyan(pc.black(' create-aphex ')));
19
45
  const options = {
@@ -139,15 +165,29 @@ function printHelp() {
139
165
  console.log(`create-aphex
140
166
 
141
167
  Usage:
142
- create-aphex [project-name] [options]
168
+ create-aphex [project-name] [options] Create a new app
169
+ create-aphex [options] Inside a SvelteKit app: add Aphex to it,
170
+ or upgrade an app that has it
143
171
 
144
- Options:
172
+ New app:
145
173
  -t, --template <name> Template to use: base or website
146
- -h, --help Show this help message
174
+
175
+ Existing app:
176
+ --db <sqlite|postgres> Database (default sqlite)
177
+ --storage <local|s3> Where uploads go (default local)
178
+ --email <none|resend> Email adapter (default none)
179
+ -y, --yes Use the defaults for anything not given, and don't ask
180
+ --dry-run Show the changes without making them
181
+ --no-install Write the files but don't install packages
182
+ (--yes, --dry-run and --no-install also work when upgrading)
183
+
184
+ -h, --help Show this help message
147
185
 
148
186
  Examples:
149
187
  pnpm create aphex my-site
150
- pnpm create aphex my-site --template website`);
188
+ pnpm create aphex my-site --template website
189
+ pnpm create aphex (inside your SvelteKit app)
190
+ pnpm create aphex --db postgres --storage s3 --dry-run`);
151
191
  }
152
192
  main().catch((error) => {
153
193
  console.error('Error:', error);
@@ -0,0 +1,114 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ function readJson(path) {
4
+ try {
5
+ return JSON.parse(readFileSync(path, 'utf8'));
6
+ }
7
+ catch {
8
+ return undefined;
9
+ }
10
+ }
11
+ export function readPackageJson(cwd) {
12
+ return readJson(join(cwd, 'package.json'));
13
+ }
14
+ /** True when the folder is a SvelteKit app, which is what switches create-aphex into init. */
15
+ export function isSvelteKitApp(cwd) {
16
+ const pkg = readPackageJson(cwd);
17
+ return Boolean(pkg?.dependencies?.['@sveltejs/kit'] ?? pkg?.devDependencies?.['@sveltejs/kit']);
18
+ }
19
+ /** The installed version, else the first version in the declared range. */
20
+ function versionOf(cwd, pkg, name) {
21
+ const installed = readJson(join(cwd, 'node_modules', name, 'package.json'));
22
+ if (installed && 'version' in installed && typeof installed.version === 'string') {
23
+ return installed.version;
24
+ }
25
+ const range = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
26
+ return range?.match(/\d+(\.\d+){0,2}/)?.[0];
27
+ }
28
+ function major(version) {
29
+ if (!version)
30
+ return undefined;
31
+ const value = Number.parseInt(version, 10);
32
+ return Number.isNaN(value) ? undefined : value;
33
+ }
34
+ export function detectPackageManager(cwd, userAgent = process.env.npm_config_user_agent) {
35
+ if (existsSync(join(cwd, 'pnpm-lock.yaml')))
36
+ return 'pnpm';
37
+ if (existsSync(join(cwd, 'bun.lock')) || existsSync(join(cwd, 'bun.lockb')))
38
+ return 'bun';
39
+ if (existsSync(join(cwd, 'yarn.lock')))
40
+ return 'yarn';
41
+ if (existsSync(join(cwd, 'package-lock.json')))
42
+ return 'npm';
43
+ const fromAgent = userAgent?.split('/')[0];
44
+ if (fromAgent === 'pnpm' || fromAgent === 'yarn' || fromAgent === 'bun')
45
+ return fromAgent;
46
+ return 'npm';
47
+ }
48
+ function firstExisting(cwd, candidates) {
49
+ return candidates.find((file) => existsSync(join(cwd, file)));
50
+ }
51
+ export function detectApp(cwd) {
52
+ const pkg = readPackageJson(cwd) ?? {};
53
+ const installed = new Set([
54
+ ...Object.keys(pkg.dependencies ?? {}),
55
+ ...Object.keys(pkg.devDependencies ?? {})
56
+ ]);
57
+ const kitVersion = versionOf(cwd, pkg, '@sveltejs/kit');
58
+ const viteConfig = firstExisting(cwd, [
59
+ 'vite.config.ts',
60
+ 'vite.config.js',
61
+ 'vite.config.mts',
62
+ 'vite.config.mjs'
63
+ ]);
64
+ const viteSource = viteConfig ? readFileSync(join(cwd, viteConfig), 'utf8') : '';
65
+ return {
66
+ cwd,
67
+ name: pkg.name ?? 'my-app',
68
+ packageManager: detectPackageManager(cwd),
69
+ installed,
70
+ kitMajor: major(kitVersion),
71
+ kitVersion,
72
+ svelteMajor: major(versionOf(cwd, pkg, 'svelte')),
73
+ tailwindMajor: installed.has('tailwindcss')
74
+ ? major(versionOf(cwd, pkg, 'tailwindcss'))
75
+ : undefined,
76
+ hasTailwindVitePlugin: installed.has('@tailwindcss/vite') || viteSource.includes('@tailwindcss/vite'),
77
+ typescript: existsSync(join(cwd, 'tsconfig.json')),
78
+ viteConfig,
79
+ hooksFile: firstExisting(cwd, ['src/hooks.server.ts', 'src/hooks.server.js']),
80
+ appDts: existsSync(join(cwd, 'src/app.d.ts'))
81
+ };
82
+ }
83
+ /**
84
+ * Reasons `init` can't go ahead. Each one is something the person has to change first,
85
+ * so they are checked before any question is asked.
86
+ */
87
+ export function findBlockers(app) {
88
+ const blockers = [];
89
+ if (app.kitMajor === undefined) {
90
+ blockers.push('Couldn’t find the @sveltejs/kit version. Run your install first.');
91
+ }
92
+ else if (app.kitMajor >= 3) {
93
+ blockers.push(`This app uses SvelteKit ${app.kitVersion}. Aphex supports SvelteKit 2 for now, and Kit 3 support is planned.`);
94
+ }
95
+ else if (app.kitMajor < 2) {
96
+ blockers.push(`This app uses SvelteKit ${app.kitVersion}. Aphex needs SvelteKit 2.49 or newer.`);
97
+ }
98
+ if (app.svelteMajor !== undefined && app.svelteMajor < 5) {
99
+ blockers.push('Aphex needs Svelte 5. Upgrade svelte first.');
100
+ }
101
+ if (app.tailwindMajor !== undefined && app.tailwindMajor < 4) {
102
+ blockers.push(`This app uses Tailwind ${app.tailwindMajor}. The admin is styled with Tailwind 4, so upgrade Tailwind first.`);
103
+ }
104
+ if (!app.typescript) {
105
+ blockers.push('Aphex’s setup files are TypeScript, and this app has no tsconfig.json.');
106
+ }
107
+ if (!app.viteConfig) {
108
+ blockers.push('Couldn’t find vite.config.ts.');
109
+ }
110
+ if (existsSync(join(app.cwd, 'src/routes/admin'))) {
111
+ blockers.push('src/routes/admin already exists, and that’s where the admin goes. Move it, then run this again.');
112
+ }
113
+ return blockers;
114
+ }
@@ -0,0 +1,252 @@
1
+ // Edits to files the app already owns. Each returns the new source, `unchanged` when
2
+ // Aphex is already there, or `manual` with the lines to add when the file has a shape
3
+ // these edits don't recognise. Guessing at someone else's file is worse than asking.
4
+ import { generateCode, parseModule } from 'magicast';
5
+ import { addVitePlugin } from 'magicast/helpers';
6
+ export function patchViteConfig(source, addTailwind) {
7
+ const needsAphex = !source.includes('@aphexcms/cms-core/vite');
8
+ const needsTailwind = addTailwind && !source.includes('@tailwindcss/vite');
9
+ if (!needsAphex && !needsTailwind)
10
+ return { kind: 'unchanged' };
11
+ const manual = {
12
+ kind: 'manual',
13
+ instructions: [
14
+ ...(needsTailwind ? ["import tailwindcss from '@tailwindcss/vite';"] : []),
15
+ ...(needsAphex ? ["import { aphex } from '@aphexcms/cms-core/vite';"] : []),
16
+ '',
17
+ `Then add ${[needsTailwind && 'tailwindcss()', needsAphex && 'aphex()'].filter(Boolean).join(' and ')} to plugins.`
18
+ ].join('\n')
19
+ };
20
+ // Only aphex() missing: add it as text, so the rest of the file isn't reprinted.
21
+ if (needsAphex && !needsTailwind) {
22
+ const inserted = appendToPlugins(source, 'aphex()');
23
+ if (inserted) {
24
+ return {
25
+ kind: 'patched',
26
+ code: `import { aphex } from '@aphexcms/cms-core/vite';\n${inserted}`
27
+ };
28
+ }
29
+ }
30
+ // recast, under magicast, re-indents the lines it reprints at four tabs per level in a
31
+ // tab-indented file. It handles spaces correctly, so tabs become two spaces for the edit
32
+ // and go back afterwards.
33
+ const usesTabs = /^\t/m.test(source);
34
+ const toSpaces = (code) => code.replace(/^\t+/gm, (tabs) => ' '.repeat(tabs.length));
35
+ const toTabs = (code) => code.replace(/^(?: {2})+/gm, (spaces) => '\t'.repeat(spaces.length / 2));
36
+ try {
37
+ const module = parseModule(usesTabs ? toSpaces(source) : source);
38
+ if (needsTailwind) {
39
+ addVitePlugin(module, { from: '@tailwindcss/vite', constructor: 'tailwindcss' });
40
+ }
41
+ if (needsAphex) {
42
+ addVitePlugin(module, {
43
+ from: '@aphexcms/cms-core/vite',
44
+ imported: 'aphex',
45
+ constructor: 'aphex'
46
+ });
47
+ }
48
+ const generated = generateCode(module, { format: { tabWidth: 2, useTabs: false } }).code;
49
+ const code = usesTabs ? toTabs(generated) : generated;
50
+ // magicast can't see into every shape (a config built by a function, say), and
51
+ // says so by changing nothing.
52
+ if (needsAphex && !code.includes('aphex()'))
53
+ return manual;
54
+ return { kind: 'patched', code: code.endsWith('\n') ? code : `${code}\n` };
55
+ }
56
+ catch {
57
+ return manual;
58
+ }
59
+ }
60
+ /**
61
+ * Adds `entry` as the last element of the first `plugins: [...]` array, keeping the
62
+ * file's own formatting. Undefined when the array can't be found with confidence.
63
+ */
64
+ export function appendToPlugins(source, entry) {
65
+ const start = source.match(/\bplugins\s*:\s*\[/);
66
+ if (!start || start.index === undefined)
67
+ return undefined;
68
+ const open = start.index + start[0].length - 1;
69
+ let depth = 0;
70
+ let quote;
71
+ for (let i = open; i < source.length; i++) {
72
+ const char = source[i];
73
+ if (quote) {
74
+ if (char === '\\')
75
+ i++;
76
+ else if (char === quote)
77
+ quote = undefined;
78
+ continue;
79
+ }
80
+ if (char === '"' || char === "'" || char === '`')
81
+ quote = char;
82
+ else if (char === '/' && source[i + 1] === '/')
83
+ i = source.indexOf('\n', i) === -1 ? source.length : source.indexOf('\n', i);
84
+ else if (char === '/' && source[i + 1] === '*')
85
+ i = source.indexOf('*/', i) + 1;
86
+ else if (char === '[' || char === '{' || char === '(')
87
+ depth++;
88
+ else if (char === ']' || char === '}' || char === ')') {
89
+ depth--;
90
+ if (depth === 0) {
91
+ if (char !== ']')
92
+ return undefined;
93
+ const inner = source.slice(open + 1, i);
94
+ const trimmed = inner.replace(/\s+$/, '');
95
+ if (trimmed.trim() === '')
96
+ return `${source.slice(0, open + 1)}${entry}${source.slice(i)}`;
97
+ // Multi-line arrays get the entry on its own line, at the last element's indent.
98
+ const multiline = /\n([ \t]*)\S[^\n]*$/.exec(trimmed);
99
+ const comma = trimmed.endsWith(',') ? '' : ',';
100
+ const addition = multiline ? `${comma}\n${multiline[1]}${entry}` : `${comma} ${entry}`;
101
+ return `${source.slice(0, open + 1)}${trimmed}${addition}${inner.slice(trimmed.length)}${source.slice(i)}`;
102
+ }
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+ const HOOK_IMPORTS = [
108
+ "import { createCMSHook } from '@aphexcms/cms-core/server';",
109
+ "import cmsConfig from '../aphex.config';"
110
+ ];
111
+ /** A name for the app's own handle that the file doesn't already use. */
112
+ function freeName(source) {
113
+ for (const name of ['appHandle', 'existingHandle', 'userHandle']) {
114
+ if (!new RegExp(`\\b${name}\\b`).test(source))
115
+ return name;
116
+ }
117
+ return 'appHandle_';
118
+ }
119
+ /**
120
+ * Puts Aphex first in `handle`. The app's handle is renamed and kept, then
121
+ * `sequence(createCMSHook(cmsConfig), <it>)` takes its place, which is what the guide
122
+ * tells people to write by hand.
123
+ */
124
+ export function patchHooks(source) {
125
+ if (source.includes('createCMSHook'))
126
+ return { kind: 'unchanged' };
127
+ const instructions = [
128
+ ...HOOK_IMPORTS,
129
+ "import { sequence } from '@sveltejs/kit/hooks';",
130
+ '',
131
+ 'export const handle = sequence(createCMSHook(cmsConfig), yourHandle);',
132
+ '',
133
+ 'Aphex has to come first, so that your hook can use locals.aphexCMS.'
134
+ ].join('\n');
135
+ // `export { handle }` or a re-export: the definition is somewhere this can't follow.
136
+ if (/export\s*\{[^}]*\bhandle\b[^}]*\}/.test(source) || /export\s+let\s+handle\b/.test(source)) {
137
+ return { kind: 'manual', instructions };
138
+ }
139
+ const name = freeName(source);
140
+ let body;
141
+ if (/^export\s+const\s+handle\b/m.test(source)) {
142
+ body = source.replace(/^export\s+const\s+handle\b/m, `const ${name}`);
143
+ }
144
+ else if (/^export\s+(async\s+)?function\s+handle\s*\(/m.test(source)) {
145
+ body = source.replace(/^export\s+(async\s+)?function\s+handle\s*\(/m, (_match, isAsync) => {
146
+ return `${isAsync ?? ''}function ${name}(`;
147
+ });
148
+ }
149
+ else if (/\bhandle\b/.test(source.replace(/\/\/.*$|\/\*[\s\S]*?\*\//gm, ''))) {
150
+ // Mentions `handle` some way not covered above.
151
+ return { kind: 'manual', instructions };
152
+ }
153
+ else {
154
+ // No handle yet: other hooks (handleError, init) stay as they are.
155
+ return {
156
+ kind: 'patched',
157
+ code: `${HOOK_IMPORTS.join('\n')}\n${source.trimEnd()}\n\nexport const handle = createCMSHook(cmsConfig);\n`
158
+ };
159
+ }
160
+ const hasSequence = /import\s*\{[^}]*\bsequence\b[^}]*\}\s*from\s*['"]@sveltejs\/kit\/hooks['"]/.test(source);
161
+ const imports = [
162
+ ...HOOK_IMPORTS,
163
+ ...(hasSequence ? [] : ["import { sequence } from '@sveltejs/kit/hooks';"])
164
+ ];
165
+ return {
166
+ kind: 'patched',
167
+ code: `${imports.join('\n')}\n${body.trimEnd()}\n\nexport const handle = sequence(createCMSHook(cmsConfig), ${name});\n`
168
+ };
169
+ }
170
+ const LOCALS_FIELDS = [
171
+ { key: 'aphexCMS', line: 'aphexCMS: CMSInstances;', type: 'CMSInstances' },
172
+ { key: 'aphexAuth', line: 'aphexAuth?: Auth;', type: 'Auth' }
173
+ ];
174
+ const AUGMENT_IMPORT = "import '@aphexcms/cms-core/app-augment';";
175
+ /** Adds whichever of Aphex's `locals`, and the imports they need, `App.Locals` lacks. */
176
+ export function patchAppDts(source) {
177
+ const fields = LOCALS_FIELDS.filter(({ key }) => !new RegExp(`\\b${key}\\??\\s*:`).test(source));
178
+ const types = fields
179
+ .map(({ type }) => type)
180
+ .filter((type) => !new RegExp(`import\\s+type\\s*\\{[^}]*\\b${type}\\b`).test(source));
181
+ const needsAugment = !source.includes('@aphexcms/cms-core/app-augment');
182
+ if (fields.length === 0 && !needsAugment)
183
+ return { kind: 'unchanged' };
184
+ const imports = [
185
+ ...(types.length > 0
186
+ ? [`import type { ${types.join(', ')} } from '@aphexcms/cms-core/server';`]
187
+ : []),
188
+ ...(needsAugment ? [AUGMENT_IMPORT] : [])
189
+ ];
190
+ const instructions = [
191
+ ...imports,
192
+ ...(fields.length > 0
193
+ ? [
194
+ '',
195
+ 'Inside declare global { namespace App { interface Locals { … } } }:',
196
+ ...fields.map(({ line }) => `\t${line}`)
197
+ ]
198
+ : [])
199
+ ].join('\n');
200
+ // Adding an import turns a script into a module, and in a module `namespace App`
201
+ // only augments the global one from inside `declare global`.
202
+ if (!/declare\s+global\s*\{/.test(source))
203
+ return { kind: 'manual', instructions };
204
+ let body = source;
205
+ if (fields.length > 0) {
206
+ const locals = source.match(/^([ \t]*)interface\s+Locals\s*\{[ \t]*(\}?)/m);
207
+ const commented = source.match(/^([ \t]*)\/\/\s*interface\s+Locals\s*\{\s*\}[ \t]*$/m);
208
+ const namespace = source.match(/^([ \t]*)namespace\s+App\s*\{[ \t]*$/m);
209
+ const lines = (indent) => fields.map(({ line }) => `${indent}${line}`).join('\n');
210
+ if (locals) {
211
+ // `interface Locals {}` on one line gets opened up.
212
+ const replacement = locals[2]
213
+ ? `${locals[1]}interface Locals {\n${lines(`${locals[1]}\t`)}\n${locals[1]}}`
214
+ : `${locals[1]}interface Locals {\n${lines(`${locals[1]}\t`)}`;
215
+ body = source.replace(locals[0], replacement);
216
+ }
217
+ else if (commented) {
218
+ const indent = commented[1];
219
+ body = source.replace(commented[0], `${indent}interface Locals {\n${lines(`${indent}\t`)}\n${indent}}`);
220
+ }
221
+ else if (namespace) {
222
+ const indent = `${namespace[1]}\t`;
223
+ body = source.replace(namespace[0], `${namespace[0]}\n${indent}interface Locals {\n${lines(`${indent}\t`)}\n${indent}}`);
224
+ }
225
+ else {
226
+ return { kind: 'manual', instructions };
227
+ }
228
+ }
229
+ return { kind: 'patched', code: imports.length > 0 ? `${imports.join('\n')}\n${body}` : body };
230
+ }
231
+ /** Appends missing lines, leaving everything already there alone. */
232
+ export function appendMissingLines(source, lines, heading) {
233
+ const existing = new Set(source.split(/\r?\n/).map((line) => line.trim()));
234
+ const missing = lines.filter((line) => !existing.has(line));
235
+ if (missing.length === 0)
236
+ return { kind: 'unchanged' };
237
+ const separator = source === '' || source.endsWith('\n\n') ? '' : source.endsWith('\n') ? '\n' : '\n\n';
238
+ return { kind: 'patched', code: `${source}${separator}# ${heading}\n${missing.join('\n')}\n` };
239
+ }
240
+ /** Adds the `.env` keys the app doesn't set yet, never changing a value that's there. */
241
+ export function patchEnv(source, entries) {
242
+ const keys = new Set(source
243
+ .split(/\r?\n/)
244
+ .map((line) => line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1])
245
+ .filter((key) => Boolean(key)));
246
+ const missing = entries
247
+ .filter(([key]) => !keys.has(key))
248
+ .map(([key, value]) => `${key}=${value}`);
249
+ if (missing.length === 0)
250
+ return { kind: 'unchanged' };
251
+ return appendMissingLines(source, missing, 'Aphex');
252
+ }
@@ -0,0 +1,102 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { appendMissingLines, patchAppDts, patchEnv, patchHooks, patchViteConfig } from './patch.js';
4
+ import { adminPageServer, adminPageSvelte, aphexClient, aphexConfig, appDts, drizzleConfig, envEntries, hooksServer, packagesFor, schemaTypesIndex, titleFromName } from './templates.js';
5
+ function read(app, path) {
6
+ const full = join(app.cwd, path);
7
+ return existsSync(full) ? readFileSync(full, 'utf8') : undefined;
8
+ }
9
+ /** A file only init writes: created when missing, otherwise left as the person has it. */
10
+ function createOnly(app, path, content, reason = 'already exists') {
11
+ return read(app, path) === undefined
12
+ ? { path, action: 'create', content }
13
+ : { path, action: 'keep', reason };
14
+ }
15
+ /** A file the app may already have: patched in place, or created from scratch. */
16
+ function patchOrCreate(app, path, create, patch) {
17
+ const source = read(app, path);
18
+ if (source === undefined)
19
+ return { path, action: 'create', content: create };
20
+ const result = patch(source);
21
+ if (result.kind === 'patched')
22
+ return { path, action: 'update', content: result.code };
23
+ if (result.kind === 'manual')
24
+ return { path, action: 'manual', instructions: result.instructions };
25
+ return { path, action: 'keep', reason: 'already set up for Aphex' };
26
+ }
27
+ function fromPatch(path, result) {
28
+ if (result.kind === 'patched')
29
+ return { path, action: 'update', content: result.code };
30
+ if (result.kind === 'manual')
31
+ return { path, action: 'manual', instructions: result.instructions };
32
+ return { path, action: 'keep', reason: 'already set up for Aphex' };
33
+ }
34
+ /**
35
+ * Everything init would do, worked out before anything is written, so `--dry-run`
36
+ * and the confirmation prompt show exactly what happens.
37
+ */
38
+ export function buildPlan(app, choices, secret) {
39
+ const needsTailwind = !app.hasTailwindVitePlugin;
40
+ const changes = [
41
+ createOnly(app, 'aphex.config.ts', aphexConfig(choices, titleFromName(app.name))),
42
+ createOnly(app, 'src/lib/schemaTypes/index.ts', schemaTypesIndex),
43
+ createOnly(app, 'src/lib/aphex.client.ts', aphexClient),
44
+ {
45
+ path: 'src/routes/admin/[...path]/+page.server.ts',
46
+ action: 'create',
47
+ content: adminPageServer
48
+ },
49
+ { path: 'src/routes/admin/[...path]/+page.svelte', action: 'create', content: adminPageSvelte }
50
+ ];
51
+ if (app.viteConfig) {
52
+ changes.push(fromPatch(app.viteConfig, patchViteConfig(read(app, app.viteConfig) ?? '', needsTailwind)));
53
+ }
54
+ changes.push(app.hooksFile
55
+ ? fromPatch(app.hooksFile, patchHooks(read(app, app.hooksFile) ?? ''))
56
+ : { path: 'src/hooks.server.ts', action: 'create', content: hooksServer });
57
+ changes.push(patchOrCreate(app, 'src/app.d.ts', appDts, patchAppDts));
58
+ if (choices.database === 'postgres') {
59
+ const existing = read(app, 'drizzle.config.ts');
60
+ changes.push(existing === undefined
61
+ ? { path: 'drizzle.config.ts', action: 'create', content: drizzleConfig }
62
+ : existing.includes('@aphexcms/postgresql-adapter')
63
+ ? { path: 'drizzle.config.ts', action: 'keep', reason: 'already lists the Aphex tables' }
64
+ : {
65
+ path: 'drizzle.config.ts',
66
+ action: 'manual',
67
+ instructions: [
68
+ 'Add the Aphex tables to schema:',
69
+ "\t'./node_modules/@aphexcms/postgresql-adapter/dist/schema.js',",
70
+ "\t'./node_modules/@aphexcms/auth/dist/schema/pg.js'"
71
+ ].join('\n')
72
+ });
73
+ }
74
+ const entries = envEntries(choices, app.name, secret);
75
+ changes.push(fromPatchOrNew('.env', read(app, '.env'), (source) => patchEnv(source, entries)));
76
+ const example = read(app, '.env.example');
77
+ if (example !== undefined) {
78
+ // The example gets the keys but never the secret.
79
+ changes.push(fromPatch('.env.example', patchEnv(example, entries.map(([key, value]) => [key, key === 'AUTH_SECRET' ? '' : value]))));
80
+ }
81
+ const ignored = [
82
+ ...(choices.database === 'sqlite' ? ['.aphex'] : []),
83
+ ...(choices.storage === 'local' ? ['/storage'] : [])
84
+ ];
85
+ if (ignored.length > 0) {
86
+ changes.push(fromPatchOrNew('.gitignore', read(app, '.gitignore'), (source) => appendMissingLines(source, ignored, 'Aphex')));
87
+ }
88
+ const packages = packagesFor(choices, needsTailwind);
89
+ const bare = (spec) => spec.replace(/(.)@[^/]*$/, '$1');
90
+ return {
91
+ changes,
92
+ dependencies: packages.dependencies.filter((spec) => !app.installed.has(bare(spec))),
93
+ devDependencies: packages.devDependencies.filter((spec) => !app.installed.has(bare(spec)))
94
+ };
95
+ }
96
+ function fromPatchOrNew(path, source, patch) {
97
+ const result = patch(source ?? '');
98
+ if (result.kind === 'patched') {
99
+ return { path, action: source === undefined ? 'create' : 'update', content: result.code };
100
+ }
101
+ return fromPatch(path, result);
102
+ }