@flisk-sh/flisk-export 0.0.0-stage → 0.2.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 +54 -2
- package/dist/cli.js +7 -0
- package/dist/compose.js +199 -0
- package/dist/discover.js +97 -0
- package/dist/errors.js +9 -0
- package/dist/generate.js +256 -0
- package/dist/index.js +4 -0
- package/dist/main.js +155 -0
- package/package.json +42 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Flisk
|
|
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
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# flisk-export
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Write a Render Blueprint (`render.yaml`) or a Cloudflare `wrangler.toml` from the `service.json` files of a [Flisk](https://github.com/flisk-sh/flisk) project.
|
|
4
|
+
|
|
5
|
+
It writes files only. It never deploys, never creates DNS records, never calls a cloud API, and writes environment variable **names**, never values. The Flisk CLI runs it at a pinned version as `flisk export`.
|
|
6
|
+
|
|
7
|
+
## Use
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx @flisk-sh/flisk-export render [dir] [--resource <name>] [--out <dir>] [--dry-run] [--force]
|
|
11
|
+
npx @flisk-sh/flisk-export wrangler [dir] --resource <name> [--route <host>] [--out <dir>] [--allow-non-worker] [--dry-run] [--force]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or, from a Flisk project: `flisk export render --dry-run`.
|
|
15
|
+
|
|
16
|
+
### render
|
|
17
|
+
|
|
18
|
+
- Exports every resource whose `appType` is `backend`, `frontend`, `agent`, `mcp` or `bring-your-own` (a resource with no `appType` is not exported), or one with `--resource`.
|
|
19
|
+
- A resource with a Dockerfile becomes `runtime: docker`; one with a `package.json` becomes `runtime: node`.
|
|
20
|
+
- The `service.json` `port` is written as `PORT`, because Render routes traffic to `$PORT`.
|
|
21
|
+
- Every other env name is written with `sync: false`, so Render asks for the value. Names that are not valid variable names are left out with a warning.
|
|
22
|
+
- `healthCheckPath` comes from `service.json` (default `/health`).
|
|
23
|
+
- Paths are relative to the **git repository root**, which is where Render reads them, and the file is written there by default. `--out` moves the file but not the paths.
|
|
24
|
+
- Two resources that become the same service name (`api_v1` and `api-v1`) are refused.
|
|
25
|
+
|
|
26
|
+
### wrangler
|
|
27
|
+
|
|
28
|
+
- One resource. `main` comes from `package.json` `main`, or the first of `src/index.ts`, `src/index.js`, `src/worker.ts`, `src/worker.js`, `index.ts`, `index.js`.
|
|
29
|
+
- A resource with a Dockerfile, or without a `wrangler`, `@cloudflare/workers-types` or `hono` dependency, is refused unless `--allow-non-worker`.
|
|
30
|
+
- `--route api.example.com` writes a Custom Domain route. Wildcards are refused: Custom Domains do not accept them.
|
|
31
|
+
- Env names are listed in a comment for `wrangler secret put`.
|
|
32
|
+
|
|
33
|
+
## Develop
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npm install
|
|
37
|
+
npm run typecheck && npm run lint && npm run build && npm test
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Compose
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
flisk-export compose # writes .autogenerated/docker-compose.yml
|
|
44
|
+
flisk-export compose --check # exit 1 when that file is out of date (for CI)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Writes a **standalone** `docker-compose.yml` from the bring-your-own `service.json` files (`image` or `dockerfile`, `env`,
|
|
48
|
+
`volumes`, `extraPorts`, `dependsOn`, `sidecarOf`, `container`). It runs with plain `docker compose up` and no Flisk: no external
|
|
49
|
+
networks, no `FLISK_*` variables, no generated secrets. `${VAR}` references in `env` pass through for Compose to read from `.env`.
|
|
50
|
+
Other resource types and `command` (native) services are not exported. The file is generated: change `service.json` and run it
|
|
51
|
+
again with `--force`. It never starts a container or calls an API.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
package/dist/cli.js
ADDED
package/dist/compose.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a standalone docker-compose.yml from the bring-your-own service.json files of a Flisk project.
|
|
3
|
+
*
|
|
4
|
+
* The result runs with plain `docker compose up` and no Flisk: no external networks, no injected Flisk variables and no
|
|
5
|
+
* generated secrets. `${VAR}` references in `env` pass through for Compose to resolve from the project `.env`. These
|
|
6
|
+
* functions only return text; nothing here starts a container, deploys or calls an API.
|
|
7
|
+
*/
|
|
8
|
+
import { relative, sep } from "node:path";
|
|
9
|
+
import { ExportError } from "./errors.js";
|
|
10
|
+
/** The only resource type the Compose export covers: it is the one whose service.json is a Compose service. */
|
|
11
|
+
export const COMPOSE_TYPE = "bring-your-own";
|
|
12
|
+
const RESTART_POLICIES = new Set(["no", "on-failure", "unless-stopped", "always"]);
|
|
13
|
+
const SERVICE_NAME = /^[a-z0-9][a-z0-9_.-]*$/i;
|
|
14
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
15
|
+
const SHM_SIZE = /^\d+(b|k|kb|m|mb|g|gb)?$/i;
|
|
16
|
+
export function isComposeExportable(resource) {
|
|
17
|
+
return resource.type === COMPOSE_TYPE && typeof resource.config.command !== "string";
|
|
18
|
+
}
|
|
19
|
+
function record(value) {
|
|
20
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
21
|
+
? value
|
|
22
|
+
: {};
|
|
23
|
+
}
|
|
24
|
+
function strings(value) {
|
|
25
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
26
|
+
}
|
|
27
|
+
/** A YAML double-quoted scalar. JSON strings are valid ones; `$` is kept so Compose can interpolate. */
|
|
28
|
+
function q(value) {
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
}
|
|
31
|
+
function relPath(from, to) {
|
|
32
|
+
const rel = relative(from, to).split(sep).join("/");
|
|
33
|
+
return rel === "" ? "." : rel;
|
|
34
|
+
}
|
|
35
|
+
/** A host path written for a Compose file in `outDir`: `./x` for children, `../x` for parents. */
|
|
36
|
+
function hostPath(outDir, absolute) {
|
|
37
|
+
const rel = relPath(outDir, absolute);
|
|
38
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
39
|
+
}
|
|
40
|
+
function isNamedVolume(source) {
|
|
41
|
+
return !/[\\/]/.test(source) && !source.startsWith(".") && !source.startsWith("~");
|
|
42
|
+
}
|
|
43
|
+
function serviceLines(resource, outDir, known) {
|
|
44
|
+
const c = resource.config;
|
|
45
|
+
const name = resource.name;
|
|
46
|
+
if (!SERVICE_NAME.test(name)) {
|
|
47
|
+
throw new ExportError(`"${name}" is not a valid Compose service name`, [
|
|
48
|
+
"Use letters, digits, dashes, dots and underscores",
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
const lines = [` ${name}:`];
|
|
52
|
+
const namedVolumes = new Set();
|
|
53
|
+
const image = typeof c.image === "string" && c.image ? c.image : undefined;
|
|
54
|
+
const dockerfile = typeof c.dockerfile === "string" && c.dockerfile ? c.dockerfile : undefined;
|
|
55
|
+
const buildContext = typeof c.buildContext === "string" ? c.buildContext : undefined;
|
|
56
|
+
if (image)
|
|
57
|
+
lines.push(` image: ${q(image)}`);
|
|
58
|
+
if (dockerfile || !image) {
|
|
59
|
+
const context = hostPath(outDir, resolveFrom(resource.path, buildContext ?? "."));
|
|
60
|
+
lines.push(" build:", ` context: ${q(context)}`);
|
|
61
|
+
if (dockerfile)
|
|
62
|
+
lines.push(` dockerfile: ${q(dockerfile)}`);
|
|
63
|
+
}
|
|
64
|
+
const container = record(c.container);
|
|
65
|
+
if (container.init !== false)
|
|
66
|
+
lines.push(" init: true");
|
|
67
|
+
if (typeof c.restart === "string") {
|
|
68
|
+
if (!RESTART_POLICIES.has(c.restart)) {
|
|
69
|
+
throw new ExportError(`${name}: restart "${c.restart}" is not a Compose restart policy`);
|
|
70
|
+
}
|
|
71
|
+
lines.push(` restart: ${q(c.restart)}`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
lines.push(' restart: "unless-stopped"');
|
|
75
|
+
}
|
|
76
|
+
const sidecarOf = typeof c.sidecarOf === "string" ? c.sidecarOf : undefined;
|
|
77
|
+
if (sidecarOf) {
|
|
78
|
+
if (!known.has(sidecarOf)) {
|
|
79
|
+
throw new ExportError(`${name}: sidecarOf "${sidecarOf}" is not a bring-your-own resource`);
|
|
80
|
+
}
|
|
81
|
+
lines.push(` network_mode: ${q(`service:${sidecarOf}`)}`);
|
|
82
|
+
}
|
|
83
|
+
const env = record(c.env);
|
|
84
|
+
const envNames = Object.keys(env)
|
|
85
|
+
.filter((k) => ENV_NAME.test(k))
|
|
86
|
+
.sort();
|
|
87
|
+
if (envNames.length > 0) {
|
|
88
|
+
lines.push(" environment:");
|
|
89
|
+
for (const key of envNames)
|
|
90
|
+
lines.push(` ${key}: ${q(String(env[key]))}`);
|
|
91
|
+
}
|
|
92
|
+
const volumes = Array.isArray(c.volumes) ? c.volumes : [];
|
|
93
|
+
if (volumes.length > 0) {
|
|
94
|
+
lines.push(" volumes:");
|
|
95
|
+
for (const raw of volumes) {
|
|
96
|
+
const v = record(raw);
|
|
97
|
+
if (typeof v.source !== "string" || typeof v.target !== "string") {
|
|
98
|
+
throw new ExportError(`${name}: a volume needs a source and a target`);
|
|
99
|
+
}
|
|
100
|
+
let source = v.source;
|
|
101
|
+
if (isNamedVolume(source)) {
|
|
102
|
+
namedVolumes.add(source);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
source = hostPath(outDir, resolveFrom(resource.path, source));
|
|
106
|
+
}
|
|
107
|
+
lines.push(` - ${q(`${source}:${v.target}${v.readOnly === true ? ":ro" : ""}`)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const ports = Array.isArray(c.extraPorts) ? c.extraPorts : [];
|
|
111
|
+
if (ports.length > 0) {
|
|
112
|
+
if (sidecarOf) {
|
|
113
|
+
throw new ExportError(`${name}: a sidecar shares another service's network and cannot publish ports`);
|
|
114
|
+
}
|
|
115
|
+
lines.push(" ports:");
|
|
116
|
+
for (const raw of ports) {
|
|
117
|
+
const p = record(raw);
|
|
118
|
+
const port = Number(p.port);
|
|
119
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
120
|
+
throw new ExportError(`${name}: extraPorts needs a port from 1 to 65535`);
|
|
121
|
+
}
|
|
122
|
+
const hostPort = p.hostPort === undefined ? port : Number(p.hostPort);
|
|
123
|
+
const bind = typeof p.bind === "string" && p.bind ? p.bind : "127.0.0.1";
|
|
124
|
+
const protocol = p.protocol === "udp" ? "udp" : "tcp";
|
|
125
|
+
lines.push(` - ${q(`${bind}:${hostPort}:${port}/${protocol}`)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const dependsOn = strings(c.dependsOn).filter((d) => known.has(d));
|
|
129
|
+
if (dependsOn.length > 0) {
|
|
130
|
+
lines.push(" depends_on:", ...dependsOn.map((d) => ` - ${q(d)}`));
|
|
131
|
+
}
|
|
132
|
+
const capAdd = strings(container.capAdd);
|
|
133
|
+
const capDrop = strings(container.capDrop);
|
|
134
|
+
const securityOpt = strings(container.securityOpt);
|
|
135
|
+
if (capAdd.length > 0)
|
|
136
|
+
lines.push(" cap_add:", ...capAdd.map((v) => ` - ${q(v)}`));
|
|
137
|
+
if (capDrop.length > 0)
|
|
138
|
+
lines.push(" cap_drop:", ...capDrop.map((v) => ` - ${q(v)}`));
|
|
139
|
+
if (container.readOnly === true)
|
|
140
|
+
lines.push(" read_only: true");
|
|
141
|
+
if (typeof container.user === "string")
|
|
142
|
+
lines.push(` user: ${q(container.user)}`);
|
|
143
|
+
if (securityOpt.length > 0) {
|
|
144
|
+
lines.push(" security_opt:", ...securityOpt.map((v) => ` - ${q(v)}`));
|
|
145
|
+
}
|
|
146
|
+
if (typeof container.shmSize === "string") {
|
|
147
|
+
if (!SHM_SIZE.test(container.shmSize)) {
|
|
148
|
+
throw new ExportError(`${name}: shmSize "${container.shmSize}" is not a Compose size`);
|
|
149
|
+
}
|
|
150
|
+
lines.push(` shm_size: ${q(container.shmSize)}`);
|
|
151
|
+
}
|
|
152
|
+
return { lines, namedVolumes };
|
|
153
|
+
}
|
|
154
|
+
function resolveFrom(base, rel) {
|
|
155
|
+
return rel.startsWith("/") ? rel : joinPosix(base, rel);
|
|
156
|
+
}
|
|
157
|
+
function joinPosix(base, rel) {
|
|
158
|
+
const parts = base.split(sep).concat(rel.split("/"));
|
|
159
|
+
const out = [];
|
|
160
|
+
for (const part of parts) {
|
|
161
|
+
if (part === "" || part === ".")
|
|
162
|
+
continue;
|
|
163
|
+
if (part === "..")
|
|
164
|
+
out.pop();
|
|
165
|
+
else
|
|
166
|
+
out.push(part);
|
|
167
|
+
}
|
|
168
|
+
return (base.startsWith("/") ? "/" : "") + out.join("/");
|
|
169
|
+
}
|
|
170
|
+
/** The text of a docker-compose.yml for `resources`, written for a file in `outDir`. */
|
|
171
|
+
export function buildCompose(outDir, resources) {
|
|
172
|
+
const services = resources.filter(isComposeExportable);
|
|
173
|
+
if (services.length === 0) {
|
|
174
|
+
throw new ExportError("No bring-your-own resource was found to export", [
|
|
175
|
+
`Exported type: ${COMPOSE_TYPE} (a service.json with an image or a Dockerfile, and no command)`,
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
178
|
+
const known = new Set(services.map((s) => s.name));
|
|
179
|
+
const body = [];
|
|
180
|
+
const volumes = new Set();
|
|
181
|
+
for (const service of services) {
|
|
182
|
+
const built = serviceLines(service, outDir, known);
|
|
183
|
+
body.push(...built.lines, "");
|
|
184
|
+
for (const v of built.namedVolumes)
|
|
185
|
+
volumes.add(v);
|
|
186
|
+
}
|
|
187
|
+
const header = [
|
|
188
|
+
"###############################################################################",
|
|
189
|
+
"# GENERATED by `flisk export compose` from the service.json files. DO NOT EDIT.",
|
|
190
|
+
"# Change service.json and run `flisk export compose --force`; CI can run",
|
|
191
|
+
"# `flisk export compose --check` to fail when this file is out of date.",
|
|
192
|
+
"# Runs with plain `docker compose up`; Flisk is not needed to start it.",
|
|
193
|
+
"###############################################################################",
|
|
194
|
+
"",
|
|
195
|
+
"services:",
|
|
196
|
+
];
|
|
197
|
+
const tail = volumes.size > 0 ? ["volumes:", ...[...volumes].sort().map((v) => ` ${v}:`), ""] : [];
|
|
198
|
+
return [...header, ...body, ...tail].join("\n").replace(/\n{3,}/g, "\n\n");
|
|
199
|
+
}
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the Flisk project root and the service.json files in it. This is a small read-only copy of how the Flisk
|
|
3
|
+
* CLI discovers resources: it never writes, never runs anything but `git rev-parse` and never calls a network.
|
|
4
|
+
*/
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { ExportError } from "./errors.js";
|
|
9
|
+
const SKIP_DIRECTORIES = new Set(["node_modules", "dist", "build", "coverage", "vendor"]);
|
|
10
|
+
const MAX_DEPTH = 5;
|
|
11
|
+
/** The nearest directory at or above `start` holding `.flisk/project.json`, or `start` itself when there is none. */
|
|
12
|
+
export function findProjectRoot(start) {
|
|
13
|
+
// The real path, so paths compare with what `git rev-parse` prints (macOS /var is a link to /private/var).
|
|
14
|
+
const from = existsSync(start) ? realpathSync(resolve(start)) : resolve(start);
|
|
15
|
+
let dir = from;
|
|
16
|
+
while (true) {
|
|
17
|
+
if (existsSync(join(dir, ".flisk", "project.json")))
|
|
18
|
+
return dir;
|
|
19
|
+
const parent = dirname(dir);
|
|
20
|
+
if (parent === dir)
|
|
21
|
+
return from;
|
|
22
|
+
dir = parent;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The root of the git repository holding `dir`, or `dir` when it is not in one. Render reads every path in a
|
|
27
|
+
* Blueprint relative to the repository root, so the Blueprint is written against this.
|
|
28
|
+
*/
|
|
29
|
+
export function findRepoRoot(dir) {
|
|
30
|
+
try {
|
|
31
|
+
const out = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
32
|
+
cwd: dir,
|
|
33
|
+
encoding: "utf-8",
|
|
34
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
35
|
+
}).trim();
|
|
36
|
+
return out ? resolve(out) : dir;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function findServiceJsonFiles(dir, depth = 0) {
|
|
43
|
+
if (depth > MAX_DEPTH)
|
|
44
|
+
return [];
|
|
45
|
+
const results = [];
|
|
46
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
47
|
+
const full = join(dir, entry.name);
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
if (SKIP_DIRECTORIES.has(entry.name) || entry.name.startsWith("."))
|
|
50
|
+
continue;
|
|
51
|
+
results.push(...findServiceJsonFiles(full, depth + 1));
|
|
52
|
+
}
|
|
53
|
+
else if (entry.isFile() && entry.name === "service.json") {
|
|
54
|
+
results.push(full);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
59
|
+
/** Every resource under the project root. A service.json that is not a JSON object with appName is skipped. */
|
|
60
|
+
export function discoverResources(projectRoot, warn = () => { }) {
|
|
61
|
+
const resources = [];
|
|
62
|
+
for (const file of findServiceJsonFiles(projectRoot)) {
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
warn(`skipped ${file}: invalid JSON`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
72
|
+
warn(`skipped ${file}: not a JSON object`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const config = parsed;
|
|
76
|
+
if (typeof config.appName !== "string" || !config.appName) {
|
|
77
|
+
warn(`skipped ${file}: missing appName`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
resources.push({
|
|
81
|
+
name: config.appName,
|
|
82
|
+
path: dirname(file),
|
|
83
|
+
type: typeof config.appType === "string" && config.appType ? config.appType : undefined,
|
|
84
|
+
config,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return resources.sort((a, b) => a.name.localeCompare(b.name));
|
|
88
|
+
}
|
|
89
|
+
export function findResource(resources, name) {
|
|
90
|
+
const found = resources.find((resource) => resource.name === name);
|
|
91
|
+
if (!found) {
|
|
92
|
+
throw new ExportError(`No resource named "${name}" was found`, [
|
|
93
|
+
`Resources found: ${resources.map((resource) => resource.name).join(", ") || "none"}`,
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
return found;
|
|
97
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A refusal the CLI prints as "Error: <message>" followed by one "-> <hint>" line per hint, then exits 1. */
|
|
2
|
+
export class ExportError extends Error {
|
|
3
|
+
hints;
|
|
4
|
+
constructor(message, hints = []) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ExportError";
|
|
7
|
+
this.hints = hints;
|
|
8
|
+
}
|
|
9
|
+
}
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the text of a Render Blueprint (render.yaml) and a Cloudflare Workers config (wrangler.toml) from Flisk
|
|
3
|
+
* service.json files.
|
|
4
|
+
*
|
|
5
|
+
* These functions only return text. Nothing here deploys, creates DNS records or calls a cloud API, and no
|
|
6
|
+
* environment value is ever copied, only the names (Render asks for each value itself because of `sync: false`).
|
|
7
|
+
* The one value written is the service port, which is not a secret.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join, relative, sep } from "node:path";
|
|
11
|
+
import { ExportError } from "./errors.js";
|
|
12
|
+
/** The resource types that serve HTTP, which is what a Render web service and a Worker are. */
|
|
13
|
+
export const EXPORTABLE_TYPES = [
|
|
14
|
+
"backend",
|
|
15
|
+
"frontend",
|
|
16
|
+
"agent",
|
|
17
|
+
"mcp",
|
|
18
|
+
"bring-your-own",
|
|
19
|
+
];
|
|
20
|
+
/** Health check path a service gets when service.json sets none (the same default the Flisk healthcheck uses). */
|
|
21
|
+
export const DEFAULT_HEALTH_CHECK_PATH = "/health";
|
|
22
|
+
/** Candidate Worker entry points, relative to the resource directory, tried in order when package.json has no main. */
|
|
23
|
+
export const WORKER_ENTRY_CANDIDATES = [
|
|
24
|
+
"src/index.ts",
|
|
25
|
+
"src/index.js",
|
|
26
|
+
"src/worker.ts",
|
|
27
|
+
"src/worker.js",
|
|
28
|
+
"index.ts",
|
|
29
|
+
"index.js",
|
|
30
|
+
];
|
|
31
|
+
/** A package.json dependency that marks a resource as written for Cloudflare Workers. */
|
|
32
|
+
export const WORKER_DEPENDENCIES = [
|
|
33
|
+
"wrangler",
|
|
34
|
+
"@cloudflare/workers-types",
|
|
35
|
+
"hono",
|
|
36
|
+
];
|
|
37
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
38
|
+
// No wildcard: Cloudflare Custom Domains (custom_domain = true) do not accept one.
|
|
39
|
+
const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
40
|
+
/** A name Render and Cloudflare both accept: lowercase letters, digits and dashes. */
|
|
41
|
+
export function deployName(name) {
|
|
42
|
+
const cleaned = name
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
45
|
+
.replace(/^-+|-+$/g, "");
|
|
46
|
+
return cleaned || "service";
|
|
47
|
+
}
|
|
48
|
+
function envKeys(resource) {
|
|
49
|
+
const env = resource.config.env;
|
|
50
|
+
if (!env || typeof env !== "object" || Array.isArray(env))
|
|
51
|
+
return [];
|
|
52
|
+
return Object.keys(env);
|
|
53
|
+
}
|
|
54
|
+
/** The environment variable names a service declares in service.json `env`. Values are never read out. */
|
|
55
|
+
export function envVarNames(resource) {
|
|
56
|
+
return envKeys(resource)
|
|
57
|
+
.filter((name) => ENV_NAME.test(name))
|
|
58
|
+
.sort();
|
|
59
|
+
}
|
|
60
|
+
/** The env names that are not valid variable names and so are left out of the export (the CLI warns about them). */
|
|
61
|
+
export function skippedEnvNames(resource) {
|
|
62
|
+
return envKeys(resource)
|
|
63
|
+
.filter((name) => !ENV_NAME.test(name))
|
|
64
|
+
.sort();
|
|
65
|
+
}
|
|
66
|
+
/** The port service.json declares, or undefined. */
|
|
67
|
+
export function servicePort(resource) {
|
|
68
|
+
const port = resource.config.port;
|
|
69
|
+
const value = typeof port === "string" ? Number(port) : port;
|
|
70
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value < 65536
|
|
71
|
+
? value
|
|
72
|
+
: undefined;
|
|
73
|
+
}
|
|
74
|
+
/** A string quoted the way both YAML and TOML read it (a JSON string is a valid double-quoted scalar in each). */
|
|
75
|
+
function quote(value) {
|
|
76
|
+
return JSON.stringify(value);
|
|
77
|
+
}
|
|
78
|
+
function relPath(from, to) {
|
|
79
|
+
return relative(from, to).split(sep).join("/");
|
|
80
|
+
}
|
|
81
|
+
/** A repository-relative path written with forward slashes and a leading `./`. */
|
|
82
|
+
function repoPath(repoRoot, absolute) {
|
|
83
|
+
const rel = relPath(repoRoot, absolute);
|
|
84
|
+
return rel ? `./${rel}` : ".";
|
|
85
|
+
}
|
|
86
|
+
function record(value) {
|
|
87
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
88
|
+
? value
|
|
89
|
+
: {};
|
|
90
|
+
}
|
|
91
|
+
function readPackageJson(dir) {
|
|
92
|
+
const file = join(dir, "package.json");
|
|
93
|
+
if (!existsSync(file))
|
|
94
|
+
return null;
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
103
|
+
return null;
|
|
104
|
+
const pkg = parsed;
|
|
105
|
+
return {
|
|
106
|
+
main: typeof pkg.main === "string" && pkg.main ? pkg.main : undefined,
|
|
107
|
+
scripts: record(pkg.scripts),
|
|
108
|
+
dependencies: { ...record(pkg.devDependencies), ...record(pkg.dependencies) },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function dockerfileOf(resource) {
|
|
112
|
+
const declared = resource.config.dockerfile;
|
|
113
|
+
const file = join(resource.path, typeof declared === "string" ? declared : "Dockerfile");
|
|
114
|
+
return existsSync(file) ? file : null;
|
|
115
|
+
}
|
|
116
|
+
/** Whether a resource is exported: it must declare one of EXPORTABLE_TYPES. A resource with no type is not. */
|
|
117
|
+
export function isExportable(resource) {
|
|
118
|
+
return resource.type !== undefined && EXPORTABLE_TYPES.includes(resource.type);
|
|
119
|
+
}
|
|
120
|
+
/** Refuses a resource that does not serve HTTP, so the export never writes a web service for a library or a job. */
|
|
121
|
+
export function assertExportable(resource) {
|
|
122
|
+
if (isExportable(resource))
|
|
123
|
+
return;
|
|
124
|
+
const what = resource.type ? `a ${resource.type}` : "a resource with no appType";
|
|
125
|
+
throw new ExportError(`Resource "${resource.name}" is ${what}, which does not serve HTTP, so it is not exported`, [`Exported types: ${EXPORTABLE_TYPES.join(", ")}`, "Set appType in its service.json"]);
|
|
126
|
+
}
|
|
127
|
+
/** Refuses two resources whose deploy names are the same, which Render would reject as one name used twice. */
|
|
128
|
+
export function assertUniqueDeployNames(resources) {
|
|
129
|
+
const seen = new Map();
|
|
130
|
+
for (const resource of resources) {
|
|
131
|
+
const name = deployName(resource.name);
|
|
132
|
+
const other = seen.get(name);
|
|
133
|
+
if (other !== undefined) {
|
|
134
|
+
throw new ExportError(`Resources "${other}" and "${resource.name}" both become the service name "${name}"`, ["Rename one of them (appName in service.json)", "Or export one with --resource <name>"]);
|
|
135
|
+
}
|
|
136
|
+
seen.set(name, resource.name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** One service block of a Render Blueprint, indented for the `services:` list. */
|
|
140
|
+
function renderService(repoRoot, resource) {
|
|
141
|
+
assertExportable(resource);
|
|
142
|
+
const lines = [" - type: web", ` name: ${quote(deployName(resource.name))}`];
|
|
143
|
+
const dockerfile = dockerfileOf(resource);
|
|
144
|
+
const pkg = readPackageJson(resource.path);
|
|
145
|
+
if (dockerfile) {
|
|
146
|
+
lines.push(" runtime: docker", ` dockerfilePath: ${quote(repoPath(repoRoot, dockerfile))}`, ` dockerContext: ${quote(repoPath(repoRoot, resource.path))}`);
|
|
147
|
+
}
|
|
148
|
+
else if (pkg) {
|
|
149
|
+
const hasBuild = typeof pkg.scripts.build === "string";
|
|
150
|
+
lines.push(" runtime: node", ` rootDir: ${quote(relPath(repoRoot, resource.path) || ".")}`, ` buildCommand: ${quote(hasBuild ? "npm install && npm run build" : "npm install")}`, ` startCommand: ${quote("npm start")}`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
throw new ExportError(`Resource "${resource.name}" has neither a Dockerfile nor a package.json, so Render has nothing to build`, ["Add a Dockerfile to the resource directory, or set dockerfile in service.json"]);
|
|
154
|
+
}
|
|
155
|
+
const health = resource.config.healthCheckPath;
|
|
156
|
+
lines.push(` healthCheckPath: ${quote(typeof health === "string" && health ? health : DEFAULT_HEALTH_CHECK_PATH)}`);
|
|
157
|
+
const port = servicePort(resource);
|
|
158
|
+
// Render routes traffic to $PORT (10000 by default), so a service listening on its own port needs PORT set.
|
|
159
|
+
const names = envVarNames(resource).filter((name) => port === undefined || name !== "PORT");
|
|
160
|
+
if (port !== undefined || names.length > 0) {
|
|
161
|
+
lines.push(" envVars:");
|
|
162
|
+
if (port !== undefined)
|
|
163
|
+
lines.push(" - key: PORT", ` value: ${quote(String(port))}`);
|
|
164
|
+
for (const name of names)
|
|
165
|
+
lines.push(` - key: ${name}`, " sync: false");
|
|
166
|
+
}
|
|
167
|
+
return lines;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* A Render Blueprint for the given resources. `repoRoot` is the git repository root, which Render reads every path
|
|
171
|
+
* against. Every env var is listed by name with `sync: false`, never a value.
|
|
172
|
+
*/
|
|
173
|
+
export function buildRenderBlueprint(repoRoot, resources) {
|
|
174
|
+
assertUniqueDeployNames(resources);
|
|
175
|
+
const header = [
|
|
176
|
+
"# Render Blueprint written by `flisk export render` from service.json. Nothing was deployed.",
|
|
177
|
+
"# Review it, commit it at the root of the git repository, then on render.com: New -> Blueprint.",
|
|
178
|
+
"# Render asks for every envVars value marked sync: false; no secret value is in this file.",
|
|
179
|
+
"services:",
|
|
180
|
+
];
|
|
181
|
+
const blocks = resources.flatMap((resource) => renderService(repoRoot, resource));
|
|
182
|
+
return `${[...header, ...blocks].join("\n")}\n`;
|
|
183
|
+
}
|
|
184
|
+
/** The Worker entry point, relative to the resource directory. */
|
|
185
|
+
export function workerEntry(resource) {
|
|
186
|
+
const pkg = readPackageJson(resource.path);
|
|
187
|
+
if (pkg?.main && existsSync(join(resource.path, pkg.main))) {
|
|
188
|
+
return pkg.main.replace(/^\.\//, "");
|
|
189
|
+
}
|
|
190
|
+
const found = WORKER_ENTRY_CANDIDATES.find((candidate) => existsSync(join(resource.path, candidate)));
|
|
191
|
+
if (!found) {
|
|
192
|
+
throw new ExportError(`Resource "${resource.name}" has no Worker entry point`, [
|
|
193
|
+
`Set "main" in its package.json, or add one of: ${WORKER_ENTRY_CANDIDATES.join(", ")}`,
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
196
|
+
return found;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Refuses a resource that does not look like a Cloudflare Worker: one with a Dockerfile, or one whose package.json
|
|
200
|
+
* has none of WORKER_DEPENDENCIES. A Docker or Express service cannot run as a Worker, so its wrangler.toml would
|
|
201
|
+
* look valid and then fail.
|
|
202
|
+
*/
|
|
203
|
+
export function assertWorkerShaped(resource) {
|
|
204
|
+
const hints = [
|
|
205
|
+
"Pass --allow-non-worker if it really is a Worker",
|
|
206
|
+
"Use `flisk export render` for a Docker or Node service",
|
|
207
|
+
];
|
|
208
|
+
if (dockerfileOf(resource)) {
|
|
209
|
+
throw new ExportError(`Resource "${resource.name}" has a Dockerfile, so it does not run as a Cloudflare Worker`, hints);
|
|
210
|
+
}
|
|
211
|
+
const deps = readPackageJson(resource.path)?.dependencies ?? {};
|
|
212
|
+
if (!WORKER_DEPENDENCIES.some((name) => name in deps)) {
|
|
213
|
+
throw new ExportError(`Resource "${resource.name}" does not depend on ${WORKER_DEPENDENCIES.join(", ")}, so it does not look like a Cloudflare Worker`, hints);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Validates a --route value: a bare host name such as api.example.com, no scheme, port, path or wildcard. */
|
|
217
|
+
export function assertRouteHost(host) {
|
|
218
|
+
if (!HOSTNAME.test(host)) {
|
|
219
|
+
const hints = host.startsWith("*.")
|
|
220
|
+
? [
|
|
221
|
+
"Cloudflare Custom Domains do not accept a wildcard; name one host such as api.example.com",
|
|
222
|
+
]
|
|
223
|
+
: ["Leave out https:// and any path"];
|
|
224
|
+
throw new ExportError(`--route must be a host name such as api.example.com, got "${host}"`, hints);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* A wrangler.toml for one resource. `outDir` is where the file will be written, so `main` is relative to it.
|
|
229
|
+
* Secrets are listed by name in a comment, for `wrangler secret put`; no value is ever written.
|
|
230
|
+
*/
|
|
231
|
+
export function buildWranglerToml(resource, outDir, options = {}) {
|
|
232
|
+
assertExportable(resource);
|
|
233
|
+
if (!options.allowNonWorker)
|
|
234
|
+
assertWorkerShaped(resource);
|
|
235
|
+
if (options.route)
|
|
236
|
+
assertRouteHost(options.route);
|
|
237
|
+
const entry = join(resource.path, workerEntry(resource));
|
|
238
|
+
const main = relPath(outDir, entry);
|
|
239
|
+
const date = (options.now ?? new Date()).toISOString().slice(0, 10);
|
|
240
|
+
const lines = [
|
|
241
|
+
"# Written by `flisk export wrangler` from service.json. Nothing was deployed and no DNS record was created.",
|
|
242
|
+
`name = ${quote(deployName(resource.name))}`,
|
|
243
|
+
`main = ${quote(main)}`,
|
|
244
|
+
`compatibility_date = ${quote(date)}`,
|
|
245
|
+
];
|
|
246
|
+
if (options.route) {
|
|
247
|
+
lines.push("", `routes = [{ pattern = ${quote(options.route)}, custom_domain = true }]`);
|
|
248
|
+
}
|
|
249
|
+
const names = envVarNames(resource);
|
|
250
|
+
if (names.length > 0) {
|
|
251
|
+
lines.push("", "# Environment from service.json, by name only. Set each one with `wrangler secret put <NAME>`:");
|
|
252
|
+
for (const name of names)
|
|
253
|
+
lines.push(`# ${name}`);
|
|
254
|
+
}
|
|
255
|
+
return `${lines.join("\n")}\n`;
|
|
256
|
+
}
|
package/dist/index.js
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flisk-export render|wrangler`: writes a deploy file from service.json and stops there. It never deploys, never
|
|
3
|
+
* creates DNS records and never calls a cloud API. The Flisk CLI runs it as `flisk export` at a pinned version.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { Command, CommanderError } from "commander";
|
|
8
|
+
import { buildCompose } from "./compose.js";
|
|
9
|
+
import { discoverResources, findProjectRoot, findRepoRoot, findResource, } from "./discover.js";
|
|
10
|
+
import { ExportError } from "./errors.js";
|
|
11
|
+
import { assertExportable, buildRenderBlueprint, buildWranglerToml, EXPORTABLE_TYPES, isExportable, skippedEnvNames, } from "./generate.js";
|
|
12
|
+
export const NOTHING_DEPLOYED = "Nothing was deployed, no DNS record was created and no cloud API was called.";
|
|
13
|
+
function emit(io, filePath, content, options) {
|
|
14
|
+
const shown = relative(realDir(io.cwd), filePath) || filePath;
|
|
15
|
+
if (options.dryRun) {
|
|
16
|
+
io.out(`Dry run - would write ${shown} (nothing written, nothing deployed):\n\n`);
|
|
17
|
+
io.out(content);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (existsSync(filePath) && !options.force) {
|
|
21
|
+
throw new ExportError(`${shown} already exists and was left unchanged`, [
|
|
22
|
+
"Pass --force to replace it",
|
|
23
|
+
"Pass --dry-run to see what would be written",
|
|
24
|
+
"Pass --out <dir> to write it somewhere else",
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
28
|
+
writeFileSync(filePath, content, "utf-8");
|
|
29
|
+
io.out(`Wrote ${shown}.\n${NOTHING_DEPLOYED}\n`);
|
|
30
|
+
}
|
|
31
|
+
/** `dir` with its nearest existing ancestor resolved through links, so it compares with the resource paths. */
|
|
32
|
+
function realDir(dir) {
|
|
33
|
+
if (existsSync(dir))
|
|
34
|
+
return realpathSync(dir);
|
|
35
|
+
const parent = dirname(dir);
|
|
36
|
+
return parent === dir ? dir : join(realDir(parent), basename(dir));
|
|
37
|
+
}
|
|
38
|
+
function warnSkippedEnv(io, resources) {
|
|
39
|
+
for (const resource of resources) {
|
|
40
|
+
const skipped = skippedEnvNames(resource);
|
|
41
|
+
if (skipped.length > 0) {
|
|
42
|
+
io.err(`Warning: ${resource.name}: env names left out because they are not valid variable names: ${skipped.join(", ")}. Set them by hand on the platform.\n`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function program(io) {
|
|
47
|
+
const root = (dir) => findProjectRoot(resolve(io.cwd, dir ?? "."));
|
|
48
|
+
const discover = (projectRoot) => discoverResources(projectRoot, (message) => io.err(`Warning: ${message}\n`));
|
|
49
|
+
const cli = new Command("flisk-export")
|
|
50
|
+
.description("Write deploy files (render.yaml, wrangler.toml) from service.json; never deploys")
|
|
51
|
+
.exitOverride()
|
|
52
|
+
.configureOutput({ writeOut: io.out, writeErr: io.err });
|
|
53
|
+
cli
|
|
54
|
+
.command("render")
|
|
55
|
+
.description("Write a Render Blueprint (render.yaml) from service.json; never deploys")
|
|
56
|
+
.argument("[dir]", "The Flisk project (default: the current directory)")
|
|
57
|
+
.option("--resource <name>", "Export only this resource (default: every resource that serves HTTP)")
|
|
58
|
+
.option("--out <dir>", "Directory to write render.yaml in (default: the git repository root). Paths inside are always relative to the git repository root, which is where Render reads them")
|
|
59
|
+
.option("--dry-run", "Print render.yaml instead of writing it", false)
|
|
60
|
+
.option("--force", "Replace an existing render.yaml", false)
|
|
61
|
+
.action((dir, options) => {
|
|
62
|
+
const projectRoot = root(dir);
|
|
63
|
+
const repoRoot = findRepoRoot(projectRoot);
|
|
64
|
+
const all = discover(projectRoot);
|
|
65
|
+
let resources;
|
|
66
|
+
if (options.resource) {
|
|
67
|
+
const resource = findResource(all, options.resource);
|
|
68
|
+
assertExportable(resource);
|
|
69
|
+
resources = [resource];
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
resources = all.filter(isExportable);
|
|
73
|
+
if (resources.length === 0) {
|
|
74
|
+
throw new ExportError("No resource that serves HTTP was found to export", [
|
|
75
|
+
`Exported types: ${EXPORTABLE_TYPES.join(", ")}`,
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const content = buildRenderBlueprint(repoRoot, resources);
|
|
80
|
+
warnSkippedEnv(io, resources);
|
|
81
|
+
const outDir = options.out ? realDir(resolve(io.cwd, options.out)) : repoRoot;
|
|
82
|
+
emit(io, join(outDir, "render.yaml"), content, options);
|
|
83
|
+
});
|
|
84
|
+
cli
|
|
85
|
+
.command("compose")
|
|
86
|
+
.description("Write a standalone docker-compose.yml from bring-your-own service.json files; runs without Flisk, never deploys")
|
|
87
|
+
.argument("[dir]", "The Flisk project (default: the current directory)")
|
|
88
|
+
.option("--out <dir>", "Directory to write docker-compose.yml in (default: <project>/.autogenerated)")
|
|
89
|
+
.option("--dry-run", "Print docker-compose.yml instead of writing it", false)
|
|
90
|
+
.option("--force", "Replace an existing docker-compose.yml", false)
|
|
91
|
+
.option("--check", "Exit 1 when the file on disk differs from what would be written", false)
|
|
92
|
+
.action((dir, options) => {
|
|
93
|
+
const projectRoot = root(dir);
|
|
94
|
+
const outDir = options.out
|
|
95
|
+
? realDir(resolve(io.cwd, options.out))
|
|
96
|
+
: join(projectRoot, ".autogenerated");
|
|
97
|
+
const content = buildCompose(outDir, discover(projectRoot));
|
|
98
|
+
const file = join(outDir, "docker-compose.yml");
|
|
99
|
+
if (options.check) {
|
|
100
|
+
const shown = relative(realDir(io.cwd), file) || file;
|
|
101
|
+
if (!existsSync(file) || readFileSync(file, "utf-8") !== content) {
|
|
102
|
+
throw new ExportError(`${shown} is out of date`, [
|
|
103
|
+
"Run `flisk export compose --force` and commit the result",
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
io.out(`${shown} is up to date.\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
emit(io, file, content, options);
|
|
110
|
+
});
|
|
111
|
+
cli
|
|
112
|
+
.command("wrangler")
|
|
113
|
+
.description("Write a Cloudflare wrangler.toml for one resource from service.json; never deploys")
|
|
114
|
+
.argument("[dir]", "The Flisk project (default: the current directory)")
|
|
115
|
+
.requiredOption("--resource <name>", "The resource to export")
|
|
116
|
+
.option("--route <host>", "Custom domain for the Worker (no wildcard; custom_domain = true)")
|
|
117
|
+
.option("--out <dir>", "Directory to write wrangler.toml in (default: the resource directory)")
|
|
118
|
+
.option("--allow-non-worker", "Export a resource that does not look like a Worker", false)
|
|
119
|
+
.option("--dry-run", "Print wrangler.toml instead of writing it", false)
|
|
120
|
+
.option("--force", "Replace an existing wrangler.toml", false)
|
|
121
|
+
.action((dir, options) => {
|
|
122
|
+
const resource = findResource(discover(root(dir)), options.resource);
|
|
123
|
+
const outDir = options.out ? realDir(resolve(io.cwd, options.out)) : resource.path;
|
|
124
|
+
const content = buildWranglerToml(resource, outDir, {
|
|
125
|
+
route: options.route,
|
|
126
|
+
allowNonWorker: options.allowNonWorker,
|
|
127
|
+
});
|
|
128
|
+
warnSkippedEnv(io, [resource]);
|
|
129
|
+
emit(io, join(outDir, "wrangler.toml"), content, options);
|
|
130
|
+
});
|
|
131
|
+
for (const sub of cli.commands) {
|
|
132
|
+
sub.exitOverride().configureOutput({ writeOut: io.out, writeErr: io.err });
|
|
133
|
+
}
|
|
134
|
+
return cli;
|
|
135
|
+
}
|
|
136
|
+
/** Runs the CLI with `args` (without node and the script) and resolves to the exit code. */
|
|
137
|
+
export async function main(args, io) {
|
|
138
|
+
try {
|
|
139
|
+
await program(io).parseAsync(args, { from: "user" });
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error instanceof CommanderError) {
|
|
144
|
+
return error.exitCode === 0 ? 0 : error.exitCode || 1;
|
|
145
|
+
}
|
|
146
|
+
if (error instanceof ExportError) {
|
|
147
|
+
io.err(`Error: ${error.message}\n`);
|
|
148
|
+
for (const hint of error.hints)
|
|
149
|
+
io.err(`-> ${hint}\n`);
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
io.err(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flisk-sh/flisk-export",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Write a Render Blueprint (render.yaml), a Cloudflare wrangler.toml or a standalone docker-compose.yml from Flisk service.json files. Never deploys.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"flisk-export": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/flisk-sh/flisk-export.git"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.12.0"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"prepare": "npm run build",
|
|
23
|
+
"build": "tsc --project tsconfig.build.json && chmod +x dist/cli.js",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"lint": "biome check .",
|
|
26
|
+
"test": "vitest run"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"commander": "^15.0.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@biomejs/biome": "~2.5.15",
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"typescript": "^7.0.2",
|
|
35
|
+
"vitest": "^5.0.0"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/flisk-sh/flisk-export#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/flisk-sh/flisk-export/issues"
|
|
43
|
+
}
|
|
44
|
+
}
|