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 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"]
+}