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

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/dist/merge.js ADDED
@@ -0,0 +1,156 @@
1
+ import { basename } from "node:path/posix";
2
+ import { precedence } from "./registry.js";
3
+ function slug(raw) {
4
+ return raw
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9]+/g, "-")
7
+ .replace(/^-+|-+$/g, "");
8
+ }
9
+ export function toResourceName(raw) {
10
+ let name = slug(raw) || "svc";
11
+ // Resource names must start with a letter and be at least 3 characters (service schema).
12
+ if (!/^[a-z]/.test(name))
13
+ name = `svc-${name}`;
14
+ if (name.length < 3)
15
+ name = `${name}-svc`;
16
+ return name;
17
+ }
18
+ /**
19
+ * Maps every raw name in one project to a distinct resource name. Names that are already valid
20
+ * claim their spelling first; the rest get a numeric suffix when they would collide (for example
21
+ * `db` and `db-svc` both normalise to `db-svc`).
22
+ */
23
+ export function assignResourceNames(rawNames) {
24
+ const unique = [...new Set(rawNames)].sort();
25
+ const claimed = new Set();
26
+ const out = new Map();
27
+ const claim = (raw) => {
28
+ const base = toResourceName(raw);
29
+ let final = base;
30
+ for (let n = 2; claimed.has(final); n++)
31
+ final = `${base}-${n}`;
32
+ claimed.add(final);
33
+ out.set(raw, final);
34
+ };
35
+ const isValid = (raw) => toResourceName(raw) === raw;
36
+ unique.filter(isValid).forEach(claim);
37
+ unique.filter((raw) => !isValid(raw)).forEach(claim);
38
+ return out;
39
+ }
40
+ const SCALAR_FIELDS = [
41
+ "image",
42
+ "dockerfile",
43
+ "command",
44
+ "port",
45
+ "language",
46
+ "healthCheckPath",
47
+ ];
48
+ function rank(candidate) {
49
+ return Math.min(...candidate.sources.map((s) => precedence(s.detector)));
50
+ }
51
+ function mergeGroup(name, dir, members, resolve) {
52
+ const ordered = [...members].sort((a, b) => rank(a) - rank(b));
53
+ const originalName = ordered.find((c) => c.name !== undefined && c.name !== name)?.name;
54
+ const merged = {
55
+ name,
56
+ ...(originalName ? { originalName } : {}),
57
+ dir,
58
+ kind: ordered[0].kind,
59
+ envKeys: [...new Set(ordered.flatMap((c) => c.envKeys ?? []))].sort(),
60
+ dependsOn: [...new Set(ordered.flatMap((c) => (c.dependsOn ?? []).map(resolve)))].sort(),
61
+ exposeViaProxy: ordered.find((c) => c.exposeViaProxy !== undefined)?.exposeViaProxy,
62
+ procfileCommands: ordered.flatMap((c) => c.sources.some((source) => source.detector === "procfile") && c.command ? [c.command] : []),
63
+ conflicts: [],
64
+ sources: [],
65
+ confidence: ordered.every((c) => c.confidence === "high") ? "high" : "low",
66
+ };
67
+ for (const field of SCALAR_FIELDS) {
68
+ const values = new Map();
69
+ for (const c of ordered) {
70
+ const v = c[field];
71
+ if (v === undefined)
72
+ continue;
73
+ values.set(String(v), [...(values.get(String(v)) ?? []), ...c.sources]);
74
+ }
75
+ if (values.size === 1) {
76
+ const holder = ordered.find((c) => c[field] !== undefined);
77
+ merged[field] = holder[field];
78
+ }
79
+ else if (values.size > 1) {
80
+ merged.conflicts.push({
81
+ field,
82
+ values: [...values].map(([value, sources]) => ({ value, sources })),
83
+ });
84
+ }
85
+ }
86
+ for (const c of ordered) {
87
+ for (const s of c.sources) {
88
+ if (!merged.sources.some((m) => m.file === s.file && m.detector === s.detector)) {
89
+ merged.sources.push(s);
90
+ }
91
+ }
92
+ }
93
+ return merged;
94
+ }
95
+ /**
96
+ * Groups candidates by directory and name. An anonymous candidate (Dockerfile, package.json)
97
+ * joins the one named service in its directory, or becomes a service named after the directory.
98
+ * It is never guessed onto one of several. A field two sources disagree on is a conflict and is omitted.
99
+ */
100
+ export function mergeCandidates(candidates, rootName) {
101
+ const skips = [];
102
+ const table = assignResourceNames([
103
+ ...candidates.flatMap((c) => (c.name === undefined ? [] : [c.name])),
104
+ ...candidates.flatMap((c) => c.dependsOn ?? []),
105
+ ...candidates.flatMap((c) => (c.name === undefined ? [basename(c.dir) || rootName] : [])),
106
+ ]);
107
+ const resolve = (raw) => table.get(raw) ?? toResourceName(raw);
108
+ const groups = new Map();
109
+ const keyOf = (dir, name) => `${dir}\0${name}`;
110
+ for (const c of candidates) {
111
+ if (c.name === undefined)
112
+ continue;
113
+ const name = resolve(c.name);
114
+ const first = c.sources[0];
115
+ if (!name) {
116
+ skips.push({ ...first, reason: `"${c.name}" is not usable as a service name` });
117
+ continue;
118
+ }
119
+ const key = keyOf(c.dir, name);
120
+ const group = groups.get(key) ?? { name, dir: c.dir, members: [] };
121
+ group.members.push(c);
122
+ groups.set(key, group);
123
+ }
124
+ const anonymous = candidates.filter((c) => c.name === undefined);
125
+ for (const c of anonymous) {
126
+ const targets = [...groups.values()].filter((g) => g.dir === c.dir && !g.members.every((m) => m.standalone));
127
+ if (targets.length === 1) {
128
+ targets[0].members.push(c);
129
+ }
130
+ else if (targets.length === 0) {
131
+ const name = resolve(basename(c.dir) || rootName);
132
+ if (!name) {
133
+ skips.push({
134
+ ...c.sources[0],
135
+ reason: "no usable service name for this directory",
136
+ });
137
+ continue;
138
+ }
139
+ const key = keyOf(c.dir, name);
140
+ const group = groups.get(key) ?? { name, dir: c.dir, members: [] };
141
+ group.members.push(c);
142
+ groups.set(key, group);
143
+ }
144
+ else {
145
+ const names = targets.map((g) => g.name).join(", ");
146
+ skips.push({
147
+ ...c.sources[0],
148
+ reason: `directory has several services (${names}); not guessed which one this file describes`,
149
+ });
150
+ }
151
+ }
152
+ const services = [...groups.values()]
153
+ .map((g) => mergeGroup(g.name, g.dir, g.members, resolve))
154
+ .sort((a, b) => (a.dir === b.dir ? (a.name < b.name ? -1 : 1) : a.dir < b.dir ? -1 : 1));
155
+ return { services, skips };
156
+ }
package/dist/plan.js ADDED
@@ -0,0 +1,105 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, resolve } from "node:path";
3
+ import "./detectors/index.js";
4
+ import { ImportError } from "./errors.js";
5
+ import { mergeCandidates } from "./merge.js";
6
+ import { selectDetectors } from "./registry.js";
7
+ import { scanTree } from "./scan.js";
8
+ import { findUnsupported } from "./unsupported.js";
9
+ const COMPOSE_FILE = "docker-compose.yml";
10
+ /** Names `docker compose` also picks up, in its own order of preference; none of them is read. */
11
+ const OTHER_COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml"];
12
+ /** Files `docker compose up` merges over the main file; none of them is applied. */
13
+ const OVERRIDE_FILES = [
14
+ "compose.override.yaml",
15
+ "compose.override.yml",
16
+ "docker-compose.override.yaml",
17
+ "docker-compose.override.yml",
18
+ ];
19
+ /** Read the one supported input, root docker-compose.yml. This function writes nothing. */
20
+ export function buildPlan(dir) {
21
+ const root = resolve(dir);
22
+ if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
23
+ throw new Error(`${dir} is not a directory`);
24
+ }
25
+ const composePath = COMPOSE_FILE;
26
+ const detectors = selectDetectors(["compose"]);
27
+ const candidates = [];
28
+ const skips = [];
29
+ const isFile = (name) => statSync(resolve(root, name), { throwIfNoEntry: false })?.isFile() ?? false;
30
+ if (!isFile(composePath)) {
31
+ const others = OTHER_COMPOSE_FILES.filter(isFile);
32
+ throw new ImportError(`No root docker-compose.yml found in ${root}${others.length ? ` (found ${others.join(", ")})` : ""}; tdk import reads only that file`, others.length
33
+ ? [
34
+ `Other Compose file names are not read yet. To import ${others[0]}, link it: ln -s ${others[0]} docker-compose.yml`,
35
+ ]
36
+ : ["Run tdk import in the directory that holds docker-compose.yml"]);
37
+ }
38
+ for (const name of OTHER_COMPOSE_FILES.filter(isFile)) {
39
+ skips.push({
40
+ file: name,
41
+ detector: "compose",
42
+ reason: `not read; \`docker compose\` prefers it over ${COMPOSE_FILE}, so what it runs may differ from this plan`,
43
+ });
44
+ }
45
+ for (const name of OVERRIDE_FILES.filter(isFile)) {
46
+ skips.push({
47
+ file: name,
48
+ detector: "compose",
49
+ reason: `not applied; \`docker compose up\` merges it over ${COMPOSE_FILE}, tdk import does not`,
50
+ });
51
+ }
52
+ for (const detector of detectors) {
53
+ if (!detector.claim(composePath))
54
+ continue;
55
+ try {
56
+ const result = detector.parse(resolve(root, composePath), composePath);
57
+ candidates.push(...result.candidates);
58
+ skips.push(...result.skips);
59
+ }
60
+ catch (err) {
61
+ skips.push({
62
+ file: composePath,
63
+ detector: detector.id,
64
+ reason: `could not be read: ${err instanceof Error ? err.message : String(err)}`,
65
+ });
66
+ }
67
+ }
68
+ const unsupported = findUnsupported(scanTree(root).files);
69
+ const merged = mergeCandidates(candidates, basename(root));
70
+ const services = merged.services;
71
+ return {
72
+ root,
73
+ services,
74
+ skips: [...skips, ...merged.skips, ...unsupported.skips],
75
+ unsupported: unsupported.labels,
76
+ skippedProcfileProcesses: 0,
77
+ notes: [],
78
+ files: [composePath],
79
+ };
80
+ }
81
+ /** Ports already claimed by service.json files under `dir` (best effort). */
82
+ export function usedManifestPorts(dir) {
83
+ const ports = new Map();
84
+ let entries;
85
+ try {
86
+ entries = readdirSync(dir, { recursive: true }).map(String);
87
+ }
88
+ catch {
89
+ return ports;
90
+ }
91
+ for (const entry of entries) {
92
+ if (!entry.endsWith("service.json") || entry.split(/[\\/]/).includes("node_modules"))
93
+ continue;
94
+ const abs = resolve(dir, entry);
95
+ try {
96
+ const port = JSON.parse(readFileSync(abs, "utf8")).port;
97
+ if (typeof port === "number")
98
+ ports.set(abs, port);
99
+ }
100
+ catch {
101
+ // unreadable or not JSON: it claims no port
102
+ }
103
+ }
104
+ return ports;
105
+ }
@@ -0,0 +1,29 @@
1
+ import { ImportError } from "./errors.js";
2
+ const detectors = [];
3
+ /** Detectors register themselves from `detectors/index.ts`; the order is the merge precedence. */
4
+ export function registerDetector(detector) {
5
+ if (detectors.some((d) => d.id === detector.id)) {
6
+ throw new Error(`Import detector "${detector.id}" is already registered`);
7
+ }
8
+ detectors.push(detector);
9
+ }
10
+ export function detectorIds() {
11
+ return detectors.map((d) => d.id);
12
+ }
13
+ /** Resolves `--only`. An unknown id fails here, before any file is read. */
14
+ export function selectDetectors(only) {
15
+ if (!only || only.length === 0)
16
+ return [...detectors];
17
+ const unknown = only.filter((id) => !detectors.some((d) => d.id === id));
18
+ if (unknown.length > 0) {
19
+ throw new ImportError(`Unknown import detector: ${unknown.join(", ")}`, [
20
+ `Registered detectors: ${detectorIds().join(", ")}`,
21
+ ]);
22
+ }
23
+ return detectors.filter((d) => only.includes(d.id));
24
+ }
25
+ /** Lower number wins a field. Unregistered ids rank last. */
26
+ export function precedence(detectorId) {
27
+ const index = detectors.findIndex((d) => d.id === detectorId);
28
+ return index === -1 ? detectors.length : index;
29
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,137 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join, posix } from "node:path";
3
+ export const HARD_SKIPS = new Set([
4
+ ".git",
5
+ "node_modules",
6
+ "vendor",
7
+ "target",
8
+ "dist",
9
+ ".autogenerated",
10
+ // Output of `tdk project`: never a source of services.
11
+ ".tdk",
12
+ "shared-platform-engineering",
13
+ ]);
14
+ export const MAX_DEPTH = 8;
15
+ export const MAX_FILES = 5000;
16
+ function globToRegex(glob) {
17
+ let out = "";
18
+ for (let i = 0; i < glob.length; i++) {
19
+ const c = glob[i];
20
+ if (c === "*" && glob[i + 1] === "*") {
21
+ if (glob[i + 2] === "/") {
22
+ out += "(?:.*/)?";
23
+ i += 2;
24
+ }
25
+ else {
26
+ out += ".*";
27
+ i += 1;
28
+ }
29
+ }
30
+ else if (c === "*")
31
+ out += "[^/]*";
32
+ else if (c === "?")
33
+ out += "[^/]";
34
+ else
35
+ out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
36
+ }
37
+ return out;
38
+ }
39
+ function readIgnoreRules(absDir, base, notes) {
40
+ let text;
41
+ try {
42
+ text = readFileSync(join(absDir, ".gitignore"), "utf8");
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ const rules = [];
48
+ for (const raw of text.split(/\r?\n/)) {
49
+ let pattern = raw.trim();
50
+ if (!pattern || pattern.startsWith("#"))
51
+ continue;
52
+ if (pattern.startsWith("!")) {
53
+ notes.push(`${posix.join(base, ".gitignore")}: negation (${pattern}) is not supported`);
54
+ continue;
55
+ }
56
+ const dirOnly = pattern.endsWith("/");
57
+ if (dirOnly)
58
+ pattern = pattern.slice(0, -1);
59
+ const anchored = pattern.includes("/");
60
+ if (pattern.startsWith("/"))
61
+ pattern = pattern.slice(1);
62
+ rules.push({ base, regex: new RegExp(`^${globToRegex(pattern)}$`), anchored, dirOnly });
63
+ }
64
+ return rules;
65
+ }
66
+ function isIgnored(rules, rel, name, isDir) {
67
+ return rules.some((rule) => {
68
+ if (rule.dirOnly && !isDir)
69
+ return false;
70
+ const underBase = rule.base === "" ? rel : rel.slice(rule.base.length + 1);
71
+ if (rule.base !== "" && !rel.startsWith(`${rule.base}/`))
72
+ return false;
73
+ return rule.regex.test(rule.anchored ? underBase : name);
74
+ });
75
+ }
76
+ /**
77
+ * Lists the files under `root` that detectors may claim. Skips build and dependency
78
+ * directories and `.gitignore`d paths, never follows symlinks, and caps depth and file count.
79
+ */
80
+ export function scanTree(root, limits = { depth: MAX_DEPTH, files: MAX_FILES }) {
81
+ const files = [];
82
+ const notes = [];
83
+ const managed = [];
84
+ let depthCapped = false;
85
+ let fileCapped = false;
86
+ // In a TDK project, `services/` holds manifests and generated compose files, not sources.
87
+ const isTdkProject = existsSync(join(root, ".tdk", "project.json"));
88
+ if (isTdkProject)
89
+ notes.push("TDK project detected: services/ was not scanned");
90
+ const walk = (rel, depth, inherited) => {
91
+ const abs = join(root, rel);
92
+ const rules = [...inherited, ...readIgnoreRules(abs, rel, notes)];
93
+ const entries = readdirSync(abs, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
94
+ // A directory with a service.json is already a TDK service (possibly written by an earlier
95
+ // import, with a scaffolded Dockerfile): re-importing it would not be idempotent.
96
+ if (rel !== "" && entries.some((e) => e.isFile() && e.name === "service.json")) {
97
+ managed.push(rel);
98
+ return;
99
+ }
100
+ for (const entry of entries) {
101
+ if (fileCapped)
102
+ return;
103
+ if (entry.isSymbolicLink())
104
+ continue;
105
+ const entryRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
106
+ if (entry.isDirectory()) {
107
+ if (HARD_SKIPS.has(entry.name) || isIgnored(rules, entryRel, entry.name, true))
108
+ continue;
109
+ if (isTdkProject && entryRel === "services")
110
+ continue;
111
+ if (depth >= limits.depth) {
112
+ depthCapped = true;
113
+ continue;
114
+ }
115
+ walk(entryRel, depth + 1, rules);
116
+ }
117
+ else if (entry.isFile()) {
118
+ if (isIgnored(rules, entryRel, entry.name, false))
119
+ continue;
120
+ if (files.length >= limits.files) {
121
+ fileCapped = true;
122
+ return;
123
+ }
124
+ files.push(entryRel);
125
+ }
126
+ }
127
+ };
128
+ walk("", 0, []);
129
+ if (managed.length) {
130
+ notes.push(`${managed.length} director${managed.length === 1 ? "y" : "ies"} with a service.json skipped (already TDK services)`);
131
+ }
132
+ if (depthCapped)
133
+ notes.push(`directories deeper than ${limits.depth} levels were not scanned`);
134
+ if (fileCapped)
135
+ notes.push(`stopped after ${limits.files} files; later files were not scanned`);
136
+ return { files: files.sort(), notes };
137
+ }
package/dist/tdk.js ADDED
@@ -0,0 +1,40 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ /** Manifest constants of the TDK service.json this tool writes. */
4
+ export const SERVICE_MANIFEST_SCHEMA_VERSION = 1;
5
+ export const SERVICE_MANIFEST_SCHEMA_URL = "https://flisk.sh/schema.service.json";
6
+ /**
7
+ * Oldest TDK core that starts an imported project: 1.3.104 added `buildContext`, and 1.3.145
8
+ * (flisk#1018) bootstraps a project that has `.tdk/project.json` but no Tiltfile yet.
9
+ * Stated, not checked: the importer does not read the installed core version.
10
+ */
11
+ export const MIN_CORE_VERSION = "1.3.145";
12
+ /** TDK's bring-your-own port range. */
13
+ export const BYO_PORT_RANGE = { min: 4000, max: 5999, label: "4000-5999" };
14
+ /**
15
+ * Values TDK copies into generated Compose and Traefik config must match these, or `tdk up` refuses
16
+ * the manifest (flisk `cli/src/utils/service-manifest.ts`, engine `manifest/parser.star`).
17
+ */
18
+ export const HEALTH_CHECK_PATH_PATTERN = /^\/[a-z0-9/-]*$/;
19
+ export const IMAGE_PATTERN = /^[A-Za-z0-9._/:@-]+$/;
20
+ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
+ export function validateName(label, name) {
22
+ return KEBAB.test(name)
23
+ ? { valid: true }
24
+ : {
25
+ valid: false,
26
+ error: `${label} "${name}" must use lowercase letters, numbers, and hyphens only`,
27
+ };
28
+ }
29
+ /** Nearest directory at or above `start` holding `.tdk/project.json`. */
30
+ export function findProjectRoot(start) {
31
+ let dir = resolve(start);
32
+ while (true) {
33
+ if (existsSync(join(dir, ".tdk", "project.json")))
34
+ return dir;
35
+ const parent = dirname(dir);
36
+ if (parent === dir)
37
+ return null;
38
+ dir = parent;
39
+ }
40
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { posix } from "node:path";
2
+ /** Orchestration formats people expect to import. They are named and skipped, never translated. */
3
+ const UNSUPPORTED = [
4
+ { label: "Helm", matches: (name) => name === "Chart.yaml" },
5
+ { label: "Kustomize", matches: (name) => /^kustomization\.ya?ml$/.test(name) },
6
+ ];
7
+ /** Skips for recognised-but-unsupported files, so a repo is never silently reported as empty. */
8
+ export function findUnsupported(files) {
9
+ const skips = [];
10
+ const labels = new Set();
11
+ for (const file of files) {
12
+ const name = posix.basename(file);
13
+ for (const format of UNSUPPORTED) {
14
+ if (!format.matches(name))
15
+ continue;
16
+ labels.add(format.label);
17
+ skips.push({
18
+ file,
19
+ detector: "unsupported",
20
+ reason: `${format.label} is not imported`,
21
+ });
22
+ }
23
+ }
24
+ return { skips, labels: [...labels] };
25
+ }
package/dist/write.js ADDED
@@ -0,0 +1,183 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import { ImportError } from "./errors.js";
4
+ import { toResourceName } from "./merge.js";
5
+ import { usedManifestPorts } from "./plan.js";
6
+ import { BYO_PORT_RANGE, findProjectRoot, IMAGE_PATTERN, SERVICE_MANIFEST_SCHEMA_URL, SERVICE_MANIFEST_SCHEMA_VERSION, validateName, } from "./tdk.js";
7
+ function existingPort(path) {
8
+ try {
9
+ const port = JSON.parse(readFileSync(path, "utf8")).port;
10
+ return typeof port === "number" ? port : undefined;
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
16
+ /**
17
+ * Maps merged services onto TDK bring-your-own manifests. This is the only stage that knows the
18
+ * layout `<project>/services/<stack>/<name>/service.json`. Nothing is written here.
19
+ */
20
+ /** Images that serve a protocol other than HTTP. Matched against the image name, not its tag. */
21
+ const NON_HTTP_IMAGE = /(^|\/)(postgres|mysql|mariadb|mongo|mongodb|redis|valkey|memcached|minio|mailpit|mailhog|rabbitmq|kafka|zookeeper|elasticsearch|opensearch|openssh-server)(:|@|$)/i;
22
+ export function planWrites(plan, force) {
23
+ const projectRoot = findProjectRoot(plan.root) ?? plan.root;
24
+ const stack = toResourceName(basename(plan.root));
25
+ const stackCheck = validateName("Stack name", stack);
26
+ if (!stackCheck.valid) {
27
+ throw new ImportError(`Cannot derive a stack name from "${basename(plan.root)}"`, [
28
+ "Run import from a directory whose name has letters or digits",
29
+ ]);
30
+ }
31
+ const servicesDir = join(projectRoot, "services");
32
+ const projectConfigPath = join(projectRoot, ".tdk", "project.json");
33
+ const projectConfig = existsSync(projectConfigPath)
34
+ ? undefined
35
+ : {
36
+ path: projectConfigPath,
37
+ content: `${JSON.stringify({
38
+ version: "1.0",
39
+ project: { name: basename(projectRoot), version: "1.0.0" },
40
+ phases: {
41
+ pre_alpha: {
42
+ name: "Pre-Alpha",
43
+ description: "TDK proxy and imported Compose services",
44
+ enabledStacks: ["proxy", stack],
45
+ },
46
+ alpha: { name: "Alpha", description: "", enabledStacks: [] },
47
+ beta: { name: "Beta", description: "", enabledStacks: [] },
48
+ out_of_scope: { name: "Out of Scope", description: "", enabledStacks: [] },
49
+ },
50
+ optional_infra: {
51
+ monitoring: false,
52
+ elk: false,
53
+ debezium: false,
54
+ golden_image: false,
55
+ verdaccio: false,
56
+ },
57
+ discovery: { paths: ["services/*/*"] },
58
+ overrides: {},
59
+ }, null, 2)}\n`,
60
+ };
61
+ const used = usedManifestPorts(servicesDir);
62
+ const items = [];
63
+ const rejected = [];
64
+ // Reject first, so no written service depends on one that is not written.
65
+ const accepted = plan.services.filter((service) => {
66
+ const check = validateName("Resource name", service.name);
67
+ const reason = !check.valid
68
+ ? (check.error ?? "invalid name")
69
+ : service.dockerfile
70
+ ? undefined
71
+ : !service.image
72
+ ? "Compose gives it neither `image` nor a usable `build`"
73
+ : !IMAGE_PATTERN.test(service.image)
74
+ ? `image "${service.image}" uses Compose variables or characters TDK does not accept; set a literal image in docker-compose.yml, or write this service.json by hand`
75
+ : undefined;
76
+ if (reason)
77
+ rejected.push({ name: service.name, reason });
78
+ return !reason;
79
+ });
80
+ const names = new Set(accepted.map((s) => s.name));
81
+ for (const service of accepted) {
82
+ const path = resolve(servicesDir, stack, service.name, "service.json");
83
+ const exists = existsSync(path);
84
+ const status = !exists ? "create" : force ? "overwrite" : "exists";
85
+ const notes = [];
86
+ if (service.originalName && service.originalName !== service.name)
87
+ notes.push(`renamed from "${service.originalName}" (Compose names must be lowercase kebab-case)`);
88
+ // A file this run replaces does not count against itself; a kept one keeps its port.
89
+ if (status === "overwrite")
90
+ used.delete(path);
91
+ const keep = status === "overwrite" ? existingPort(path) : undefined;
92
+ const isPrivate = service.exposeViaProxy === false;
93
+ const taken = new Set(used.values());
94
+ const free = (p) => p >= BYO_PORT_RANGE.min && p <= BYO_PORT_RANGE.max && !taken.has(p);
95
+ let port;
96
+ if (status === "exists") {
97
+ // Never written, so no port is needed; keep the one on disk for the plan.
98
+ port = existingPort(path) ?? BYO_PORT_RANGE.min;
99
+ }
100
+ else if (service.port !== undefined && free(service.port)) {
101
+ // The schema accepts this port for bring-your-own and no other service in the project uses it.
102
+ port = service.port;
103
+ }
104
+ else if (keep !== undefined && free(keep)) {
105
+ port = keep;
106
+ }
107
+ else {
108
+ for (let p = BYO_PORT_RANGE.min; p <= BYO_PORT_RANGE.max; p++) {
109
+ if (free(p)) {
110
+ port = p;
111
+ break;
112
+ }
113
+ }
114
+ }
115
+ if (status !== "exists" &&
116
+ service.port !== undefined &&
117
+ port !== undefined &&
118
+ port !== service.port)
119
+ notes.push(`Compose port ${service.port} is outside ${BYO_PORT_RANGE.label} or already used; assigned ${port}. The container must listen on $PORT (${port}) or it will not answer`);
120
+ if (port === undefined) {
121
+ rejected.push({ name: service.name, reason: `no free port in ${BYO_PORT_RANGE.label}` });
122
+ continue;
123
+ }
124
+ if (status !== "exists")
125
+ used.set(path, port);
126
+ const dependsOn = service.dependsOn.filter((d) => d !== service.name && names.has(d));
127
+ const dropped = service.dependsOn.filter((d) => d !== service.name && !names.has(d));
128
+ if (dropped.length)
129
+ notes.push(`dependsOn not written, not in this import: ${dropped.join(", ")}`);
130
+ // Infrastructure images and one-shot jobs do not serve HTTP, so an assumed "/" would only
131
+ // fail the probe. Leave the path out; the runtime then skips the HTTP probe.
132
+ const nonHttp = service.kind === "job" || (service.image !== undefined && NON_HTTP_IMAGE.test(service.image));
133
+ const healthCheckPath = isPrivate || nonHttp ? undefined : (service.healthCheckPath ?? "/");
134
+ if (nonHttp && !isPrivate)
135
+ notes.push("no health path: the service does not serve HTTP (non-HTTP image or job)");
136
+ if (!isPrivate && !nonHttp && !service.healthCheckPath)
137
+ notes.push("health path / is assumed: no HTTP health path could be read from Compose. Traefik routes only while it answers 2xx or 3xx");
138
+ if (service.envKeys.length)
139
+ notes.push("environment values are not written; put them in the project .env");
140
+ if (service.dockerfile && service.image)
141
+ notes.push(`image "${service.image}" is not written: with build and image, TDK builds from ${service.dockerfile} and does not pull the tag`);
142
+ // With both build and image, Compose builds the Dockerfile and tags the result as `image`.
143
+ // Omit `image` from service.json so TDK builds from the Dockerfile instead of pulling the tag.
144
+ const source = service.dockerfile
145
+ ? {
146
+ buildContext: relative(dirname(path), resolve(plan.root, service.dir)).split("\\").join("/") || ".",
147
+ dockerfile: service.dockerfile,
148
+ }
149
+ : { image: service.image };
150
+ const manifest = {
151
+ $schema: SERVICE_MANIFEST_SCHEMA_URL,
152
+ schemaVersion: SERVICE_MANIFEST_SCHEMA_VERSION,
153
+ appName: service.name,
154
+ appType: "bring-your-own",
155
+ stack,
156
+ port,
157
+ ...(healthCheckPath ? { healthCheckPath } : {}),
158
+ ...source,
159
+ ...(isPrivate ? { exposeViaProxy: false } : {}),
160
+ ...(dependsOn.length ? { dependsOn } : {}),
161
+ };
162
+ items.push({ service, path, manifest, status, notes });
163
+ }
164
+ return { projectRoot, stack, items, projectConfig, rejected };
165
+ }
166
+ export function applyWrites(writes) {
167
+ const written = [];
168
+ if (writes.projectConfig) {
169
+ mkdirSync(dirname(writes.projectConfig.path), { recursive: true });
170
+ writeFileSync(writes.projectConfig.path, writes.projectConfig.content, { flag: "wx" });
171
+ }
172
+ for (const item of writes.items) {
173
+ if (item.status === "exists")
174
+ continue;
175
+ mkdirSync(dirname(item.path), { recursive: true });
176
+ writeFileSync(item.path, `${JSON.stringify(item.manifest, null, 2)}\n`);
177
+ written.push(item);
178
+ }
179
+ return written;
180
+ }
181
+ export function manifestRelPath(plan, item) {
182
+ return relative(plan.root, item.path).split("\\").join("/");
183
+ }