diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..a07f497 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,86 @@ +--- +name: release +description: Cut a Scratchwork release end to end — version bump PR, tag, GitHub Release, npm publish, homepage republish, install smoke test. Use when asked to release, ship, or cut vX.Y.Z. +--- + +# Release + +Drive the release loop in `RELEASING.md` (the normative doc — read it first; if it +and this skill disagree, follow it and fix the drift in the same PR). One lockstep +version for the whole repo, one tag per release. + +**Confirmation gates:** pushing the tag, publishing to npm, and republishing the +homepage are public and effectively irreversible. Pause and confirm with the user +before each one, unless they explicitly asked for an unattended release up front. +Everything else (branches, PRs, dry runs) proceeds without asking. + +## Procedure + +### 1. Preflight + +- Start from a clean, up-to-date `main` (`git status`, `git pull`). +- Determine the version. If the user didn't give one, propose it from the + `## Unreleased` section of `CHANGELOG.md` (breaking → minor while pre-1.0, + otherwise patch) and confirm. +- `## Unreleased` must have real content; if it's empty, stop and ask what this + release is. +- `gh auth status` and `npm whoami` must both succeed. Fail fast, not at step 5. + +### 2. Bump PR + +- Branch: `git checkout -b release-vX.Y.Z`. +- `bun scripts/set-version.ts X.Y.Z` (stamps root + every workspace). +- In `CHANGELOG.md`: retitle `## Unreleased` to `## vX.Y.Z` and add a fresh empty + `## Unreleased` above it. +- `bun run ci` locally (enforces lockstep; requires Docker running for the e2e + LocalStack lane). +- Commit, push, `gh pr create`. Watch CI with `gh pr checks --watch`. + +### 3. Merge and tag + +- **Gate:** confirm the user is ready to ship, then merge the PR + (`gh pr merge --merge`), pull `main`, and verify HEAD carries the bump + (`package.json` version is X.Y.Z). +- **Gate passed above covers the tag too:** `git tag vX.Y.Z && git push origin vX.Y.Z`. +- The tag triggers `.github/workflows/release.yml`. Watch it + (`gh run watch --exit-status`); on success verify the Release exists with the four + `*.tar.gz` archives + `checksums.txt`: `gh release view vX.Y.Z`. + +### 4. npm publish + +- Publish from a clean checkout of the tag, never the working tree. Use a scratch + clone/worktree checked out at `vX.Y.Z` (the script itself refuses dirty trees and + untagged HEADs). +- Dry-run first: `bun scripts/publish-packages.ts --dry-run` — review the five + package names and the version. +- **Gate:** confirm, then `bun scripts/publish-packages.ts`. If a mid-sequence + publish fails, report which packages went live and which didn't; already-published + versions cannot be re-pushed, so the fix is forward (patch release), not retry. + +### 5. Homepage republish + +- **Gate:** confirm, then `scratchwork publish scratchwork.dev/www --project www --public` + with production credentials, so `https://scratchwork.dev/install.sh` serves the current content. + This needs an interactive `scratchwork login` against production — if not logged + in, hand this step to the user rather than skipping it silently. + +### 6. Smoke test + +Run the real install path in a clean container (no Scratchwork, no repo): + +```sh +docker run --rm debian:stable-slim sh -c ' + apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null && + curl -fsSL https://scratchwork.dev/install.sh | bash && + "$HOME/.local/bin/scratchwork" --version' +``` + +Pass = it prints `X.Y.Z`. If the homepage step was deferred, smoke against the +GitHub Release directly instead by setting `SCRATCHWORK_DOWNLOAD_BASE` to +`https://github.com/scratch/scratchwork/releases` inside the container. + +## Report + +End with: version shipped, PR and Release URLs, the five npm package versions, +whether the homepage was republished, and the smoke-test output. List any step that +was skipped or handed to the user — never imply an unfinished release is done. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a04e40e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +# Release: triggered by pushing a tag vX.Y.Z (see RELEASING.md). Runs the same +# one gate as ci.yml — a release never ships what `bun run ci` wouldn't pass — +# then cross-compiles the CLI target matrix, packages the archives, and +# publishes a GitHub Release with the matching CHANGELOG.md section as notes. +# npm publishing stays a manual local step for now (scripts/publish-packages.ts). +name: release + +on: + push: + tags: ["v*"] + +# Creates the GitHub Release and uploads its assets. +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - run: bun install --frozen-lockfile + - run: bun run ci + - name: Assert the tag matches the lockstep version + run: | + version="$(bun -e 'console.log(JSON.parse(await Bun.file("package.json").text()).version)')" + if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then + echo "tag ${GITHUB_REF_NAME} does not match package.json version v${version}" >&2 + echo "bump with \`bun scripts/set-version.ts\` and re-tag" >&2 + exit 1 + fi + - name: Cross-compile the CLI target matrix + run: cd cli && bun build.js --all-targets + - name: Package archives and checksums + run: bun scripts/package-release.ts + - name: Extract release notes from CHANGELOG.md + run: bun scripts/release-notes.ts "${GITHUB_REF_NAME#v}" > release/notes.md + - name: Create the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" \ + release/*.tar.gz release/checksums.txt \ + --title "${GITHUB_REF_NAME}" \ + --notes-file release/notes.md diff --git a/.gitignore b/.gitignore index 1ceb2e4..aada4e1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,12 @@ node_modules/ dist/ +# Release staging written by scripts/package-release.ts and +# scripts/build-packages.ts; assets upload to GitHub Releases / npm, never git. +# Anchored to the repo root so directories like .claude/skills/release/ stay +# trackable. +/release/ + # Local environment files. Keep .env.example files committed. **/.env **/.env.* diff --git a/AGENTS.md b/AGENTS.md index 9aa3f82..3a97cd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,19 +15,27 @@ Package names are `@scratchwork/` for everything under `server/` and `deplo importable by both cli and server. - `cli/` — the `scratchwork` CLI, built with Effect: dev server with hot reload, publishing, login. -- `server/` — deploy tooling scripts only; the server itself lives in its sub-workspaces: +- `server/` — the server workspaces (the directory itself is not a workspace): - `server/core/` — platform-neutral publishing server core: auth, routing, storage - contracts. + contracts, and the deploy-script tooling (`src/deploy/`). - `server/deploy-aws/` — AWS Lambda + S3/DynamoDB adapters. - `server/deploy-cloudflare/` — Cloudflare Worker + R2/D1 adapters. - `server/deploy-local/` — local Bun deploy target. - `deploy/*` — one deployment project per domain (generic-aws, cloudflare-vanilla, cloudflare-access, local-dev), each deployable with one command. +- `scratchwork.dev/` — the scratchwork.dev site: `server/` is its Cloudflare + deployment workspace (same shape as `deploy/*`), `www/` the homepage project + published to it (content, not a workspace). - `e2e/` — full-loop publish e2e: real server (local-dev, miniflare, LocalStack) driven by the real CLI, hermetic OAuth provider standing in for Google. -`examples/` and `notes/` are deliberately outside the gate: examples are user-facing -sample content, notes are working documents. Nothing in them is imported by shipped code. +`examples/`, `notes/`, and `scratchwork.dev/www/` are deliberately outside the gate: +examples are user-facing sample content, notes are working documents, and +`scratchwork.dev/www/` is the published homepage project (landing page plus the +`install.sh` / `install.md` entry points — the install script alone is exercised by +`scripts/check-install-sh.ts` in the gate). Nothing in them is imported by shipped code. +`scratchwork.dev/server/` is different: it is the deployment workspace for the +scratchwork.dev server, inside the gate like the `deploy/*` projects. ## Build and test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5c8b621 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +Newest first. Maintained by hand per release; the release workflow copies the +top released section into the GitHub Release notes (scripts/release-notes.ts). + +## Unreleased + +- Distribution: cross-platform CLI binaries on GitHub Releases, `install.sh` / + `install.md` served from scratchwork.dev, and the server packages + (`@scratchwork/shared`, `@scratchwork/server-core`, + `@scratchwork/server-deploy-{aws,cloudflare,local}`) published to npm as + built JS + type declarations. + +## v0.1.0 + +- Pre-distribution baseline: the `scratchwork` CLI (dev server, publish, + share), the single-file Markdown renderer, and the publishing server for + local, AWS, and Cloudflare deploy targets. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9c1baf6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Peter Koomen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 19ce861..f2de94f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Scratchwork + Scratchwork

