create-payload-canvas 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/config.js +36 -0
- package/dist/database.js +95 -0
- package/dist/index.js +147 -0
- package/dist/options.js +187 -0
- package/dist/project.js +115 -0
- package/dist/run.js +16 -0
- package/dist/source.js +95 -0
- package/package.json +56 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eugene Lominos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,115 @@
|
|
|
1
|
-
#
|
|
1
|
+
# create-payload-canvas
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://github.com/jon8800/payload-canvas/blob/main/docs/media/drag-and-drop.mp4"><img src="https://raw.githubusercontent.com/jon8800/payload-canvas/main/docs/media/drag-and-drop.gif" width="960" alt="Dragging a section on the canvas while the other sections slide out of the way, reordering it in the Layers tree, and dropping a ready-made section onto the page"></a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
Creates a new Payload CMS website builder project from the Payload Canvas starter.
|
|
8
|
+
|
|
9
|
+
Requirements: Node.js 20.9 or later, a running PostgreSQL server, and pnpm (npm works too). The starter itself is downloaded from GitHub, so the first run needs network access.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm create payload-canvas my-website
|
|
13
|
+
# or
|
|
14
|
+
npx create-payload-canvas my-website
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The CLI asks for the project name, your Postgres details, and whether to seed demo content. It asks nothing when you pass `--yes`, or when the input is not a terminal (CI, agents). Every question has a flag.
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
1. Checks Postgres first: the server must be reachable, and the database must not exist yet. If something is wrong, it stops before it writes any file.
|
|
22
|
+
2. Copies the starter app (`apps/starter`) and the AI guides it links to (`docs/ai`).
|
|
23
|
+
- Inside a checkout of the repo, it copies from the checkout.
|
|
24
|
+
- Anywhere else, it downloads the repo from GitHub (`jon8800/payload-canvas`).
|
|
25
|
+
3. Leaves out repo-only files: `node_modules`, `.next`, `.turbo`, `.env`, `next-env.d.ts`, Docker files (they expect the monorepo), `scripts/setup.ts`, `scripts/lib`, `scripts/_*`, `docs/qa`, screenshots, and uploaded files in `public/media`.
|
|
26
|
+
4. Sets the package name, removes the repo-only scripts and the dev dependencies they needed, and points `payload-canvas` at a source (see below).
|
|
27
|
+
5. Writes `.env` from `.env.example` with your `DATABASE_URL` and a new random `PAYLOAD_SECRET`.
|
|
28
|
+
6. Creates the database.
|
|
29
|
+
7. Installs dependencies with pnpm (npm if pnpm is missing).
|
|
30
|
+
8. Runs `seed:demo` if you chose to seed. On a fresh database the seed creates the tables.
|
|
31
|
+
9. Prints a summary with the exact next commands.
|
|
32
|
+
|
|
33
|
+
It never runs `payload migrate`. Local development uses `push: true`, so the schema syncs when you run `pnpm dev`.
|
|
34
|
+
|
|
35
|
+
## Flags
|
|
36
|
+
|
|
37
|
+
| Flag | Meaning |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `[project-dir]` | Folder to create. Its name is the package name. Lowercase letters, numbers, `-` and `_`. The folder must not exist, or must be empty. Default with `--yes`: `my-website`. |
|
|
40
|
+
| `-y`, `--yes` | Ask nothing. Use defaults. Seeds demo content unless you pass `--no-seed`. |
|
|
41
|
+
| `--db-url <url>` | `postgresql://user:password@host:5432/name`. Replaces the five flags below. |
|
|
42
|
+
| `--db-host <host>` | Default `localhost`. |
|
|
43
|
+
| `--db-port <port>` | Default `5432`. |
|
|
44
|
+
| `--db-user <user>` | Default `postgres`. |
|
|
45
|
+
| `--db-password <pw>` | Default `postgres`. |
|
|
46
|
+
| `--db-name <name>` | Default: the project name, with `_` instead of `-`. |
|
|
47
|
+
| `--reuse-db` | Use the database if it already exists. Without this flag, an existing database is an error. |
|
|
48
|
+
| `--skip-db` | Do not connect to Postgres. Writes `.env` only. This also skips seeding. |
|
|
49
|
+
| `--seed` / `--no-seed` | Seed demo content, or not. |
|
|
50
|
+
| `--no-install` | Do not install. This also skips seeding. |
|
|
51
|
+
| `--github` | Download the starter from GitHub, even when the CLI runs inside a checkout. |
|
|
52
|
+
| `--ref <name>` | Branch or tag to download. Default `main`. |
|
|
53
|
+
| `--packages <path>` | Path to a checkout of the repo. The CLI packs the `payload-canvas` package from there. |
|
|
54
|
+
| `-h`, `--help` | Show help. |
|
|
55
|
+
|
|
56
|
+
## Where the package comes from
|
|
57
|
+
|
|
58
|
+
| Case | `payload-canvas` in the new project |
|
|
59
|
+
|---|---|
|
|
60
|
+
| The CLI runs inside a checkout, or you pass `--packages <path>` | a `file:./.tarballs/...` tarball, packed with `pnpm pack` (it builds `dist/` first). Commit `.tarballs/` with the project. |
|
|
61
|
+
| Neither | The version on npm (`^0.1.0`). |
|
|
62
|
+
|
|
63
|
+
Without `--packages`, and outside a checkout, the CLI checks the npm registry first. If the package is not on npm (for example, a `--ref` with an unreleased version), it stops before it writes any file and tells you to use `--packages <path>` or `--no-install`.
|
|
64
|
+
|
|
65
|
+
## Examples
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Interactive
|
|
69
|
+
pnpm create payload-canvas
|
|
70
|
+
|
|
71
|
+
# Fully non-interactive, with demo content
|
|
72
|
+
npx create-payload-canvas my-website --yes --db-url postgresql://postgres:postgres@localhost:5432/my_website
|
|
73
|
+
|
|
74
|
+
# Only the files, no install, no database
|
|
75
|
+
npx create-payload-canvas my-website --yes --no-install --skip-db
|
|
76
|
+
|
|
77
|
+
# From GitHub, with the package from a local checkout (before it is published)
|
|
78
|
+
npx create-payload-canvas my-website --yes --github --packages C:\code\payload-canvas
|
|
79
|
+
|
|
80
|
+
# Use a database that already exists
|
|
81
|
+
npx create-payload-canvas my-website --yes --db-name my_site --reuse-db
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## After it finishes
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
cd my-website
|
|
88
|
+
pnpm dev
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Open http://localhost:3000/admin and create the first user.
|
|
92
|
+
|
|
93
|
+
Before your first production deploy, run this in the project and commit the new files in `src/migrations`:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pnpm payload migrate:create
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Migrations belong to your app, not to the plugin. Do not run `payload migrate` on your local database.
|
|
100
|
+
|
|
101
|
+
## Errors it explains
|
|
102
|
+
|
|
103
|
+
- Postgres is not running, the host is wrong, or the password is wrong.
|
|
104
|
+
- The database already exists (use `--reuse-db` or another `--db-name`).
|
|
105
|
+
- The folder already exists and is not empty.
|
|
106
|
+
- The `payload-canvas` package is not on npm.
|
|
107
|
+
- The GitHub download fails, or the `--ref` does not exist.
|
|
108
|
+
|
|
109
|
+
If the copy or pack step fails, the CLI deletes the half-made folder. If the install fails, it keeps the folder so you can fix the problem and run `pnpm install` there.
|
|
110
|
+
|
|
111
|
+
## Maintainers
|
|
112
|
+
|
|
113
|
+
- The GitHub owner is `GITHUB_OWNER` in `src/config.ts`. The list of files the CLI leaves out is `SKIP_NAMES` and `SKIP_PATHS` in the same file.
|
|
114
|
+
- After copying, the CLI checks the new project for leftovers (`workspace:` links, paths into `packages/`, `baseUrl`, Docker files) and prints a warning for each one it finds.
|
|
115
|
+
- Build with `pnpm build`. Run from the repo with `node dist/index.js`. `pnpm pack` builds first (`prepack`).
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The GitHub repo that hosts Payload Canvas. Change it here if the repo moves.
|
|
2
|
+
export const GITHUB_OWNER = 'jon8800';
|
|
3
|
+
export const GITHUB_REPO = 'payload-canvas';
|
|
4
|
+
export const DEFAULT_REF = 'main';
|
|
5
|
+
export const STARTER_SUBDIR = 'apps/starter';
|
|
6
|
+
// Guides that the starter's .env.example points to. They live at the repo root.
|
|
7
|
+
export const DOCS_SUBDIR = 'docs/ai';
|
|
8
|
+
export function tarballUrl(ref) {
|
|
9
|
+
return `https://codeload.github.com/${GITHUB_OWNER}/${GITHUB_REPO}/tar.gz/${ref}`;
|
|
10
|
+
}
|
|
11
|
+
// Version range for the payload-canvas package when it comes from npm.
|
|
12
|
+
export const PUBLISHED_VERSION = '^0.1.0';
|
|
13
|
+
// The package the starter depends on: its npm name and its folder under `packages/` in the repo.
|
|
14
|
+
export const BUILDER_PACKAGE = { name: 'payload-canvas', dir: 'payload-canvas' };
|
|
15
|
+
// Files and folders of the starter that only make sense inside the repo. The CLI does not copy them.
|
|
16
|
+
// - Docker files expect the repo root as build context and the workspace packages.
|
|
17
|
+
// - `setup.ts` is the repo's own scaffold script; the CLI replaces it.
|
|
18
|
+
// - `_` scripts are developer test helpers.
|
|
19
|
+
export const SKIP_NAMES = new Set([
|
|
20
|
+
'node_modules',
|
|
21
|
+
'.next',
|
|
22
|
+
'.turbo',
|
|
23
|
+
'.git',
|
|
24
|
+
'.env',
|
|
25
|
+
'.tarballs',
|
|
26
|
+
'.playwright-mcp',
|
|
27
|
+
'.claude',
|
|
28
|
+
'screenshots',
|
|
29
|
+
'tsconfig.tsbuildinfo',
|
|
30
|
+
'next-env.d.ts',
|
|
31
|
+
'Dockerfile',
|
|
32
|
+
'Dockerfile.dockerignore',
|
|
33
|
+
'docker-compose.yml',
|
|
34
|
+
]);
|
|
35
|
+
// Paths relative to the starter folder.
|
|
36
|
+
export const SKIP_PATHS = ['scripts/setup.ts', 'scripts/lib', 'docs/qa'];
|
package/dist/database.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Client } from 'pg';
|
|
2
|
+
export function buildDatabaseUrl(db) {
|
|
3
|
+
const user = encodeURIComponent(db.user);
|
|
4
|
+
const password = encodeURIComponent(db.password);
|
|
5
|
+
return `postgresql://${user}:${password}@${db.host}:${db.port}/${db.name}`;
|
|
6
|
+
}
|
|
7
|
+
export function parseDatabaseUrl(value) {
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(value);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
throw new Error('The database URL is not valid. Use postgresql://USER:PASSWORD@HOST:PORT/DATABASE', { cause: error });
|
|
14
|
+
}
|
|
15
|
+
if (url.protocol !== 'postgresql:' && url.protocol !== 'postgres:') {
|
|
16
|
+
throw new Error('The database URL must start with postgresql:// or postgres://');
|
|
17
|
+
}
|
|
18
|
+
const name = decodeURIComponent(url.pathname.slice(1));
|
|
19
|
+
if (!name)
|
|
20
|
+
throw new Error('The database URL has no database name.');
|
|
21
|
+
return {
|
|
22
|
+
host: url.hostname || 'localhost',
|
|
23
|
+
port: Number(url.port || 5432),
|
|
24
|
+
user: decodeURIComponent(url.username) || 'postgres',
|
|
25
|
+
password: decodeURIComponent(url.password),
|
|
26
|
+
name,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Turns a project name into a safe Postgres database name. */
|
|
30
|
+
export function databaseNameFor(projectName) {
|
|
31
|
+
return projectName.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'payload_site';
|
|
32
|
+
}
|
|
33
|
+
/** Turns a driver error into a short message that says what to do. Never includes the password. */
|
|
34
|
+
export function describeDbError(error, db) {
|
|
35
|
+
const code = error?.code;
|
|
36
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
37
|
+
const where = `${db.host}:${db.port}`;
|
|
38
|
+
switch (code) {
|
|
39
|
+
case 'ECONNREFUSED':
|
|
40
|
+
case 'ETIMEDOUT':
|
|
41
|
+
return `Cannot reach Postgres at ${where}. Start the server, or set --db-host and --db-port (or --db-url). Use --skip-db to continue without a database.`;
|
|
42
|
+
case 'ENOTFOUND':
|
|
43
|
+
return `The Postgres host "${db.host}" was not found. Check --db-host (or --db-url).`;
|
|
44
|
+
case '28P01':
|
|
45
|
+
case '28000':
|
|
46
|
+
return `Postgres at ${where} rejected the login for user "${db.user}". Check --db-user and --db-password (or --db-url).`;
|
|
47
|
+
case '42501':
|
|
48
|
+
return `User "${db.user}" is not allowed to create databases. Use a user that can, or create "${db.name}" yourself and pass --reuse-db.`;
|
|
49
|
+
default:
|
|
50
|
+
return `Postgres error at ${where}: ${raw}`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function connect(db, database) {
|
|
54
|
+
return new Client({
|
|
55
|
+
host: db.host,
|
|
56
|
+
port: db.port,
|
|
57
|
+
user: db.user,
|
|
58
|
+
password: db.password,
|
|
59
|
+
database,
|
|
60
|
+
connectionTimeoutMillis: 8000,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Connects to the server and says if the database exists. Throws a clear error when it cannot connect. */
|
|
64
|
+
export async function checkDatabase(db) {
|
|
65
|
+
const client = connect(db, db.name);
|
|
66
|
+
client.on('error', () => { });
|
|
67
|
+
try {
|
|
68
|
+
await client.connect();
|
|
69
|
+
return 'exists';
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// 3D000: the server is fine, the database does not exist.
|
|
73
|
+
if (error.code === '3D000')
|
|
74
|
+
return 'missing';
|
|
75
|
+
throw new Error(describeDbError(error, db), { cause: error });
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await client.end().catch(() => { });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Creates the database. */
|
|
82
|
+
export async function createDatabase(db) {
|
|
83
|
+
const client = connect(db, 'postgres');
|
|
84
|
+
client.on('error', () => { });
|
|
85
|
+
try {
|
|
86
|
+
await client.connect();
|
|
87
|
+
await client.query(`CREATE DATABASE "${db.name.replaceAll('"', '""')}"`);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
throw new Error(describeDbError(error, db), { cause: error });
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
await client.end().catch(() => { });
|
|
94
|
+
}
|
|
95
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as p from '@clack/prompts';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { BUILDER_PACKAGE } from './config.js';
|
|
6
|
+
import { buildDatabaseUrl, checkDatabase, createDatabase } from './database.js';
|
|
7
|
+
import { resolveOptions } from './options.js';
|
|
8
|
+
import { builderPackageIsPublished, checkProject, hasBuilderPackage, packBuilderPackage, updatePackageJson, writeEnv, } from './project.js';
|
|
9
|
+
import { hasCommand, run } from './run.js';
|
|
10
|
+
import { copyStarterFromRepo, downloadRepo, findRepoRoot, isRepoRoot } from './source.js';
|
|
11
|
+
/** Works out where the starter files and the payload-canvas package come from. Stops early if the install cannot work. */
|
|
12
|
+
async function resolveSource(options) {
|
|
13
|
+
if (options.packages && !hasBuilderPackage(options.packages)) {
|
|
14
|
+
throw new Error(`--packages "${options.packages}" is not a Payload Canvas checkout (no packages/${BUILDER_PACKAGE.dir} folder).`);
|
|
15
|
+
}
|
|
16
|
+
// A repo checkout also holds the matching starter, so use it unless --github says otherwise.
|
|
17
|
+
const checkout = options.packages && isRepoRoot(options.packages) ? options.packages : null;
|
|
18
|
+
const localRepo = options.github ? null : (findRepoRoot() ?? checkout);
|
|
19
|
+
// Where `pnpm pack` finds the payload-canvas package. Null means: use the version on npm.
|
|
20
|
+
const packagesRoot = options.packages ?? localRepo;
|
|
21
|
+
if (!packagesRoot && options.install) {
|
|
22
|
+
const published = await builderPackageIsPublished();
|
|
23
|
+
if (published === false) {
|
|
24
|
+
throw new Error([
|
|
25
|
+
`${BUILDER_PACKAGE.name} is not published to npm yet, so the install would fail.`,
|
|
26
|
+
'Pick one:',
|
|
27
|
+
' --packages <path-to-a-payload-canvas-checkout> pack it from a local checkout',
|
|
28
|
+
' --no-install only create the files, install later',
|
|
29
|
+
].join('\n'));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { localRepo, packagesRoot };
|
|
33
|
+
}
|
|
34
|
+
const quote = (value) => (/\s/.test(value) ? `"${value}"` : value);
|
|
35
|
+
async function main() {
|
|
36
|
+
const options = await resolveOptions();
|
|
37
|
+
if (!options)
|
|
38
|
+
return;
|
|
39
|
+
const { targetDir, name, db } = options;
|
|
40
|
+
// 0. Check everything that can fail before we write a single file.
|
|
41
|
+
const { localRepo, packagesRoot } = await resolveSource(options);
|
|
42
|
+
const manager = hasCommand('pnpm') ? 'pnpm' : 'npm';
|
|
43
|
+
let dbState = 'missing';
|
|
44
|
+
if (!options.skipDb) {
|
|
45
|
+
dbState = await checkDatabase(db);
|
|
46
|
+
if (dbState === 'exists' && !options.reuseDb) {
|
|
47
|
+
throw new Error(`The database "${db.name}" already exists on ${db.host}:${db.port}. Pass --reuse-db to use it, or pick another --db-name.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 1. Copy the starter. 2. Point payload-canvas at a local tarball or at npm. Write .env.
|
|
51
|
+
// If this part fails, delete the half-made folder so the command can run again.
|
|
52
|
+
const spinner = p.spinner();
|
|
53
|
+
let tarball = null;
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
56
|
+
if (localRepo) {
|
|
57
|
+
spinner.start('Copying the starter from the repo...');
|
|
58
|
+
copyStarterFromRepo(localRepo, targetDir);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
spinner.start(`Downloading the starter from GitHub (${options.ref})...`);
|
|
62
|
+
const downloaded = await downloadRepo(options.ref);
|
|
63
|
+
try {
|
|
64
|
+
copyStarterFromRepo(downloaded, targetDir);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
fs.rmSync(downloaded, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
spinner.stop('Starter files ready.');
|
|
71
|
+
spinner.start(packagesRoot ? `Packing ${BUILDER_PACKAGE.name}...` : 'Writing package.json and .env...');
|
|
72
|
+
tarball = packagesRoot ? packBuilderPackage(packagesRoot, targetDir) : null;
|
|
73
|
+
updatePackageJson(targetDir, name, tarball, manager);
|
|
74
|
+
writeEnv(targetDir, buildDatabaseUrl(db));
|
|
75
|
+
spinner.stop('package.json and .env written.');
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
spinner.error('Failed.');
|
|
79
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
for (const problem of checkProject(targetDir))
|
|
83
|
+
p.log.warn(problem);
|
|
84
|
+
if (!tarball && !options.install) {
|
|
85
|
+
p.log.warn(`${BUILDER_PACKAGE.name} comes from npm. It must be published before "${manager} install" works.`);
|
|
86
|
+
}
|
|
87
|
+
// 3. Create the database.
|
|
88
|
+
if (options.skipDb) {
|
|
89
|
+
p.log.info(`Skipped the database. Put your connection string in ${path.join(targetDir, '.env')}.`);
|
|
90
|
+
}
|
|
91
|
+
else if (dbState === 'exists') {
|
|
92
|
+
p.log.success(`Using the existing database "${db.name}".`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
await createDatabase(db);
|
|
96
|
+
p.log.success(`Created database "${db.name}".`);
|
|
97
|
+
}
|
|
98
|
+
// 4. Install. The schema syncs on the first `dev` run (push: true), so there is no migrate step.
|
|
99
|
+
let installed = false;
|
|
100
|
+
if (options.install) {
|
|
101
|
+
p.log.step(`Installing dependencies with ${manager}...`);
|
|
102
|
+
installed = run(manager, ['install'], targetDir);
|
|
103
|
+
if (!installed) {
|
|
104
|
+
throw new Error(`"${manager} install" failed. The project is in ${targetDir}. Fix the error above, then run "${manager} install" there.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// 5. Seed.
|
|
108
|
+
let seeded = false;
|
|
109
|
+
if (options.seed) {
|
|
110
|
+
p.log.step('Seeding demo content...');
|
|
111
|
+
seeded = run(manager, ['run', 'seed:demo'], targetDir);
|
|
112
|
+
if (!seeded)
|
|
113
|
+
p.log.warn(`Seeding failed. Run "${manager} run seed:demo" in ${targetDir} to try again.`);
|
|
114
|
+
}
|
|
115
|
+
// 6. Summary.
|
|
116
|
+
const relative = path.relative(process.cwd(), targetDir);
|
|
117
|
+
const cdTarget = !relative ? null : relative.startsWith('..') || path.isAbsolute(relative) ? targetDir : relative;
|
|
118
|
+
const run_ = manager === 'pnpm' ? 'pnpm' : 'npm run';
|
|
119
|
+
const lines = [
|
|
120
|
+
`Project: ${targetDir}`,
|
|
121
|
+
options.skipDb ? 'Database: not set up (--skip-db)' : `Database: ${db.name} on ${db.host}:${db.port}`,
|
|
122
|
+
`Demo data: ${seeded ? 'seeded' : 'not seeded'}`,
|
|
123
|
+
'',
|
|
124
|
+
'Next steps:',
|
|
125
|
+
...[
|
|
126
|
+
cdTarget ? `cd ${quote(cdTarget)}` : null,
|
|
127
|
+
options.install ? null : `${manager} install`,
|
|
128
|
+
options.skipDb ? 'Set DATABASE_URL in .env' : null,
|
|
129
|
+
`${run_} dev`,
|
|
130
|
+
]
|
|
131
|
+
.filter((step) => step !== null)
|
|
132
|
+
.map((step) => ` ${step}`),
|
|
133
|
+
' Open http://localhost:3000/admin and create the first user.',
|
|
134
|
+
'',
|
|
135
|
+
'Do not run "payload migrate" on the local database. "dev" syncs the schema.',
|
|
136
|
+
'Before your first production deploy, run this in the project:',
|
|
137
|
+
` ${manager === 'pnpm' ? 'pnpm' : 'npx'} payload migrate:create`,
|
|
138
|
+
'Commit the new files in src/migrations. Migrations belong to your app, not the plugin.',
|
|
139
|
+
];
|
|
140
|
+
p.note(lines.join('\n'), 'Done');
|
|
141
|
+
p.outro('Your project is ready.');
|
|
142
|
+
}
|
|
143
|
+
main().catch((error) => {
|
|
144
|
+
p.log.error(error instanceof Error ? error.message : String(error));
|
|
145
|
+
p.cancel('Setup failed.');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
});
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { DEFAULT_REF } from './config.js';
|
|
6
|
+
import { databaseNameFor, parseDatabaseUrl } from './database.js';
|
|
7
|
+
export const HELP = `Usage: create-payload-canvas [project-dir] [flags]
|
|
8
|
+
|
|
9
|
+
Creates a Payload CMS website with the Payload Canvas builder. Nothing is asked when you pass --yes
|
|
10
|
+
or when the input is not a terminal.
|
|
11
|
+
|
|
12
|
+
Project:
|
|
13
|
+
-y, --yes Use defaults and ask nothing
|
|
14
|
+
--no-install Do not install dependencies (this also skips seeding)
|
|
15
|
+
--seed / --no-seed Seed demo content (default: yes with --yes, else ask)
|
|
16
|
+
|
|
17
|
+
Database (default user and password: postgres):
|
|
18
|
+
--db-url <url> postgresql://user:pass@host:5432/name (replaces the flags below)
|
|
19
|
+
--db-host <host> Default: localhost
|
|
20
|
+
--db-port <port> Default: 5432
|
|
21
|
+
--db-user <user>
|
|
22
|
+
--db-password <pw>
|
|
23
|
+
--db-name <name> Default: the project name with underscores
|
|
24
|
+
--reuse-db Use the database if it already exists (default: stop with an error)
|
|
25
|
+
--skip-db Do not connect to Postgres. Writes .env only. Skips seeding.
|
|
26
|
+
|
|
27
|
+
Source:
|
|
28
|
+
--github Download the starter from GitHub, even inside the repo
|
|
29
|
+
--ref <name> Branch or tag to download (default: ${DEFAULT_REF})
|
|
30
|
+
--packages <path> Path to a Payload Canvas checkout. The payload-canvas package is
|
|
31
|
+
packed from there. Use this until it is published to npm.
|
|
32
|
+
|
|
33
|
+
-h, --help Show this help
|
|
34
|
+
`;
|
|
35
|
+
function parseFlags() {
|
|
36
|
+
return parseArgs({
|
|
37
|
+
allowPositionals: true,
|
|
38
|
+
options: {
|
|
39
|
+
yes: { type: 'boolean', short: 'y' },
|
|
40
|
+
'db-url': { type: 'string' },
|
|
41
|
+
'db-host': { type: 'string' },
|
|
42
|
+
'db-port': { type: 'string' },
|
|
43
|
+
'db-user': { type: 'string' },
|
|
44
|
+
'db-password': { type: 'string' },
|
|
45
|
+
'db-name': { type: 'string' },
|
|
46
|
+
'reuse-db': { type: 'boolean' },
|
|
47
|
+
'skip-db': { type: 'boolean' },
|
|
48
|
+
seed: { type: 'boolean' },
|
|
49
|
+
'no-seed': { type: 'boolean' },
|
|
50
|
+
'no-install': { type: 'boolean' },
|
|
51
|
+
github: { type: 'boolean' },
|
|
52
|
+
ref: { type: 'string' },
|
|
53
|
+
packages: { type: 'string' },
|
|
54
|
+
help: { type: 'boolean', short: 'h' },
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function cancelled() {
|
|
59
|
+
p.cancel('Cancelled.');
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
async function ask(prompt) {
|
|
63
|
+
const value = await prompt;
|
|
64
|
+
if (typeof value !== 'string')
|
|
65
|
+
cancelled();
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
function validateName(value) {
|
|
69
|
+
if (!value || !/^[a-z0-9][a-z0-9-_]*$/.test(value)) {
|
|
70
|
+
return 'Use lowercase letters, numbers, hyphens and underscores.';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function validateTarget(dir) {
|
|
74
|
+
if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory())
|
|
75
|
+
return `"${dir}" exists and is a file.`;
|
|
76
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0) {
|
|
77
|
+
return `The folder "${dir}" already exists and is not empty. Pick another name, or delete it.`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Reads flags, asks for anything missing, and returns the final options. Returns null after --help. */
|
|
81
|
+
export async function resolveOptions() {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = parseFlags();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}\nRun with --help to see the flags.`, { cause: error });
|
|
88
|
+
}
|
|
89
|
+
const { values, positionals } = parsed;
|
|
90
|
+
if (values.help) {
|
|
91
|
+
console.log(HELP);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
p.intro('Create Payload Canvas');
|
|
95
|
+
// Without a terminal nobody can answer a prompt, so use the defaults.
|
|
96
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
97
|
+
const yes = values.yes === true || !interactive;
|
|
98
|
+
if (!interactive && values.yes !== true)
|
|
99
|
+
p.log.info('No terminal found. Using defaults for everything you did not pass as a flag.');
|
|
100
|
+
// Project directory and name.
|
|
101
|
+
let dirInput = positionals[0];
|
|
102
|
+
if (!dirInput) {
|
|
103
|
+
dirInput = yes
|
|
104
|
+
? 'my-website'
|
|
105
|
+
: await ask(p.text({
|
|
106
|
+
message: 'Project name (also the folder name)',
|
|
107
|
+
placeholder: 'my-website',
|
|
108
|
+
defaultValue: 'my-website',
|
|
109
|
+
validate: (value) => {
|
|
110
|
+
const text = value || 'my-website';
|
|
111
|
+
return validateName(text) ?? validateTarget(path.resolve(process.cwd(), text));
|
|
112
|
+
},
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
const targetDir = path.resolve(process.cwd(), dirInput);
|
|
116
|
+
const name = path.basename(targetDir);
|
|
117
|
+
const nameError = validateName(name) ?? validateTarget(targetDir);
|
|
118
|
+
if (nameError)
|
|
119
|
+
throw new Error(nameError);
|
|
120
|
+
// Database.
|
|
121
|
+
const skipDb = values['skip-db'] === true;
|
|
122
|
+
let db;
|
|
123
|
+
if (values['db-url']) {
|
|
124
|
+
db = parseDatabaseUrl(values['db-url']);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const field = async (flag, message, fallback, secret = false) => {
|
|
128
|
+
if (flag !== undefined)
|
|
129
|
+
return flag;
|
|
130
|
+
if (yes || skipDb)
|
|
131
|
+
return fallback;
|
|
132
|
+
if (secret)
|
|
133
|
+
return ask(p.password({ message }));
|
|
134
|
+
return ask(p.text({ message, placeholder: fallback, defaultValue: fallback }));
|
|
135
|
+
};
|
|
136
|
+
const host = await field(values['db-host'], 'Postgres host', 'localhost');
|
|
137
|
+
const port = await field(values['db-port'], 'Postgres port', '5432');
|
|
138
|
+
const user = await field(values['db-user'], 'Postgres user', 'postgres');
|
|
139
|
+
const password = await field(values['db-password'], 'Postgres password', 'postgres', true);
|
|
140
|
+
const dbName = await field(values['db-name'], 'Database name', databaseNameFor(name));
|
|
141
|
+
if (!/^\d+$/.test(port) || Number(port) > 65535)
|
|
142
|
+
throw new Error(`"${port}" is not a valid Postgres port.`);
|
|
143
|
+
db = { host, port: Number(port), user, password, name: dbName };
|
|
144
|
+
}
|
|
145
|
+
if (values['db-name'] && values['db-url'])
|
|
146
|
+
db.name = values['db-name'];
|
|
147
|
+
// Source.
|
|
148
|
+
const packages = values.packages ? path.resolve(process.cwd(), values.packages) : null;
|
|
149
|
+
// Install and seed.
|
|
150
|
+
const install = values['no-install'] !== true;
|
|
151
|
+
const canSeed = install && !skipDb;
|
|
152
|
+
let seed;
|
|
153
|
+
if (values['no-seed']) {
|
|
154
|
+
seed = false;
|
|
155
|
+
}
|
|
156
|
+
else if (values.seed) {
|
|
157
|
+
seed = true;
|
|
158
|
+
}
|
|
159
|
+
else if (!canSeed) {
|
|
160
|
+
seed = false;
|
|
161
|
+
}
|
|
162
|
+
else if (yes) {
|
|
163
|
+
seed = true;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const answer = await p.confirm({ message: 'Seed demo content?', initialValue: true });
|
|
167
|
+
if (p.isCancel(answer))
|
|
168
|
+
cancelled();
|
|
169
|
+
seed = answer;
|
|
170
|
+
}
|
|
171
|
+
if (seed && !canSeed) {
|
|
172
|
+
p.log.warn(`Seeding needs installed dependencies and a database, so ${skipDb ? '--skip-db' : '--no-install'} turns it off.`);
|
|
173
|
+
seed = false;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
targetDir,
|
|
177
|
+
name,
|
|
178
|
+
db,
|
|
179
|
+
seed,
|
|
180
|
+
install,
|
|
181
|
+
skipDb,
|
|
182
|
+
reuseDb: values['reuse-db'] === true,
|
|
183
|
+
github: values.github === true,
|
|
184
|
+
ref: values.ref ?? DEFAULT_REF,
|
|
185
|
+
packages,
|
|
186
|
+
};
|
|
187
|
+
}
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { BUILDER_PACKAGE, PUBLISHED_VERSION } from './config.js';
|
|
5
|
+
import { capture } from './run.js';
|
|
6
|
+
function readPackageJson(dir) {
|
|
7
|
+
return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
8
|
+
}
|
|
9
|
+
/** Checks that `<root>/packages/payload-canvas` exists. */
|
|
10
|
+
export function hasBuilderPackage(root) {
|
|
11
|
+
return fs.existsSync(path.join(root, 'packages', BUILDER_PACKAGE.dir, 'package.json'));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Packs payload-canvas from a repo checkout into `<target>/.tarballs` and returns the file name.
|
|
15
|
+
* `pnpm pack` runs the package's `prepack` script, which builds `dist/` first.
|
|
16
|
+
*/
|
|
17
|
+
export function packBuilderPackage(repoRoot, targetDir) {
|
|
18
|
+
const destination = path.join(targetDir, '.tarballs');
|
|
19
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
20
|
+
const dir = path.join(repoRoot, 'packages', BUILDER_PACKAGE.dir);
|
|
21
|
+
const result = capture('pnpm', ['pack', '--pack-destination', destination], dir);
|
|
22
|
+
if (!result.ok)
|
|
23
|
+
throw new Error(`"pnpm pack" failed in ${dir}:\n${result.error}`);
|
|
24
|
+
const file = `${BUILDER_PACKAGE.name}-${readPackageJson(dir).version ?? '0.0.0'}.tgz`;
|
|
25
|
+
if (!fs.existsSync(path.join(destination, file)))
|
|
26
|
+
throw new Error(`Expected tarball ${file} was not created.`);
|
|
27
|
+
return file;
|
|
28
|
+
}
|
|
29
|
+
/** Asks the npm registry if payload-canvas exists. Returns null when the registry cannot be reached. */
|
|
30
|
+
export async function builderPackageIsPublished() {
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(`https://registry.npmjs.org/${BUILDER_PACKAGE.name}`, {
|
|
33
|
+
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
|
34
|
+
signal: AbortSignal.timeout(8000),
|
|
35
|
+
});
|
|
36
|
+
if (response.status === 404)
|
|
37
|
+
return false;
|
|
38
|
+
return response.ok ? true : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** True if any source file in the project imports the package. */
|
|
45
|
+
function isImported(targetDir, pkg) {
|
|
46
|
+
const pattern = new RegExp(`from\\s+['"]${pkg}(/[^'"]*)?['"]|require\\(['"]${pkg}['"]\\)`);
|
|
47
|
+
const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).some((entry) => {
|
|
48
|
+
const full = path.join(dir, entry.name);
|
|
49
|
+
if (entry.isDirectory())
|
|
50
|
+
return !SKIP_DIRS.has(entry.name) && walk(full);
|
|
51
|
+
return /\.(m?[jt]sx?)$/.test(entry.name) && pattern.test(fs.readFileSync(full, 'utf8'));
|
|
52
|
+
});
|
|
53
|
+
return walk(targetDir);
|
|
54
|
+
}
|
|
55
|
+
const SKIP_DIRS = new Set(['node_modules', '.next', '.tarballs']);
|
|
56
|
+
export function updatePackageJson(targetDir, name, tarball, manager) {
|
|
57
|
+
const file = path.join(targetDir, 'package.json');
|
|
58
|
+
const pkg = readPackageJson(targetDir);
|
|
59
|
+
pkg.name = name;
|
|
60
|
+
pkg.version = '0.1.0';
|
|
61
|
+
delete pkg.private;
|
|
62
|
+
// Repo-only scripts: the repo's own scaffold script and `_` developer test helpers.
|
|
63
|
+
for (const key of Object.keys(pkg.scripts ?? {})) {
|
|
64
|
+
if (key === 'setup' || key.startsWith('_'))
|
|
65
|
+
delete pkg.scripts?.[key];
|
|
66
|
+
}
|
|
67
|
+
// Only the removed scaffold script used these packages. Keep them if the app imports them.
|
|
68
|
+
for (const dep of ['@clack/prompts', 'pg', '@types/pg']) {
|
|
69
|
+
if (pkg.devDependencies?.[dep] && !isImported(targetDir, dep.replace('@types/', ''))) {
|
|
70
|
+
delete pkg.devDependencies[dep];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
pkg.dependencies = {
|
|
74
|
+
...pkg.dependencies,
|
|
75
|
+
[BUILDER_PACKAGE.name]: tarball ? `file:./.tarballs/${tarball}` : PUBLISHED_VERSION,
|
|
76
|
+
};
|
|
77
|
+
// The build-script list lets pnpm 10 run the install scripts that the dependencies need.
|
|
78
|
+
if (manager === 'pnpm')
|
|
79
|
+
pkg.pnpm = { ...pkg.pnpm, onlyBuiltDependencies: ['esbuild', 'sharp', 'unrs-resolver'] };
|
|
80
|
+
fs.writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
81
|
+
}
|
|
82
|
+
const secret = () => crypto.randomBytes(32).toString('hex');
|
|
83
|
+
const set = (content, key, value) => content.replace(new RegExp(`^${key}=.*$`, 'm'), () => `${key}=${value}`);
|
|
84
|
+
export function writeEnv(targetDir, databaseUrl) {
|
|
85
|
+
const example = path.join(targetDir, '.env.example');
|
|
86
|
+
if (!fs.existsSync(example))
|
|
87
|
+
throw new Error('.env.example not found in the template.');
|
|
88
|
+
let content = fs.readFileSync(example, 'utf8');
|
|
89
|
+
content = set(content, 'DATABASE_URL', databaseUrl);
|
|
90
|
+
content = set(content, 'PAYLOAD_SECRET', secret());
|
|
91
|
+
content = set(content, 'NEXT_PUBLIC_SERVER_URL', 'http://localhost:3000');
|
|
92
|
+
fs.writeFileSync(path.join(targetDir, '.env'), content);
|
|
93
|
+
}
|
|
94
|
+
/** Looks for repo-only leftovers in the generated project. Returns one message per problem. */
|
|
95
|
+
export function checkProject(targetDir) {
|
|
96
|
+
const problems = [];
|
|
97
|
+
const read = (file) => {
|
|
98
|
+
const full = path.join(targetDir, file);
|
|
99
|
+
return fs.existsSync(full) ? fs.readFileSync(full, 'utf8') : '';
|
|
100
|
+
};
|
|
101
|
+
const manifest = read('package.json');
|
|
102
|
+
if (manifest.includes('workspace:'))
|
|
103
|
+
problems.push('package.json still has a "workspace:" dependency.');
|
|
104
|
+
if (/\.\.\/(\.\.\/)*packages\//.test(read('next.config.ts') + read('tsconfig.json'))) {
|
|
105
|
+
problems.push('next.config.ts or tsconfig.json still points into ../packages/.');
|
|
106
|
+
}
|
|
107
|
+
if (/"baseUrl"/.test(read('tsconfig.json')))
|
|
108
|
+
problems.push('tsconfig.json has "baseUrl", which TypeScript 7 removed.');
|
|
109
|
+
const leftovers = ['Dockerfile', 'docker-compose.yml', 'scripts/setup.ts', 'docs/qa', 'screenshots', '.next', 'node_modules'];
|
|
110
|
+
for (const item of leftovers) {
|
|
111
|
+
if (fs.existsSync(path.join(targetDir, item)))
|
|
112
|
+
problems.push(`"${item}" should not be in the generated project.`);
|
|
113
|
+
}
|
|
114
|
+
return problems;
|
|
115
|
+
}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import spawn from 'cross-spawn';
|
|
2
|
+
/** Runs a command, shows its output, and returns true if it exits with code 0. */
|
|
3
|
+
export function run(command, args, cwd) {
|
|
4
|
+
return spawn.sync(command, args, { cwd, stdio: 'inherit' }).status === 0;
|
|
5
|
+
}
|
|
6
|
+
/** Runs a command and returns its output. On failure it returns null and the error text. */
|
|
7
|
+
export function capture(command, args, cwd) {
|
|
8
|
+
const result = spawn.sync(command, args, { cwd, encoding: 'utf8' });
|
|
9
|
+
if (result.status === 0)
|
|
10
|
+
return { ok: true, stdout: String(result.stdout) };
|
|
11
|
+
const error = result.error?.message ?? (String(result.stderr).trim() || `exit code ${result.status}`);
|
|
12
|
+
return { ok: false, error };
|
|
13
|
+
}
|
|
14
|
+
export function hasCommand(command) {
|
|
15
|
+
return spawn.sync(command, ['--version'], { stdio: 'ignore' }).status === 0;
|
|
16
|
+
}
|
package/dist/source.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { x as extractTar } from 'tar';
|
|
8
|
+
import { DOCS_SUBDIR, GITHUB_OWNER, GITHUB_REPO, SKIP_NAMES, SKIP_PATHS, STARTER_SUBDIR, tarballUrl } from './config.js';
|
|
9
|
+
/** True for files that must not be copied. `rel` is relative to the starter folder, with `/` separators. */
|
|
10
|
+
function shouldSkip(rel) {
|
|
11
|
+
const parts = rel.split('/').filter(Boolean);
|
|
12
|
+
if (parts.some((part) => SKIP_NAMES.has(part)))
|
|
13
|
+
return true;
|
|
14
|
+
if (SKIP_PATHS.some((skip) => rel === skip || rel.startsWith(`${skip}/`)))
|
|
15
|
+
return true;
|
|
16
|
+
// Developer test helpers, such as scripts/_dev-user.ts.
|
|
17
|
+
if (parts[0] === 'scripts' && parts[1]?.startsWith('_'))
|
|
18
|
+
return true;
|
|
19
|
+
// Uploaded files: keep the folder, drop the files.
|
|
20
|
+
return parts[0] === 'public' && parts[1] === 'media' && parts.length > 2;
|
|
21
|
+
}
|
|
22
|
+
export function isRepoRoot(dir) {
|
|
23
|
+
return fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) && fs.existsSync(path.join(dir, STARTER_SUBDIR, 'package.json'));
|
|
24
|
+
}
|
|
25
|
+
/** Finds the repo root when the CLI file lives inside the Payload Canvas repo. */
|
|
26
|
+
export function findRepoRoot() {
|
|
27
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
for (;;) {
|
|
29
|
+
if (isRepoRoot(dir))
|
|
30
|
+
return dir;
|
|
31
|
+
const parent = path.dirname(dir);
|
|
32
|
+
if (parent === dir)
|
|
33
|
+
return null;
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Copies the starter, and the AI guides it links to, from a repo checkout into `targetDir`. */
|
|
38
|
+
export function copyStarterFromRepo(repoRoot, targetDir) {
|
|
39
|
+
const source = path.join(repoRoot, STARTER_SUBDIR);
|
|
40
|
+
fs.cpSync(source, targetDir, {
|
|
41
|
+
recursive: true,
|
|
42
|
+
filter: (src) => !shouldSkip(path.relative(source, src).split(path.sep).join('/')),
|
|
43
|
+
});
|
|
44
|
+
const docs = path.join(repoRoot, DOCS_SUBDIR);
|
|
45
|
+
if (fs.existsSync(docs))
|
|
46
|
+
fs.cpSync(docs, path.join(targetDir, DOCS_SUBDIR), { recursive: true });
|
|
47
|
+
ensureMediaFolder(targetDir);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Downloads the repo from GitHub and unpacks only the starter and the AI guides into a temp folder.
|
|
51
|
+
* The result has the same layout as a repo checkout. The caller deletes it.
|
|
52
|
+
*/
|
|
53
|
+
export async function downloadRepo(ref) {
|
|
54
|
+
const url = tarballUrl(ref);
|
|
55
|
+
let response;
|
|
56
|
+
try {
|
|
57
|
+
response = await fetch(url, { signal: AbortSignal.timeout(60_000) });
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw new Error(`Could not reach GitHub (${error instanceof Error ? error.message : String(error)}). Check your network, or run the CLI from a Payload Canvas checkout.`, { cause: error });
|
|
61
|
+
}
|
|
62
|
+
if (response.status === 404) {
|
|
63
|
+
throw new Error(`Branch or tag "${ref}" was not found in ${GITHUB_OWNER}/${GITHUB_REPO}.`);
|
|
64
|
+
}
|
|
65
|
+
if (!response.ok || !response.body)
|
|
66
|
+
throw new Error(`Download failed (HTTP ${response.status}) from ${url}`);
|
|
67
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-payload-canvas-'));
|
|
68
|
+
try {
|
|
69
|
+
// GitHub puts everything in one top folder, `strip: 1` removes it.
|
|
70
|
+
const wanted = [`${STARTER_SUBDIR}/`, `${DOCS_SUBDIR}/`];
|
|
71
|
+
await pipeline(Readable.fromWeb(response.body), extractTar({
|
|
72
|
+
cwd: temp,
|
|
73
|
+
strip: 1,
|
|
74
|
+
filter: (entryPath) => {
|
|
75
|
+
const inner = entryPath.split('/').slice(1).join('/');
|
|
76
|
+
return wanted.some((prefix) => inner.startsWith(prefix));
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
if (!fs.existsSync(path.join(temp, STARTER_SUBDIR, 'package.json'))) {
|
|
85
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
86
|
+
throw new Error(`The download has no ${STARTER_SUBDIR} folder. Is "${ref}" the right branch?`);
|
|
87
|
+
}
|
|
88
|
+
return temp;
|
|
89
|
+
}
|
|
90
|
+
/** Makes sure `public/media` exists, so uploads work on the first run. */
|
|
91
|
+
function ensureMediaFolder(targetDir) {
|
|
92
|
+
const media = path.join(targetDir, 'public', 'media');
|
|
93
|
+
fs.mkdirSync(media, { recursive: true });
|
|
94
|
+
fs.writeFileSync(path.join(media, '.gitkeep'), '');
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-payload-canvas",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Create a new Payload CMS website with Payload Canvas: the visual page builder, Postgres, a seeded demo and the AI guides.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"payload",
|
|
7
|
+
"payloadcms",
|
|
8
|
+
"create",
|
|
9
|
+
"scaffold",
|
|
10
|
+
"starter",
|
|
11
|
+
"template",
|
|
12
|
+
"website-builder",
|
|
13
|
+
"page-builder",
|
|
14
|
+
"nextjs",
|
|
15
|
+
"cli"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Eugene Lominos <hello@eugenelominos.com>",
|
|
19
|
+
"homepage": "https://github.com/jon8800/payload-canvas/tree/main/packages/create-payload-canvas#readme",
|
|
20
|
+
"bugs": "https://github.com/jon8800/payload-canvas/issues",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/jon8800/payload-canvas.git",
|
|
24
|
+
"directory": "packages/create-payload-canvas"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"create-payload-canvas": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@clack/prompts": "^1.8.1",
|
|
40
|
+
"cross-spawn": "^7.0.6",
|
|
41
|
+
"pg": "^8.23.1",
|
|
42
|
+
"tar": "^7.5.22"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/cross-spawn": "^6.0.6",
|
|
46
|
+
"@types/node": "^24.19.1",
|
|
47
|
+
"@types/pg": "^8.23.1",
|
|
48
|
+
"tsx": "^4.23.15",
|
|
49
|
+
"typescript": "^7.0.2"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20.9.0"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
56
|
+
"dev": "tsx src/index.ts",
|
|
57
|
+
"typecheck": "tsc --noEmit"
|
|
58
|
+
}
|
|
6
59
|
}
|