@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 +21 -0
- package/README.md +68 -0
- package/mieweb-config.schema.json +132 -0
- package/package.json +40 -0
- package/src/cloudflare.mjs +34 -0
- package/src/config.mjs +106 -0
- package/src/images.mjs +331 -0
- package/src/index.mjs +156 -0
- package/src/init.mjs +260 -0
- package/src/jsonc.mjs +68 -0
- package/src/local.mjs +94 -0
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
|
+
}
|