Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/deploy-provider-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@mieweb/deploy-contract": minor
"@mieweb/deploy-wrangler": minor
"@mieweb/cli": minor
---

Introduce the deploy-provider contract and a Cloudflare reference provider.

- `@mieweb/deploy-contract` (new): a minimal, provider-agnostic `DeployProvider`
TypeScript interface plus a conformance test-kit. The CLI consumes it; deploy
backends implement it. No wrangler.jsonc field names, backend API shapes, or
resource-URI grammar leak into the contract — those stay provider details.
Includes an auth surface: optional `login`/`logout`/`whoami` verbs, an
`AuthStatus` type, and an `AuthError` (the control-plane analogue of
`UnsupportedBindingError`) providers throw on backend 401/403 so the CLI can
prompt the user to log in. Credentials never travel through the contract — a
provider reads them from the environment via `createProvider(env)`, and
`targetConfig` is documented as non-secret. Runtime values (`AuthError`,
`RESOURCE_KINDS`) ship as plain ESM so bare-`node` providers can import them
without a TypeScript loader; declarations use `.d.mts` and the factory env
type is a dependency-free record (no `@types/node` required). Also exports a
shared string-aware `./jsonc` parser used by the CLI and providers.
- `@mieweb/deploy-wrangler` (new): the Cloudflare **reference** provider. Wraps
the pinned `wrangler` binary (`deploy`/`dev`/`tail`, plus
`login`/`logout`/`whoami` mapped to their wrangler equivalents) and reads
resource handles back from the manifest — reloading `wrangler.jsonc` after a
deploy so auto-provisioned, written-back ids are surfaced. Deploy failures that
are actually auth failures map to `AuthError`; a signal-killed child is treated
as failure; `dev` exposes a `closed` promise so a crashed dev returns instead
of hanging. `wrangler` is an optional peer dependency (the `MIEWEB_REAL_WRANGLER`
escape hatch also satisfies it). It is the canonical implementation other
providers (opensource-server, future AWS/GCP) are measured against via the
test-kit.
- `@mieweb/cli`: `deploy`/`dev`/`tail`/`login`/`logout`/`whoami`/`destroy` now
route through a resolved `DeployProvider`. Cloudflare resolves to the wrangler
reference provider; other targets can name a provider package in
`mieweb.jsonc` (`targets[t].provider`). Targets without a provider fall
through to the existing behavior unchanged. Provider context is recursively
redacted of secrets before it reaches a provider, and `AuthError` is surfaced
with an actionable "run `mieweb login`" hint.
86 changes: 86 additions & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# PR preview packages.
#
# On every push to a same-repo pull request, publish every public @mieweb/*
# workspace package to the GitHub Packages npm registry (npm.pkg.github.com)
# as `<version>-pr<PR>.<run>` under the dist-tag `pr<PR>`. This lets
# cross-repo PRs depend on each other with ordinary semver references while
# they are in review, e.g.
#
# "@mieweb/deploy-contract": "0.2.1-pr14.3"
#
# plus, in the consumer, an .npmrc line:
#
# @mieweb:registry=https://npm.pkg.github.com
#
# Once the PRs merge, the real versions go to npmjs via release.yml and the
# consumers drop the .npmrc line and switch to the released version.
#
# Installing from GitHub Packages needs a token with `read:packages`, even for
# public packages.
name: PR preview packages

on:
pull_request:

concurrency:
group: pr-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
contents: read
packages: write
Comment on lines +29 to +31

jobs:
publish:
# GITHUB_TOKEN is read-only on fork PRs.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: https://npm.pkg.github.com
scope: '@mieweb'

- run: pnpm install --frozen-lockfile

- name: Stamp preview versions
id: stamp
env:
SUFFIX: pr${{ github.event.pull_request.number }}.${{ github.run_number }}
run: |
node -e '
const fs = require("fs"), path = require("path");
const out = [];
for (const dir of fs.readdirSync("packages")) {
const f = path.join("packages", dir, "package.json");
if (!fs.existsSync(f)) continue;
const p = JSON.parse(fs.readFileSync(f, "utf8"));
if (p.private) continue;
p.version = `${p.version.replace(/-.*$/, "")}-${process.env.SUFFIX}`;
fs.writeFileSync(f, JSON.stringify(p, null, 2) + "\n");
out.push(`${p.name}@${p.version}`);
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `packages=${out.join(" ")}\n`);
'

