@mieweb/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Medical Informatics Engineering, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # `@mieweb/cli` — the `mieweb` command
2
+
3
+ Target-aware wrapper over `wrangler`. On the `cloudflare` target (default)
4
+ every command is forwarded verbatim to `wrangler`; on `local`/`mieweb` the CLI
5
+ runs your unchanged worker on the Node host harness backed by the matching
6
+ adapters. See the [root README](../../README.md) for the full model.
7
+
8
+ ```sh
9
+ mieweb [--target <cloudflare|local|mieweb>] <command> [...args]
10
+ ```
11
+
12
+ ## Images (Cloudflare Containers)
13
+
14
+ Build container images once, distribute them with **skopeo**
15
+ (see [container-plan.md](../../container-plan.md)).
16
+
17
+ **Prerequisites:** `skopeo` plus a builder — `buildah` (preferred) or `docker`.
18
+
19
+ ```sh
20
+ brew install skopeo buildah # macOS
21
+ # apt/dnf install skopeo buildah # Linux
22
+ ```
23
+
24
+ The CLI reads the `containers` array in `wrangler.jsonc` (class → Dockerfile)
25
+ and the per-target `registry` block in `mieweb.jsonc`:
26
+
27
+ ```jsonc
28
+ // mieweb.jsonc
29
+ {
30
+ "targets": {
31
+ "mieweb": {
32
+ "registry": {
33
+ "url": "cr.os.mieweb.org",
34
+ "project": "cloud-apps",
35
+ "username": "robot$cloud-apps+ci",
36
+ "authFile": "~/.config/mieweb/registry-auth.json"
37
+ }
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ ### Commands
44
+
45
+ ```sh
46
+ mieweb images build # build every containers[] image (buildah/docker)
47
+ mieweb images push --target mieweb # build + skopeo copy → registry, pin digests
48
+ mieweb images push # cloudflare target: delegates to `wrangler containers push`
49
+ mieweb images inspect CONVERTER # skopeo inspect by binding name or class name
50
+ mieweb images status # lockfile pins vs. what's live in the registry
51
+
52
+ mieweb registry login --target mieweb # skopeo login (writes authFile; password prompted, never argv)
53
+ mieweb registry logout --target mieweb
54
+ ```
55
+
56
+ ### Conventions
57
+
58
+ - **Naming:** `docker://<registry.url>/<project>/<class_name lowercased>:<git short SHA>`,
59
+ plus a `latest` moving tag. `project` defaults to the wrangler app `name`.
60
+ - **Lockfile:** pushes pin the manifest digest per class per target in
61
+ `.mieweb/images.lock.json` (commit it — it's what makes deploys reproducible).
62
+ `mieweb images status` reports drift between the pin and the registry's `latest`.
63
+ - **Auth:** prefer `authFile` (written by `mieweb registry login`) over inline
64
+ credentials; secrets are never passed on the command line or logged.
65
+ Harbor robot accounts (`robot$project+name`) are the expected CI identity.
66
+ - **Cloudflare:** the managed registry is wrangler's job — `images push` on the
67
+ `cloudflare` target hands off to `wrangler containers push` rather than
68
+ reimplementing its auth with skopeo.
@@ -0,0 +1,132 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "mieweb.jsonc",
4
+ "description": "Portability sidecar consumed by the @mieweb CLI. wrangler.jsonc remains the source of truth for bindings; this file only records the active target and non-Cloudflare adapter hints.",
5
+ "type": "object",
6
+ "additionalProperties": true,
7
+ "properties": {
8
+ "wrangler": {
9
+ "type": "string",
10
+ "description": "Path to the canonical wrangler config the CLI reads bindings from and delegates to on Cloudflare."
11
+ },
12
+ "target": {
13
+ "type": "string",
14
+ "enum": ["cloudflare", "local", "mieweb", "aws", "gcp"],
15
+ "description": "Default deployment target when none is given via --target or MIEWEB_TARGET."
16
+ },
17
+ "targets": {
18
+ "type": "object",
19
+ "description": "Per-target adapter configuration. Only consulted for non-cloudflare targets.",
20
+ "additionalProperties": {
21
+ "type": "object",
22
+ "properties": {
23
+ "runtime": { "type": "string" },
24
+ "port": { "type": "number" },
25
+ "registry": {
26
+ "type": "object",
27
+ "description": "OCI registry the CLI pushes container images to for this target (skopeo copy destination). Harbor for the mieweb target; unused on cloudflare (wrangler's managed registry).",
28
+ "properties": {
29
+ "url": { "type": "string", "description": "Registry host, e.g. cr.os.mieweb.org." },
30
+ "project": { "type": "string", "description": "Registry project/namespace images are pushed under." },
31
+ "username": { "type": "string", "description": "Registry user (e.g. a Harbor robot account: robot$project+name)." },
32
+ "authFile": { "type": "string", "description": "Path to a containers-auth.json written by `skopeo login` / `mieweb registry login`. Preferred over inline credentials." },
33
+ "insecureSkipTlsVerify": { "type": "boolean", "description": "Disable TLS verification (dev registries only)." }
34
+ },
35
+ "required": ["url"],
36
+ "additionalProperties": true
37
+ },
38
+ "bindings": {
39
+ "type": "object",
40
+ "additionalProperties": {
41
+ "type": "object",
42
+ "properties": {
43
+ "driver": {
44
+ "type": "string",
45
+ "enum": [
46
+ "sqlite",
47
+ "sqlite-vec",
48
+ "fs",
49
+ "memory",
50
+ "redis",
51
+ "inproc",
52
+ "ai",
53
+ "libsql",
54
+ "libsql-vec",
55
+ "s3",
56
+ "valkey",
57
+ "valkey-queue",
58
+ "docker",
59
+ "unsupported"
60
+ ]
61
+ },
62
+ "path": { "type": "string" },
63
+ "url": { "type": "string" },
64
+ "queue": { "type": "string" },
65
+ "dim": {
66
+ "type": "number",
67
+ "description": "Vector dimension for the sqlite-vec / libsql-vec drivers (default 768)."
68
+ },
69
+ "backend": {
70
+ "type": "string",
71
+ "description": "AI backend for the ai driver (e.g. 'ollama')."
72
+ },
73
+ "host": {
74
+ "type": "string",
75
+ "description": "Hostname/base URL for service-backed drivers (ai's Ollama host, valkey host)."
76
+ },
77
+ "port": {
78
+ "type": "number",
79
+ "description": "Port for service-backed drivers (e.g. valkey/valkey-queue)."
80
+ },
81
+ "namespace": {
82
+ "type": "string",
83
+ "description": "Key namespace for the valkey / valkey-queue drivers."
84
+ },
85
+ "authToken": {
86
+ "type": "string",
87
+ "description": "Auth token for the libsql / libsql-vec drivers."
88
+ },
89
+ "table": {
90
+ "type": "string",
91
+ "description": "Table name for the libsql-vec driver (default vec_items)."
92
+ },
93
+ "endpoint": {
94
+ "type": "string",
95
+ "description": "S3 endpoint URL for the s3 driver (e.g. MinIO)."
96
+ },
97
+ "region": {
98
+ "type": "string",
99
+ "description": "S3 region for the s3 driver (default us-east-1)."
100
+ },
101
+ "bucket": {
102
+ "type": "string",
103
+ "description": "Bucket name for the s3 driver."
104
+ },
105
+ "accessKeyId": {
106
+ "type": "string",
107
+ "description": "S3 access key id for the s3 driver."
108
+ },
109
+ "secretAccessKey": {
110
+ "type": "string",
111
+ "description": "S3 secret access key for the s3 driver."
112
+ },
113
+ "forcePathStyle": {
114
+ "type": "boolean",
115
+ "description": "Use path-style S3 addressing (required by MinIO)."
116
+ },
117
+ "models": {
118
+ "type": "object",
119
+ "description": "Model names for the ai driver, e.g. { \"embed\": \"nomic-embed-text\" }.",
120
+ "additionalProperties": { "type": "string" }
121
+ }
122
+ },
123
+ "required": ["driver"],
124
+ "additionalProperties": true
125
+ }
126
+ }
127
+ },
128
+ "additionalProperties": true
129
+ }
130
+ }
131
+ }
132
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@mieweb/cli",
3
+ "version": "0.1.0",
4
+ "description": "`mieweb` CLI — a thin, target-aware wrapper over wrangler. On the cloudflare target it delegates verbatim to the real wrangler binary; on other targets it drives the matching @mieweb adapter (e.g. the local Node host harness).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "MIEWEB",
8
+ "homepage": "https://github.com/mieweb/cloud#readme",
9
+ "bugs": "https://github.com/mieweb/cloud/issues",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/mieweb/cloud.git",
13
+ "directory": "packages/cli"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "keywords": [
19
+ "mieweb",
20
+ "cloudflare",
21
+ "wrangler",
22
+ "workers",
23
+ "cli",
24
+ "serverless"
25
+ ],
26
+ "engines": {
27
+ "node": ">=22.16.0"
28
+ },
29
+ "bin": {
30
+ "mieweb": "./src/index.mjs"
31
+ },
32
+ "dependencies": {
33
+ "@mieweb/cloud-local": "0.1.0",
34
+ "@mieweb/cloud-os": "0.1.0"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "mieweb-config.schema.json"
39
+ ]
40
+ }
@@ -0,0 +1,34 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Delegate a command verbatim to the real `wrangler` binary.
5
+ *
6
+ * Used on the `cloudflare` target so that `mieweb dev`, `mieweb deploy`,
7
+ * `mieweb tail`, `mieweb d1 migrations apply ...`, etc. behave exactly like
8
+ * their wrangler equivalents — Cloudflare stays first-class with zero
9
+ * translation. We resolve wrangler through the package manager so the repo's
10
+ * pinned version is used.
11
+ *
12
+ * @param {string[]} args arguments to forward to wrangler
13
+ * @param {{ cwd?: string }} [opts]
14
+ * @returns {Promise<number>} wrangler's exit code
15
+ */
16
+ export function delegateToWrangler(args, opts = {}) {
17
+ const cwd = opts.cwd ?? process.cwd();
18
+
19
+ // Prefer an explicit escape hatch, then a repo-local wrangler, then PATH.
20
+ const real = process.env.MIEWEB_REAL_WRANGLER;
21
+ const command = real || 'wrangler';
22
+ // When falling back to the bare name we go through the package runner so the
23
+ // workspace-pinned wrangler resolves even if it isn't on PATH globally.
24
+ const useRunner = !real;
25
+
26
+ const finalCmd = useRunner ? 'pnpm' : command;
27
+ const finalArgs = useRunner ? ['exec', 'wrangler', ...args] : args;
28
+
29
+ return new Promise((resolvePromise, reject) => {
30
+ const child = spawn(finalCmd, finalArgs, { cwd, stdio: 'inherit' });
31
+ child.on('error', reject);
32
+ child.on('exit', (code) => resolvePromise(code ?? 0));
33
+ });
34
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,106 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { dirname, resolve, isAbsolute } from 'node:path';
3
+ import { parseJsonc } from './jsonc.mjs';
4
+
5
+ /**
6
+ * @typedef {'cloudflare'|'local'|'mieweb'|'aws'|'gcp'} CloudTarget
7
+ *
8
+ * @typedef {Object} MiewebConfig
9
+ * @property {string} configPath absolute path to mieweb.jsonc (or wrangler.jsonc if no sidecar)
10
+ * @property {string} root absolute repo root (dir holding the config)
11
+ * @property {CloudTarget} target resolved active target
12
+ * @property {string} wranglerPath absolute path to wrangler.jsonc
13
+ * @property {Record<string, unknown>} wrangler parsed wrangler.jsonc
14
+ * @property {Record<string, unknown>} raw parsed mieweb.jsonc (or {} )
15
+ * @property {Record<string, any>} targetConfig adapter config for the active target
16
+ * @property {Array<Record<string, any>>} containers wrangler `containers` entries (Cloudflare Containers)
17
+ * @property {Record<string, Record<string, any>>} containerBindings DO binding name → its wrangler `containers` entry, for container-backed bindings
18
+ */
19
+
20
+ /**
21
+ * Locate and load the mieweb/wrangler configuration.
22
+ *
23
+ * Resolution order for the active target:
24
+ * 1. explicit `--target <t>` (passed in `overrideTarget`)
25
+ * 2. `MIEWEB_TARGET` env var
26
+ * 3. `target` field in mieweb.jsonc
27
+ * 4. `'cloudflare'` (the reference default)
28
+ *
29
+ * @param {{ cwd?: string, overrideTarget?: string|null }} [opts]
30
+ * @returns {MiewebConfig}
31
+ */
32
+ export function loadConfig(opts = {}) {
33
+ const cwd = opts.cwd ?? process.cwd();
34
+ const miewebPath = findUp('mieweb.jsonc', cwd);
35
+ const root = miewebPath ? dirname(miewebPath) : (findUp('wrangler.jsonc', cwd) ? dirname(/** @type {string} */ (findUp('wrangler.jsonc', cwd))) : cwd);
36
+
37
+ /** @type {Record<string, any>} */
38
+ let raw = {};
39
+ if (miewebPath && existsSync(miewebPath)) {
40
+ raw = /** @type {Record<string, any>} */ (parseJsonc(readFileSync(miewebPath, 'utf8')));
41
+ }
42
+
43
+ const wranglerRel = typeof raw.wrangler === 'string' ? raw.wrangler : './wrangler.jsonc';
44
+ const wranglerPath = isAbsolute(wranglerRel) ? wranglerRel : resolve(root, wranglerRel);
45
+ /** @type {Record<string, unknown>} */
46
+ let wrangler = {};
47
+ if (existsSync(wranglerPath)) {
48
+ wrangler = /** @type {Record<string, unknown>} */ (parseJsonc(readFileSync(wranglerPath, 'utf8')));
49
+ }
50
+
51
+ const target = /** @type {CloudTarget} */ (
52
+ opts.overrideTarget || process.env.MIEWEB_TARGET || raw.target || 'cloudflare'
53
+ );
54
+
55
+ const targetConfig = (raw.targets && raw.targets[target]) || {};
56
+
57
+ // Cloudflare Containers: wrangler's `containers` array pairs a DO class with
58
+ // an image; a DO binding whose class appears there is a container binding.
59
+ // (Reserved surface — see container-plan.md. wrangler handles these natively
60
+ // on the cloudflare target; other targets throw UnsupportedBindingError on
61
+ // use until a runtime adapter exists.)
62
+ const containers = Array.isArray(wrangler.containers)
63
+ ? /** @type {Array<Record<string, any>>} */ (wrangler.containers)
64
+ : [];
65
+ /** @type {Record<string, Record<string, any>>} */
66
+ const containerBindings = {};
67
+ const doBindings =
68
+ /** @type {{ bindings?: Array<{ name: string, class_name: string }> }} */ (
69
+ wrangler.durable_objects
70
+ )?.bindings ?? [];
71
+ for (const c of containers) {
72
+ for (const b of doBindings) {
73
+ if (b.class_name === c.class_name) containerBindings[b.name] = c;
74
+ }
75
+ }
76
+
77
+ return {
78
+ configPath: miewebPath ?? wranglerPath,
79
+ root,
80
+ target,
81
+ wranglerPath,
82
+ wrangler,
83
+ raw,
84
+ targetConfig,
85
+ containers,
86
+ containerBindings,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Walk up from `start` looking for `name`.
92
+ * @param {string} name
93
+ * @param {string} start
94
+ * @returns {string|null}
95
+ */
96
+ function findUp(name, start) {
97
+ let dir = start;
98
+ // eslint-disable-next-line no-constant-condition
99
+ while (true) {
100
+ const candidate = resolve(dir, name);
101
+ if (existsSync(candidate)) return candidate;
102
+ const parent = dirname(dir);
103
+ if (parent === dir) return null;
104
+ dir = parent;
105
+ }
106
+ }
package/src/images.mjs ADDED
@@ -0,0 +1,331 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
3
+ import { resolve, dirname, join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+
6
+ /**
7
+ * `mieweb images …` / `mieweb registry …` — container image plumbing.
8
+ *
9
+ * Build once, distribute with skopeo (container-plan.md Milestone 2):
10
+ *
11
+ * mieweb images build build every wrangler `containers` image
12
+ * mieweb images push [--target mieweb] build + skopeo copy to the target registry
13
+ * mieweb images inspect <BINDING|class> skopeo inspect what the target would run
14
+ * mieweb images status digest lockfile vs. registry
15
+ * mieweb registry login|logout skopeo login against the target registry
16
+ *
17
+ * On `--target cloudflare`, push delegates to `wrangler containers push`
18
+ * (Cloudflare's managed registry has its own auth dance) — skopeo is only used
19
+ * for self-managed registries (Harbor on the `mieweb` target, local dev).
20
+ *
21
+ * Naming convention (container-plan.md Milestone 3):
22
+ * docker://<registry.url>/<registry.project>/<class_name lowercased>:<git short SHA>
23
+ * plus a `latest` moving tag. Pushed digests are pinned in
24
+ * `.mieweb/images.lock.json` so deploys are reproducible.
25
+ */
26
+
27
+ /* ---------------------------------------------------------------- helpers */
28
+
29
+ /**
30
+ * Run a command, inheriting stdio (interactive-friendly).
31
+ * @param {string} cmd @param {string[]} args @param {{cwd?: string}} [opts]
32
+ * @returns {Promise<number>}
33
+ */
34
+ function run(cmd, args, opts = {}) {
35
+ return new Promise((res, rej) => {
36
+ const child = spawn(cmd, args, { cwd: opts.cwd, stdio: 'inherit' });
37
+ child.on('error', rej);
38
+ child.on('exit', (code) => res(code ?? 0));
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Run a command capturing stdout (for skopeo inspect etc.).
44
+ * @param {string} cmd @param {string[]} args @param {{cwd?: string}} [opts]
45
+ * @returns {Promise<{code: number, stdout: string, stderr: string}>}
46
+ */
47
+ function capture(cmd, args, opts = {}) {
48
+ return new Promise((res, rej) => {
49
+ const child = spawn(cmd, args, { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
50
+ let stdout = '';
51
+ let stderr = '';
52
+ child.stdout.on('data', (d) => { stdout += d; });
53
+ child.stderr.on('data', (d) => { stderr += d; });
54
+ child.on('error', rej);
55
+ child.on('exit', (code) => res({ code: code ?? 0, stdout, stderr }));
56
+ });
57
+ }
58
+
59
+ /** @param {string} cmd */
60
+ async function commandExists(cmd) {
61
+ const { code } = await capture('sh', ['-c', `command -v ${cmd}`]);
62
+ return code === 0;
63
+ }
64
+
65
+ /**
66
+ * Prefer buildah, fall back to docker. Determines both the build command and
67
+ * the skopeo *source* transport for the built image.
68
+ * @returns {Promise<{ name: 'buildah'|'docker', srcTransport: (ref: string) => string }>}
69
+ */
70
+ export async function detectBuilder() {
71
+ if (await commandExists('buildah')) {
72
+ return { name: 'buildah', srcTransport: (ref) => `containers-storage:${ref}` };
73
+ }
74
+ if (await commandExists('docker')) {
75
+ return { name: 'docker', srcTransport: (ref) => `docker-daemon:${ref}` };
76
+ }
77
+ throw new Error(
78
+ 'mieweb images: neither buildah nor docker found on PATH. ' +
79
+ 'Install one of them (and skopeo) — e.g. `brew install buildah skopeo`.',
80
+ );
81
+ }
82
+
83
+ /** Git short SHA of HEAD (falls back to "dev" outside a repo). */
84
+ async function gitShortSha(cwd) {
85
+ const { code, stdout } = await capture('git', ['rev-parse', '--short', 'HEAD'], { cwd });
86
+ return code === 0 ? stdout.trim() : 'dev';
87
+ }
88
+
89
+ /** Expand a leading `~` (authFile paths). @param {string} p */
90
+ function expandHome(p) {
91
+ return p.startsWith('~') ? join(homedir(), p.slice(1)) : p;
92
+ }
93
+
94
+ /**
95
+ * The wrangler `containers` entries that are locally built (image is a
96
+ * Dockerfile path, not a remote ref).
97
+ * @param {import('./config.mjs').MiewebConfig} config
98
+ */
99
+ function buildableContainers(config) {
100
+ return (config.containers ?? []).filter(
101
+ (c) => typeof c.image === 'string' && !c.image.includes('://') && !c.image.startsWith('registry.'),
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Registry config for the active target (mieweb.jsonc `targets.<t>.registry`).
107
+ * @param {import('./config.mjs').MiewebConfig} config
108
+ */
109
+ function registryFor(config) {
110
+ const reg = config.targetConfig?.registry;
111
+ if (!reg || typeof reg.url !== 'string') {
112
+ throw new Error(
113
+ `mieweb images: no registry configured for target "${config.target}". ` +
114
+ `Add targets.${config.target}.registry = { url, project, … } to mieweb.jsonc.`,
115
+ );
116
+ }
117
+ return reg;
118
+ }
119
+
120
+ /**
121
+ * Fully-qualified repo ref (no tag) for a container class on a registry.
122
+ * @param {{ url: string, project?: string }} reg
123
+ * @param {Record<string, any>} c wrangler containers entry
124
+ * @param {import('./config.mjs').MiewebConfig} config
125
+ */
126
+ function repoRef(reg, c, config) {
127
+ const project = reg.project ?? config.wrangler?.name ?? 'mieweb';
128
+ return `${reg.url}/${project}/${String(c.class_name).toLowerCase()}`;
129
+ }
130
+
131
+ /** skopeo auth/TLS flags for a destination registry. @param {any} reg @param {'src'|'dest'} side */
132
+ function skopeoAuthFlags(reg, side) {
133
+ const flags = [];
134
+ if (reg.authFile) flags.push(`--${side}-authfile`, expandHome(reg.authFile));
135
+ if (reg.insecureSkipTlsVerify) flags.push(`--${side}-tls-verify=false`);
136
+ return flags;
137
+ }
138
+
139
+ /* ------------------------------------------------------------- lockfile */
140
+
141
+ /** @param {string} root */
142
+ function lockPath(root) {
143
+ return resolve(root, '.mieweb/images.lock.json');
144
+ }
145
+
146
+ /** @param {string} root */
147
+ function readLock(root) {
148
+ const p = lockPath(root);
149
+ if (!existsSync(p)) return {};
150
+ try {
151
+ return JSON.parse(readFileSync(p, 'utf8'));
152
+ } catch {
153
+ return {};
154
+ }
155
+ }
156
+
157
+ /** @param {string} root @param {Record<string, any>} lock */
158
+ function writeLock(root, lock) {
159
+ const p = lockPath(root);
160
+ mkdirSync(dirname(p), { recursive: true });
161
+ writeFileSync(p, `${JSON.stringify(lock, null, 2)}\n`);
162
+ }
163
+
164
+ /* ------------------------------------------------------------ operations */
165
+
166
+ /**
167
+ * Build one container image locally.
168
+ * @param {{ name: 'buildah'|'docker' }} builder
169
+ * @param {Record<string, any>} c wrangler containers entry
170
+ * @param {string} root
171
+ * @param {string} tag local ref, e.g. mieweb/jobrunner:abc1234
172
+ */
173
+ export async function buildImage(builder, c, root, tag) {
174
+ const dockerfile = resolve(root, c.image);
175
+ const context = dirname(dockerfile);
176
+ const args =
177
+ builder.name === 'buildah'
178
+ ? ['bud', '-f', dockerfile, '-t', tag, context]
179
+ : ['build', '-f', dockerfile, '-t', tag, context];
180
+ const code = await run(builder.name, args, { cwd: root });
181
+ if (code !== 0) throw new Error(`mieweb images: ${builder.name} build failed for ${c.class_name} (exit ${code})`);
182
+ }
183
+
184
+ /**
185
+ * skopeo copy a locally built image to the registry (sha tag + latest).
186
+ * @returns {Promise<string>} the pushed manifest digest
187
+ */
188
+ export async function pushImage(builder, reg, localRef, remoteRepo, sha) {
189
+ if (!(await commandExists('skopeo'))) {
190
+ throw new Error('mieweb images: skopeo not found on PATH (`brew install skopeo`).');
191
+ }
192
+ for (const tag of [sha, 'latest']) {
193
+ const dest = `docker://${remoteRepo}:${tag}`;
194
+ const code = await run('skopeo', [
195
+ 'copy',
196
+ ...skopeoAuthFlags(reg, 'dest'),
197
+ builder.srcTransport(localRef),
198
+ dest,
199
+ ]);
200
+ if (code !== 0) throw new Error(`mieweb images: skopeo copy to ${dest} failed (exit ${code})`);
201
+ }
202
+ return inspectDigest(reg, `${remoteRepo}:${sha}`);
203
+ }
204
+
205
+ /** skopeo inspect a remote ref and return its digest. */
206
+ async function inspectDigest(reg, ref) {
207
+ const { code, stdout, stderr } = await capture('skopeo', [
208
+ 'inspect',
209
+ ...skopeoAuthFlags(reg, 'src'),
210
+ `docker://${ref}`,
211
+ ]);
212
+ if (code !== 0) throw new Error(`mieweb images: skopeo inspect docker://${ref} failed: ${stderr.trim()}`);
213
+ return JSON.parse(stdout).Digest;
214
+ }
215
+
216
+ /* -------------------------------------------------------------- commands */
217
+
218
+ /**
219
+ * Entry point for `mieweb images <build|push|inspect|status> [...]`.
220
+ * @param {string[]} args after "images"
221
+ * @param {import('./config.mjs').MiewebConfig} config
222
+ * @returns {Promise<number>}
223
+ */
224
+ export async function runImagesCommand(args, config) {
225
+ const sub = args[0];
226
+ const containers = buildableContainers(config);
227
+ if (containers.length === 0) {
228
+ console.error('mieweb images: no `containers` entries with a local Dockerfile in wrangler.jsonc.');
229
+ return 1;
230
+ }
231
+
232
+ const sha = await gitShortSha(config.root);
233
+
234
+ if (sub === 'build' || sub === 'push') {
235
+ const builder = await detectBuilder();
236
+ /** @type {Record<string, any>} */
237
+ const lock = readLock(config.root);
238
+
239
+ for (const c of containers) {
240
+ const localRef = `mieweb/${String(c.class_name).toLowerCase()}:${sha}`;
241
+ console.log(`[mieweb] building ${c.class_name} → ${localRef} (${builder.name})`);
242
+ await buildImage(builder, c, config.root, localRef);
243
+
244
+ if (sub === 'push') {
245
+ const reg = registryFor(config);
246
+ const repo = repoRef(reg, c, config);
247
+ console.log(`[mieweb] pushing ${localRef} → ${repo}:{${sha},latest} (skopeo)`);
248
+ const digest = await pushImage(builder, reg, localRef, repo, sha);
249
+ lock[c.class_name] = { ...(lock[c.class_name] ?? {}), [config.target]: { repo, tag: sha, digest } };
250
+ console.log(`[mieweb] pinned ${c.class_name}@${digest}`);
251
+ }
252
+ }
253
+ if (sub === 'push') writeLock(config.root, lock);
254
+ return 0;
255
+ }
256
+
257
+ if (sub === 'inspect') {
258
+ const reg = registryFor(config);
259
+ const nameArg = args[1];
260
+ const targets = nameArg
261
+ ? containers.filter(
262
+ (c) => c.class_name === nameArg || config.containerBindings?.[nameArg]?.class_name === c.class_name,
263
+ )
264
+ : containers;
265
+ if (targets.length === 0) {
266
+ console.error(`mieweb images: no container matches "${nameArg}".`);
267
+ return 1;
268
+ }
269
+ for (const c of targets) {
270
+ const code = await run('skopeo', [
271
+ 'inspect',
272
+ ...skopeoAuthFlags(reg, 'src'),
273
+ `docker://${repoRef(reg, c, config)}:latest`,
274
+ ]);
275
+ if (code !== 0) return code;
276
+ }
277
+ return 0;
278
+ }
279
+
280
+ if (sub === 'status') {
281
+ const lock = readLock(config.root);
282
+ if (Object.keys(lock).length === 0) {
283
+ console.log('mieweb images: no lockfile yet (.mieweb/images.lock.json) — run `mieweb images push`.');
284
+ return 0;
285
+ }
286
+ const reg = registryFor(config);
287
+ for (const [className, perTarget] of Object.entries(lock)) {
288
+ const pinned = perTarget?.[config.target];
289
+ if (!pinned) {
290
+ console.log(`${className}: no pin for target "${config.target}"`);
291
+ continue;
292
+ }
293
+ try {
294
+ const live = await inspectDigest(reg, `${pinned.repo}:latest`);
295
+ const match = live === pinned.digest ? 'in sync' : `DRIFT (latest=${live})`;
296
+ console.log(`${className}: pinned ${pinned.tag} ${pinned.digest} — ${match}`);
297
+ } catch (err) {
298
+ console.log(`${className}: pinned ${pinned.tag} ${pinned.digest} — inspect failed: ${/** @type {Error} */ (err).message}`);
299
+ }
300
+ }
301
+ return 0;
302
+ }
303
+
304
+ console.error('Usage: mieweb images <build|push|inspect [BINDING|Class]|status>');
305
+ return 1;
306
+ }
307
+
308
+ /**
309
+ * Entry point for `mieweb registry <login|logout>`.
310
+ * @param {string[]} args after "registry"
311
+ * @param {import('./config.mjs').MiewebConfig} config
312
+ * @returns {Promise<number>}
313
+ */
314
+ export async function runRegistryCommand(args, config) {
315
+ const sub = args[0];
316
+ if (sub !== 'login' && sub !== 'logout') {
317
+ console.error('Usage: mieweb registry <login|logout>');
318
+ return 1;
319
+ }
320
+ if (!(await commandExists('skopeo'))) {
321
+ console.error('mieweb registry: skopeo not found on PATH (`brew install skopeo`).');
322
+ return 1;
323
+ }
324
+ const reg = registryFor(config);
325
+ const flags = [];
326
+ if (reg.authFile) flags.push('--authfile', expandHome(reg.authFile));
327
+ if (reg.insecureSkipTlsVerify) flags.push('--tls-verify=false');
328
+ if (sub === 'login' && reg.username) flags.push('--username', reg.username);
329
+ // Interactive: skopeo prompts for the password itself — never passed via argv.
330
+ return run('skopeo', [sub, ...flags, reg.url]);
331
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `mieweb` — target-aware wrapper over wrangler.
4
+ *
5
+ * mieweb dev
6
+ * mieweb deploy
7
+ * mieweb tail
8
+ * mieweb d1 migrations apply bluehive-hum
9
+ *
10
+ * On the `cloudflare` target (the default) every command is forwarded
11
+ * verbatim to the real `wrangler` binary, so Cloudflare behavior is identical
12
+ * and nothing about the existing workflow changes. Select another environment
13
+ * with `--target <t>` or `MIEWEB_TARGET=<t>`; those commands are handled by the
14
+ * matching @mieweb adapter instead.
15
+ *
16
+ * This file is plain ESM JavaScript on purpose so `mieweb` runs with bare
17
+ * `node` — no build step, no transpiler, no extra runtime dependency.
18
+ */
19
+ import { readFileSync } from 'node:fs';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { loadConfig } from './config.mjs';
22
+ import { delegateToWrangler } from './cloudflare.mjs';
23
+ import { runHostTarget } from './local.mjs';
24
+ import { runInit } from './init.mjs';
25
+ import { runImagesCommand, runRegistryCommand } from './images.mjs';
26
+
27
+ /** Read this CLI's version from its package.json. */
28
+ function miewebVersion() {
29
+ try {
30
+ const pkgUrl = new URL('../package.json', import.meta.url);
31
+ const pkg = JSON.parse(readFileSync(fileURLToPath(pkgUrl), 'utf8'));
32
+ return pkg.version ?? '0.0.0';
33
+ } catch {
34
+ return '0.0.0';
35
+ }
36
+ }
37
+
38
+ /** @param {string[]} argv */
39
+ async function main(argv) {
40
+ // Pull a leading `--target <t>` / `--target=<t>` out of the arg list before
41
+ // it reaches wrangler (which wouldn't understand it).
42
+ let overrideTarget = null;
43
+ /** @type {string[]} */
44
+ const args = [];
45
+ for (let i = 0; i < argv.length; i += 1) {
46
+ const a = argv[i];
47
+ if (a === '--target' || a === '-t') {
48
+ overrideTarget = argv[i + 1] ?? null;
49
+ i += 1;
50
+ continue;
51
+ }
52
+ if (a.startsWith('--target=')) {
53
+ overrideTarget = a.slice('--target='.length);
54
+ continue;
55
+ }
56
+ args.push(a);
57
+ }
58
+
59
+ if (args[0] === 'help' || args.length === 0 || args[0] === '--help' || args[0] === '-h') {
60
+ printHelp();
61
+ return 0;
62
+ }
63
+
64
+ // Version flags: always show the mieweb version first. On cloudflare we then
65
+ // let wrangler print its own version below; other targets show mieweb only.
66
+ if (args[0] === '--version' || args[0] === '-v' || args[0] === '-V') {
67
+ process.stdout.write(`mieweb ${miewebVersion()}\n`);
68
+ }
69
+
70
+ // `init` scaffolds a NEW project, so it runs before config resolution (there
71
+ // is no mieweb.jsonc to load yet) and is target-independent.
72
+ if (args[0] === 'init') {
73
+ return runInit(args.slice(1));
74
+ }
75
+
76
+ const config = loadConfig({ overrideTarget });
77
+
78
+ // Container image plumbing (build once, skopeo copy — container-plan.md M2).
79
+ // `images push` on the cloudflare target delegates to wrangler's managed
80
+ // registry; every other target goes through skopeo to its own registry.
81
+ if (args[0] === 'images') {
82
+ if (config.target === 'cloudflare' && args[1] === 'push') {
83
+ return delegateToWrangler(['containers', 'push', ...args.slice(2)], { cwd: config.root });
84
+ }
85
+ return runImagesCommand(args.slice(1), config).catch((err) => {
86
+ console.error(err?.message ?? err);
87
+ return 1;
88
+ });
89
+ }
90
+ if (args[0] === 'registry') {
91
+ return runRegistryCommand(args.slice(1), config).catch((err) => {
92
+ console.error(err?.message ?? err);
93
+ return 1;
94
+ });
95
+ }
96
+
97
+ if (config.target === 'cloudflare') {
98
+ // Reference path: hand everything to wrangler untouched.
99
+ return delegateToWrangler(args, { cwd: config.root });
100
+ }
101
+
102
+ // Node "host" targets run the unchanged worker via the host harness. `local`
103
+ // uses the in-process adapters; `mieweb` (os.mieweb.org) uses the networked
104
+ // ones (libSQL/S3/Valkey), registered when local.mjs imports @mieweb/cloud-os.
105
+ if (config.target === 'local' || config.target === 'mieweb') {
106
+ return runHostTarget(args, config);
107
+ }
108
+
109
+ console.error(
110
+ `mieweb: target "${config.target}" has no adapter yet. ` +
111
+ `Supported today: cloudflare (delegates to wrangler), local + mieweb (Node host harness).`,
112
+ );
113
+ return 1;
114
+ }
115
+
116
+ function printHelp() {
117
+ process.stdout.write(
118
+ [
119
+ 'mieweb — target-aware wrapper over wrangler',
120
+ '',
121
+ 'Usage:',
122
+ ' mieweb [--target <cloudflare|local|mieweb>] <command> [...args]',
123
+ '',
124
+ 'Targets:',
125
+ ' cloudflare (default) Forward the command verbatim to wrangler.',
126
+ ' local Run against the local Node host harness / adapters.',
127
+ ' mieweb Run against os.mieweb.org adapters (libSQL/S3/Valkey).',
128
+ '',
129
+ 'Common commands:',
130
+ ' mieweb init [dir] Scaffold a new mieweb project.',
131
+ ' mieweb dev Start a dev server for the active target.',
132
+ ' mieweb deploy Deploy (cloudflare only).',
133
+ ' mieweb tail Stream logs (cloudflare only).',
134
+ ' mieweb d1 migrations apply <db> Apply ./migrations to the target DB.',
135
+ ' mieweb images build|push|inspect|status Build & skopeo-push container images.',
136
+ ' mieweb registry login|logout skopeo login to the target registry.',
137
+ '',
138
+ 'Selecting a target:',
139
+ ' mieweb --target local dev Flag form.',
140
+ ' mieweb --target mieweb dev Run the os.mieweb.org adapters.',
141
+ ' MIEWEB_TARGET=local mieweb dev Env form.',
142
+ ' (or set "target" in mieweb.jsonc)',
143
+ '',
144
+ 'Escape hatch:',
145
+ ' MIEWEB_REAL_WRANGLER=/path/to/wrangler mieweb deploy',
146
+ '',
147
+ ].join('\n'),
148
+ );
149
+ }
150
+
151
+ main(process.argv.slice(2))
152
+ .then((code) => process.exit(code))
153
+ .catch((err) => {
154
+ console.error(err);
155
+ process.exit(1);
156
+ });
package/src/init.mjs ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * `mieweb init [dir]` — scaffold a new mieweb project.
3
+ *
4
+ * Stamps out a minimal, runnable worker plus the three config surfaces the
5
+ * layer expects:
6
+ *
7
+ * wrangler.jsonc source of truth for bindings (Cloudflare shapes)
8
+ * mieweb.jsonc off-Cloudflare driver hints + default target
9
+ * worker/index.mjs a normal `export default { fetch }` worker
10
+ * package.json wired to @mieweb/cli with dev/deploy scripts
11
+ * .gitignore ignores .data/ + node_modules
12
+ *
13
+ * Mirrors `wrangler init` / `npm create cloudflare`: it only writes files, then
14
+ * prints next steps. It never installs deps or touches anything outside the
15
+ * target directory.
16
+ */
17
+ import { mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
18
+ import { resolve, join, basename } from 'node:path';
19
+
20
+ /**
21
+ * @param {string[]} args args after the `init` verb (e.g. ['my-app'])
22
+ * @param {{ cwd?: string }} [opts]
23
+ * @returns {number} exit code
24
+ */
25
+ export function runInit(args, opts = {}) {
26
+ const cwd = opts.cwd ?? process.cwd();
27
+
28
+ // First non-flag arg is the target directory; default to '.' (cwd).
29
+ const dirArg = args.find((a) => !a.startsWith('-')) ?? '.';
30
+ const force = args.includes('--force') || args.includes('-f');
31
+
32
+ const targetDir = resolve(cwd, dirArg);
33
+ const projectName = sanitizeName(basename(targetDir));
34
+
35
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0 && !force) {
36
+ console.error(
37
+ `mieweb init: "${dirArg}" already exists and is not empty. ` +
38
+ `Pass --force to scaffold into it anyway.`,
39
+ );
40
+ return 1;
41
+ }
42
+
43
+ mkdirSync(join(targetDir, 'worker'), { recursive: true });
44
+
45
+ /** @type {Array<[string, string]>} */
46
+ const files = [
47
+ ['package.json', packageJson(projectName)],
48
+ ['wrangler.jsonc', wranglerJsonc(projectName)],
49
+ ['mieweb.jsonc', miewebJsonc()],
50
+ ['worker/index.mjs', workerIndex()],
51
+ ['.gitignore', gitignore()],
52
+ ['README.md', readme(projectName)],
53
+ ];
54
+
55
+ for (const [rel, contents] of files) {
56
+ const dest = join(targetDir, rel);
57
+ if (existsSync(dest) && !force) {
58
+ console.error(`mieweb init: refusing to overwrite ${rel} (use --force)`);
59
+ return 1;
60
+ }
61
+ writeFileSync(dest, contents);
62
+ }
63
+
64
+ const where = dirArg === '.' ? '.' : dirArg;
65
+ process.stdout.write(
66
+ [
67
+ `Created a mieweb project in ${where}`,
68
+ '',
69
+ 'Next steps:',
70
+ ...(dirArg === '.' ? [] : [` cd ${dirArg}`]),
71
+ ' npm install # or: pnpm install',
72
+ ' npx mieweb --target local dev',
73
+ '',
74
+ 'Then deploy to Cloudflare with:',
75
+ ' npx mieweb deploy',
76
+ '',
77
+ ].join('\n'),
78
+ );
79
+ return 0;
80
+ }
81
+
82
+ /** npm package names must be lowercase, url-safe. */
83
+ function sanitizeName(name) {
84
+ const cleaned = name
85
+ .toLowerCase()
86
+ .replace(/[^a-z0-9._-]+/g, '-')
87
+ .replace(/^[-_.]+|[-_.]+$/g, '');
88
+ return cleaned || 'mieweb-app';
89
+ }
90
+
91
+ function packageJson(name) {
92
+ return (
93
+ JSON.stringify(
94
+ {
95
+ name,
96
+ version: '0.0.0',
97
+ private: true,
98
+ type: 'module',
99
+ scripts: {
100
+ dev: 'mieweb --target local dev',
101
+ 'dev:cf': 'mieweb dev',
102
+ deploy: 'mieweb deploy',
103
+ },
104
+ devDependencies: {
105
+ '@mieweb/cli': '^0.1.0',
106
+ },
107
+ },
108
+ null,
109
+ 2,
110
+ ) + '\n'
111
+ );
112
+ }
113
+
114
+ function wranglerJsonc(name) {
115
+ return `{
116
+ // wrangler.jsonc is the source of truth for your bindings. On the cloudflare
117
+ // target \`mieweb\` forwards verbatim to wrangler; other targets read these
118
+ // shapes and back each binding with an adapter (see mieweb.jsonc).
119
+ "$schema": "node_modules/wrangler/config-schema.json",
120
+ "name": "${name}",
121
+ "main": "worker/index.mjs",
122
+ "compatibility_date": "2025-01-01",
123
+ "compatibility_flags": ["nodejs_compat"],
124
+
125
+ // A starter D1 + KV binding. Add R2/Queues/Durable Objects/Vectorize/AI as
126
+ // you need them — every contract surface is portable across targets.
127
+ "d1_databases": [
128
+ { "binding": "DB", "database_name": "${name}", "database_id": "local-${name}" }
129
+ ],
130
+ "kv_namespaces": [{ "binding": "CACHE", "id": "local-${name}-cache" }]
131
+ }
132
+ `;
133
+ }
134
+
135
+ function miewebJsonc() {
136
+ return `{
137
+ // Off-Cloudflare driver hints. wrangler.jsonc stays the source of truth for
138
+ // WHICH bindings exist; this sidecar only says which adapter backs each one
139
+ // per target. Pick a target with \`mieweb --target <t> dev\` or the field below.
140
+ "$schema": "node_modules/@mieweb/cli/mieweb-config.schema.json",
141
+ "wrangler": "./wrangler.jsonc",
142
+ "target": "local",
143
+ "targets": {
144
+ // local: in-process Node adapters, zero external services.
145
+ "local": {
146
+ "port": 8787,
147
+ "bindings": {
148
+ "DB": { "driver": "sqlite", "path": ".data/local/d1.sqlite" },
149
+ "CACHE": { "driver": "memory" }
150
+ }
151
+ },
152
+
153
+ // mieweb (os.mieweb.org): libSQL + Valkey. Bring the backing services up
154
+ // with the docker-compose.yml from @mieweb/cloud-os.
155
+ "mieweb": {
156
+ "port": 8787,
157
+ "bindings": {
158
+ "DB": { "driver": "libsql", "url": "http://localhost:8080" },
159
+ "CACHE": { "driver": "valkey", "host": "127.0.0.1", "port": 6379, "namespace": "app" }
160
+ }
161
+ }
162
+ }
163
+ }
164
+ `;
165
+ }
166
+
167
+ function workerIndex() {
168
+ return `/**
169
+ * A normal Cloudflare worker — \`export default { fetch }\`. The same module runs
170
+ * unchanged on every target:
171
+ *
172
+ * cloudflare : \`mieweb dev\` (wrangler / Miniflare) with native bindings
173
+ * local : \`mieweb --target local dev\` (SQLite + in-memory KV)
174
+ * mieweb/os : \`mieweb --target mieweb dev\` (libSQL + Valkey)
175
+ *
176
+ * @typedef {Object} Env
177
+ * @property {D1Database} DB
178
+ * @property {KVNamespace} CACHE
179
+ */
180
+
181
+ const json = (data, status = 200) =>
182
+ new Response(JSON.stringify(data, null, 2), {
183
+ status,
184
+ headers: { 'content-type': 'application/json' },
185
+ });
186
+
187
+ export default {
188
+ /**
189
+ * @param {Request} request
190
+ * @param {Env} env
191
+ */
192
+ async fetch(request, env) {
193
+ const url = new URL(request.url);
194
+
195
+ if (url.pathname === '/') {
196
+ return json({ ok: true, hint: 'try /hits' });
197
+ }
198
+
199
+ // /hits — a tiny D1 + KV example: count requests in D1, cache the last
200
+ // count in KV. Touches two portable surfaces with no Cloudflare lock-in.
201
+ if (url.pathname === '/hits') {
202
+ await env.DB.prepare(
203
+ 'CREATE TABLE IF NOT EXISTS hits (id INTEGER PRIMARY KEY, at TEXT)',
204
+ ).run();
205
+ await env.DB.prepare('INSERT INTO hits (at) VALUES (?)')
206
+ .bind(new Date().toISOString())
207
+ .run();
208
+ const { results } = await env.DB.prepare(
209
+ 'SELECT COUNT(*) AS n FROM hits',
210
+ ).all();
211
+ const n = results?.[0]?.n ?? 0;
212
+ await env.CACHE.put('last-count', String(n));
213
+ return json({ hits: n });
214
+ }
215
+
216
+ return json({ error: 'not found' }, 404);
217
+ },
218
+ };
219
+ `;
220
+ }
221
+
222
+ function gitignore() {
223
+ return `node_modules/
224
+ .data/
225
+ .wrangler/
226
+ *.log
227
+ `;
228
+ }
229
+
230
+ function readme(name) {
231
+ return `# ${name}
232
+
233
+ A [mieweb](https://github.com/mieweb/cloud) project — one worker, portable across
234
+ Cloudflare, local Node, and os.mieweb.org.
235
+
236
+ ## Develop
237
+
238
+ \`\`\`sh
239
+ npm install
240
+
241
+ # local Node adapters (SQLite + in-memory KV), no external services
242
+ npx mieweb --target local dev
243
+
244
+ # Cloudflare (wrangler / Miniflare)
245
+ npx mieweb dev
246
+ \`\`\`
247
+
248
+ Hit a route:
249
+
250
+ \`\`\`sh
251
+ curl localhost:8787/hits
252
+ \`\`\`
253
+
254
+ ## Deploy
255
+
256
+ \`\`\`sh
257
+ npx mieweb deploy # forwards to wrangler on the cloudflare target
258
+ \`\`\`
259
+ `;
260
+ }
package/src/jsonc.mjs ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Minimal JSONC reader shared by the mieweb CLI.
3
+ *
4
+ * wrangler.jsonc (and mieweb.jsonc) use comments and trailing commas, which
5
+ * `JSON.parse` rejects. Rather than pull in a dependency (and to keep the CLI
6
+ * runnable with bare `node`), we strip comments + trailing commas in a
7
+ * string-aware single pass, then hand the result to `JSON.parse`.
8
+ *
9
+ * This is deliberately small: it understands `//` line comments, `/​* *​/`
10
+ * block comments, double-quoted strings with escapes, and trailing commas
11
+ * before `}`/`]`. That covers everything wrangler emits.
12
+ *
13
+ * @param {string} text raw JSONC source
14
+ * @returns {unknown} parsed value
15
+ */
16
+ export function parseJsonc(text) {
17
+ let out = '';
18
+ let i = 0;
19
+ const n = text.length;
20
+ let inString = false;
21
+
22
+ while (i < n) {
23
+ const ch = text[i];
24
+ const next = text[i + 1];
25
+
26
+ if (inString) {
27
+ out += ch;
28
+ if (ch === '\\') {
29
+ // Copy the escaped character verbatim.
30
+ out += text[i + 1] ?? '';
31
+ i += 2;
32
+ continue;
33
+ }
34
+ if (ch === '"') inString = false;
35
+ i += 1;
36
+ continue;
37
+ }
38
+
39
+ if (ch === '"') {
40
+ inString = true;
41
+ out += ch;
42
+ i += 1;
43
+ continue;
44
+ }
45
+
46
+ if (ch === '/' && next === '/') {
47
+ // Line comment: skip to end of line.
48
+ i += 2;
49
+ while (i < n && text[i] !== '\n') i += 1;
50
+ continue;
51
+ }
52
+
53
+ if (ch === '/' && next === '*') {
54
+ // Block comment: skip to closing */.
55
+ i += 2;
56
+ while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
57
+ i += 2;
58
+ continue;
59
+ }
60
+
61
+ out += ch;
62
+ i += 1;
63
+ }
64
+
65
+ // Remove trailing commas (`,]` / `,}`), tolerating whitespace between.
66
+ out = out.replace(/,(\s*[}\]])/g, '$1');
67
+ return JSON.parse(out);
68
+ }
package/src/local.mjs ADDED
@@ -0,0 +1,94 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ /**
4
+ * Dispatch a command on a Node "host" target (`local`, `mieweb`, …) through the
5
+ * shared Node host harness in @mieweb/cloud-local.
6
+ *
7
+ * * `local` → @mieweb/cloud-local drivers (SQLite / fs / memory / in-proc).
8
+ * * `mieweb` → @mieweb/cloud-os drivers (libSQL / S3 / Valkey). Importing that
9
+ * package registers its drivers into the shared registry; the very same
10
+ * host harness then builds the env and runs the unchanged worker.
11
+ *
12
+ * Supported commands:
13
+ * * `mieweb [--target <t>] d1 migrations apply [db]` (local / sqlite only)
14
+ * * `mieweb [--target <t>] dev` → start the host harness
15
+ *
16
+ * Anything else returns a clear "not implemented" message rather than silently
17
+ * doing the wrong thing.
18
+ *
19
+ * @param {string[]} args command + args (target flag already stripped)
20
+ * @param {import('./config.mjs').MiewebConfig} config
21
+ * @returns {Promise<number>} exit code
22
+ */
23
+ export async function runHostTarget(args, config) {
24
+ // Non-local host targets ship their drivers in a separate package; importing
25
+ // it is enough to register them into @mieweb/cloud-local's shared registry.
26
+ if (config.target === 'mieweb') {
27
+ await import('@mieweb/cloud-os');
28
+ }
29
+
30
+ const [cmd, ...rest] = args;
31
+
32
+ if (cmd === 'd1' && rest[0] === 'migrations' && rest[1] === 'apply') {
33
+ return d1MigrationsApply(config);
34
+ }
35
+
36
+ if (cmd === 'dev') {
37
+ return dev(config);
38
+ }
39
+
40
+ console.error(
41
+ `mieweb: command "${args.join(' ')}" is not implemented for target "${config.target}" yet.\n` +
42
+ 'Supported commands: `d1 migrations apply` (local), `dev`.',
43
+ );
44
+ return 1;
45
+ }
46
+
47
+ /**
48
+ * Apply migrations to the local SQLite DB configured for the DB binding.
49
+ * @param {import('./config.mjs').MiewebConfig} config
50
+ */
51
+ async function d1MigrationsApply(config) {
52
+ const dbCfg = config.targetConfig?.bindings?.DB;
53
+ if (!dbCfg || dbCfg.driver !== 'sqlite') {
54
+ console.error(
55
+ `mieweb: target "${config.target}" has no sqlite DB binding to migrate. ` +
56
+ 'Migrations are only wired for the local/sqlite driver today; other ' +
57
+ 'targets manage schema with their own tooling (e.g. libSQL).',
58
+ );
59
+ return 1;
60
+ }
61
+ const migrationsDir = resolve(
62
+ config.root,
63
+ /** @type {string} */ (config.wrangler?.d1_databases?.[0]?.migrations_dir) || 'migrations',
64
+ );
65
+ const dbPath = resolve(config.root, dbCfg.path);
66
+
67
+ const { applyMigrations } = await import('@mieweb/cloud-local/migrate');
68
+ const { applied, skipped } = await applyMigrations({ dbPath, migrationsDir });
69
+
70
+ console.log(
71
+ `[mieweb] migrations applied to ${dbPath}\n` +
72
+ ` applied: ${applied.length}${applied.length ? ` (${applied[applied.length - 1]})` : ''}\n` +
73
+ ` already up to date: ${skipped.length}`,
74
+ );
75
+ return 0;
76
+ }
77
+
78
+ /**
79
+ * Start the local Node host harness.
80
+ * @param {import('./config.mjs').MiewebConfig} config
81
+ */
82
+ async function dev(config) {
83
+ const { startLocalHost } = await import('@mieweb/cloud-local/host');
84
+ const handle = await startLocalHost({ config });
85
+ // Keep the process alive until interrupted.
86
+ return new Promise((resolvePromise) => {
87
+ const shutdown = () => {
88
+ handle.stop();
89
+ resolvePromise(0);
90
+ };
91
+ process.on('SIGINT', shutdown);
92
+ process.on('SIGTERM', shutdown);
93
+ });
94
+ }