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 +34 -2
- package/dist/index.js +45 -5
- package/dist/init/detect.js +114 -0
- package/dist/init/patch.js +252 -0
- package/dist/init/plan.js +102 -0
- package/dist/init/run.js +165 -0
- package/dist/init/templates.js +180 -0
- package/dist/options.js +50 -1
- package/dist/upgrade/known-files.js +794 -0
- package/dist/upgrade/lines.js +13 -0
- package/dist/upgrade/plan.js +349 -0
- package/dist/upgrade/run.js +164 -0
- package/dist/upgrade/units.js +70 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,8 +1,40 @@
|
|
|
1
1
|
# create-aphex
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Create a new Aphex CMS project, or add Aphex to the SvelteKit app you already have.
|
|
4
4
|
|
|
5
|
-
##
|
|
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
|
-
|
|
172
|
+
New app:
|
|
145
173
|
-t, --template <name> Template to use: base or website
|
|
146
|
-
|
|
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
|
+
}
|