# pnpm rewrites workspace:* to the stamped versions and runs prepack.
- name: Publish to GitHub Packages
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm -r publish --no-git-checks --tag pr${{ github.event.pull_request.number }}

- name: Summary
run: |
{
echo "### Preview packages (npm.pkg.github.com)"
echo
echo "Add \`@mieweb:registry=https://npm.pkg.github.com\` to .npmrc, then:"
echo
for p in ${{ steps.stamp.outputs.packages }}; do echo "- \`$p\`"; done
} >> "$GITHUB_STEP_SUMMARY"
32 changes: 21 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ organizing principle:

## Packages

Three packages, split by *what a consumer must install*, not by module:
Packages, split by *what a consumer must install*, not by module:

| Package | Role |
| ------- | ---- |
| [`@mieweb/cloud`](packages/cloud) | **Zero dependencies.** The portable contracts (`CloudDatabase`, `CloudBucket`, `CloudKV`, `CloudQueue`, `CloudStatefulNamespace`, `CloudVectorIndex`, `CloudAI`, `CloudContainerNamespace`, `UnsupportedBindingError`) and, at `@mieweb/cloud/workers`, the `DurableObject` base behind the **`mieweb:workers`** import — re-exports `cloudflare:workers` on Cloudflare (workerd export condition), pure-JS base everywhere else. This is the only package a Cloudflare app touches. |
| [`@mieweb/cloud-adapters`](packages/cloud-adapters) | Off-Cloudflare **adapters** + the Node host harness and migration runner. `./local`: D1→SQLite, R2→filesystem, KV→in-memory, Queues→in-process, Durable Objects→in-process, Vectorize→sqlite-vec. `./os` (os.mieweb.org / self-hosted): D1→libSQL, Vectorize→libSQL vectors, R2→S3/MinIO, KV+Queues→Valkey; ships a `docker-compose.yml`. Backend SDKs are **optional peers** — install only what your target needs. |
| [`@mieweb/cli`](packages/cli) | The **`mieweb`** CLI. On the `cloudflare` target it delegates verbatim to `wrangler`; on the `local`/`mieweb` targets it runs the matching adapter via the Node host harness. |
| [`@mieweb/cli`](packages/cli) | The **`mieweb`** CLI. On the `cloudflare` target most commands delegate to `wrangler`; the deploy lifecycle verbs (`deploy`/`dev`/`tail`, plus `login`/`logout`/`whoami`/`destroy`) run through a deploy provider (below). On the `local`/`mieweb` targets it runs the matching adapter via the Node host harness. |
| [`@mieweb/deploy-contract`](packages/deploy-contract) | **Zero dependencies.** The provider-agnostic control-plane contract: the `DeployProvider` interface (+ `AuthError`, `RESOURCE_KINDS`), a `./jsonc` parser, and a `./testkit` conformance suite. The CLI consumes it; deploy backends implement it. |
| [`@mieweb/deploy-wrangler`](packages/deploy-wrangler) | The **Cloudflare reference** `DeployProvider` — wraps the `wrangler` binary (an optional peer). The canonical implementation other providers (opensource-server, future AWS/GCP) are measured against via the test-kit. |
| [`@mieweb/test-app`](packages/test-app) *(private)* | A tiny worker that exercises **every** contract surface over plain HTTP, plus a cross-target runner. The same worker + the same assertions prove the layer on `cloudflare`, `local`, and `mieweb`. See [Try it](#try-it-the-test-app). |

## How a consuming app wires it in
Expand All @@ -47,16 +49,23 @@ Three packages, split by *what a consumer must install*, not by module:
## Using the `mieweb` CLI

What the CLI *is* depends on where you point it. **If you're targeting
Cloudflare (or already know `wrangler`), think of it as a thin pass-through:**
every command is forwarded verbatim to `wrangler`, so there's nothing new to
learn and zero overhead. **On the other targets it is not a wrapper** — there is
no `wrangler` underneath; the CLI runs your unchanged worker on a Node host
harness backed by the adapters, reusing your `wrangler.jsonc` purely as
configuration. The active target comes from `--target <t>`, `MIEWEB_TARGET`, or
the `target` field in `mieweb.jsonc` (default `cloudflare`).
Cloudflare (or already know `wrangler`), think of it as a near pass-through:**
most commands are forwarded verbatim to `wrangler`, and the deploy lifecycle
verbs (`deploy`/`dev`/`tail`, plus `login`/`logout`/`whoami`/`destroy`) run
through a pluggable deploy provider whose Cloudflare reference implementation
still drives `wrangler` underneath — so behavior matches `wrangler` with a thin
layer of structured logging, resource reporting, and auth-aware errors on top.
**On the built-in `local`/`mieweb` targets it is not a wrapper** — there is no
`wrangler` underneath; the CLI runs your unchanged worker on a Node host harness
backed by the adapters, reusing your `wrangler.jsonc` purely as configuration.
(A custom target that sets `targets[t].provider` instead routes the provider
verbs through that provider, not the host harness.) The active target comes from
`--target <t>`, `MIEWEB_TARGET`, or the `target` field in `mieweb.jsonc`
(default `cloudflare`).

```sh
# cloudflare (default): every command is forwarded verbatim to wrangler
# cloudflare (default): commands run via wrangler (deploy/dev/tail through the
# wrangler reference deploy provider, others forwarded verbatim)
mieweb dev
mieweb deploy
mieweb d1 migrations apply <db>
Expand All @@ -69,7 +78,8 @@ mieweb --target local dev
mieweb --target mieweb dev
```

On `cloudflare`, behavior is identical to `wrangler` (zero overhead). On the
On `cloudflare`, behavior tracks `wrangler` (the deploy provider drives it
directly). On the
`local`/`mieweb` targets the CLI imports the worker your `wrangler.jsonc` `main`
points at, builds an `Env` from the `mieweb.jsonc` driver hints, and serves
`fetch`/`queue`/`scheduled` over HTTP — the same handler Cloudflare runs.
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
# `@mieweb/cli` — the `mieweb` command

Target-aware wrapper over `wrangler`. On the `cloudflare` target (default)
every command is forwarded verbatim to `wrangler`; on `local`/`mieweb` the CLI
runs your unchanged worker on the Node host harness backed by the matching
adapters. See the [root README](../../README.md) for the full model.
most commands are forwarded verbatim to `wrangler`; the deploy lifecycle verbs
(`deploy`, `dev`, `tail`, plus `login`/`logout`/`whoami`, and `destroy`) go
through a pluggable **deploy provider** (`@mieweb/deploy-contract`) whose
Cloudflare reference implementation still delegates to `wrangler` — adding
structured logging, resource reporting, and auth-aware error handling around it.
On the built-in `local`/`mieweb` targets the CLI runs your unchanged worker on
the Node host harness backed by the matching adapters (a custom target with
`targets[t].provider` routes provider verbs through that provider instead). See
the [root README](../../README.md) for the full model.

```sh
mieweb [--target <cloudflare|local|mieweb>] <command> [...args]
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/mieweb-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
},
"target": {
"type": "string",
"enum": ["cloudflare", "local", "mieweb", "aws", "gcp"],
"description": "Default deployment target when none is given via --target or MIEWEB_TARGET."
"examples": ["cloudflare", "local", "mieweb", "aws", "gcp"],
"description": "Default deployment target when none is given via --target or MIEWEB_TARGET. The built-ins are cloudflare/local/mieweb (aws/gcp reserved); custom deploy providers may introduce their own target names (see targets.<t>.provider), so any string is accepted."
},
"targets": {
"type": "object",
Expand All @@ -21,6 +21,7 @@
"type": "object",
"properties": {
"runtime": { "type": "string" },
"provider": { "type": "string", "description": "Deploy provider for this target: a package name or path to a module exporting a DeployProvider (@mieweb/deploy-contract). Cloudflare defaults to the wrangler reference provider; a custom provider may serve a custom target name." },
"port": { "type": "number" },
"registry": {
"type": "object",
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
"mieweb": "./src/index.mjs"
},
"dependencies": {
"@mieweb/cloud-adapters": "workspace:*"
"@mieweb/cloud-adapters": "workspace:*",
"@mieweb/deploy-contract": "workspace:*",
"@mieweb/deploy-wrangler": "workspace:*"
},
"files": [
"src",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync, existsSync } from 'node:fs';
import { dirname, resolve, isAbsolute } from 'node:path';
import { parseJsonc } from './jsonc.mjs';
import { parseJsonc } from '@mieweb/deploy-contract/jsonc';

/**
* @typedef {'cloudflare'|'local'|'mieweb'|'aws'|'gcp'} CloudTarget
Expand Down
50 changes: 42 additions & 8 deletions packages/cli/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
* mieweb tail
* mieweb d1 migrations apply bluehive-hum
*
* On the `cloudflare` target (the default) every command is forwarded
* verbatim to the real `wrangler` binary, so Cloudflare behavior is identical
* and nothing about the existing workflow changes. Select another environment
* with `--target <t>` or `MIEWEB_TARGET=<t>`; those commands are handled by the
* matching @mieweb adapter instead.
* On the `cloudflare` target (the default) most commands are forwarded verbatim
* to the real `wrangler` binary. The deploy lifecycle verbs (`deploy`, `dev`,
* `tail`, plus `login`/`logout`/`whoami`/`destroy`) instead run through a
* pluggable deploy provider (`@mieweb/deploy-contract`); the Cloudflare
* reference provider still drives `wrangler` underneath, adding config
* injection, structured logging, resource reporting, and auth-aware errors.
* Select another environment with `--target <t>` or `MIEWEB_TARGET=<t>`; those
* commands are handled by the matching @mieweb adapter/provider instead.
*
* This file is plain ESM JavaScript on purpose so `mieweb` runs with bare
* `node` — no build step, no transpiler, no extra runtime dependency.
Expand All @@ -23,6 +26,10 @@ import { delegateToWrangler } from './cloudflare.mjs';
import { runHostTarget } from './local.mjs';
import { runInit } from './init.mjs';
import { runImagesCommand, runRegistryCommand } from './images.mjs';
import { resolveProvider, runProviderVerb } from './provider.mjs';

/** Verbs handled by the deploy-contract provider layer. */
const PROVIDER_VERBS = new Set(['deploy', 'dev', 'tail', 'login', 'logout', 'whoami', 'destroy']);

/** Read this CLI's version from its package.json. */
function miewebVersion() {
Expand Down Expand Up @@ -94,8 +101,32 @@ async function main(argv) {
});
}

// Deploy-contract verbs route through a DeployProvider when one resolves for
// the active target: deploy, dev, tail, login, logout, whoami, destroy (see
// PROVIDER_VERBS). Cloudflare resolves to the wrangler reference provider;
// other targets can name a provider package in mieweb.jsonc
// (`targets[t].provider`). Everything else (d1 migrations, etc.) and any
// target without a provider falls through to the legacy paths below.
if (PROVIDER_VERBS.has(args[0])) {
let provider = null;
try {
provider = await resolveProvider(config);
} catch (err) {
console.error(`mieweb: ${err?.message ?? err}`);
return 1;
}
if (provider) {
return runProviderVerb(
/** @type {'deploy'|'dev'|'tail'|'login'|'logout'|'whoami'|'destroy'} */ (args[0]),
provider,
config,
args.slice(1),
);
}
}

if (config.target === 'cloudflare') {
// Reference path: hand everything to wrangler untouched.
// Reference path for non-provider commands: hand to wrangler untouched.
return delegateToWrangler(args, { cwd: config.root });
}

Expand Down Expand Up @@ -128,9 +159,12 @@ function printHelp() {
'',
'Common commands:',
' mieweb init [dir] Scaffold a new mieweb project.',
" mieweb login Authenticate the active target's provider.",
" mieweb logout Clear the provider's stored session.",
" mieweb whoami Show the provider's auth status.",
' mieweb dev Start a dev server for the active target.',
' mieweb deploy Deploy (cloudflare only).',
' mieweb tail Stream logs (cloudflare only).',
" mieweb deploy Deploy via the active target's provider.",
' mieweb tail Stream logs from the deployed worker.',
' mieweb d1 migrations apply <db> Apply ./migrations to the target DB.',
' mieweb images build|push|inspect|status Build & skopeo-push container images.',
' mieweb registry login|logout skopeo login to the target registry.',
Expand Down
68 changes: 0 additions & 68 deletions packages/cli/src/jsonc.mjs

This file was deleted.

Loading
Loading