@cloudhal/cli 0.1.0 → 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 +2 -0
- package/dist/assets/skills/site-design/references/page-recipes.md +2 -2
- package/dist/assets/starters/blog/package.json +4 -4
- package/dist/assets/starters/blog/src/styles/features/live-surfaces.css +3 -0
- package/dist/src/cli/commands-remote.js +1 -0
- package/dist/src/commands/deploy/attach.js +2 -1
- package/dist/src/commands/deploy/context.d.ts +2 -0
- package/dist/src/commands/deploy/context.js +2 -0
- package/dist/src/commands/deploy/options.d.ts +8 -0
- package/dist/src/commands/deploy/options.js +8 -0
- package/dist/src/commands/deploy/prepare.js +15 -4
- package/dist/src/commands/deploy/resources.d.ts +3 -1
- package/dist/src/commands/deploy/resources.js +4 -1
- package/dist/src/commands/deploy/ship.d.ts +3 -1
- package/dist/src/commands/deploy/ship.js +13 -13
- package/dist/src/commands/deploy/state.d.ts +2 -0
- package/dist/src/commands/deploy/state.js +6 -0
- package/dist/src/commands/deploy/workers-builds.d.ts +30 -0
- package/dist/src/commands/deploy/workers-builds.js +146 -0
- package/dist/src/commands/deploy.d.ts +3 -0
- package/dist/src/commands/deploy.js +33 -14
- package/dist/src/commands/init.js +13 -33
- package/dist/src/render-chrome.js +2 -1
- package/dist/src/seed-site.d.ts +7 -0
- package/dist/src/seed-site.js +34 -0
- package/package.json +11 -11
package/README.md
CHANGED
|
@@ -161,6 +161,8 @@ Locally, `cloudhal studio` reads the project's SQLite (`analytics_events`), roll
|
|
|
161
161
|
|
|
162
162
|
`cloudhal deploy` uses **your** wrangler login (`npx wrangler login`, or `CLOUDFLARE_API_TOKEN`); Cloudhal never reads those credentials. It creates the D1 database and R2 bucket if needed (with a lifecycle rule that expires the snapshot bundles under `bundles/` after 30 days, RFC-0010), generates the deployment secrets once into `.cloudhal/deploy/secrets.json` (0600 — keep it out of git), builds and deploys the **site Worker** (Astro on demand, edge-cached) and the **admin Worker** (Studio, API, auth, MCP), and on the first deploy pushes your local site.
|
|
163
163
|
|
|
164
|
+
**From Git (Workers Builds).** With `WORKERS_CI=1`, which Cloudflare sets in [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/), `cloudhal deploy` works from a fresh clone. It takes the names from the committed `wrangler.jsonc`, seeds the starter site when the checkout has none, keeps the secrets on the Workers and does not print the setup link. This is what the Deploy to Cloudflare button runs: see [docs/guides/deploy-button.md](../../docs/guides/deploy-button.md).
|
|
165
|
+
|
|
164
166
|
**Custom domain.** `cloudhal domain set news.example.com --studio studio.example.com` records the hostnames in `cloudhal.json`; the next `cloudhal deploy` routes them to the Workers as [Cloudflare Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) (the zone must be in the same Cloudflare account — Cloudflare creates the DNS record and certificate) and makes the site's one its primary hostname, so preview links use it (canonical URLs in the starter are not rendered yet). The workers.dev addresses keep working. `cloudhal domain remove` and a deploy undo it.
|
|
165
167
|
|
|
166
168
|
**Email (RFC-0011).** Add `"mail": { "from": "studio@example.com", "fromName": "Example Studio" }` to `cloudhal.json` and the next `cloudhal deploy` gives the admin Worker a [Cloudflare Email Service](https://developers.cloudflare.com/email-service/) `send_email` binding locked to that address. Invites can then be emailed, and staff can reset a forgotten password from the sign-in screen. Onboard the address's domain in Email Service first. Sending to any recipient needs Workers Paid (3,000 emails a month included); on Free, Email Service delivers only to verified destination addresses of your account. Locally, `cloudhal studio` prints these emails in the terminal instead of sending them.
|
|
@@ -6,7 +6,7 @@ Starting points, not templates: adapt them to the site's rules and real content.
|
|
|
6
6
|
1. **Hero:** `Band` (surface `gradient`, `mesh`, `inverse` or `brand`, width `full`, spacing `xl`) → `Heading` level 1 (the promise, ≤ 8 words) → `Text` size `l` (one sentence) → `Row` with one primary and one secondary `Button`.
|
|
7
7
|
2. **Features:** plain section → `Heading` level 2 → `Grid` (columns 3 or 4) of `Card`s with a short `Heading` level 3 and one sentence each.
|
|
8
8
|
3. **Proof:** `Band` surface `muted` or `dots` → `Grid` of `Stat`s, or `Columns 2-1` with a `Quote` / `testimonial` next to an `Image`.
|
|
9
|
-
4. **Pricing:** `Tabs` (monthly / yearly) or a `Toggle` bound to data and `Text` bound with `formatCurrency`; one `Card` per real plan.
|
|
9
|
+
4. **Pricing:** `Tabs` (monthly / yearly) or a `Toggle` with a `label` (shown above it) bound to data and `Text` bound with `formatCurrency`; one `Card` per real plan. A price with no currency symbol is `formatNumber` with `digits` 2 and `minDigits` 2 (9.60, not 9.6).
|
|
10
10
|
5. **Questions:** `faq` block or `Accordion` of `Disclosure`s (the real questions, as many as there are).
|
|
11
11
|
6. **Call to action:** `Band` surface `brand` or `accent` (anchor `start`) → `Heading` level 2 → one primary `Button`.
|
|
12
12
|
|
|
@@ -30,5 +30,5 @@ Compute, don't promise: a result is a function call over the inputs, never text
|
|
|
30
30
|
- **Calculator** (calories, loan, savings, price × seats): a `Card` holding a `Heading`, the inputs (`NumberField` with `unit`, `min`, `max`; `RadioGroup` `variant: buttons` for two to four choices; `Checkbox` for yes/no), two or three to a `Grid` row, then the result as a `Stat` right below. Start every number input as `""` in `data`; bind each input's `value` with `{path}`; write the result as one formula with `calculate` and `vars` (each input's path, or a `choose` for a choice), and show it with `formatNumber` or `formatCurrency`. Calories: `(10*w + 6.25*h - 5*a + s) * f` with `s` and `f` from `choose`; a loan payment: `p * r / (1 - (1 + r)^(-12*y))` with `r` the yearly percent ÷ 1200.
|
|
31
31
|
- **Keypad calculator:** a `Text` bound to `/display`, a `Grid` of `Button`s with `append` (digits and operators), `C` as `set` "" and `=` as `set` with `calculate` over `/display`.
|
|
32
32
|
- **Chart:** a `Chart` bound to rows in `data` (`x` the label field, `series` the number fields), titled with what it shows, with a `caption` naming the source. Bar to compare, line over time, donut for parts of a whole.
|
|
33
|
-
- **Comparison:** a `Table` bound to rows, the first column naming the row, numbers aligned `end` with a `format`.
|
|
33
|
+
- **Comparison:** a `Table` bound to rows, the first column naming the row, numbers aligned `end` with a `format`. With more than four columns, mark the ones a phone reader can skip `hideOnPhone: true`, so each phone card keeps three or four lines. To say which option wins, give a `Text` a `visible` that compares the computed values: `{"lt": [{"call": "calculate", …}, {"call": "calculate", …}]}`; no sign tricks with `clamp` and `choose`.
|
|
34
34
|
Check with `render_page`: no `input-label`, `chart-empty` or `overflow-x`, and the result shows a number with the sample values.
|
|
@@ -14,18 +14,18 @@
|
|
|
14
14
|
"astro": "astro"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@cloudhal/astro-renderer": "0.
|
|
18
|
-
"@cloudhal/core-ts": "0.
|
|
17
|
+
"@cloudhal/astro-renderer": "0.2.0",
|
|
18
|
+
"@cloudhal/core-ts": "0.2.0",
|
|
19
19
|
"@tailwindcss/vite": "^4.3.3",
|
|
20
20
|
"astro": "7.3.6",
|
|
21
21
|
"better-sqlite3": "13.0.3",
|
|
22
22
|
"tailwindcss": "^4.3.3",
|
|
23
23
|
"@astrojs/cloudflare": "14.3.4",
|
|
24
|
-
"@cloudhal/cli": "0.
|
|
24
|
+
"@cloudhal/cli": "0.2.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"wrangler": "^4.148.0",
|
|
28
|
-
"@cloudhal/cloudflare": "0.
|
|
28
|
+
"@cloudhal/cloudflare": "0.2.0"
|
|
29
29
|
},
|
|
30
30
|
"trustedDependencies": [
|
|
31
31
|
"better-sqlite3",
|
|
@@ -216,6 +216,9 @@ button.ch-button--secondary {
|
|
|
216
216
|
@apply text-left font-medium text-zinc-600;
|
|
217
217
|
content: attr(data-label);
|
|
218
218
|
}
|
|
219
|
+
.ch-table td[data-phone="hide"] {
|
|
220
|
+
@apply hidden;
|
|
221
|
+
}
|
|
219
222
|
.ch-table tr[data-highlight="yes"] td:first-child {
|
|
220
223
|
box-shadow: none;
|
|
221
224
|
}
|
|
@@ -53,6 +53,7 @@ const deployCommand = async (_rest, values, { projectDir }) => {
|
|
|
53
53
|
);
|
|
54
54
|
if (r.setupUrl)
|
|
55
55
|
console.log(`\nLocal site pushed. Create the owner account (once) at:\n ${r.setupUrl}`);
|
|
56
|
+
if (r.setupInstructions) console.log(`\nStarter site pushed. ${r.setupInstructions}`);
|
|
56
57
|
return 0;
|
|
57
58
|
};
|
|
58
59
|
|
|
@@ -52,7 +52,8 @@ async function installationOf(projectDir , folder ) {
|
|
|
52
52
|
export async function deployAttached(options ) {
|
|
53
53
|
const own = await readProjectConfig(options.projectDir);
|
|
54
54
|
const inst = await installationOf(options.projectDir, own.installation );
|
|
55
|
-
|
|
55
|
+
// the installation lives in another local project, never in a Workers Builds checkout
|
|
56
|
+
const loaded = await loadDeploy({ ...options, name: inst.state.name, workersBuilds: false });
|
|
56
57
|
const base = { ...loaded.base, siteName: `${inst.state.name}-site-${loaded.site.slug}` };
|
|
57
58
|
const previous = {
|
|
58
59
|
...(loaded.previous ?? inst.state),
|
|
@@ -18,4 +18,6 @@ export interface DeployContext {
|
|
|
18
18
|
readonly previous: DeployState | undefined;
|
|
19
19
|
readonly base: DeploymentNames;
|
|
20
20
|
readonly names: ReturnType<typeof workerNames>;
|
|
21
|
+
/** Running in Cloudflare Workers Builds (`workers-builds.ts`). */
|
|
22
|
+
readonly workersBuilds: boolean;
|
|
21
23
|
}
|
|
@@ -56,6 +56,12 @@ export interface DeployOptions {
|
|
|
56
56
|
readonly buildStudio?: boolean;
|
|
57
57
|
/** Injected in tests: runs the Studio build. */
|
|
58
58
|
readonly studioBuilder?: StudioBuilder;
|
|
59
|
+
/**
|
|
60
|
+
* Deploying from Cloudflare Workers Builds (the Deploy to Cloudflare button): names from the
|
|
61
|
+
* committed `wrangler.jsonc`, the starter seeded when missing, secrets kept on the Workers.
|
|
62
|
+
* Default: `WORKERS_CI=1` in the environment.
|
|
63
|
+
*/
|
|
64
|
+
readonly workersBuilds?: boolean;
|
|
59
65
|
}
|
|
60
66
|
export interface DeployResult {
|
|
61
67
|
dryRun: boolean;
|
|
@@ -67,6 +73,8 @@ export interface DeployResult {
|
|
|
67
73
|
pushed?: boolean;
|
|
68
74
|
/** First deploy only: open it to create the owner account. */
|
|
69
75
|
setupUrl?: string;
|
|
76
|
+
/** First deploy from Workers Builds: how to create the owner account (no link is printed). */
|
|
77
|
+
setupInstructions?: string;
|
|
70
78
|
/** Custom domains from cloudhal.json, routed by this deploy. */
|
|
71
79
|
customSiteUrl?: string;
|
|
72
80
|
customStudioUrl?: string;
|
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
rateLimitDeployment,
|
|
64
64
|
} from "./settings.js";
|
|
65
65
|
import { deployDir, exists, readDeployState } from "./state.js";
|
|
66
|
+
import { committedNames, inWorkersBuilds, seedIfMissing } from "./workers-builds.js";
|
|
66
67
|
|
|
67
68
|
export function checkOptions(options ) {
|
|
68
69
|
if (options.jurisdiction !== undefined && !["auto", "eu"].includes(options.jurisdiction))
|
|
@@ -80,16 +81,22 @@ export async function loadDeploy(options )
|
|
|
80
81
|
const dir = deployDir(options.projectDir, cfg.dataDir);
|
|
81
82
|
await mkdir(dir, { recursive: true });
|
|
82
83
|
const run = options.wrangler ?? wranglerRunner(options.projectDir, log);
|
|
84
|
+
// Workers Builds: the button's names, and the starter site in a checkout that has none
|
|
85
|
+
const workersBuilds = inWorkersBuilds(options.workersBuilds);
|
|
86
|
+
const committed = workersBuilds
|
|
87
|
+
? await committedNames(siteConfigPath(options.projectDir))
|
|
88
|
+
: undefined;
|
|
89
|
+
if (committed) await seedIfMissing(options.projectDir, cfg, committed.name, log);
|
|
83
90
|
|
|
84
91
|
const local = await openLocalStore(path.join(options.projectDir, cfg.dataDir));
|
|
85
92
|
const site = await local.store.getSite(cfg.siteId);
|
|
86
93
|
await local.close();
|
|
87
94
|
if (!site) throw new Error(`site ${cfg.siteId} not found in the local store`);
|
|
88
95
|
const previous = await readDeployState(options.projectDir, cfg.dataDir);
|
|
89
|
-
const name = options.name ?? previous?.name ?? site.slug;
|
|
96
|
+
const name = options.name ?? committed?.name ?? previous?.name ?? site.slug;
|
|
90
97
|
// RFC-0029 §3: the search provider's instance and bucket, bound by both Workers.
|
|
91
98
|
const search = searchDeploymentOf(cfg.search, name);
|
|
92
|
-
const base = { name, ...(search ? { search } : {}) };
|
|
99
|
+
const base = { ...committed, name, ...(search ? { search } : {}) };
|
|
93
100
|
if (search)
|
|
94
101
|
log(
|
|
95
102
|
`search: Cloudflare AI Search instance ${search.instance} over R2 bucket ${search.bucket} (the instance must exist, read that bucket and declare the text metadata fields locale and kind); ${search.monthlyLimit} semantic searches a month for the deployment (Cloudflare includes 1,000 per account), then FTS5 alone until the month ends`,
|
|
@@ -97,7 +104,7 @@ export async function loadDeploy(options )
|
|
|
97
104
|
const names = workerNames(base);
|
|
98
105
|
// 0. analytics: decided before anything is generated; the wrangler version gates the configs
|
|
99
106
|
const decided = await decideAnalytics(options, cfg, site.name, log);
|
|
100
|
-
return { options, log, cfg: decided, dir, run, site, previous, base, names };
|
|
107
|
+
return { options, log, cfg: decided, dir, run, site, previous, base, names, workersBuilds };
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
/** 0. analytics bindings (wrangler ≥ 4.145); 0b. reader comments (RFC-0007 §15.4). */
|
|
@@ -159,8 +166,11 @@ export async function provision(
|
|
|
159
166
|
ctx ,
|
|
160
167
|
) {
|
|
161
168
|
const { options, names, log, base } = ctx;
|
|
169
|
+
// Workers Builds: the database the button provisioned (its token may not read D1)
|
|
170
|
+
const known = ctx.workersBuilds ? base.databaseId : undefined;
|
|
162
171
|
const existing = await readExistingIds(siteConfigPath(options.projectDir));
|
|
163
|
-
|
|
172
|
+
// (a placeholder id in the committed file is no id: never carried into the config)
|
|
173
|
+
let databaseId = ctx.workersBuilds ? known : existing.databaseId;
|
|
164
174
|
let databaseCreated ;
|
|
165
175
|
if (!options.dryRun) {
|
|
166
176
|
const r = await ensureResources(ctx.run, {
|
|
@@ -168,6 +178,7 @@ export async function provision(
|
|
|
168
178
|
names,
|
|
169
179
|
log,
|
|
170
180
|
where: jurisdictionChooser(options, log),
|
|
181
|
+
...(known ? { databaseId: known } : {}),
|
|
171
182
|
});
|
|
172
183
|
databaseId = r.databaseId;
|
|
173
184
|
databaseCreated = r.created;
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
import type { workerNames } from "../../cloudflare/config.js";
|
|
3
3
|
import type { WranglerRunner } from "../../cloudflare/wrangler.js";
|
|
4
4
|
import type { Jurisdiction } from "./state.js";
|
|
5
|
-
export declare function ensureResources(run: WranglerRunner, { cwd, names, log, where, }: {
|
|
5
|
+
export declare function ensureResources(run: WranglerRunner, { cwd, names, log, where, databaseId: known, }: {
|
|
6
6
|
cwd: string;
|
|
7
7
|
names: ReturnType<typeof workerNames>;
|
|
8
8
|
log: (l: string) => void;
|
|
9
9
|
/** Asked only when the database is created: where it lives. */
|
|
10
10
|
where: () => Promise<Jurisdiction>;
|
|
11
|
+
/** A database already provisioned (Workers Builds): no D1 call at all. */
|
|
12
|
+
databaseId?: string;
|
|
11
13
|
}): Promise<{
|
|
12
14
|
databaseId: string;
|
|
13
15
|
created?: Jurisdiction;
|
|
@@ -12,12 +12,15 @@ export async function ensureResources(
|
|
|
12
12
|
names,
|
|
13
13
|
log,
|
|
14
14
|
where,
|
|
15
|
+
databaseId: known,
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
|
|
23
|
+
|
|
21
24
|
,
|
|
22
25
|
) {
|
|
23
26
|
const info = async () => {
|
|
@@ -29,7 +32,7 @@ export async function ensureResources(
|
|
|
29
32
|
return undefined;
|
|
30
33
|
}
|
|
31
34
|
};
|
|
32
|
-
let databaseId = await info();
|
|
35
|
+
let databaseId = known ?? (await info());
|
|
33
36
|
let made ;
|
|
34
37
|
if (!databaseId) {
|
|
35
38
|
made = await where();
|
|
@@ -9,7 +9,9 @@ type PreparedAdmin = Awaited<ReturnType<typeof prepareAdminWorker>>;
|
|
|
9
9
|
* the SchedulerClock alarm ticks instead.
|
|
10
10
|
*/
|
|
11
11
|
export declare function shipWorkers(ctx: DeployContext, input: {
|
|
12
|
-
|
|
12
|
+
/** What this deploy sets on each Worker (the site Worker's share of the admin Worker's). */
|
|
13
|
+
siteSecrets: Partial<DeploySecrets>;
|
|
14
|
+
adminSecrets: Partial<DeploySecrets>;
|
|
13
15
|
admin: PreparedAdmin;
|
|
14
16
|
prepareAdmin: (ticker: SchedulerTicker) => Promise<PreparedAdmin>;
|
|
15
17
|
scheduler: SchedulerTicker;
|
|
@@ -28,7 +28,9 @@ import { CRON_REFUSED_MESSAGE } from "./schedules-guard.js";
|
|
|
28
28
|
export async function shipWorkers(
|
|
29
29
|
ctx ,
|
|
30
30
|
input
|
|
31
|
-
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
|
|
@@ -39,29 +41,27 @@ export async function shipWorkers(
|
|
|
39
41
|
|
|
40
42
|
{
|
|
41
43
|
const { options, run, names, log, dir } = ctx;
|
|
42
|
-
const {
|
|
44
|
+
const { admin } = input;
|
|
43
45
|
let scheduler = input.scheduler;
|
|
44
|
-
// Secrets go to wrangler through files that exist only while it runs (0600, then removed)
|
|
46
|
+
// Secrets go to wrangler through files that exist only while it runs (0600, then removed); a
|
|
47
|
+
// Worker keeps every secret a deploy does not set.
|
|
45
48
|
const siteSecretsFile = path.join(dir, "site-secrets.json");
|
|
46
49
|
const adminSecretsFile = path.join(dir, "admin-secrets.json");
|
|
47
|
-
const
|
|
50
|
+
const secretsArgs = async (file , value ) => {
|
|
51
|
+
if (Object.keys(value).length === 0) return [];
|
|
48
52
|
await writeFile(file, JSON.stringify(value), { mode: 0o600 });
|
|
49
53
|
await chmod(file, 0o600);
|
|
54
|
+
return ["--secrets-file", file];
|
|
50
55
|
};
|
|
51
56
|
let siteDeploy ;
|
|
52
57
|
let adminDeploy ;
|
|
53
58
|
try {
|
|
54
|
-
await
|
|
55
|
-
|
|
56
|
-
CLOUDHAL_PURGE_TOKEN: secrets.CLOUDHAL_PURGE_TOKEN,
|
|
57
|
-
// RFC-0007: the same value as the admin Worker's (proof of work, emails, stored secrets)
|
|
58
|
-
CLOUDHAL_COMMENTS_KEY: secrets.CLOUDHAL_COMMENTS_KEY,
|
|
59
|
-
});
|
|
60
|
-
await writeSecret(adminSecretsFile, secrets);
|
|
59
|
+
const siteArgs = await secretsArgs(siteSecretsFile, input.siteSecrets);
|
|
60
|
+
const adminSecrets = await secretsArgs(adminSecretsFile, input.adminSecrets);
|
|
61
61
|
log(`deploying ${names.site}`);
|
|
62
|
-
siteDeploy = await must(run, ["deploy",
|
|
62
|
+
siteDeploy = await must(run, ["deploy", ...siteArgs], options.projectDir);
|
|
63
63
|
log(`deploying ${names.admin}`);
|
|
64
|
-
const adminArgs = ["deploy", "-c", admin.configPath,
|
|
64
|
+
const adminArgs = ["deploy", "-c", admin.configPath, ...adminSecrets];
|
|
65
65
|
adminDeploy = await run(adminArgs, { cwd: admin.dir });
|
|
66
66
|
if (
|
|
67
67
|
adminDeploy.code !== 0 &&
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { SchedulerTicker, WorkersPlan } from "../../cloudflare/config.js";
|
|
2
2
|
export declare const SECRET_KEYS: readonly ["BETTER_AUTH_SECRET", "MCP_STATE_KEY", "CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_DEPLOY_TOKEN", "CLOUDHAL_SETUP_TOKEN", "CLOUDHAL_COMMENTS_KEY"];
|
|
3
3
|
export type DeploySecrets = Record<(typeof SECRET_KEYS)[number], string>;
|
|
4
|
+
/** The secrets the site Worker has too, with the same values as the admin Worker's. */
|
|
5
|
+
export declare const SITE_SECRET_KEYS: readonly ["CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_COMMENTS_KEY"];
|
|
4
6
|
export interface DeployState {
|
|
5
7
|
name: string;
|
|
6
8
|
adminUrl?: string;
|
|
@@ -14,6 +14,12 @@ export const SECRET_KEYS = [
|
|
|
14
14
|
"CLOUDHAL_COMMENTS_KEY",
|
|
15
15
|
] ;
|
|
16
16
|
|
|
17
|
+
/** The secrets the site Worker has too, with the same values as the admin Worker's. */
|
|
18
|
+
export const SITE_SECRET_KEYS = [
|
|
19
|
+
"CLOUDHAL_PREVIEW_SECRET",
|
|
20
|
+
"CLOUDHAL_PURGE_TOKEN",
|
|
21
|
+
"CLOUDHAL_COMMENTS_KEY",
|
|
22
|
+
] ;
|
|
17
23
|
|
|
18
24
|
|
|
19
25
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DeploymentNames, workerNames } from "../../cloudflare/config.js";
|
|
2
|
+
import type { WranglerRunner } from "../../cloudflare/wrangler.js";
|
|
3
|
+
import type { ProjectConfig } from "../../config.js";
|
|
4
|
+
import { type DeploySecrets } from "./state.js";
|
|
5
|
+
/** Whether this deploy runs in Workers Builds (`WORKERS_CI=1`, set by Cloudflare). */
|
|
6
|
+
export declare const inWorkersBuilds: (flag: boolean | undefined) => boolean;
|
|
7
|
+
/** The committed `wrangler.jsonc`'s names, read before this deploy rewrites it. */
|
|
8
|
+
export declare function committedNames(file: string): Promise<Omit<DeploymentNames, "search">>;
|
|
9
|
+
/** Seeds the starter site under `cloudhal.json`'s `siteId` when this checkout has none. */
|
|
10
|
+
export declare function seedIfMissing(projectDir: string, cfg: ProjectConfig, name: string, log: (line: string) => void): Promise<void>;
|
|
11
|
+
export interface BuildsSecrets {
|
|
12
|
+
/** What this deploy sets on each Worker; everything else the Workers keep. */
|
|
13
|
+
readonly site: Partial<DeploySecrets>;
|
|
14
|
+
readonly admin: Partial<DeploySecrets>;
|
|
15
|
+
/** Every value this deploy knows (always the new deployment token). */
|
|
16
|
+
readonly known: Partial<DeploySecrets> & {
|
|
17
|
+
CLOUDHAL_DEPLOY_TOKEN: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The secrets to set: the missing ones and a new deployment token, written to the checkout's
|
|
22
|
+
* `secrets.json` (0600, gone with the build) for `cloudhal push`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildsSecrets(run: WranglerRunner, input: {
|
|
25
|
+
cwd: string;
|
|
26
|
+
dir: string;
|
|
27
|
+
names: ReturnType<typeof workerNames>;
|
|
28
|
+
}): Promise<BuildsSecrets>;
|
|
29
|
+
/** What the owner does once, instead of a printed setup link. */
|
|
30
|
+
export declare function setupInstructions(adminUrl: string, adminWorker: string): string;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cloudhal deploy` inside Cloudflare Workers Builds (`WORKERS_CI=1`), which the Deploy to
|
|
3
|
+
* Cloudflare button sets up (docs/guides/deploy-button.md). Every build starts from a fresh clone:
|
|
4
|
+
* no local store, no `secrets.json`, no `state.json`. So:
|
|
5
|
+
*
|
|
6
|
+
* - names: the committed `wrangler.jsonc` is the button's — the Worker it connected (which
|
|
7
|
+
* Workers Builds requires the config to name) and the D1 database and R2 bucket it provisioned,
|
|
8
|
+
* possibly renamed by the person who clicked. The deployment takes those names; the admin Worker
|
|
9
|
+
* is `<base>-admin`, where `<base>` is the site Worker's name without `-site`.
|
|
10
|
+
* - the site: when the local store lacks `cloudhal.json`'s `siteId`, the starter site is seeded
|
|
11
|
+
* under that id. Only the first deploy pushes it; afterwards the deployment is the source of
|
|
12
|
+
* truth, as always.
|
|
13
|
+
* - secrets: they live only on the Workers. A secret either Worker already has is kept (wrangler
|
|
14
|
+
* keeps every secret a deploy does not set); a missing one is generated and set. The secrets
|
|
15
|
+
* both Workers share are set together, so they never differ. The deployment token, which only
|
|
16
|
+
* this deploy uses, is new on every build.
|
|
17
|
+
* - the setup link is never printed (build logs are kept): the owner sets their own setup token
|
|
18
|
+
* on the admin Worker and opens the link themselves.
|
|
19
|
+
*/
|
|
20
|
+
import { chmod, readFile, writeFile } from "node:fs/promises";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { openLocalStore } from "@cloudhal/core-ts/local";
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
import { seedProjectSite } from "../../seed-site.js";
|
|
27
|
+
import { randomSecret, SECRET_KEYS, SITE_SECRET_KEYS } from "./state.js";
|
|
28
|
+
|
|
29
|
+
/** Whether this deploy runs in Workers Builds (`WORKERS_CI=1`, set by Cloudflare). */
|
|
30
|
+
export const inWorkersBuilds = (flag ) =>
|
|
31
|
+
flag ?? process.env.WORKERS_CI === "1";
|
|
32
|
+
|
|
33
|
+
/** A placeholder D1 id a template commits before the button provisions the real database. */
|
|
34
|
+
const PLACEHOLDER_ID = /^0{8}-0{4}-0{4}-0{4}-0{12}$/;
|
|
35
|
+
|
|
36
|
+
/** The committed `wrangler.jsonc`'s names, read before this deploy rewrites it. */
|
|
37
|
+
export async function committedNames(file ) {
|
|
38
|
+
let cfg
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
;
|
|
43
|
+
try {
|
|
44
|
+
cfg = JSON.parse((await readFile(file, "utf8")).replace(/^\s*\/\/.*$/gm, ""));
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Workers Builds: ${path.basename(file)} is missing or not JSON; it must name the site Worker, its D1 database and R2 bucket (docs/guides/deploy-button.md)`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (!cfg.name) throw new Error(`Workers Builds: ${path.basename(file)} has no "name"`);
|
|
51
|
+
const db = cfg.d1_databases?.find((d) => d.binding === "DB");
|
|
52
|
+
const bucket = cfg.r2_buckets?.find((b) => b.binding === "BLOBS");
|
|
53
|
+
const base = cfg.name.replace(/-site$/, "") || cfg.name;
|
|
54
|
+
return {
|
|
55
|
+
name: base,
|
|
56
|
+
...(cfg.name !== `${base}-site` ? { siteName: cfg.name } : {}),
|
|
57
|
+
...(db?.database_name ? { databaseName: db.database_name } : {}),
|
|
58
|
+
...(db?.database_id && !PLACEHOLDER_ID.test(db.database_id)
|
|
59
|
+
? { databaseId: db.database_id }
|
|
60
|
+
: {}),
|
|
61
|
+
...(bucket?.bucket_name ? { bucketName: bucket.bucket_name } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Seeds the starter site under `cloudhal.json`'s `siteId` when this checkout has none. */
|
|
66
|
+
export async function seedIfMissing(
|
|
67
|
+
projectDir ,
|
|
68
|
+
cfg ,
|
|
69
|
+
name ,
|
|
70
|
+
log ,
|
|
71
|
+
) {
|
|
72
|
+
const local = await openLocalStore(path.join(projectDir, cfg.dataDir));
|
|
73
|
+
const site = await local.store.getSite(cfg.siteId);
|
|
74
|
+
await local.close();
|
|
75
|
+
if (site) return;
|
|
76
|
+
await seedProjectSite(projectDir, { dataDir: cfg.dataDir, name, slug: name, siteId: cfg.siteId });
|
|
77
|
+
log(
|
|
78
|
+
`Workers Builds: seeded the starter site ${cfg.siteId}; it is pushed only if the deployment does not have it yet`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function secretNames(run , worker , cwd ) {
|
|
83
|
+
const r = await run(["secret", "list", "--name", worker, "--format", "json"], {
|
|
84
|
+
cwd,
|
|
85
|
+
quiet: true,
|
|
86
|
+
});
|
|
87
|
+
if (r.code !== 0) {
|
|
88
|
+
// A Worker that does not exist yet has no secrets; any other failure must stop the deploy,
|
|
89
|
+
// or the secrets would be replaced and what they encrypt lost.
|
|
90
|
+
if (/not found|10007/i.test(r.stderr + r.stdout)) return new Set();
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Workers Builds: could not list the secrets of ${worker}:\n${(r.stderr || r.stdout).slice(-2000)}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return new Set((JSON.parse(r.stdout) ).map((s) => s.name));
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error(`Workers Builds: could not read the secret list of ${worker}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The secrets to set: the missing ones and a new deployment token, written to the checkout's
|
|
112
|
+
* `secrets.json` (0600, gone with the build) for `cloudhal push`.
|
|
113
|
+
*/
|
|
114
|
+
export async function buildsSecrets(
|
|
115
|
+
run ,
|
|
116
|
+
input ,
|
|
117
|
+
) {
|
|
118
|
+
const site = await secretNames(run, input.names.site, input.cwd);
|
|
119
|
+
const admin = await secretNames(run, input.names.admin, input.cwd);
|
|
120
|
+
const shared = SITE_SECRET_KEYS;
|
|
121
|
+
const known = {
|
|
122
|
+
CLOUDHAL_DEPLOY_TOKEN: randomSecret(),
|
|
123
|
+
};
|
|
124
|
+
for (const key of SECRET_KEYS) {
|
|
125
|
+
const missing = shared.includes(key) ? !site.has(key) || !admin.has(key) : !admin.has(key);
|
|
126
|
+
if (missing && !known[key]) known[key] = randomSecret();
|
|
127
|
+
}
|
|
128
|
+
const pick = (keys ) =>
|
|
129
|
+
Object.fromEntries(Object.entries(known).filter(([k]) => keys.includes(k)));
|
|
130
|
+
const file = path.join(input.dir, "secrets.json");
|
|
131
|
+
await writeFile(file, `${JSON.stringify(known, null, 2)}\n`, { mode: 0o600 });
|
|
132
|
+
await chmod(file, 0o600);
|
|
133
|
+
return { site: pick(shared), admin: known, known };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What the owner does once, instead of a printed setup link. */
|
|
137
|
+
export function setupInstructions(adminUrl , adminWorker ) {
|
|
138
|
+
const login = `${adminUrl.replace(/\/$/, "")}/login#setup=`;
|
|
139
|
+
return [
|
|
140
|
+
"Create the owner account (once). The setup link is not printed, because build logs are kept:",
|
|
141
|
+
` 1. Cloudflare dashboard › Workers & Pages › ${adminWorker} › Settings › Variables and Secrets:`,
|
|
142
|
+
" edit CLOUDHAL_SETUP_TOKEN and set a random value of your own (32 characters or more),",
|
|
143
|
+
` or run \`npx wrangler secret put CLOUDHAL_SETUP_TOKEN --name ${adminWorker}\``,
|
|
144
|
+
` 2. open ${login}<that value>`,
|
|
145
|
+
].join("\n");
|
|
146
|
+
}
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
* `apps/studio/src`, `apps/studio/index.html` or `packages/ui/src`, the deploy runs
|
|
40
40
|
* `pnpm --filter @cloudhal/studio build` first; `--no-build-studio` fails instead. A published
|
|
41
41
|
* install ships Studio prebuilt and is never checked.
|
|
42
|
+
* 13. Workers Builds (`WORKERS_CI=1`, the Deploy to Cloudflare button): names from the committed
|
|
43
|
+
* `wrangler.jsonc`, the starter seeded when the checkout has none, secrets kept on the Workers
|
|
44
|
+
* and no setup link printed (`deploy/workers-builds.ts`, docs/guides/deploy-button.md).
|
|
42
45
|
*
|
|
43
46
|
* `--dry-run` runs everything up to wrangler's own `--dry-run` (bundles, configs) and skips
|
|
44
47
|
* provisioning, deployment and push — no Cloudflare account needed.
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
* `apps/studio/src`, `apps/studio/index.html` or `packages/ui/src`, the deploy runs
|
|
40
40
|
* `pnpm --filter @cloudhal/studio build` first; `--no-build-studio` fails instead. A published
|
|
41
41
|
* install ships Studio prebuilt and is never checked.
|
|
42
|
+
* 13. Workers Builds (`WORKERS_CI=1`, the Deploy to Cloudflare button): names from the committed
|
|
43
|
+
* `wrangler.jsonc`, the starter seeded when the checkout has none, secrets kept on the Workers
|
|
44
|
+
* and no setup link printed (`deploy/workers-builds.ts`, docs/guides/deploy-button.md).
|
|
42
45
|
*
|
|
43
46
|
* `--dry-run` runs everything up to wrangler's own `--dry-run` (bundles, configs) and skips
|
|
44
47
|
* provisioning, deployment and push — no Cloudflare account needed.
|
|
@@ -64,7 +67,8 @@ import { downgradeGuard } from "./deploy/schedules-guard.js";
|
|
|
64
67
|
import { attachedWorkers } from "./deploy/served-sites.js";
|
|
65
68
|
import { commentsResult } from "./deploy/settings.js";
|
|
66
69
|
import { afterDeploy, purgeIfSwitched, shipWorkers } from "./deploy/ship.js";
|
|
67
|
-
import { loadOrCreateSecrets, writeDeployState } from "./deploy/state.js";
|
|
70
|
+
import { loadOrCreateSecrets, SITE_SECRET_KEYS, writeDeployState } from "./deploy/state.js";
|
|
71
|
+
import { buildsSecrets, setupInstructions } from "./deploy/workers-builds.js";
|
|
68
72
|
import { resolveSkillsDir } from "./mcp.js";
|
|
69
73
|
|
|
70
74
|
|
|
@@ -90,6 +94,15 @@ export {
|
|
|
90
94
|
SECRET_KEYS,
|
|
91
95
|
} from "./deploy/state.js";
|
|
92
96
|
|
|
97
|
+
/** What this deploy sets on each Worker, and the values it knows (all of them, outside Workers Builds). */
|
|
98
|
+
async function deploySecrets(ctx ) {
|
|
99
|
+
if (ctx.workersBuilds && !ctx.options.dryRun)
|
|
100
|
+
return buildsSecrets(ctx.run, { cwd: ctx.options.projectDir, dir: ctx.dir, names: ctx.names });
|
|
101
|
+
const all = await loadOrCreateSecrets(ctx.dir);
|
|
102
|
+
const site = Object.fromEntries(SITE_SECRET_KEYS.map((k) => [k, all[k]]));
|
|
103
|
+
return { site, admin: all, known: all };
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
export async function deploy(options ) {
|
|
94
107
|
checkOptions(options);
|
|
95
108
|
// RFC-0034 §4: a site Worker that joins another installation deploys only itself.
|
|
@@ -99,10 +112,11 @@ export async function deploy(options ) {
|
|
|
99
112
|
const { analytics, comments } = await analyticsAndComments(ctx);
|
|
100
113
|
const studioDir = await studioDirFor(ctx);
|
|
101
114
|
const { withIds, databaseCreated } = await provision(ctx);
|
|
102
|
-
// 2. secrets; 2b. RFC-0008: never deploy code that
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
// 2. secrets (Workers Builds: kept on the Workers); 2b. RFC-0008: never deploy code that
|
|
116
|
+
// ignores holds and gates over open releases
|
|
117
|
+
const secrets = await deploySecrets(ctx);
|
|
118
|
+
if (!options.dryRun && previous?.adminUrl && !ctx.workersBuilds)
|
|
119
|
+
await downgradeGuard(options, previous.adminUrl, secrets.known.CLOUDHAL_DEPLOY_TOKEN, log);
|
|
106
120
|
const settings = await deploySettings(ctx);
|
|
107
121
|
const { plan, schedules, observability } = settings;
|
|
108
122
|
const site = await buildSite(ctx, { withIds, analytics, comments, observability });
|
|
@@ -163,7 +177,8 @@ export async function deploy(options ) {
|
|
|
163
177
|
}
|
|
164
178
|
|
|
165
179
|
const { siteUrl, adminUrl, scheduler } = await shipWorkers(ctx, {
|
|
166
|
-
secrets,
|
|
180
|
+
siteSecrets: secrets.site,
|
|
181
|
+
adminSecrets: secrets.admin,
|
|
167
182
|
admin,
|
|
168
183
|
prepareAdmin,
|
|
169
184
|
scheduler: settings.scheduler,
|
|
@@ -184,18 +199,21 @@ export async function deploy(options ) {
|
|
|
184
199
|
plan,
|
|
185
200
|
});
|
|
186
201
|
const pushed = await afterDeploy(ctx, { siteUrl, adminUrl });
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
comments,
|
|
191
|
-
|
|
192
|
-
});
|
|
202
|
+
// Workers Builds knows no previous deploy, nor (after the first) the purge token: no purge
|
|
203
|
+
const purgeToken = secrets.known.CLOUDHAL_PURGE_TOKEN;
|
|
204
|
+
const { purged, analyticsChanged, commentsChanged } = purgeToken
|
|
205
|
+
? await purgeIfSwitched(ctx, { siteUrl, analytics, comments, purgeToken })
|
|
206
|
+
: { purged: false, analyticsChanged: false, commentsChanged: false };
|
|
193
207
|
|
|
194
208
|
// First deploy: the owner account can only be created through this one-time link.
|
|
209
|
+
const setupToken = secrets.known.CLOUDHAL_SETUP_TOKEN;
|
|
210
|
+
const first = pushed && adminUrl;
|
|
195
211
|
const setupUrl =
|
|
196
|
-
|
|
197
|
-
? `${adminUrl.replace(/\/$/, "")}/login#setup=${
|
|
212
|
+
first && setupToken && !ctx.workersBuilds
|
|
213
|
+
? `${adminUrl.replace(/\/$/, "")}/login#setup=${setupToken}`
|
|
198
214
|
: undefined;
|
|
215
|
+
const instructions =
|
|
216
|
+
first && ctx.workersBuilds ? setupInstructions(adminUrl, names.admin) : undefined;
|
|
199
217
|
return {
|
|
200
218
|
dryRun: false,
|
|
201
219
|
names,
|
|
@@ -205,6 +223,7 @@ export async function deploy(options ) {
|
|
|
205
223
|
adminUrl,
|
|
206
224
|
pushed,
|
|
207
225
|
...(setupUrl ? { setupUrl } : {}),
|
|
226
|
+
...(instructions ? { setupInstructions: instructions } : {}),
|
|
208
227
|
...(cfg.domains?.site ? { customSiteUrl: `https://${cfg.domains.site}` } : {}),
|
|
209
228
|
...(cfg.domains?.studio ? { customStudioUrl: `https://${cfg.domains.studio}` } : {}),
|
|
210
229
|
scheduler,
|
|
@@ -9,12 +9,10 @@ import {
|
|
|
9
9
|
writeFile,
|
|
10
10
|
} from "node:fs/promises";
|
|
11
11
|
import path from "node:path";
|
|
12
|
-
import { seedStarterSite } from "@cloudhal/core-ts";
|
|
13
|
-
import { openLocalStore } from "@cloudhal/core-ts/local";
|
|
14
12
|
import { assetDir, materializeDependencies, monorepoRoot, monorepoVersions } from "../assets.js";
|
|
15
|
-
import { loadThemeSettings } from "../blocks.js";
|
|
16
13
|
import { writeProjectConfig } from "../config.js";
|
|
17
14
|
import { pnpmWorkspaceYaml } from "../project-setup.js";
|
|
15
|
+
import { seedProjectSite } from "../seed-site.js";
|
|
18
16
|
import { starterFilter } from "../starter-files.js";
|
|
19
17
|
|
|
20
18
|
|
|
@@ -109,34 +107,16 @@ export async function init(options ) {
|
|
|
109
107
|
await writeFile(path.join(projectDir, "pnpm-workspace.yaml"), pnpmWorkspaceYaml());
|
|
110
108
|
|
|
111
109
|
const dataDir = ".cloudhal";
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
config: { ...(seeded.theme.config ?? {}), motion: "standard" },
|
|
125
|
-
},
|
|
126
|
-
})
|
|
127
|
-
: seeded;
|
|
128
|
-
// RFC-0006 owner decision 4: new projects record cookie-less analytics by default.
|
|
129
|
-
// RFC-0007 §15.4: reader comments are available to the deployment, but stay off for the site
|
|
130
|
-
// until someone turns them on in Studio (D4).
|
|
131
|
-
await writeProjectConfig(projectDir, {
|
|
132
|
-
siteId: site.id,
|
|
133
|
-
dataDir,
|
|
134
|
-
analytics: { enabled: true },
|
|
135
|
-
comments: { available: true },
|
|
136
|
-
});
|
|
137
|
-
log(`seeded site ${site.id} (${name}) in ${dataDir}/`);
|
|
138
|
-
return { projectDir, siteId: site.id, linked: repo !== undefined };
|
|
139
|
-
} finally {
|
|
140
|
-
await local.close();
|
|
141
|
-
}
|
|
110
|
+
const site = await seedProjectSite(projectDir, { dataDir, name, slug });
|
|
111
|
+
// RFC-0006 owner decision 4: new projects record cookie-less analytics by default.
|
|
112
|
+
// RFC-0007 §15.4: reader comments are available to the deployment, but stay off for the site
|
|
113
|
+
// until someone turns them on in Studio (D4).
|
|
114
|
+
await writeProjectConfig(projectDir, {
|
|
115
|
+
siteId: site.id,
|
|
116
|
+
dataDir,
|
|
117
|
+
analytics: { enabled: true },
|
|
118
|
+
comments: { available: true },
|
|
119
|
+
});
|
|
120
|
+
log(`seeded site ${site.id} (${name}) in ${dataDir}/`);
|
|
121
|
+
return { projectDir, siteId: site.id, linked: repo !== undefined };
|
|
142
122
|
}
|
|
@@ -90,7 +90,8 @@ export function chromeRenderer(
|
|
|
90
90
|
);
|
|
91
91
|
const endpoint = new Promise ((resolve, reject) => {
|
|
92
92
|
let err = "";
|
|
93
|
-
|
|
93
|
+
// A cold start on a busy CI runner can take well over 10 s; a warm one is under a second.
|
|
94
|
+
const timer = setTimeout(() => reject(new Error("Chrome did not start")), 30_000);
|
|
94
95
|
child.stderr?.on("data", (chunk ) => {
|
|
95
96
|
err += chunk.toString();
|
|
96
97
|
const m = /DevTools listening on (ws:\/\/\S+)/.exec(err);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** The starter site a new project starts with (`cloudhal init`, and a deploy from Workers Builds). */
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { seedStarterSite } from "@cloudhal/core-ts";
|
|
5
|
+
import { openLocalStore } from "@cloudhal/core-ts/local";
|
|
6
|
+
import { loadThemeSettings } from "./blocks.js";
|
|
7
|
+
|
|
8
|
+
export async function seedProjectSite(
|
|
9
|
+
projectDir ,
|
|
10
|
+
input ,
|
|
11
|
+
) {
|
|
12
|
+
const local = await openLocalStore(path.join(projectDir, input.dataDir));
|
|
13
|
+
try {
|
|
14
|
+
const seeded = await seedStarterSite(local.store, {
|
|
15
|
+
name: input.name,
|
|
16
|
+
slug: input.slug,
|
|
17
|
+
...(input.siteId ? { siteId: input.siteId } : {}),
|
|
18
|
+
});
|
|
19
|
+
// RFC-0024 decision 4: new sites start with standard motion when the theme offers it; readers
|
|
20
|
+
// who ask for reduced motion still get less, and existing sites keep theirs.
|
|
21
|
+
const settings = await loadThemeSettings(projectDir);
|
|
22
|
+
return settings && "motion" in settings.properties
|
|
23
|
+
? await local.store.updateSite({
|
|
24
|
+
...seeded,
|
|
25
|
+
theme: {
|
|
26
|
+
...seeded.theme,
|
|
27
|
+
config: { ...(seeded.theme.config ?? {}), motion: "standard" },
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
: seeded;
|
|
31
|
+
} finally {
|
|
32
|
+
await local.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudhal/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "cloudhal — create, run and build Cloudhal sites locally",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://cloudhal.dev",
|
|
@@ -27,16 +27,16 @@
|
|
|
27
27
|
"README.md"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@cloudhal/agent-runtime": "0.
|
|
31
|
-
"@cloudhal/analytics": "0.
|
|
32
|
-
"@cloudhal/api": "0.
|
|
33
|
-
"@cloudhal/cloudflare": "0.
|
|
34
|
-
"@cloudhal/comments": "0.
|
|
35
|
-
"@cloudhal/core-contracts": "0.
|
|
36
|
-
"@cloudhal/core-ts": "0.
|
|
37
|
-
"@cloudhal/mcp-server": "0.
|
|
38
|
-
"@cloudhal/schedules": "0.
|
|
39
|
-
"@cloudhal/storage": "0.
|
|
30
|
+
"@cloudhal/agent-runtime": "0.2.0",
|
|
31
|
+
"@cloudhal/analytics": "0.2.0",
|
|
32
|
+
"@cloudhal/api": "0.2.0",
|
|
33
|
+
"@cloudhal/cloudflare": "0.2.0",
|
|
34
|
+
"@cloudhal/comments": "0.2.0",
|
|
35
|
+
"@cloudhal/core-contracts": "0.2.0",
|
|
36
|
+
"@cloudhal/core-ts": "0.2.0",
|
|
37
|
+
"@cloudhal/mcp-server": "0.2.0",
|
|
38
|
+
"@cloudhal/schedules": "0.2.0",
|
|
39
|
+
"@cloudhal/storage": "0.2.0",
|
|
40
40
|
"@hono/node-server": "^2.1.4",
|
|
41
41
|
"better-sqlite3": "13.0.3"
|
|
42
42
|
},
|