Scratchwork is a local development tool for static HTML and Markdown artifacts created by your coding agent. @@ -58,7 +58,7 @@ changing to another directory. ## Working with Markdown -Scratchwork renders Markdown with an embedded default renderer, and Markdown files can reference React components from nearby component files. See [`docs/index.md`](docs/index.md) for live examples. +Scratchwork renders Markdown with an embedded default renderer, and Markdown files can reference React components from nearby component files. See [`scratchwork.dev/www/index.md`](scratchwork.dev/www/index.md) for live examples. To use the docs page as a starting point for your own project, run: diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..41c38b3 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,30 @@ +# Releasing Scratchwork + +One version for the whole repo (CLI binaries + npm packages), one tag per +release. Short enough to actually follow — or ask an agent to run the +`/release` skill (`.claude/skills/release/SKILL.md`), which drives this loop +with confirmation gates at the irreversible steps: + +1. **Bump:** `bun scripts/set-version.ts X.Y.Z` (stamps root + every + workspace; `bun run ci` enforces lockstep). +2. **Changelog:** retitle the `## Unreleased` section of `CHANGELOG.md` to + `## vX.Y.Z` and start a fresh `## Unreleased` above it. +3. **PR + merge:** open a PR with the bump, let ci pass, merge to main. +4. **Tag:** `git tag vX.Y.Z && git push origin vX.Y.Z` on the merge commit. + `.github/workflows/release.yml` runs the full gate, asserts the tag matches + the lockstep version, cross-compiles the four CLI targets, and publishes + the GitHub Release with archives + `checksums.txt` and the changelog + section as notes. +5. **npm:** from a clean checkout of the tag, with an authenticated npm CLI: + `bun scripts/publish-packages.ts` (add `--dry-run` first if in doubt). + Publishes `@scratchwork/shared`, `@scratchwork/server-core`, and the three + `@scratchwork/server-deploy-*` packages in dependency order. +6. **Homepage:** republish the install entry points so + `https://scratchwork.dev/install.sh` serves the current content: + `scratchwork publish scratchwork.dev/www --project www --public` (with the + production server credentials). Automating this in release.yml is a follow-up. +7. **Smoke:** on a machine (or clean container) without Scratchwork: + `curl -fsSL https://scratchwork.dev/install.sh | bash` and check + `scratchwork --version` prints X.Y.Z. + +Fix this document in the same PR whenever reality disagrees with it. diff --git a/bun.lock b/bun.lock index a210556..3580827 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "@effect/printer": "0.49.0", "@effect/printer-ansi": "0.49.0", "@effect/typeclass": "0.40.0", + "@scratchwork/shared": "workspace:*", "effect": "3.21.4", }, "devDependencies": { @@ -105,9 +106,12 @@ "esbuild": "^0.21.5", }, }, - "server": { - "name": "scratchwork-server", + "scratchwork.dev/server": { + "name": "@scratchwork/deploy-scratchwork-dev", "version": "0.1.0", + "dependencies": { + "@scratchwork/server-deploy-cloudflare": "workspace:*", + }, "devDependencies": { "@types/bun": "^1.3.14", "typescript": "^6.0.3", @@ -118,6 +122,7 @@ "version": "0.1.0", "dependencies": { "@effect/platform": "0.96.2", + "@scratchwork/shared": "workspace:*", "effect": "3.21.4", }, "devDependencies": { @@ -147,6 +152,7 @@ "dependencies": { "@effect/platform": "0.96.2", "@scratchwork/server-core": "workspace:*", + "@scratchwork/shared": "workspace:*", "cloudflare": "^6.5.0", "effect": "3.21.4", }, @@ -450,6 +456,8 @@ "@scratchwork/deploy-local-dev": ["@scratchwork/deploy-local-dev@workspace:deploy/local-dev"], + "@scratchwork/deploy-scratchwork-dev": ["@scratchwork/deploy-scratchwork-dev@workspace:scratchwork.dev/server"], + "@scratchwork/e2e": ["@scratchwork/e2e@workspace:e2e"], "@scratchwork/server-core": ["@scratchwork/server-core@workspace:server/core"], @@ -632,8 +640,6 @@ "scratchwork-renderer": ["scratchwork-renderer@workspace:renderer"], - "scratchwork-server": ["scratchwork-server@workspace:server"], - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], diff --git a/cli/build.js b/cli/build.js index f5f77e3..0d7fee2 100644 --- a/cli/build.js +++ b/cli/build.js @@ -8,28 +8,41 @@ * compiler embeds into the binary, so it runs anywhere with no renderer * source or dist on disk. * + * With --all-targets, cross-compiles the release target matrix instead + * (decision 4 in notes/distribution-plan.md) into cli/dist/scratchwork--. + * The default (no flag) build is unchanged so `bun run ci` cost stays the same. + * * Requires the renderer's deps to be installed (cd ../renderer && bun install). */ import { buildDist } from "../renderer/build.js"; +import { RELEASE_TARGETS } from "../scripts/release-targets"; import { mkdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const here = dirname(fileURLToPath(import.meta.url)); const OUT_DIR = join(here, "dist"); -const OUT_BIN = join(OUT_DIR, "scratchwork"); // 1. Build the renderer shell the binary will embed through shared/src/site. await buildDist(); -// 2. Compile src/index.ts into a binary. +// 2. Compile src/index.ts — host binary by default, the matrix with --all-targets. mkdirSync(OUT_DIR, { recursive: true }); -const proc = Bun.spawnSync( - ["bun", "build", join(here, "src", "index.ts"), "--compile", "--outfile", OUT_BIN], - { cwd: here, stdout: "inherit", stderr: "inherit" }, -); -if (!proc.success) { - console.error("cli build: `bun build --compile` failed"); - process.exit(1); +const builds = process.argv.includes("--all-targets") + ? RELEASE_TARGETS.map((target) => ({ + outfile: join(OUT_DIR, `scratchwork-${target}`), + extraArgs: [`--target=bun-${target}`], + })) + : [{ outfile: join(OUT_DIR, "scratchwork"), extraArgs: [] }]; + +for (const { outfile, extraArgs } of builds) { + const proc = Bun.spawnSync( + ["bun", "build", join(here, "src", "index.ts"), "--compile", ...extraArgs, "--outfile", outfile], + { cwd: here, stdout: "inherit", stderr: "inherit" }, + ); + if (!proc.success) { + console.error(`cli build: \`bun build --compile ${extraArgs.join(" ")}\` failed`); + process.exit(1); + } + console.log(`Built ${outfile.slice(dirname(here).length + 1)} (standalone binary, renderer shell embedded)`); } -console.log(`Built cli/dist/scratchwork (standalone binary, renderer shell embedded)`); diff --git a/cli/package.json b/cli/package.json index d8fcc79..395c80b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,6 +20,7 @@ "@effect/printer": "0.49.0", "@effect/printer-ansi": "0.49.0", "@effect/typeclass": "0.40.0", + "@scratchwork/shared": "workspace:*", "effect": "3.21.4" }, "files": [ diff --git a/cli/src/api.ts b/cli/src/api.ts index a05a842..a970c12 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -29,7 +29,7 @@ import * as Either from "effect/Either"; import * as Option from "effect/Option"; import type * as ParseResult from "effect/ParseResult"; import * as Schema from "effect/Schema"; -import { ApiErrorBodySchema, CLI_TOKEN_EXCHANGE_PATH, ScratchworkApi } from "../../shared/src/publish/api"; +import { ApiErrorBodySchema, CLI_TOKEN_EXCHANGE_PATH, ScratchworkApi } from "@scratchwork/shared/publish/api"; import { readAuthToken, readCfToken } from "./auth"; import { CliError, errorMessage } from "./errors"; diff --git a/cli/src/assets.d.ts b/cli/src/assets.d.ts index 3916936..8cb95c7 100644 --- a/cli/src/assets.d.ts +++ b/cli/src/assets.d.ts @@ -1,17 +1,12 @@ /* * Type declarations for Bun's `with { type: "text" }` imports, which load - * markdown, SVG, and text assets as plain strings. + * markdown and text assets as plain strings. */ declare module "*.md" { const text: string; export default text; } -declare module "*.svg" { - const text: string; - export default text; -} - declare module "*.txt" { const text: string; export default text; diff --git a/cli/src/auth.ts b/cli/src/auth.ts index fb1f1f4..0a809e0 100644 --- a/cli/src/auth.ts +++ b/cli/src/auth.ts @@ -12,8 +12,8 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Schema from "effect/Schema"; import { homedir } from "node:os"; -import { sha256Base64Url } from "../../shared/src/crypto/digest"; -import { isLoopbackHost } from "../../shared/src/util/url"; +import { sha256Base64Url } from "@scratchwork/shared/crypto/digest"; +import { isLoopbackHost } from "@scratchwork/shared/util/url"; import { CliError, errorMessage } from "./errors"; const AUTH_FILE = "auth.json"; diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index e38144a..0017de2 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -18,7 +18,7 @@ import * as Effect from "effect/Effect"; import { type CliTokenRequest, type CliTokenResponse, -} from "../../../shared/src/publish/api"; +} from "@scratchwork/shared/publish/api"; import { apiClient, mapApiErrors } from "../api"; import { generateLoginProof, diff --git a/cli/src/commands/projects.ts b/cli/src/commands/projects.ts index ac64240..a2e1cf9 100644 --- a/cli/src/commands/projects.ts +++ b/cli/src/commands/projects.ts @@ -13,7 +13,7 @@ import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import * as Either from "effect/Either"; import * as Encoding from "effect/Encoding"; -import { isSafeProjectIdentifier } from "../../../shared/src/site/identifiers"; +import { isSafeProjectIdentifier } from "@scratchwork/shared/site/identifiers"; import { apiClient, mapApiErrors } from "../api"; import { readAuthToken } from "../auth"; import { SKIPPED_DIRECTORIES } from "../dev/target"; diff --git a/cli/src/commands/publish.ts b/cli/src/commands/publish.ts index da67614..6bbc83d 100644 --- a/cli/src/commands/publish.ts +++ b/cli/src/commands/publish.ts @@ -11,14 +11,14 @@ import * as Path from "@effect/platform/Path"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; -import { decodedBase64ByteLength } from "../../../shared/src/encoding/base64"; +import { decodedBase64ByteLength } from "@scratchwork/shared/encoding/base64"; import { type PublishRequestBody, type PublishResponse, -} from "../../../shared/src/publish/api"; -import { PUBLISH_BUNDLE_VERSION, type PublishBundle } from "../../../shared/src/publish/bundle"; -import { isSafeProjectIdentifier, slugifyIdentifier } from "../../../shared/src/site/identifiers"; -import { isSafeSitePath, type SitePath } from "../../../shared/src/site/paths"; +} from "@scratchwork/shared/publish/api"; +import { PUBLISH_BUNDLE_VERSION, type PublishBundle } from "@scratchwork/shared/publish/bundle"; +import { isSafeProjectIdentifier, slugifyIdentifier } from "@scratchwork/shared/site/identifiers"; +import { isSafeSitePath, type SitePath } from "@scratchwork/shared/site/paths"; import { apiClient, mapApiErrors } from "../api"; import { readAuthToken } from "../auth"; import { openBrowser } from "../browser"; diff --git a/cli/src/commands/template.ts b/cli/src/commands/template.ts index 1d62eb8..2484755 100644 --- a/cli/src/commands/template.ts +++ b/cli/src/commands/template.ts @@ -8,7 +8,7 @@ import * as FileSystem from "@effect/platform/FileSystem"; import * as Path from "@effect/platform/Path"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -import { markMarkdownRenderer } from "../../../shared/src/site/marker"; +import { markMarkdownRenderer } from "@scratchwork/shared/site/marker"; import { CliError } from "../errors"; import { loadShell } from "../renderer/default"; import type { TemplateConfig } from "../types"; diff --git a/cli/src/dev/diagnostics.ts b/cli/src/dev/diagnostics.ts index d4740ea..5493c5f 100644 --- a/cli/src/dev/diagnostics.ts +++ b/cli/src/dev/diagnostics.ts @@ -7,13 +7,13 @@ import * as Effect from "effect/Effect"; import { collectComponentNames, componentFileCandidates, -} from "../../../shared/src/site/components"; -import { SiteFiles } from "../../../shared/src/site/files"; -import type { SitePath } from "../../../shared/src/site/paths"; +} from "@scratchwork/shared/site/components"; +import { SiteFiles } from "@scratchwork/shared/site/files"; +import type { SitePath } from "@scratchwork/shared/site/paths"; import type { RendererSource, SiteServeEvent, -} from "../../../shared/src/site/serve"; +} from "@scratchwork/shared/site/serve"; import { errorMessage } from "../errors"; import { problem, status } from "./output"; import type { DevState } from "./types"; diff --git a/cli/src/dev/live-reload.ts b/cli/src/dev/live-reload.ts index d94b8b1..4c02592 100644 --- a/cli/src/dev/live-reload.ts +++ b/cli/src/dev/live-reload.ts @@ -11,7 +11,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; -import type { HtmlTransform } from "../../../shared/src/site/html"; +import type { HtmlTransform } from "@scratchwork/shared/site/html"; import { errorMessage, CliError } from "../errors"; import { status } from "./output"; import type { DevState, ReloadPayload } from "./types"; diff --git a/cli/src/dev/server.ts b/cli/src/dev/server.ts index a9758b2..758a3bd 100644 --- a/cli/src/dev/server.ts +++ b/cli/src/dev/server.ts @@ -8,8 +8,8 @@ import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; import * as Effect from "effect/Effect"; -import { serveRequest } from "../../../shared/src/site/serve"; -import FIGURE_SVG from "../../../shared/assets/figure.svg" with { type: "text" }; +import { serveRequest } from "@scratchwork/shared/site/serve"; +import { FIGURE_SVG } from "@scratchwork/shared/assets/figure-svg.generated"; import { CliError, errorMessage } from "../errors"; import { loadShell } from "../renderer/default"; import * as SiteFilesLive from "./site-files"; diff --git a/cli/src/dev/site-files.ts b/cli/src/dev/site-files.ts index 87e02ef..0ffc06e 100644 --- a/cli/src/dev/site-files.ts +++ b/cli/src/dev/site-files.ts @@ -10,9 +10,9 @@ import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import * as Path from "@effect/platform/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { SiteFileError, SiteFiles } from "../../../shared/src/site/files"; -import type { SitePath } from "../../../shared/src/site/paths"; -import { isWithinRoot } from "../../../shared/src/util/fs"; +import { SiteFileError, SiteFiles } from "@scratchwork/shared/site/files"; +import type { SitePath } from "@scratchwork/shared/site/paths"; +import { isWithinRoot } from "@scratchwork/shared/util/fs"; /** Builds a SiteFiles service that reads from the given site root directory. */ export function layer( diff --git a/cli/src/dev/target.ts b/cli/src/dev/target.ts index 565d7a9..407df0b 100644 --- a/cli/src/dev/target.ts +++ b/cli/src/dev/target.ts @@ -7,8 +7,8 @@ import * as FileSystem from "@effect/platform/FileSystem"; import type { PlatformError } from "@effect/platform/Error"; import * as Path from "@effect/platform/Path"; import * as Effect from "effect/Effect"; -import { isMarkedMarkdownRenderer } from "../../../shared/src/site/marker"; -import { isSafeSitePath } from "../../../shared/src/site/paths"; +import { isMarkedMarkdownRenderer } from "@scratchwork/shared/site/marker"; +import { isSafeSitePath } from "@scratchwork/shared/site/paths"; import { CliError } from "../errors"; import type { DevTarget } from "./types"; diff --git a/cli/src/errors.ts b/cli/src/errors.ts index d1965e6..5ac75b8 100644 --- a/cli/src/errors.ts +++ b/cli/src/errors.ts @@ -4,7 +4,7 @@ */ import * as Data from "effect/Data"; -export { errorMessage } from "../../shared/src/util/errors"; +export { errorMessage } from "@scratchwork/shared/util/errors"; /** A user-facing CLI failure: `message` is printed to stderr, `code` becomes the exit code. */ export class CliError extends Data.TaggedError("CliError")<{ diff --git a/cli/src/project-config.ts b/cli/src/project-config.ts index 039afd9..b5f120e 100644 --- a/cli/src/project-config.ts +++ b/cli/src/project-config.ts @@ -15,7 +15,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Either from "effect/Either"; import * as Predicate from "effect/Predicate"; -import { isSafeProjectIdentifier } from "../../shared/src/site/identifiers"; +import { isSafeProjectIdentifier } from "@scratchwork/shared/site/identifiers"; import { resolveProjectByPath, type ResolvedProjectRef } from "./api"; import { normalizeServerUrl } from "./auth"; import { CliError } from "./errors"; diff --git a/cli/src/renderer/default.ts b/cli/src/renderer/default.ts index 38ddbe6..1cd635a 100644 --- a/cli/src/renderer/default.ts +++ b/cli/src/renderer/default.ts @@ -18,7 +18,7 @@ import { fileURLToPath } from "node:url"; import { defaultRendererHtml, defaultRendererSourceHash, -} from "../../../shared/src/site/default-renderer.generated.js"; +} from "@scratchwork/shared/site/default-renderer.generated.js"; const rendererRootUrl = new URL("../../../renderer/", import.meta.url); const rendererRoot = fileURLToPath(rendererRootUrl); diff --git a/deploy/cloudflare-access/tsconfig.json b/deploy/cloudflare-access/tsconfig.json index fd04b7e..4a03b30 100644 --- a/deploy/cloudflare-access/tsconfig.json +++ b/deploy/cloudflare-access/tsconfig.json @@ -21,7 +21,6 @@ "local.ts", "cloudflare-config.ts", "server-config.ts", - "../../server/core/src/assets.d.ts", "../../shared/src/**/*.ts", "../../shared/**/*.js" ] diff --git a/deploy/cloudflare-vanilla/tsconfig.json b/deploy/cloudflare-vanilla/tsconfig.json index fd04b7e..4a03b30 100644 --- a/deploy/cloudflare-vanilla/tsconfig.json +++ b/deploy/cloudflare-vanilla/tsconfig.json @@ -21,7 +21,6 @@ "local.ts", "cloudflare-config.ts", "server-config.ts", - "../../server/core/src/assets.d.ts", "../../shared/src/**/*.ts", "../../shared/**/*.js" ] diff --git a/deploy/generic-aws/tsconfig.json b/deploy/generic-aws/tsconfig.json index 0860dc6..c7ecfbc 100644 --- a/deploy/generic-aws/tsconfig.json +++ b/deploy/generic-aws/tsconfig.json @@ -20,7 +20,6 @@ "deploy.ts", "local.ts", "server-config.ts", - "../../server/core/src/assets.d.ts", "../../shared/src/**/*.ts", "../../shared/**/*.js" ] diff --git a/deploy/local-dev/tsconfig.json b/deploy/local-dev/tsconfig.json index 10e2e7d..e76bd50 100644 --- a/deploy/local-dev/tsconfig.json +++ b/deploy/local-dev/tsconfig.json @@ -18,7 +18,6 @@ }, "include": [ "local.ts", - "../../server/core/src/assets.d.ts", "../../shared/src/**/*.ts", "../../shared/**/*.js" ] diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 95f9959..2040229 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -18,7 +18,6 @@ }, "include": [ "src/**/*.ts", - "test/**/*.ts", - "../server/core/src/assets.d.ts" + "test/**/*.ts" ] } diff --git a/notes/distribution-plan.md b/notes/distribution-plan.md new file mode 100644 index 0000000..1fb6043 --- /dev/null +++ b/notes/distribution-plan.md @@ -0,0 +1,248 @@ +# Distributing Scratchwork: CLI binaries + npm packages + +**Goal:** anyone can install the `scratchwork` CLI with one command and deploy their own +server from published packages: + +1. CLI binaries versioned and released on GitHub Releases; +2. `https://scratchwork.dev/install.sh` (humans) and `https://scratchwork.dev/install.md` + (agents) as the install entry points; +3. the server packages (`@scratchwork/shared`, `@scratchwork/server-core`, + `@scratchwork/server-deploy-{aws,cloudflare,local}`) published to npm. + +Status legend: `[ ]` not started · `[~]` in progress · `[x]` done + +## Where we are today + +- **CLI:** `cli/build.js` compiles `src/index.ts` into a single self-contained binary + (`bun build --compile`) with the renderer shell embedded — host platform only, no + target matrix. Version comes from `cli/package.json` (`0.1.0`) via `import pkg from + "../package.json"`, already wired to `scratchwork version`, `--version`, and `-v`. + So the binary already embeds its version at build time; nothing new needed there. +- **Packages:** every workspace is `private: true` at `0.1.0`, inter-deps use + `workspace:*`, and `exports` point at TypeScript source (`./src/*.ts`). Nothing is + publishable as-is. +- **Automation:** the only workflow is `ci.yml` (exactly `bun run ci`). No release + workflow, no tags, no changelog. +- **Docs already promise the endpoint:** `README.md` and `docs/index.md` both say + `curl -fsSL https://scratchwork.dev/install.sh | bash`, but nothing produces or + serves `install.sh`, and there are no releases to download. +- **scratchwork.dev is itself a published project.** The homepage domains serve an + ordinary project (`homepageDomains`/`homepageProject`, see + `deploy/cloudflare-vanilla`); `docs/` is the homepage content, published with + `scratchwork publish`. So the install assets ship by adding files to `docs/` and + republishing — no server change needed to host them. +- **Raw markdown serving already works:** requesting a `.md` path directly returns the + raw markdown as `text/plain` (`RawMarkdownServed` in `shared/src/site/serve.ts`), + while extensionless routes get the rendered shell. `install.md` therefore serves + agent-readable raw markdown for free. `.sh` content-type needs verifying in + `shared/src/site/content.ts` (any type works for `curl | bash`, but `text/plain` + beats a download prompt for humans who open it in a browser). + +## Decisions (recommendations inline; flag disagreement before Phase 1) + +1. **Lockstep versioning.** One version for the whole repo: CLI binaries and all npm + packages share it, and a git tag `vX.Y.Z` on main is the single release trigger. + Independent per-package versions buy nothing at this stage and complicate the + `workspace:*` story. Source of truth: the `version` field in each `package.json`, + kept in lockstep by a version-bump script and a mechanized ci check. +2. **npm packages ship built JS + `.d.ts`, not TypeScript source.** (Pete, 2026-07-21; + supersedes the earlier ship-TS-source recommendation.) Node deliberately refuses to + type-strip files under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), + so TS-source packages would be Bun/bundler-only — and users should be able to deploy + with plain Node if they want. Each publishable package gets a `tsc` emit (ESM JS + + declarations) into `dist/`, and the published `exports` point at `dist/`. In-repo + development is unchanged: workspace resolution keeps consuming `src/*.ts` directly. +3. **What publishes where.** GitHub Releases: the CLI binary only. npm: `shared`, + `server/core`, `server/deploy-aws`, `server/deploy-cloudflare`, `server/deploy-local`. + Never published: `renderer` (embedded in the CLI), `deploy/*` (per-domain instances — + they become the template users copy), `e2e`, the `server` tooling workspace. +4. **Cross-compile on one runner.** `bun build --compile --target=...` cross-compiles + from a single Linux runner; no per-OS build matrix. Targets: `bun-darwin-arm64`, + `bun-darwin-x64`, `bun-linux-x64`, `bun-linux-arm64`. Windows and musl (Alpine) + are explicit non-goals for v0; install.sh says so clearly when it can't match. +5. **Install destination `~/.local/bin`, no sudo.** Overridable with + `SCRATCHWORK_INSTALL_DIR`; version pinnable with `SCRATCHWORK_VERSION`. The script + never escalates privileges. + +## Resolved questions (Pete, 2026-07-20) + +- **Repo visibility:** `github.com/scratch/scratchwork` is public (verified via + `gh repo view`), so GitHub Release assets are publicly downloadable — install.sh can + fetch them directly. +- **npm scope:** Pete owns the `@scratchwork` org and the local npm CLI is + authenticated (`npm whoami` → `koomen`, org owner). First releases publish from + Pete's machine; moving publishing into the release workflow (granular `NPM_TOKEN` in + Actions secrets) is a follow-up once the manual loop is boring. +- **macOS code signing:** unsigned binaries are accepted for v0. `curl | bash` installs + don't set the quarantine attribute; revisit notarization only if browser downloads + become a supported path. + +## Plan + +### Phase 1 — Version plumbing + +`[x]` A `bun scripts/set-version.ts ` script that stamps `version` in the root +and every workspace `package.json` in lockstep. No other duty — tagging and changelog +stay manual and visible. + +`[x]` Mechanized lockstep check in `bun run ci` (natural home: `check-boundaries.ts` or +a sibling script): every workspace `version` equals the root version. A drifted bump +fails the gate. + +`[x]` `CHANGELOG.md` at root, maintained by hand per release, newest first. The release +workflow copies the top section into the GitHub Release notes. + +Acceptance: `bun scripts/set-version.ts 0.2.0` updates every package.json; `bun run ci` +fails if any one is edited out of lockstep. + +### Phase 2 — Cross-platform CLI builds + GitHub Release workflow + +`[x]` Extend `cli/build.js` with a target matrix mode (e.g. `bun build.js --all-targets`): +builds the renderer once, then `bun build --compile --target=bun--` per target +into `cli/dist/scratchwork--`. Default (no flag) behavior stays exactly as +today so `bun run ci` cost doesn't change. + +`[x]` `scripts/package-release.ts`: tars each binary as +`scratchwork-v--.tar.gz` (binary named `scratchwork` inside) and +writes `checksums.txt` (SHA-256 of each archive). Tarball rather than bare binary so +the executable bit survives and the name inside is stable. + +`[x]` `.github/workflows/release.yml`, triggered by tags matching `v*`: +1. checkout, pinned Bun, `bun install --frozen-lockfile`; +2. `bun run ci` (the same one gate — release never ships what ci wouldn't pass); +3. assert the tag matches the package.json version (fail loudly on mismatch); +4. build all targets, package, `gh release create` with archives + `checksums.txt` + and the changelog section as notes. +`ci.yml` is untouched; the gate stays exactly `bun run ci`. + +Acceptance: pushing tag `v0.2.0` produces a GitHub Release with four archives + +checksums, and `gh release download v0.2.0` on a mac yields a runnable binary that +prints `0.2.0` from `scratchwork --version`. + +### Phase 3 — install.sh and install.md on scratchwork.dev + +`[x]` `docs/install.sh` — POSIX sh, `set -euf`. Detects `uname -s`/`-m`, maps to a +release target (clear error for unsupported platforms, mentioning Windows/musl +explicitly), downloads from the stable no-API URL +`https://github.com/scratch/scratchwork/releases/latest/download/` (or the +pinned `SCRATCHWORK_VERSION`), verifies SHA-256 against `checksums.txt` (uses +`shasum -a 256` or `sha256sum`, whichever exists), installs to +`$SCRATCHWORK_INSTALL_DIR` (default `~/.local/bin`), and prints PATH guidance only when +the directory isn't on `$PATH`. Re-running upgrades in place. No sudo, ever. + +`[x]` `docs/install.md` — the agent-facing page: what Scratchwork is (one paragraph), +the one-liner, the manual steps (exact URL pattern per platform, checksum verification, +chmod, PATH), version pinning, uninstall (`rm` one binary), and a pointer to +`scratchwork --help`. Written to be executed by an agent without fetching anything else. + +`[x]` Verify/extend the `.sh` content-type mapping in `shared/src/site/content.ts` +(want `text/plain; charset=utf-8` or `text/x-shellscript`), with a serving test. Add a +test asserting a published `install.md` round-trips raw (the `RawMarkdownServed` path) +— that behavior is now load-bearing for distribution. + +`[x]` Mechanized checks for the script itself inside `bun run ci`: `sh -n docs/install.sh` +(syntax) plus a unit test driving the platform-mapping + download against a local HTTP +fixture standing in for GitHub (same hermetic spirit as the e2e OAuth stand-in). No +network in ci. + +`[x]` Release step: republish the homepage project after each release +(`scratchwork publish docs --project www ...`). Manual at first, listed in RELEASING.md; +automating it in release.yml (server credentials as secrets) is a follow-up once the +manual loop is boring. + +Acceptance: with a release published, `curl -fsSL https://scratchwork.dev/install.sh | bash` +on clean macOS and Linux machines installs the latest binary and `scratchwork --version` +prints the released version; `curl https://scratchwork.dev/install.md` returns raw +markdown an agent can follow end-to-end. + +### Phase 4 — npm packages + +`[x]` A build step per publishable package: `tsc` emit of ESM JS + `.d.ts` into +`dist/`, driven by one shared script (`scripts/build-packages.ts` or per-package +`build`). Published `exports`/`types` point at `dist/`; in-repo `exports` keep pointing +at `src/*.ts` so workspace dev needs no build. Use `publishConfig` to swap the fields at +publish time (verify `bun publish`/`bun pm pack` applies `publishConfig.exports`; if +not, the release script rewrites package.json at pack time). `dist/` stays gitignored; +the release flow builds before packing. + +`[x]` Make the five packages publishable: drop `private: true`; add `license`, +`repository` (with `directory`), `files` (dist + README, no tests or src), and a short +README each (what it is, works under Node ≥ 22 or Bun, minimal usage). Root, +`renderer`, `cli`, `server` (tooling), `deploy/*`, and `e2e` stay private. + +`[x]` Verify `bun publish` rewrites `workspace:*` to the concrete lockstep version in +the published tarball (it should; check with `bun pm pack` + inspect). If it doesn't, +the release script rewrites versions at publish time. + +`[x]` Dry-run check in ci or release workflow: build, then `bun pm pack` each +publishable package and assert the tarball contains `dist/` with `.js` + `.d.ts`, its +`exports` resolve to `dist/`, and it has no test files and no `workspace:` strings. + +`[x]` A `scripts/publish-packages.ts` release step that runs `bun publish` for each +package in dependency order (shared → server-core → deploy adapters), refusing to run +on a dirty tree or when the checked-out tag doesn't match the lockstep version. Run +locally with Pete's authenticated npm CLI for the first releases. Follow-up (not this +plan): move it into `release.yml` with a granular `NPM_TOKEN` and `--provenance` +(free supply-chain attestation from Actions OIDC now that the repo is public). + +`[x]` Consumer walkthrough in `server/README.md` (or `docs/`): "deploy your own" — a +fresh directory, `bun add @scratchwork/server-deploy-cloudflare`, copy the config shape +from `deploy/cloudflare-vanilla`, one command deploy. A `scratchwork server init` +scaffolder is explicitly future work, not this plan. + +Acceptance: in a fresh directory outside the repo, `bun add +@scratchwork/server-deploy-cloudflare` + the documented config typechecks and deploys a +working server at the released version — and the same walkthrough works with plain +Node/npm (no Bun installed), since the packages are built JS. + +### Phase 5 — Release process doc + +`[x]` `RELEASING.md` at root: bump with `set-version.ts` → update `CHANGELOG.md` → PR → +merge → tag `vX.Y.Z` → workflow does the GitHub Release → run +`scripts/publish-packages.ts` locally for npm → republish homepage → smoke-test +install.sh from a clean machine. Short enough to actually be followed. + +`[ ]` First real release: `v0.2.0` end-to-end, following RELEASING.md as written and +fixing the doc where reality disagrees. + +## Discovered work (implementation, 2026-07-21) + +Building Phase 4 surfaced prerequisites the plan hadn't named — all shipped in +the same PR: + +- **Package-specifier imports.** server/core, the deploy packages, and the CLI + imported shared via relative `../../../shared/src/...` paths, which escape + the package root and can't publish. All cross-package imports now use + `@scratchwork/shared/...` / `@scratchwork/server-core/...` specifiers with + explicit workspace dependencies; shared's exports map is the pattern pair + `"./*.js"`/`"./*"` → `src`, swapped to `dist` at staging time. +- **`.ts` import extensions.** Relative imports inside the five publishable + packages carry explicit `.ts` extensions so tsc's + `rewriteRelativeImportExtensions` can emit Node-runnable `.js` specifiers. + tsc does not rewrite them in declaration output, so build-packages.ts + post-processes the emitted `.d.ts`. +- **figure.svg module.** The Bun-only ``import ... with { type: "text" }`` + svg imports became a generated module + (`shared/src/assets/figure-svg.generated.ts`, scripts/generate-assets.ts, + freshness-checked in ci) so published code contains no loader-specific syntax. +- **Server tooling workspace dissolved.** `server/scripts/{env,proc,server-settings}.ts` + were imported from published deploy `src/` via relative escapes; they moved + into `server/core/src/deploy/` (published as + `@scratchwork/server-core/deploy/*`), proc.ts was ported from Bun.spawn to + node:child_process so deploys run under plain Node, and the now-empty + `server` tooling workspace was removed. +- **LICENSE.** The repo had none; publishing requires one. Added MIT at the + root (flagged in the PR for veto), `license`/`repository`/`engines` in the + five package manifests. + +## Invariant compliance notes + +- Release/packaging scripts under `scripts/` and the workflow are deploy tooling — + outside the Effect-boundary lint scope (`cli/src`, `server/**/src`, `shared/src`), + same as the existing root scripts. `install.sh` is not TypeScript and lives in + `docs/`, which is published content, deliberately outside the gate — but its syntax + check and mapping test (Phase 3) run inside `bun run ci` so the gate still covers it. +- Any serving change for `.sh`/`.md` content types touches `shared/src/site` — plain + mechanized-test territory (invariant 1 applies; no new async boundaries expected). +- No new auth surface, routes, or storage semantics anywhere in this plan + (invariants 3–6 untouched). diff --git a/package.json b/package.json index 604e5e0..828ffc1 100644 --- a/package.json +++ b/package.json @@ -8,26 +8,28 @@ "renderer", "shared", "cli", - "server", "server/core", "server/deploy-aws", "server/deploy-cloudflare", "server/deploy-local", "deploy/*", + "scratchwork.dev/server", "e2e" ], "scripts": { "build": "cd cli && bun run build", "build:renderer": "cd renderer && bun run build", - "ci": "bun scripts/check-boundaries.ts && bun scripts/each-workspace.ts ci && bun scripts/check-generated-fresh.ts", + "ci": "bun scripts/check-boundaries.ts && bun scripts/check-versions.ts && bun scripts/each-workspace.ts ci && bun scripts/check-install-sh.ts && bun scripts/check-npm-pack.ts && bun scripts/generate-assets.ts && bun scripts/check-generated-fresh.ts", "deploy:generic-aws": "cd deploy/generic-aws && bun run deploy", "deploy:cloudflare-vanilla": "cd deploy/cloudflare-vanilla && bun run deploy", "deploy:cloudflare-access": "cd deploy/cloudflare-access && bun run deploy", + "deploy:scratchwork-dev": "cd scratchwork.dev/server && bun run deploy", "local:generic-aws": "cd deploy/generic-aws && bun run local", "local:cloudflare": "cd server/deploy-cloudflare && bun run dev", "local:cloudflare-access": "cd deploy/cloudflare-access && bun run local", "local:local-dev": "cd deploy/local-dev && bun run local", "local:cloudflare-vanilla": "cd deploy/cloudflare-vanilla && bun run local", + "local:scratchwork-dev": "cd scratchwork.dev/server && bun run local", "dev:renderer": "cd renderer && bun run dev", "test": "bun scripts/each-workspace.ts test", "typecheck": "bun scripts/each-workspace.ts typecheck" diff --git a/renderer/README.md b/renderer/README.md index 9d68be4..b39b70c 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -45,9 +45,9 @@ name → component map; consulted **before** lazy-loading `components/*.js`). The renderer embeds **no** images — no favicon, no logo, no Scratchwork-specific components. Branding lives with the project content instead: -- The sample project (`docs/`) ships `scratchwork-logo.svg` (figure + wordmark) +- The sample project (`scratchwork.dev/www/`) ships `scratchwork-logo.svg` (figure + wordmark) and uses it directly in `index.md`, plus a `MadeWithScratchwork` component - under `docs/components/`. + under `scratchwork.dev/www/components/`. - The `scratchwork dev` server serves the Scratchwork figure mark as the **default favicon** when a project ships none of its own (see `../cli/src/dev/server.ts` and `../cli/assets/figure.svg`). @@ -125,7 +125,7 @@ statements (not full MDX), footnotes, and definition lists. There is no CSS framework. Rendered markdown is styled by `prose.css` (scoped to `.scratchwork-prose`). Components style themselves with inline `style` or a small -scoped `\n"; diff --git a/shared/src/crypto/digest.ts b/shared/src/crypto/digest.ts index e48032e..f9efb2b 100644 --- a/shared/src/crypto/digest.ts +++ b/shared/src/crypto/digest.ts @@ -7,7 +7,7 @@ * and callers wrap these helpers exactly once with Effect.tryPromise. */ import * as Encoding from "effect/Encoding"; -import { toArrayBuffer } from "../encoding/bytes"; +import { toArrayBuffer } from "../encoding/bytes.ts"; /** Computes the base64url SHA-256 digest of a UTF-8 string (PKCE S256). */ export async function sha256Base64Url(value: string): Promise { diff --git a/shared/src/publish/api.ts b/shared/src/publish/api.ts index 35cc615..8aeb6cb 100644 --- a/shared/src/publish/api.ts +++ b/shared/src/publish/api.ts @@ -17,8 +17,8 @@ import * as HttpApiEndpoint from "@effect/platform/HttpApiEndpoint"; import * as HttpApiGroup from "@effect/platform/HttpApiGroup"; import * as HttpApiSchema from "@effect/platform/HttpApiSchema"; import * as Schema from "effect/Schema"; -import { isSafeProjectIdentifier } from "../site/identifiers"; -import { PublishBundleSchema } from "./bundle"; +import { isSafeProjectIdentifier } from "../site/identifiers.ts"; +import { PublishBundleSchema } from "./bundle.ts"; export { PublishBundleSchema }; diff --git a/shared/src/publish/bundle.ts b/shared/src/publish/bundle.ts index be861b0..c41b4df 100644 --- a/shared/src/publish/bundle.ts +++ b/shared/src/publish/bundle.ts @@ -5,8 +5,8 @@ * request schema in api.ts) and the CLI decodes clone downloads through it. */ import * as Schema from "effect/Schema"; -import { decodedBase64ByteLength } from "../encoding/base64"; -import { isSafeSitePath } from "../site/paths"; +import { decodedBase64ByteLength } from "../encoding/base64.ts"; +import { isSafeSitePath } from "../site/paths.ts"; /** Version number both sides must agree on before reading a bundle. */ export const PUBLISH_BUNDLE_VERSION = 1; diff --git a/shared/src/site/components.ts b/shared/src/site/components.ts index f172599..6eeebfd 100644 --- a/shared/src/site/components.ts +++ b/shared/src/site/components.ts @@ -5,7 +5,7 @@ * this file in sync with renderer/src/components.js so dev diagnostics agree * with what the renderer actually loads. */ -import { dirnameSitePath, joinSitePath, type SitePath } from "./paths"; +import { dirnameSitePath, joinSitePath, type SitePath } from "./paths.ts"; /** * Blanks out `inline code` spans in a line so their contents are not scanned. diff --git a/shared/src/site/default-renderer.generated.js b/shared/src/site/default-renderer.generated.js index 6e752f2..68e1844 100644 --- a/shared/src/site/default-renderer.generated.js +++ b/shared/src/site/default-renderer.generated.js @@ -1,4 +1,6 @@ // AUTO-GENERATED by renderer/build.js — do not edit. -export const defaultRendererSourceHash = "67ac94fe85f68eb4448c9aa641ff5d49be3986d4b5ce67c6675c8f6474cdf2d4"; -export const defaultRendererHtml = "\n\n\n \n \n \n \n\n \n \n \n \n \n \n\n
\n\n \n \n \n\n"; +// The JSDoc casts keep tsc declaration emit at `string` instead of a +// megabyte-scale literal type when shared is built for npm publishing. +export const defaultRendererSourceHash = /** @type {string} */ ("4b5c703820134edb241196bf38f90b0c4dd960ea17ca85e3677ca188dc426ca7"); +export const defaultRendererHtml = /** @type {string} */ ("\n\n\n \n \n \n \n\n \n \n \n \n \n \n\n
\n\n \n \n \n\n"); export default defaultRendererHtml; diff --git a/shared/src/site/files.ts b/shared/src/site/files.ts index bb244a2..d75cb58 100644 --- a/shared/src/site/files.ts +++ b/shared/src/site/files.ts @@ -8,7 +8,7 @@ import type * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import type * as Effect from "effect/Effect"; -import type { SitePath } from "./paths"; +import type { SitePath } from "./paths.ts"; /** Failure reading a site file, tagged with why (forbidden, missing, or IO error). */ export class SiteFileError extends Data.TaggedError("SiteFileError")<{ diff --git a/shared/src/site/html.ts b/shared/src/site/html.ts index d5a5b0c..27ac7d8 100644 --- a/shared/src/site/html.ts +++ b/shared/src/site/html.ts @@ -4,7 +4,7 @@ * served — for example to inject the live-reload script. */ import * as Effect from "effect/Effect"; -import type { SitePath } from "./paths"; +import type { SitePath } from "./paths.ts"; /** Where the HTML being transformed came from: a static file or a renderer shell. */ export interface HtmlContext { diff --git a/shared/src/site/renderer.ts b/shared/src/site/renderer.ts index 816e6e2..4becb6c 100644 --- a/shared/src/site/renderer.ts +++ b/shared/src/site/renderer.ts @@ -4,9 +4,9 @@ * fallback shell (the one embedded in the CLI/server) is used. */ import * as Effect from "effect/Effect"; -import { SiteFileError, SiteFiles } from "./files"; -import { isMarkedMarkdownRenderer } from "./marker"; -import { dirnameSitePath, joinSitePath, type SitePath } from "./paths"; +import { SiteFileError, SiteFiles } from "./files.ts"; +import { isMarkedMarkdownRenderer } from "./marker.ts"; +import { dirnameSitePath, joinSitePath, type SitePath } from "./paths.ts"; /** The renderer shell to serve for a Markdown route, and where it came from. */ export type MarkdownRenderer = diff --git a/shared/src/site/routing.ts b/shared/src/site/routing.ts index b3da87b..3806c38 100644 --- a/shared/src/site/routing.ts +++ b/shared/src/site/routing.ts @@ -7,9 +7,9 @@ */ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; -import { extensionOf } from "./content"; -import { SiteFileError, SiteFiles } from "./files"; -import { isMarkedMarkdownRenderer } from "./marker"; +import { extensionOf } from "./content.ts"; +import { SiteFileError, SiteFiles } from "./files.ts"; +import { isMarkedMarkdownRenderer } from "./marker.ts"; import { basenameSitePath, dirnameSitePath, @@ -17,7 +17,7 @@ import { joinSitePath, stripExtension, type SitePath, -} from "./paths"; +} from "./paths.ts"; /** The favicon a browser requests by default when a page names none. */ export const FAVICON_ICO_PATH = "favicon.ico"; diff --git a/shared/src/site/serve.ts b/shared/src/site/serve.ts index af76c9b..5cd64cf 100644 --- a/shared/src/site/serve.ts +++ b/shared/src/site/serve.ts @@ -9,20 +9,20 @@ import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import * as Effect from "effect/Effect"; -import { contentType, defaultCacheControl, isMarkdownPath } from "./content"; -import { SiteFileError, SiteFiles } from "./files"; -import { applyHtmlTransforms, type HtmlTransform } from "./html"; +import { contentType, defaultCacheControl, isMarkdownPath } from "./content.ts"; +import { SiteFileError, SiteFiles } from "./files.ts"; +import { applyHtmlTransforms, type HtmlTransform } from "./html.ts"; import { resolveMarkdownRenderer, type MarkdownRenderer, -} from "./renderer"; +} from "./renderer.ts"; import { FAVICON_SVG_PATH, parseRouteRequest, resolveRoute, SiteRouteError, type ResolvedRoute, -} from "./routing"; +} from "./routing.ts"; /** Which renderer shell answered a Markdown route, for logging/diagnostics. */ export type RendererSource = diff --git a/shared/test/serve-install-assets.test.ts b/shared/test/serve-install-assets.test.ts new file mode 100644 index 0000000..ff4fb63 --- /dev/null +++ b/shared/test/serve-install-assets.test.ts @@ -0,0 +1,73 @@ +/* + * Distribution-serving behavior (notes/distribution-plan.md Phase 3): the + * install entry points on scratchwork.dev are ordinary published files, so + * these two behaviors are load-bearing for `curl | bash`: + * + * - a published .sh file serves as text/plain (curl-able, readable in a + * browser, never a download prompt); + * - requesting a .md path directly round-trips the raw markdown bytes + * (the RawMarkdownServed path), so install.md is agent-readable as-is. + */ +import { describe, expect, test } from "bun:test"; +import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { SiteFileError, SiteFiles } from "../src/site/files"; +import { servePath, type SiteServeEvent } from "../src/site/serve"; + +const INSTALL_SH = "#!/bin/sh\nset -euf\necho 'fake installer'\n"; +const INSTALL_MD = "# Installing\n\n```sh\ncurl -fsSL https://scratchwork.dev/install.sh | bash\n```\n"; + +const files: Record = { + "install.sh": INSTALL_SH, + "install.md": INSTALL_MD, +}; + +const notFound = (path: string) => + new SiteFileError({ path: path as never, reason: "NotFound", message: `not found: ${path}` }); + +const siteFilesLayer = Layer.succeed(SiteFiles, { + exists: (path) => Effect.succeed(files[path as string] != null), + readText: (path) => + files[path as string] != null ? Effect.succeed(files[path as string]) : Effect.fail(notFound(path as string)), + readBytes: (path) => + files[path as string] != null + ? Effect.succeed(new TextEncoder().encode(files[path as string])) + : Effect.fail(notFound(path as string)), + fileResponse: (path, options) => + files[path as string] != null + ? Effect.succeed( + HttpServerResponse.text(files[path as string], { + contentType: options?.contentType, + headers: options?.headers, + }), + ) + : Effect.fail(notFound(path as string)), +}); + +function serve(pathname: string) { + const events: SiteServeEvent[] = []; + return Effect.runPromise( + servePath(pathname, "", { + rendererFallback: Effect.succeed(null), + onServeEvent: (event) => Effect.sync(() => void events.push(event)), + }).pipe(Effect.provide(siteFilesLayer)), + ).then((response) => ({ web: HttpServerResponse.toWeb(response), events })); +} + +describe("distribution install assets", () => { + test("a published .sh file serves as text/plain with its exact bytes", async () => { + const { web } = await serve("/install.sh"); + expect(web.status).toBe(200); + expect(web.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(await web.text()).toBe(INSTALL_SH); + }); + + test("a .md path requested directly round-trips raw markdown", async () => { + const { web, events } = await serve("/install.md"); + expect(web.status).toBe(200); + expect(web.headers.get("content-type")).toBe("text/markdown; charset=utf-8"); + expect(await web.text()).toBe(INSTALL_MD); + expect(events).toContainEqual({ _tag: "RawMarkdownServed", path: "install.md" }); + }); +}); diff --git a/shared/tsconfig.build.json b/shared/tsconfig.build.json new file mode 100644 index 0000000..5d7e6df --- /dev/null +++ b/shared/tsconfig.build.json @@ -0,0 +1,16 @@ +// Publish build (scripts/build-packages.ts): per-file tsc emit of ESM JS + +// declarations into dist/, mirroring src/ so the package.json exports +// patterns map 1:1. rewriteRelativeImportExtensions turns the in-repo `.ts` +// specifiers into `.js` in the emitted output so dist runs under plain Node. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "rewriteRelativeImportExtensions": true + }, + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["src/**/*.test.ts"] +}