@flisk-sh/flisk-import 0.0.0-stage → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TDK Landscape
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,24 @@
1
- # Temporary Holding Version
1
+ # tdk import
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Import the services in a repo's root `docker-compose.yml` into [TDK](https://github.com/flisk-sh/flisk) as `service.json` files. This is the Compose first tier of flisk-sh/flisk#511.
4
+
5
+ ```bash
6
+ tdk import . --dry-run # plan only
7
+ tdk import . # plan, then ask before writing
8
+ tdk import . --yes # write service.json files and project setup
9
+ ```
10
+
11
+ ## What to expect
12
+
13
+ - **Reads one file:** the root `docker-compose.yml`. Not `compose.yaml`, override files, nested Compose files, Dockerfiles on their own, `package.json`, or Procfiles.
14
+ - **Writes:** one bring-your-own `service.json` per Compose service, plus `.tdk/project.json` if the repo is not a TDK project yet. It never edits the Compose file or overwrites a `service.json` without `--force`.
15
+ - **Keeps:** the build context and Dockerfile (or the image), the container port, `depends_on`, and an HTTP health path. Databases and brokers are private, with no proxy route.
16
+ - **Drops, and says so:** env values, volumes, `command`, `env_file` and every other untranslated key; services with `profiles`, `extends`, an out-of-tree build or a `${VAR}` image.
17
+ - **Does not start anything.** Put env values in `.env`, then run `tdk up` with TDK core **1.3.145 or later**. The importer does not check the core version.
18
+ - **Exit codes:** `0` when the plan succeeds (`--dry-run`, files written, or all `service.json` files already exist), `1` when no root `docker-compose.yml` or mappable service is found, and `2` when the write prompt is declined or closed without an answer (`Nothing written.`).
19
+
20
+ See [docs/import.md](docs/import.md) for the full mapping and limits. `tdk import` is the core CLI command backed by this package.
21
+
22
+ ```bash
23
+ bun install && bun run build && bun run test
24
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { Command } from "commander";
6
+ import "./detectors/index.js";
7
+ import { ImportError } from "./errors.js";
8
+ import { formatPlan } from "./format.js";
9
+ import { buildPlan } from "./plan.js";
10
+ import { MIN_CORE_VERSION } from "./tdk.js";
11
+ import { applyWrites, manifestRelPath, planWrites } from "./write.js";
12
+ async function confirm(message) {
13
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
14
+ try {
15
+ const closed = new Promise((resolve) => rl.once("close", () => resolve(null)));
16
+ const answer = await Promise.race([rl.question(`${message} (Y/n) `), closed]);
17
+ if (answer === null)
18
+ return false;
19
+ const trimmed = answer.trim().toLowerCase();
20
+ return trimmed === "" || trimmed === "y" || trimmed === "yes";
21
+ }
22
+ finally {
23
+ rl.close();
24
+ }
25
+ }
26
+ const program = new Command("flisk-import")
27
+ .version(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version)
28
+ .description("Import services from the root docker-compose.yml into Flisk")
29
+ .argument("[dir]", "Directory containing docker-compose.yml", ".")
30
+ .option("--dry-run", "Print the plan and write nothing", false)
31
+ .option("--yes", "Write without prompting", false)
32
+ .option("--force", "Overwrite an existing service.json", false)
33
+ .action(async (dir, options) => {
34
+ const plan = buildPlan(dir);
35
+ const writes = planWrites(plan, options.force);
36
+ console.log(formatPlan(plan, writes));
37
+ const pending = writes.items.filter((i) => i.status !== "exists");
38
+ if (plan.services.length === 0) {
39
+ console.log("Nothing to import: no service in docker-compose.yml could be mapped.");
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+ if (options.dryRun)
44
+ return void console.log("Dry run: nothing written.");
45
+ if (pending.length === 0 && !writes.projectConfig) {
46
+ return void console.log("Nothing to write: every service.json already exists.");
47
+ }
48
+ if (!options.yes &&
49
+ !(await confirm(`Write ${pending.length} service.json file(s)${writes.projectConfig ? " and create .tdk/project.json" : ""}?`))) {
50
+ console.log("Nothing written.");
51
+ process.exitCode = 2;
52
+ return;
53
+ }
54
+ const written = applyWrites(writes);
55
+ for (const item of written) {
56
+ console.log(` wrote ${relative(process.cwd(), item.path) || manifestRelPath(plan, item)}`);
57
+ }
58
+ console.log(`\nWrote ${written.length} service.json file(s)${writes.projectConfig ? " and created .tdk/project.json" : ""}.`);
59
+ const envFile = relative(process.cwd(), join(writes.projectRoot, ".env")) || ".env";
60
+ console.log("\nNext:");
61
+ if (plan.services.some((s) => s.envKeys.length))
62
+ console.log(` - Put the environment values these services need in ${envFile}; values in docker-compose.yml were not copied.`);
63
+ console.log(` - Run \`flisk up\` with Flisk core ${MIN_CORE_VERSION} or later. The import did not start anything.`);
64
+ });
65
+ try {
66
+ await program.parseAsync();
67
+ }
68
+ catch (err) {
69
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
70
+ if (err instanceof ImportError)
71
+ for (const s of err.suggestions)
72
+ console.error(` -> ${s}`);
73
+ process.exit(err instanceof ImportError && err.exitCode ? err.exitCode : 1);
74
+ }
@@ -0,0 +1,218 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, normalize } from "node:path/posix";
3
+ import { isAlias, isMap, parseDocument, visit } from "yaml";
4
+ import { registerDetector } from "../registry.js";
5
+ import { HEALTH_CHECK_PATH_PATTERN } from "../tdk.js";
6
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
7
+ /** Container-side port of one `ports:` entry, in short or long syntax. */
8
+ function containerPort(entry) {
9
+ if (typeof entry === "number")
10
+ return entry;
11
+ if (isObj(entry))
12
+ return typeof entry.target === "number" ? entry.target : undefined;
13
+ if (typeof entry !== "string")
14
+ return undefined;
15
+ const last = entry.split("/")[0]?.split(":").pop() ?? "";
16
+ const first = last.split("-")[0] ?? "";
17
+ return /^\d+$/.test(first) ? Number.parseInt(first, 10) : undefined;
18
+ }
19
+ /** Well-known non-HTTP ports: SSH, SMTP, ZooKeeper, MQTT, then databases and brokers. */
20
+ const NON_HTTP_PORTS = new Set([
21
+ 22, 25, 1883, 2181, 3306, 5432, 5672, 6379, 9092, 9200, 11211, 27017,
22
+ ]);
23
+ /**
24
+ * Service keys this detector translates. Every other key is named in the report: Flisk runs a
25
+ * bring-your-own container without volumes, command overrides, env files and the rest.
26
+ * `healthcheck` only contributes an HTTP path; `environment` only contributes names.
27
+ */
28
+ const TRANSLATED_KEYS = new Set([
29
+ "image",
30
+ "build",
31
+ "ports",
32
+ "environment",
33
+ "depends_on",
34
+ "healthcheck",
35
+ ]);
36
+ function envNames(environment) {
37
+ if (Array.isArray(environment)) {
38
+ return environment
39
+ .filter((e) => typeof e === "string")
40
+ .map((e) => e.split("=")[0]);
41
+ }
42
+ return isObj(environment) ? Object.keys(environment) : [];
43
+ }
44
+ function commandOf(command) {
45
+ if (typeof command === "string")
46
+ return command;
47
+ if (Array.isArray(command) && command.every((c) => typeof c === "string"))
48
+ return command.join(" ");
49
+ return undefined;
50
+ }
51
+ function healthPathOf(healthcheck) {
52
+ if (!isObj(healthcheck) || !Array.isArray(healthcheck.test))
53
+ return undefined;
54
+ const test = healthcheck.test
55
+ .filter((part) => typeof part === "string")
56
+ .join(" ");
57
+ const match = test.match(/(?:https?:\/\/[^\s"'/]+|(?:^|(?<=[\s"']))(?:[a-z0-9.-]+:\d+|localhost|127\.0\.0\.1))(\/[^\s"']*)/i);
58
+ if (!match)
59
+ return undefined;
60
+ try {
61
+ const raw = match[0];
62
+ return new URL(/^https?:\/\//i.test(raw) ? raw : `http://${raw}`).pathname || "/";
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ registerDetector({
69
+ id: "compose",
70
+ claim: (relPath) => relPath === "docker-compose.yml",
71
+ parse(absPath, relPath) {
72
+ const composeDir = dirname(relPath) === "." ? "" : dirname(relPath);
73
+ const base = { file: relPath, detector: "compose" };
74
+ const skips = [];
75
+ const candidates = [];
76
+ const doc = parseDocument(readFileSync(absPath, "utf8"), { merge: true });
77
+ if (doc.errors.length > 0) {
78
+ return {
79
+ candidates,
80
+ skips: [{ ...base, reason: `invalid YAML: ${doc.errors[0]?.message}` }],
81
+ };
82
+ }
83
+ let usesAnchors = false;
84
+ visit(doc, {
85
+ Alias() {
86
+ usesAnchors = true;
87
+ },
88
+ Node(_key, node) {
89
+ if (node.anchor || isAlias(node))
90
+ usesAnchors = true;
91
+ },
92
+ });
93
+ if (usesAnchors) {
94
+ skips.push({
95
+ ...base,
96
+ reason: "uses YAML anchors/aliases; values were read after resolution",
97
+ });
98
+ }
99
+ const root = doc.toJS();
100
+ const services = isObj(root) && isObj(root.services) ? root.services : null;
101
+ if (!services || !isMap(doc.get("services", true))) {
102
+ return { candidates, skips: [...skips, { ...base, reason: "no `services:` map" }] };
103
+ }
104
+ for (const [name, raw] of Object.entries(services)) {
105
+ if (!isObj(raw)) {
106
+ skips.push({ ...base, reason: `service "${name}" is not a map` });
107
+ continue;
108
+ }
109
+ if (raw.extends !== undefined) {
110
+ skips.push({
111
+ ...base,
112
+ reason: `service "${name}" uses \`extends\`; values are not resolved`,
113
+ });
114
+ continue;
115
+ }
116
+ if (raw.profiles !== undefined) {
117
+ skips.push({
118
+ ...base,
119
+ reason: `service "${name}" has profiles ${JSON.stringify(raw.profiles)}; profiles are not enforced by Flisk`,
120
+ });
121
+ continue;
122
+ }
123
+ let dir = composeDir;
124
+ let dockerfile;
125
+ let standalone = true;
126
+ if (raw.build !== undefined) {
127
+ const build = isObj(raw.build) ? raw.build : { context: raw.build };
128
+ const context = typeof build.context === "string" ? build.context : ".";
129
+ const resolved = normalize(join(composeDir, context));
130
+ if (isAbsolute(context) ||
131
+ context.includes("://") ||
132
+ resolved === ".." ||
133
+ resolved.startsWith("../")) {
134
+ skips.push({
135
+ ...base,
136
+ reason: `service "${name}" build context "${context}" is outside the imported directory; not imported`,
137
+ });
138
+ continue;
139
+ }
140
+ dir = resolved === "." ? "" : resolved;
141
+ standalone = false;
142
+ dockerfile = typeof build.dockerfile === "string" ? build.dockerfile : "Dockerfile";
143
+ }
144
+ const ports = [
145
+ ...new Set((Array.isArray(raw.ports) ? raw.ports : [])
146
+ .map(containerPort)
147
+ .filter((p) => p !== undefined)),
148
+ ];
149
+ const unreadable = (Array.isArray(raw.ports) ? raw.ports : []).filter((entry) => containerPort(entry) === undefined);
150
+ if (unreadable.length > 0) {
151
+ skips.push({
152
+ ...base,
153
+ reason: ports.length === 0
154
+ ? `service "${name}" ports not understood (${JSON.stringify(unreadable)}); it is imported as a private service with no route`
155
+ : `service "${name}" has port entries that were not understood and ignored (${JSON.stringify(unreadable)}); only ${ports.join(", ")} was read`,
156
+ });
157
+ }
158
+ const httpPorts = ports.filter((port) => port <= 5999 && !NON_HTTP_PORTS.has(port));
159
+ const selectedPort = httpPorts.length === 1 ? httpPorts[0] : ports.length === 1 ? ports[0] : undefined;
160
+ if (ports.length > 1 && selectedPort === undefined) {
161
+ skips.push({
162
+ ...base,
163
+ reason: `service "${name}" lists several ports (${ports.join(", ")}); no single HTTP container port could be identified`,
164
+ });
165
+ }
166
+ else if (ports.length > 1) {
167
+ skips.push({
168
+ ...base,
169
+ reason: `service "${name}" publishes extra container ports (${ports.filter((port) => port !== selectedPort).join(", ")}); Flisk routes only ${selectedPort}`,
170
+ });
171
+ }
172
+ const dependsOn = Array.isArray(raw.depends_on)
173
+ ? raw.depends_on.filter((d) => typeof d === "string")
174
+ : isObj(raw.depends_on)
175
+ ? Object.keys(raw.depends_on)
176
+ : [];
177
+ const command = commandOf(raw.command);
178
+ const envKeys = envNames(raw.environment);
179
+ const foundHealthPath = healthPathOf(raw.healthcheck);
180
+ const healthCheckPath = foundHealthPath && HEALTH_CHECK_PATH_PATTERN.test(foundHealthPath)
181
+ ? foundHealthPath
182
+ : undefined;
183
+ if (foundHealthPath && !healthCheckPath) {
184
+ skips.push({
185
+ ...base,
186
+ reason: `service "${name}" health path "${foundHealthPath}" has characters Flisk does not accept (only a-z, 0-9, / and -); not used`,
187
+ });
188
+ }
189
+ const untranslated = Object.keys(raw).filter((key) => !TRANSLATED_KEYS.has(key));
190
+ if (untranslated.length) {
191
+ skips.push({
192
+ ...base,
193
+ reason: `service "${name}" keys not translated: ${untranslated.join(", ")}`,
194
+ });
195
+ }
196
+ // Any single published port that is not a known database/broker port is treated as HTTP,
197
+ // including 8000 and 8080; a service with no published port stays private, as in Compose.
198
+ const exposeViaProxy = selectedPort !== undefined && !NON_HTTP_PORTS.has(selectedPort);
199
+ candidates.push({
200
+ kind: "service",
201
+ name,
202
+ dir,
203
+ standalone,
204
+ ...(typeof raw.image === "string" ? { image: raw.image } : {}),
205
+ ...(dockerfile ? { dockerfile } : {}),
206
+ ...(command ? { command } : {}),
207
+ ...(selectedPort !== undefined ? { port: selectedPort } : {}),
208
+ ...(healthCheckPath ? { healthCheckPath } : {}),
209
+ exposeViaProxy,
210
+ ...(envKeys.length ? { envKeys } : {}),
211
+ ...(dependsOn.length ? { dependsOn } : {}),
212
+ confidence: "high",
213
+ sources: [base],
214
+ });
215
+ }
216
+ return { candidates, skips };
217
+ },
218
+ });
@@ -0,0 +1,103 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, dirname } from "node:path/posix";
3
+ import { registerDetector } from "../registry.js";
4
+ /** Joins `\` continuations and drops comments, keeping the first physical line number of each instruction. */
5
+ function instructions(text) {
6
+ const out = [];
7
+ let pending = null;
8
+ text.split(/\r?\n/).forEach((raw, index) => {
9
+ const trimmed = raw.trim();
10
+ if (!pending && (trimmed === "" || trimmed.startsWith("#")))
11
+ return;
12
+ if (pending && trimmed.startsWith("#"))
13
+ return;
14
+ const continued = trimmed.endsWith("\\");
15
+ const piece = continued ? trimmed.slice(0, -1).trim() : trimmed;
16
+ pending = pending
17
+ ? { line: pending.line, text: `${pending.text} ${piece}`.trim() }
18
+ : { line: index + 1, text: piece };
19
+ if (!continued) {
20
+ out.push(pending);
21
+ pending = null;
22
+ }
23
+ });
24
+ if (pending)
25
+ out.push(pending);
26
+ return out;
27
+ }
28
+ function cmdToString(args) {
29
+ if (args.startsWith("[")) {
30
+ try {
31
+ const parsed = JSON.parse(args);
32
+ if (Array.isArray(parsed) && parsed.every((p) => typeof p === "string")) {
33
+ return parsed.join(" ");
34
+ }
35
+ }
36
+ catch {
37
+ // not exec form after all: fall through and keep the text
38
+ }
39
+ }
40
+ return args;
41
+ }
42
+ registerDetector({
43
+ id: "dockerfile",
44
+ claim: (relPath) => basename(relPath) === "Dockerfile",
45
+ parse(absPath, relPath) {
46
+ const dir = dirname(relPath) === "." ? "" : dirname(relPath);
47
+ let skips = [];
48
+ // Only the last build stage is the image that runs.
49
+ let ports = [];
50
+ let command;
51
+ for (const ins of instructions(readFileSync(absPath, "utf8"))) {
52
+ const [keyword = "", ...rest] = ins.text.split(/\s+/);
53
+ const args = rest.join(" ");
54
+ switch (keyword.toUpperCase()) {
55
+ case "FROM":
56
+ ports = [];
57
+ skips = [];
58
+ command = undefined;
59
+ break;
60
+ case "EXPOSE":
61
+ for (const token of rest) {
62
+ if (!token)
63
+ continue;
64
+ const rawPort = token.split("/")[0] ?? "";
65
+ const port = /^\d+$/.test(rawPort) ? Number.parseInt(rawPort, 10) : Number.NaN;
66
+ if (Number.isInteger(port) && port > 0 && port < 65536) {
67
+ ports.push(port);
68
+ }
69
+ else {
70
+ skips.push({
71
+ file: relPath,
72
+ detector: "dockerfile",
73
+ line: ins.line,
74
+ reason: "EXPOSE uses a variable; port not imported",
75
+ });
76
+ }
77
+ }
78
+ break;
79
+ case "CMD":
80
+ command = cmdToString(args);
81
+ break;
82
+ }
83
+ }
84
+ const distinct = [...new Set(ports)];
85
+ if (distinct.length > 1) {
86
+ skips.push({
87
+ file: relPath,
88
+ detector: "dockerfile",
89
+ reason: `EXPOSE lists several ports (${distinct.join(", ")}); port not imported`,
90
+ });
91
+ }
92
+ const candidate = {
93
+ kind: "service",
94
+ dir,
95
+ dockerfile: "Dockerfile",
96
+ ...(distinct.length === 1 ? { port: distinct[0] } : {}),
97
+ ...(command ? { command } : {}),
98
+ confidence: "high",
99
+ sources: [{ file: relPath, detector: "dockerfile" }],
100
+ };
101
+ return { candidates: [candidate], skips };
102
+ },
103
+ });
@@ -0,0 +1,5 @@
1
+ // This release deliberately imports one source: the repository's root docker-compose.yml.
2
+ // The standalone detectors in `./dockerfile.ts`, `./package-json.ts`, and `./procfile.ts`
3
+ // are staged for later releases and tested directly, so they are not imported here or
4
+ // selected by `buildPlan` (`selectDetectors(["compose"])`).
5
+ import "./compose.js";
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, dirname } from "node:path/posix";
3
+ import { registerDetector } from "../registry.js";
4
+ /** `bun` or `node` as the program, after any `KEY=value` prefixes. Anything else is not sure. */
5
+ function runtimeOf(command) {
6
+ const words = command.trim().split(/\s+/);
7
+ const program = words.find((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
8
+ return program === "bun" || program === "node" ? program : undefined;
9
+ }
10
+ registerDetector({
11
+ id: "package-json",
12
+ claim: (relPath) => basename(relPath) === "package.json",
13
+ parse(absPath, relPath) {
14
+ const dir = dirname(relPath) === "." ? "" : dirname(relPath);
15
+ const source = { file: relPath, detector: "package-json" };
16
+ let scripts = {};
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(absPath, "utf8"));
19
+ if (parsed && typeof parsed === "object" && typeof parsed.scripts === "object") {
20
+ scripts = parsed.scripts ?? {};
21
+ }
22
+ }
23
+ catch (err) {
24
+ return {
25
+ candidates: [],
26
+ skips: [{ ...source, reason: `invalid JSON: ${err.message}` }],
27
+ };
28
+ }
29
+ const pick = (key) => typeof scripts[key] === "string" ? scripts[key] : "";
30
+ const command = pick("start") || pick("dev");
31
+ if (!command) {
32
+ return {
33
+ candidates: [],
34
+ skips: [{ ...source, reason: "no `start` or `dev` script" }],
35
+ };
36
+ }
37
+ const language = runtimeOf(command);
38
+ return {
39
+ candidates: [
40
+ {
41
+ kind: "service",
42
+ dir,
43
+ command,
44
+ ...(language ? { language } : {}),
45
+ confidence: language ? "high" : "low",
46
+ sources: [source],
47
+ },
48
+ ],
49
+ skips: [],
50
+ };
51
+ },
52
+ });
@@ -0,0 +1,48 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, dirname } from "node:path/posix";
3
+ import { registerDetector } from "../registry.js";
4
+ const LINE = /^([A-Za-z0-9_-]+):\s*(.*)$/;
5
+ registerDetector({
6
+ id: "procfile",
7
+ claim: (relPath) => /^Procfile(\..+)?$/.test(basename(relPath)),
8
+ parse(absPath, relPath) {
9
+ const dir = dirname(relPath) === "." ? "" : dirname(relPath);
10
+ const candidates = [];
11
+ const skips = [];
12
+ const lines = readFileSync(absPath, "utf8").split(/\r?\n/);
13
+ lines.forEach((raw, index) => {
14
+ const line = raw.trim();
15
+ if (!line || line.startsWith("#"))
16
+ return;
17
+ const match = LINE.exec(line);
18
+ if (!match) {
19
+ skips.push({
20
+ file: relPath,
21
+ detector: "procfile",
22
+ line: index + 1,
23
+ reason: "not a `name: command` line",
24
+ });
25
+ return;
26
+ }
27
+ const [, name, command] = match;
28
+ if (!command) {
29
+ skips.push({
30
+ file: relPath,
31
+ detector: "procfile",
32
+ line: index + 1,
33
+ reason: `process "${name}" has no command`,
34
+ });
35
+ return;
36
+ }
37
+ candidates.push({
38
+ kind: name === "release" ? "job" : "service",
39
+ name,
40
+ dir,
41
+ command,
42
+ confidence: "high",
43
+ sources: [{ file: relPath, detector: "procfile" }],
44
+ });
45
+ });
46
+ return { candidates, skips };
47
+ },
48
+ });
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ /** A user-facing failure: the message, plus what to try. */
2
+ export class ImportError extends Error {
3
+ suggestions;
4
+ exitCode;
5
+ constructor(message, suggestions = [], exitCode) {
6
+ super(message);
7
+ this.suggestions = suggestions;
8
+ this.exitCode = exitCode;
9
+ this.name = "ImportError";
10
+ }
11
+ }
package/dist/format.js ADDED
@@ -0,0 +1,66 @@
1
+ import { manifestRelPath } from "./write.js";
2
+ const STATUS_TEXT = {
3
+ create: "create",
4
+ overwrite: "overwrite (--force)",
5
+ exists: "exists, kept (use --force to overwrite)",
6
+ };
7
+ /** The printed plan: what was found, where it came from, what conflicts, what was not translated. */
8
+ export function formatPlan(plan, writes) {
9
+ const out = [];
10
+ const jobs = plan.services.filter((s) => s.kind === "job").length;
11
+ out.push(`Found ${plan.services.length - jobs} service(s) and ${jobs} job(s) in ${plan.root}`, `Read only: ${plan.files.join(", ")}. Lines below name what was not translated.`, "");
12
+ if (writes.projectConfig)
13
+ out.push(` .tdk/project.json [create Flisk project]`, "");
14
+ for (const service of plan.services) {
15
+ const item = writes.items.find((i) => i.service === service);
16
+ out.push(` ${service.name} (${service.kind}${service.dir ? `, ${service.dir}/` : ""})`);
17
+ out.push(` from: ${service.sources.map((s) => `${s.file} [${s.detector}]`).join(", ")}`);
18
+ if (service.dockerfile)
19
+ out.push(` build: ${service.dir || "."}/ with ${service.dockerfile}`);
20
+ else if (service.image)
21
+ out.push(` image: ${service.image}`);
22
+ const manifest = item && item.status !== "exists" ? item.manifest : undefined;
23
+ if (manifest) {
24
+ out.push(` port: ${manifest.port} ${manifest.port === service.port ? "(from Compose)" : "(assigned)"}`);
25
+ out.push(manifest.exposeViaProxy === false
26
+ ? ` proxy: none, private (${service.port === undefined ? "no single published port" : `${service.port} is a database or broker port`})`
27
+ : " proxy: routed through Traefik");
28
+ if (manifest.healthCheckPath)
29
+ out.push(` health: ${manifest.healthCheckPath}${service.healthCheckPath ? "" : " (assumed)"}`);
30
+ }
31
+ if (service.dependsOn.length)
32
+ out.push(` depends: ${service.dependsOn.join(", ")}`);
33
+ if (service.language)
34
+ out.push(` runtime: ${service.language} (detected, not mapped to a native provider)`);
35
+ if (service.envKeys.length)
36
+ out.push(` env: ${service.envKeys.join(", ")} (names only)`);
37
+ for (const conflict of service.conflicts) {
38
+ const values = conflict.values
39
+ .map((v) => `${v.value} (${v.sources.map((s) => s.file).join(", ")})`)
40
+ .join(" vs ");
41
+ out.push(` CONFLICT ${conflict.field}: ${values}; not used`);
42
+ }
43
+ if (item) {
44
+ out.push(` write: ${manifestRelPath(plan, item)} [${STATUS_TEXT[item.status]}]`);
45
+ for (const note of item.notes)
46
+ out.push(` note: ${note}`);
47
+ }
48
+ out.push("");
49
+ }
50
+ for (const r of writes.rejected)
51
+ out.push(` not written: ${r.name}: ${r.reason}`, "");
52
+ if (plan.skips.length) {
53
+ out.push("Skipped / not fully imported:");
54
+ for (const skip of plan.skips) {
55
+ out.push(` ${skip.file}${skip.line ? `:${skip.line}` : ""} [${skip.detector}]: ${skip.reason}`);
56
+ }
57
+ out.push("");
58
+ }
59
+ if (plan.notes.length) {
60
+ out.push("Scan notes:");
61
+ for (const note of plan.notes)
62
+ out.push(` ${note}`);
63
+ out.push("");
64
+ }
65
+ return out.join("\n");
66
+ }