@cloudhal/cli 0.1.1 → 0.3.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 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.1.1",
18
- "@cloudhal/core-ts": "0.1.1",
17
+ "@cloudhal/astro-renderer": "0.3.0",
18
+ "@cloudhal/core-ts": "0.3.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.1.1"
24
+ "@cloudhal/cli": "0.3.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "wrangler": "^4.148.0",
28
- "@cloudhal/cloudflare": "0.1.1"
28
+ "@cloudhal/cloudflare": "0.3.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
- const loaded = await loadDeploy({ ...options, name: inst.state.name });
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
- let databaseId = existing.databaseId;
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
- secrets: DeploySecrets;
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 { secrets, admin } = input;
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 writeSecret = async (file , value ) => {
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 writeSecret(siteSecretsFile, {
55
- CLOUDHAL_PREVIEW_SECRET: secrets.CLOUDHAL_PREVIEW_SECRET,
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", "--secrets-file", siteSecretsFile], options.projectDir);
62
+ siteDeploy = await must(run, ["deploy", ...siteArgs], options.projectDir);
63
63
  log(`deploying ${names.admin}`);
64
- const adminArgs = ["deploy", "-c", admin.configPath, "--secrets-file", adminSecretsFile];
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 ignores holds and gates over open releases
103
- const secrets = await loadOrCreateSecrets(ctx.dir);
104
- if (!options.dryRun && previous?.adminUrl)
105
- await downgradeGuard(options, previous.adminUrl, secrets.CLOUDHAL_DEPLOY_TOKEN, log);
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
- const { purged, analyticsChanged, commentsChanged } = await purgeIfSwitched(ctx, {
188
- siteUrl,
189
- analytics,
190
- comments,
191
- purgeToken: secrets.CLOUDHAL_PURGE_TOKEN,
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
- pushed && adminUrl
197
- ? `${adminUrl.replace(/\/$/, "")}/login#setup=${secrets.CLOUDHAL_SETUP_TOKEN}`
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 local = await openLocalStore(path.join(projectDir, dataDir));
113
- try {
114
- const seeded = await seedStarterSite(local.store, { name, slug });
115
- // RFC-0024 decision 4: new sites start with standard motion when the theme offers it; readers
116
- // who ask for reduced motion still get less, and existing sites keep theirs.
117
- const settings = await loadThemeSettings(projectDir);
118
- const site =
119
- settings && "motion" in settings.properties
120
- ? await local.store.updateSite({
121
- ...seeded,
122
- theme: {
123
- ...seeded.theme,
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
- const timer = setTimeout(() => reject(new Error("Chrome did not start")), 10_000);
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,7 @@
1
+ import type { Site } from "@cloudhal/core-contracts";
2
+ export declare function seedProjectSite(projectDir: string, input: {
3
+ dataDir: string;
4
+ name: string;
5
+ slug: string;
6
+ siteId?: string;
7
+ }): Promise<Site>;
@@ -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.1.1",
3
+ "version": "0.3.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.1.1",
31
- "@cloudhal/analytics": "0.1.1",
32
- "@cloudhal/api": "0.1.1",
33
- "@cloudhal/cloudflare": "0.1.1",
34
- "@cloudhal/comments": "0.1.1",
35
- "@cloudhal/core-contracts": "0.1.1",
36
- "@cloudhal/core-ts": "0.1.1",
37
- "@cloudhal/mcp-server": "0.1.1",
38
- "@cloudhal/schedules": "0.1.1",
39
- "@cloudhal/storage": "0.1.1",
30
+ "@cloudhal/agent-runtime": "0.3.0",
31
+ "@cloudhal/analytics": "0.3.0",
32
+ "@cloudhal/api": "0.3.0",
33
+ "@cloudhal/cloudflare": "0.3.0",
34
+ "@cloudhal/comments": "0.3.0",
35
+ "@cloudhal/core-contracts": "0.3.0",
36
+ "@cloudhal/core-ts": "0.3.0",
37
+ "@cloudhal/mcp-server": "0.3.0",
38
+ "@cloudhal/schedules": "0.3.0",
39
+ "@cloudhal/storage": "0.3.0",
40
40
  "@hono/node-server": "^2.1.4",
41
41
  "better-sqlite3": "13.0.3"
42
42
  },