redclove 0.1.2 → 0.2.0
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 +12 -0
- package/dist/cli.js +75 -0
- package/dist/hosting.d.ts +50 -0
- package/dist/hosting.js +113 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/sites.d.ts +76 -0
- package/dist/sites.js +167 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,4 +22,16 @@ Every block comes with its tests. Run them with `npx vitest run src/blocks --max
|
|
|
22
22
|
|
|
23
23
|
Packages your project already lists are left at the version you chose.
|
|
24
24
|
|
|
25
|
+
## Websites (RedClove Sites)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx redclove sites templates # saas, docs, restaurant, portfolio
|
|
29
|
+
npx redclove sites create restaurant my-site # a Next.js site, blocks included
|
|
30
|
+
npx redclove sites pull <site-id> # bring a site from the editor (REDCLOVE_SITES_KEY)
|
|
31
|
+
npx redclove sites push # send content/site.json back
|
|
32
|
+
npx redclove sites deploy # upload this folder to Vercel (VERCEL_TOKEN)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`pull` always takes the editor's `content/site.json` (keeping yours as `.bak`) and keeps any other file you changed unless you pass `--overwrite`. `deploy` skips `node_modules`, build output and `.env*` files, and sets the values in `.env.local` as encrypted project settings.
|
|
36
|
+
|
|
25
37
|
Browse blocks at https://blocks.redclove.space.
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
3
5
|
import { parseArgs } from "node:util";
|
|
4
6
|
import { addBlocks, diffBlock } from "./add.js";
|
|
5
7
|
import { CONFIG_FILE, defaultConfig, readConfig, writeConfig } from "./project.js";
|
|
6
8
|
import { fetchIndex } from "./registry.js";
|
|
9
|
+
import { createFromTemplate, deploySite, listTemplates, pullSite, pushSite } from "./sites.js";
|
|
7
10
|
const HELP = `redclove: add tested backend blocks to your project
|
|
8
11
|
|
|
9
12
|
Usage
|
|
@@ -12,12 +15,23 @@ Usage
|
|
|
12
15
|
npx redclove diff <block> compare your copy of a block with the latest one
|
|
13
16
|
npx redclove init write ${CONFIG_FILE} with where blocks and migrations go
|
|
14
17
|
|
|
18
|
+
RedClove Sites
|
|
19
|
+
npx redclove sites templates list the website templates
|
|
20
|
+
npx redclove sites create <template> [dir] start a Next.js site from one
|
|
21
|
+
npx redclove sites pull [id] bring a site from the editor into this folder
|
|
22
|
+
npx redclove sites push send content/site.json back to the editor
|
|
23
|
+
npx redclove sites deploy deploy this folder to Vercel (needs VERCEL_TOKEN)
|
|
24
|
+
|
|
15
25
|
Options
|
|
16
26
|
-y, --yes don't ask; keep files you have changed (with --overwrite, replace them)
|
|
17
27
|
--overwrite replace files that differ from the block
|
|
18
28
|
--no-install copy files but don't install packages
|
|
19
29
|
--cwd <dir> project folder (default: current folder)
|
|
20
30
|
--registry <u> registry URL or folder (default: https://blocks.redclove.space/r)
|
|
31
|
+
--key <k> Sites API key (or REDCLOVE_SITES_KEY), for pull and push
|
|
32
|
+
--site <id> which site to push to (default: the one this folder was pulled from)
|
|
33
|
+
--token <t> Vercel token for deploy (or VERCEL_TOKEN)
|
|
34
|
+
--preview deploy a preview instead of production
|
|
21
35
|
-h, --help show this help
|
|
22
36
|
`;
|
|
23
37
|
const bold = (s) => (process.stdout.isTTY ? `\x1b[1m${s}\x1b[0m` : s);
|
|
@@ -31,6 +45,10 @@ async function main() {
|
|
|
31
45
|
"no-install": { type: "boolean" },
|
|
32
46
|
cwd: { type: "string" },
|
|
33
47
|
registry: { type: "string" },
|
|
48
|
+
key: { type: "string" },
|
|
49
|
+
site: { type: "string" },
|
|
50
|
+
token: { type: "string" },
|
|
51
|
+
preview: { type: "boolean" },
|
|
34
52
|
help: { type: "boolean", short: "h" },
|
|
35
53
|
},
|
|
36
54
|
});
|
|
@@ -92,6 +110,63 @@ async function main() {
|
|
|
92
110
|
console.log(`Wrote ${CONFIG_FILE}: blocks go in ${config.blocksDir}/, migrations in ${config.migrationsDir}/`);
|
|
93
111
|
return;
|
|
94
112
|
}
|
|
113
|
+
case "sites": {
|
|
114
|
+
const [sub, ...rest] = args;
|
|
115
|
+
const log = (l) => console.log(dim(l));
|
|
116
|
+
if (sub === "templates") {
|
|
117
|
+
for (const t of await listTemplates())
|
|
118
|
+
console.log(`${bold(t.slug.padEnd(12))} ${t.description}${t.blocks.length ? dim(` (${t.blocks.join(", ")})`) : ""}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (sub === "create") {
|
|
122
|
+
const [slug, folder] = rest;
|
|
123
|
+
if (!slug)
|
|
124
|
+
throw new UsageError("Usage: npx redclove sites create <template> [folder]");
|
|
125
|
+
const dir = folder ? join(cwd, folder) : join(cwd, slug);
|
|
126
|
+
const r = await createFromTemplate(slug, dir);
|
|
127
|
+
console.log(`Made ${bold(r.name)} in ${dir} (${r.written.length} files).`);
|
|
128
|
+
if (!values["no-install"]) {
|
|
129
|
+
log("$ npm install");
|
|
130
|
+
spawnSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: dir, stdio: "inherit", shell: process.platform === "win32" });
|
|
131
|
+
}
|
|
132
|
+
console.log(bold("\nNext:") + `\n cd ${relative(cwd, dir) || "."}\n npm run dev\n` + dim(" README.md says what's where and how to deploy."));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (sub === "pull") {
|
|
136
|
+
const interactive = process.stdin.isTTY && !values.yes && !values.overwrite;
|
|
137
|
+
const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
138
|
+
try {
|
|
139
|
+
const r = await pullSite(rest[0], cwd, {
|
|
140
|
+
key: values.key,
|
|
141
|
+
overwrite: values.overwrite ? true : rl ? async (p) => /^y/i.test(await rl.question(`${p} has changes of yours. Replace it? (y/N) `)) : false,
|
|
142
|
+
});
|
|
143
|
+
for (const f of r.written)
|
|
144
|
+
console.log(` + ${f}`);
|
|
145
|
+
for (const f of r.skipped)
|
|
146
|
+
console.log(` ! kept your ${f}`);
|
|
147
|
+
if (r.backup)
|
|
148
|
+
console.log(dim(` your previous content is in ${r.backup}`));
|
|
149
|
+
console.log(`${r.written.length} written, ${r.unchanged.length} unchanged, ${r.skipped.length} kept.`);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
rl?.close();
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (sub === "push") {
|
|
157
|
+
const r = await pushSite(cwd, { key: values.key, id: values.site });
|
|
158
|
+
console.log(`Sent content/site.json to site ${r.id} (now version ${r.version}). Undo it in the editor if you need to.`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (sub === "deploy") {
|
|
162
|
+
const r = await deploySite(cwd, { token: values.token, production: !values.preview, log });
|
|
163
|
+
if (r.readyState !== "READY")
|
|
164
|
+
throw new Error(`The build ended as ${r.readyState}. See ${r.inspectorUrl ?? r.url}`);
|
|
165
|
+
console.log(`${bold("Live:")} ${r.aliases[0] ?? r.url}`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
throw new UsageError(`Usage: npx redclove sites <templates|create|pull|push|deploy>\n\n${HELP}`);
|
|
169
|
+
}
|
|
95
170
|
default:
|
|
96
171
|
throw new UsageError(`Unknown command "${command}".\n\n${HELP}`);
|
|
97
172
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type HostFile = {
|
|
2
|
+
path: string;
|
|
3
|
+
bytes: Uint8Array;
|
|
4
|
+
};
|
|
5
|
+
type Log = (line: string) => void;
|
|
6
|
+
declare class HostError extends Error {
|
|
7
|
+
service: string;
|
|
8
|
+
status: number;
|
|
9
|
+
constructor(service: string, status: number, message: string);
|
|
10
|
+
}
|
|
11
|
+
export type VercelOptions = {
|
|
12
|
+
token: string;
|
|
13
|
+
/** Project name; made if it doesn't exist. */
|
|
14
|
+
name: string;
|
|
15
|
+
teamId?: string;
|
|
16
|
+
/** Environment variables to set on the project before it builds (encrypted, production and preview). */
|
|
17
|
+
env?: Record<string, string>;
|
|
18
|
+
production?: boolean;
|
|
19
|
+
/** Wait for the build to finish. Defaults to true. */
|
|
20
|
+
wait?: boolean;
|
|
21
|
+
/** How often to check on the build. */
|
|
22
|
+
pollMs?: number;
|
|
23
|
+
log?: Log;
|
|
24
|
+
fetch?: typeof fetch;
|
|
25
|
+
};
|
|
26
|
+
export type VercelResult = {
|
|
27
|
+
id: string;
|
|
28
|
+
url: string;
|
|
29
|
+
inspectorUrl?: string;
|
|
30
|
+
readyState: string;
|
|
31
|
+
aliases: string[];
|
|
32
|
+
};
|
|
33
|
+
/** Uploads the files, creates or reuses the project, sets its settings and deploys it. */
|
|
34
|
+
export declare function deployToVercel(files: HostFile[], opts: VercelOptions): Promise<VercelResult>;
|
|
35
|
+
export type GitHubOptions = {
|
|
36
|
+
token: string;
|
|
37
|
+
repo: string;
|
|
38
|
+
private?: boolean;
|
|
39
|
+
description?: string;
|
|
40
|
+
log?: Log;
|
|
41
|
+
fetch?: typeof fetch;
|
|
42
|
+
};
|
|
43
|
+
export type GitHubResult = {
|
|
44
|
+
url: string;
|
|
45
|
+
cloneUrl: string;
|
|
46
|
+
commit: string;
|
|
47
|
+
};
|
|
48
|
+
/** Makes a new repository and commits every file to it in one commit. */
|
|
49
|
+
export declare function pushToGitHub(files: HostFile[], opts: GitHubOptions): Promise<GitHubResult>;
|
|
50
|
+
export { HostError };
|
package/dist/hosting.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
class HostError extends Error {
|
|
3
|
+
service;
|
|
4
|
+
status;
|
|
5
|
+
constructor(service, status, message) {
|
|
6
|
+
super(`${service} answered ${status}: ${message}`);
|
|
7
|
+
this.service = service;
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async function call(service, fetchImpl, url, init) {
|
|
12
|
+
const res = await fetchImpl(url, init);
|
|
13
|
+
const text = await res.text();
|
|
14
|
+
let body = null;
|
|
15
|
+
try {
|
|
16
|
+
body = text ? JSON.parse(text) : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
body = text;
|
|
20
|
+
}
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
const b = body;
|
|
23
|
+
const message = (typeof b?.error === "object" ? b.error?.message : b?.error) ?? b?.message ?? (typeof body === "string" ? body.slice(0, 200) : "request failed");
|
|
24
|
+
throw new HostError(service, res.status, message);
|
|
25
|
+
}
|
|
26
|
+
return body;
|
|
27
|
+
}
|
|
28
|
+
const VERCEL = "https://api.vercel.com";
|
|
29
|
+
/** Uploads the files, creates or reuses the project, sets its settings and deploys it. */
|
|
30
|
+
export async function deployToVercel(files, opts) {
|
|
31
|
+
const f = opts.fetch ?? fetch;
|
|
32
|
+
const log = opts.log ?? (() => { });
|
|
33
|
+
const q = opts.teamId ? `?teamId=${encodeURIComponent(opts.teamId)}` : "";
|
|
34
|
+
const auth = { Authorization: `Bearer ${opts.token}` };
|
|
35
|
+
const name = opts.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100) || "redclove-site";
|
|
36
|
+
// The project first, so its environment variables are there for the first build.
|
|
37
|
+
try {
|
|
38
|
+
await call("Vercel", f, `${VERCEL}/v11/projects${q}`, { method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ name, framework: "nextjs" }) });
|
|
39
|
+
log(`Made the Vercel project ${name}`);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
if (!(e instanceof HostError && e.status === 409))
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
const env = Object.entries(opts.env ?? {}).filter(([, v]) => v);
|
|
46
|
+
if (env.length) {
|
|
47
|
+
const sep = q ? "&" : "?";
|
|
48
|
+
await call("Vercel", f, `${VERCEL}/v10/projects/${encodeURIComponent(name)}/env${q}${sep}upsert=true`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { ...auth, "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify(env.map(([key, value]) => ({ key, value, type: "encrypted", target: ["production", "preview"] }))),
|
|
52
|
+
});
|
|
53
|
+
log(`Set ${env.map(([k]) => k).join(", ")}`);
|
|
54
|
+
}
|
|
55
|
+
const refs = [];
|
|
56
|
+
let uploaded = 0;
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const sha = createHash("sha1").update(file.bytes).digest("hex");
|
|
59
|
+
await call("Vercel", f, `${VERCEL}/v2/files${q}`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { ...auth, "Content-Type": "application/octet-stream", "x-vercel-digest": sha, "Content-Length": String(file.bytes.byteLength) },
|
|
62
|
+
body: file.bytes,
|
|
63
|
+
});
|
|
64
|
+
refs.push({ file: file.path, sha, size: file.bytes.byteLength });
|
|
65
|
+
if (++uploaded % 25 === 0)
|
|
66
|
+
log(`Uploaded ${uploaded} of ${files.length} files`);
|
|
67
|
+
}
|
|
68
|
+
log(`Uploaded ${files.length} files`);
|
|
69
|
+
const created = await call("Vercel", f, `${VERCEL}/v13/deployments${q}`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { ...auth, "Content-Type": "application/json" },
|
|
72
|
+
body: JSON.stringify({ name, project: name, files: refs, projectSettings: { framework: "nextjs" }, ...(opts.production === false ? {} : { target: "production" }) }),
|
|
73
|
+
});
|
|
74
|
+
log(`Building https://${created.url}`);
|
|
75
|
+
let d = created;
|
|
76
|
+
if (opts.wait !== false) {
|
|
77
|
+
const started = Date.now();
|
|
78
|
+
while (!["READY", "ERROR", "CANCELED"].includes(d.readyState) && Date.now() - started < 15 * 60_000) {
|
|
79
|
+
await new Promise((r) => setTimeout(r, opts.pollMs ?? 4000));
|
|
80
|
+
d = await call("Vercel", f, `${VERCEL}/v13/deployments/${created.id}${q}`, { headers: auth });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { id: d.id, url: `https://${d.url}`, inspectorUrl: d.inspectorUrl, readyState: d.readyState, aliases: (d.alias ?? []).map((a) => `https://${a}`) };
|
|
84
|
+
}
|
|
85
|
+
const GITHUB = "https://api.github.com";
|
|
86
|
+
/** Makes a new repository and commits every file to it in one commit. */
|
|
87
|
+
export async function pushToGitHub(files, opts) {
|
|
88
|
+
const f = opts.fetch ?? fetch;
|
|
89
|
+
const log = opts.log ?? (() => { });
|
|
90
|
+
const headers = { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "Content-Type": "application/json" };
|
|
91
|
+
const gh = (path, method = "GET", body) => call("GitHub", f, `${GITHUB}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
92
|
+
const name = opts.repo.replace(/[^A-Za-z0-9._-]+/g, "-").slice(0, 100);
|
|
93
|
+
// auto_init gives the repository a first commit; the Git data API can't write to an empty one.
|
|
94
|
+
const repo = await gh("/user/repos", "POST", {
|
|
95
|
+
name,
|
|
96
|
+
private: opts.private ?? true,
|
|
97
|
+
description: opts.description,
|
|
98
|
+
auto_init: true,
|
|
99
|
+
});
|
|
100
|
+
log(`Made ${repo.html_url}`);
|
|
101
|
+
const base = await gh(`/repos/${repo.full_name}/git/ref/heads/${repo.default_branch}`);
|
|
102
|
+
const tree = [];
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const blob = await gh(`/repos/${repo.full_name}/git/blobs`, "POST", { content: Buffer.from(file.bytes).toString("base64"), encoding: "base64" });
|
|
105
|
+
tree.push({ path: file.path, mode: "100644", type: "blob", sha: blob.sha });
|
|
106
|
+
}
|
|
107
|
+
const t = await gh(`/repos/${repo.full_name}/git/trees`, "POST", { tree });
|
|
108
|
+
const commit = await gh(`/repos/${repo.full_name}/git/commits`, "POST", { message: "The site, from RedClove Sites", tree: t.sha, parents: [base.object.sha] });
|
|
109
|
+
await gh(`/repos/${repo.full_name}/git/refs/heads/${repo.default_branch}`, "PATCH", { sha: commit.sha, force: true });
|
|
110
|
+
log(`Committed ${files.length} files`);
|
|
111
|
+
return { url: repo.html_url, cloneUrl: repo.clone_url, commit: commit.sha };
|
|
112
|
+
}
|
|
113
|
+
export { HostError };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { addBlock, addBlocks, diffBlock, missingPackages, BlockNotFoundError, type AddOptions, type AddResult, type ConflictPolicy, type DiffResult } from "./add.js";
|
|
2
2
|
export { fetchBlock, fetchIndex, registryUrl, DEFAULT_REGISTRY, MIRRORS, type IndexEntry, type RegistryBlock, type EnvVar } from "./registry.js";
|
|
3
3
|
export { readConfig, writeConfig, defaultConfig, detectPackageManager, addCommand, CONFIG_FILE, type Config } from "./project.js";
|
|
4
|
+
export { createFromTemplate, deploySite, editSite, getSections, getSite, listTemplates, projectFiles, pullSite, pushSite, readEnvFile, sitesUrl, DEFAULT_SITES_URL, type PullResult, type SitesOptions, type Template } from "./sites.js";
|
|
5
|
+
export { deployToVercel, pushToGitHub, HostError, type GitHubOptions, type GitHubResult, type HostFile, type VercelOptions, type VercelResult } from "./hosting.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { addBlock, addBlocks, diffBlock, missingPackages, BlockNotFoundError } from "./add.js";
|
|
2
2
|
export { fetchBlock, fetchIndex, registryUrl, DEFAULT_REGISTRY, MIRRORS } from "./registry.js";
|
|
3
3
|
export { readConfig, writeConfig, defaultConfig, detectPackageManager, addCommand, CONFIG_FILE } from "./project.js";
|
|
4
|
+
export { createFromTemplate, deploySite, editSite, getSections, getSite, listTemplates, projectFiles, pullSite, pushSite, readEnvFile, sitesUrl, DEFAULT_SITES_URL } from "./sites.js";
|
|
5
|
+
export { deployToVercel, pushToGitHub, HostError } from "./hosting.js";
|
package/dist/sites.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type HostFile, type VercelResult } from "./hosting.js";
|
|
2
|
+
export declare const DEFAULT_SITES_URL = "https://blocks.redclove.space";
|
|
3
|
+
export declare const sitesUrl: (override?: string) => string;
|
|
4
|
+
export type SitesOptions = {
|
|
5
|
+
url?: string;
|
|
6
|
+
key?: string;
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
log?: (line: string) => void;
|
|
9
|
+
};
|
|
10
|
+
/** A site's document and version. */
|
|
11
|
+
export declare const getSite: (id: string, opts?: SitesOptions) => Promise<{
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
version: number;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
site: Record<string, unknown>;
|
|
17
|
+
}>;
|
|
18
|
+
/** Applies operations to a site. Throws with the server's explanation when one doesn't fit. */
|
|
19
|
+
export declare const editSite: (id: string, ops: unknown[], opts?: SitesOptions) => Promise<{
|
|
20
|
+
id: string;
|
|
21
|
+
version: number;
|
|
22
|
+
applied: unknown[];
|
|
23
|
+
site: Record<string, unknown>;
|
|
24
|
+
}>;
|
|
25
|
+
/** Every section type with its layouts, sample content and props schema, plus the operation schema. */
|
|
26
|
+
export declare const getSections: (opts?: SitesOptions) => Promise<{
|
|
27
|
+
sections: unknown[];
|
|
28
|
+
operation: unknown;
|
|
29
|
+
}>;
|
|
30
|
+
export type Template = {
|
|
31
|
+
slug: string;
|
|
32
|
+
name: string;
|
|
33
|
+
description: string;
|
|
34
|
+
pages: string[];
|
|
35
|
+
blocks: string[];
|
|
36
|
+
};
|
|
37
|
+
export declare const listTemplates: (opts?: SitesOptions) => Promise<Template[]>;
|
|
38
|
+
/** Starts a new project in `dir` (which must be empty or missing) from a template. */
|
|
39
|
+
export declare function createFromTemplate(slug: string, dir: string, opts?: SitesOptions): Promise<{
|
|
40
|
+
name: string;
|
|
41
|
+
written: string[];
|
|
42
|
+
}>;
|
|
43
|
+
export type PullResult = {
|
|
44
|
+
written: string[];
|
|
45
|
+
unchanged: string[];
|
|
46
|
+
skipped: string[];
|
|
47
|
+
backup: string | null;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Brings a site from the editor into `dir`. content/site.json always takes the editor's version (the old one is
|
|
51
|
+
* kept as content/site.json.bak if it differed). Other files you changed are kept unless overwrite is set.
|
|
52
|
+
*/
|
|
53
|
+
export declare function pullSite(id: string | undefined, dir: string, opts?: SitesOptions & {
|
|
54
|
+
overwrite?: boolean | ((path: string) => Promise<boolean>);
|
|
55
|
+
}): Promise<PullResult>;
|
|
56
|
+
/** Sends content/site.json to the editor, replacing the site there (it can be undone in the editor). */
|
|
57
|
+
export declare function pushSite(dir: string, opts?: SitesOptions & {
|
|
58
|
+
id?: string;
|
|
59
|
+
}): Promise<{
|
|
60
|
+
id: string;
|
|
61
|
+
version: number;
|
|
62
|
+
}>;
|
|
63
|
+
/** Every file in a project worth deploying: no dependencies, build output, git data or secrets. */
|
|
64
|
+
export declare function projectFiles(dir: string, base?: string): Promise<HostFile[]>;
|
|
65
|
+
/** Reads KEY=value lines from a .env file, if there is one. */
|
|
66
|
+
export declare function readEnvFile(path: string): Promise<Record<string, string>>;
|
|
67
|
+
/** Deploys the project in `dir` to Vercel, with the settings from .env.local. */
|
|
68
|
+
export declare function deploySite(dir: string, opts?: {
|
|
69
|
+
token?: string;
|
|
70
|
+
name?: string;
|
|
71
|
+
teamId?: string;
|
|
72
|
+
production?: boolean;
|
|
73
|
+
pollMs?: number;
|
|
74
|
+
log?: (l: string) => void;
|
|
75
|
+
fetch?: typeof fetch;
|
|
76
|
+
}): Promise<VercelResult>;
|
package/dist/sites.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, readdir, writeFile, copyFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import { readConfig, writeConfig } from "./project.js";
|
|
5
|
+
import { deployToVercel } from "./hosting.js";
|
|
6
|
+
// `redclove sites …`: start a project from a RedClove Sites template, pull a site from the editor, push content
|
|
7
|
+
// back, and deploy. Talks to the RedClove Sites HTTP API.
|
|
8
|
+
export const DEFAULT_SITES_URL = "https://blocks.redclove.space";
|
|
9
|
+
export const sitesUrl = (override) => (override ?? process.env.REDCLOVE_SITES_URL ?? DEFAULT_SITES_URL).replace(/\/$/, "");
|
|
10
|
+
async function api(opts, path, init = {}) {
|
|
11
|
+
const key = opts.key ?? process.env.REDCLOVE_SITES_KEY;
|
|
12
|
+
const res = await (opts.fetch ?? fetch)(`${sitesUrl(opts.url)}${path}`, {
|
|
13
|
+
...init,
|
|
14
|
+
headers: { ...(key ? { Authorization: `Bearer ${key}` } : {}), ...(init.body ? { "Content-Type": "application/json" } : {}), ...(init.headers ?? {}) },
|
|
15
|
+
signal: AbortSignal.timeout(120_000),
|
|
16
|
+
});
|
|
17
|
+
const body = (await res.json().catch(() => ({})));
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
if (res.status === 404 && body.error === "not_found")
|
|
20
|
+
throw new Error(`Not found at ${sitesUrl(opts.url)}${path}. Check the site id (the last part of its editor address).`);
|
|
21
|
+
throw new Error(body.message ?? body.error ?? `The server answered ${res.status}`);
|
|
22
|
+
}
|
|
23
|
+
return body;
|
|
24
|
+
}
|
|
25
|
+
/** A site's document and version. */
|
|
26
|
+
export const getSite = (id, opts = {}) => api(opts, `/api/sites/${encodeURIComponent(id)}`);
|
|
27
|
+
/** Applies operations to a site. Throws with the server's explanation when one doesn't fit. */
|
|
28
|
+
export const editSite = (id, ops, opts = {}) => api(opts, `/api/sites/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ ops }) });
|
|
29
|
+
/** Every section type with its layouts, sample content and props schema, plus the operation schema. */
|
|
30
|
+
export const getSections = (opts = {}) => api(opts, "/api/sections");
|
|
31
|
+
export const listTemplates = (opts = {}) => api(opts, "/api/templates");
|
|
32
|
+
/** Starts a new project in `dir` (which must be empty or missing) from a template. */
|
|
33
|
+
export async function createFromTemplate(slug, dir, opts = {}) {
|
|
34
|
+
if (!/^[a-z0-9-]+$/.test(slug))
|
|
35
|
+
throw new Error(`"${slug}" isn't a template name. Run "redclove sites templates" to see them.`);
|
|
36
|
+
if (existsSync(dir) && (await readdir(dir)).length)
|
|
37
|
+
throw new Error(`${dir} isn't empty. Pick a new folder.`);
|
|
38
|
+
const data = await api(opts, `/api/templates/${slug}/export?format=json`);
|
|
39
|
+
const written = [];
|
|
40
|
+
for (const f of data.files)
|
|
41
|
+
written.push(await writeSafe(dir, f.path, Buffer.from(f.base64, "base64")));
|
|
42
|
+
return { name: data.name, written };
|
|
43
|
+
}
|
|
44
|
+
async function writeSafe(dir, path, bytes) {
|
|
45
|
+
const target = join(dir, path);
|
|
46
|
+
const rel = relative(dir, target);
|
|
47
|
+
if (!rel || rel.startsWith("..") || /^[/\\]/.test(path))
|
|
48
|
+
throw new Error(`Refusing to write outside the project: ${path}`);
|
|
49
|
+
await mkdir(dirname(target), { recursive: true });
|
|
50
|
+
await writeFile(target, bytes);
|
|
51
|
+
return rel;
|
|
52
|
+
}
|
|
53
|
+
const sameJson = (a, b) => {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.stringify(JSON.parse(Buffer.from(a).toString("utf8"))) === JSON.stringify(JSON.parse(Buffer.from(b).toString("utf8")));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Brings a site from the editor into `dir`. content/site.json always takes the editor's version (the old one is
|
|
63
|
+
* kept as content/site.json.bak if it differed). Other files you changed are kept unless overwrite is set.
|
|
64
|
+
*/
|
|
65
|
+
export async function pullSite(id, dir, opts = {}) {
|
|
66
|
+
const config = await readConfig(dir);
|
|
67
|
+
const siteId = id ?? config?.site?.id;
|
|
68
|
+
if (!siteId)
|
|
69
|
+
throw new Error('Which site? Pass its id ("redclove sites pull <id>"), or run this in a folder you pulled before.');
|
|
70
|
+
const data = await api({ ...opts, url: opts.url ?? config?.site?.url }, `/api/sites/${encodeURIComponent(siteId)}/export?format=json`);
|
|
71
|
+
const result = { written: [], unchanged: [], skipped: [], backup: null };
|
|
72
|
+
for (const f of data.files) {
|
|
73
|
+
const bytes = Buffer.from(f.base64, "base64");
|
|
74
|
+
const target = join(dir, f.path);
|
|
75
|
+
if (f.path === "redclove.json" && config) {
|
|
76
|
+
// Keep the project's own record of blocks; just note which site it came from.
|
|
77
|
+
const incoming = JSON.parse(bytes.toString("utf8"));
|
|
78
|
+
await writeConfig(dir, { ...config, blocks: { ...incoming.blocks, ...config.blocks }, ...{ site: incoming.site } });
|
|
79
|
+
result.written.push(f.path);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (existsSync(target)) {
|
|
83
|
+
const current = await readFile(target);
|
|
84
|
+
if (current.equals(bytes)) {
|
|
85
|
+
result.unchanged.push(f.path);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (f.path === "content/site.json") {
|
|
89
|
+
if (sameJson(current, bytes)) {
|
|
90
|
+
result.unchanged.push(f.path);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
await copyFile(target, `${target}.bak`);
|
|
94
|
+
result.backup = "content/site.json.bak";
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const replace = typeof opts.overwrite === "function" ? await opts.overwrite(f.path) : !!opts.overwrite;
|
|
98
|
+
if (!replace) {
|
|
99
|
+
result.skipped.push(f.path);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
result.written.push(await writeSafe(dir, f.path, bytes));
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
/** Sends content/site.json to the editor, replacing the site there (it can be undone in the editor). */
|
|
109
|
+
export async function pushSite(dir, opts = {}) {
|
|
110
|
+
const config = (await readConfig(dir));
|
|
111
|
+
const id = opts.id ?? config?.site?.id;
|
|
112
|
+
if (!id)
|
|
113
|
+
throw new Error("This folder doesn't say which site it is. Pass --site <id>.");
|
|
114
|
+
const site = JSON.parse(await readFile(join(dir, "content", "site.json"), "utf8"));
|
|
115
|
+
const res = await api({ ...opts, url: opts.url ?? config?.site?.url }, `/api/sites/${encodeURIComponent(id)}`, {
|
|
116
|
+
method: "PATCH",
|
|
117
|
+
body: JSON.stringify({ ops: [{ op: "replace_site", site }] }),
|
|
118
|
+
});
|
|
119
|
+
return { id, version: res.version };
|
|
120
|
+
}
|
|
121
|
+
const SKIP = /^(node_modules|\.next|\.git|\.vercel|out|coverage)$|^\.env(?!\.example$)|\.tsbuildinfo$|\.bak$/;
|
|
122
|
+
/** Every file in a project worth deploying: no dependencies, build output, git data or secrets. */
|
|
123
|
+
export async function projectFiles(dir, base = dir) {
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
126
|
+
if (SKIP.test(e.name))
|
|
127
|
+
continue;
|
|
128
|
+
const p = join(dir, e.name);
|
|
129
|
+
if (e.isDirectory())
|
|
130
|
+
out.push(...(await projectFiles(p, base)));
|
|
131
|
+
else if (e.isFile())
|
|
132
|
+
out.push({ path: relative(base, p).split("\\").join("/"), bytes: new Uint8Array(await readFile(p)) });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/** Reads KEY=value lines from a .env file, if there is one. */
|
|
137
|
+
export async function readEnvFile(path) {
|
|
138
|
+
if (!existsSync(path))
|
|
139
|
+
return {};
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const line of (await readFile(path, "utf8")).split(/\r?\n/)) {
|
|
142
|
+
const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
|
143
|
+
if (m)
|
|
144
|
+
out[m[1]] = m[2].replace(/^(["'])(.*)\1$/, "$2");
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
/** Deploys the project in `dir` to Vercel, with the settings from .env.local. */
|
|
149
|
+
export async function deploySite(dir, opts = {}) {
|
|
150
|
+
const token = opts.token ?? process.env.VERCEL_TOKEN;
|
|
151
|
+
if (!token)
|
|
152
|
+
throw new Error("Set VERCEL_TOKEN (make one at https://vercel.com/account/tokens), or pass --token.");
|
|
153
|
+
if (!existsSync(join(dir, "package.json")))
|
|
154
|
+
throw new Error(`${dir} has no package.json. Run this in the site's folder.`);
|
|
155
|
+
const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
|
156
|
+
const env = await readEnvFile(join(dir, ".env.local"));
|
|
157
|
+
return deployToVercel(await projectFiles(dir), {
|
|
158
|
+
token,
|
|
159
|
+
name: opts.name ?? pkg.name,
|
|
160
|
+
teamId: opts.teamId ?? process.env.VERCEL_TEAM_ID,
|
|
161
|
+
env,
|
|
162
|
+
production: opts.production,
|
|
163
|
+
pollMs: opts.pollMs,
|
|
164
|
+
log: opts.log,
|
|
165
|
+
fetch: opts.fetch,
|
|
166
|
+
});
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redclove",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Add tested backend blocks (auth, payments, uploads…) to your project: npx redclove add auth",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Add tested backend blocks (auth, payments, uploads…) to your project, and start, pull and deploy RedClove Sites: npx redclove add auth",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": { "redclove": "dist/cli.js" },
|