@flisk-sh/flisk-import 0.2.4 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 TDK Landscape
3
+ Copyright (c) 2026 Flisk
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # flisk import
2
2
 
3
- Import the services in a repo's root `docker-compose.yml` into [Flisk](https://github.com/flisk-sh/flisk) as `service.json` files. This is the Compose first tier of flisk-sh/flisk#511.
3
+ Import the services in a repo's root `docker-compose.yml` into [Flisk](https://github.com/flisk-sh/flisk) as `service.json` files, or register the repo's own Tiltfile so `flisk up` runs it. This is the Compose first tier of flisk-sh/flisk#511.
4
4
 
5
5
  ```bash
6
6
  flisk import . --dry-run # plan only
@@ -11,14 +11,33 @@ flisk import . --yes # write service.json files and project setup
11
11
  ## What to expect
12
12
 
13
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 Flisk 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 `flisk up` with Flisk core **1.3.145 or later**. The importer does not check the core version.
14
+ - **Writes:** one bring-your-own `service.json` per Compose service, plus `.flisk/project.json` if the repo is not a Flisk project yet. It never edits the Compose file or overwrites a `service.json` without `--force`.
15
+ - **Keeps:** the service names (`db` stays `db`, so it stays the hostname), the build context and Dockerfile (or the image), the container port as written (3000, 80, 5432), `depends_on`, and an HTTP health path. Databases and brokers are private, with no proxy route.
16
+ - **Environment:** names go into `service.json` as `secrets.required`; literal values are copied to the project `.env`, which is added to `.gitignore`. Values in `.env` already are kept, and `${VAR}` values are left for you.
17
+ - **Drops, and says so:** volumes, `command`, `env_file` and every other untranslated key; services with `profiles`, `extends`, an out-of-tree build or a `${VAR}` image.
18
+ - **Does not start anything.** Run `flisk up` with Flisk core **1.4.11 or later**. The importer does not check the core version.
18
19
  - **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
 
20
21
  See [docs/import.md](docs/import.md) for the full mapping and limits. `flisk import` is the core CLI command backed by this package.
21
22
 
23
+ ## Import a Tiltfile
24
+
25
+ A repo that already runs on Tilt keeps its own Tiltfile. `flisk-import tiltfile` registers it in `.flisk/project.json`, so `flisk up` runs it unchanged.
26
+
27
+ ```bash
28
+ flisk import tiltfile --dry-run # plan only
29
+ flisk import tiltfile # plan, then ask before writing
30
+ flisk import tiltfile dev/Tiltfile --yes # a named file, or a folder that holds a Tiltfile
31
+ ```
32
+
33
+ - **Path:** `[path]` defaults to `Tiltfile`; a folder means `<folder>/Tiltfile`. It is resolved from the current directory. The project root is the nearest folder with `.flisk/project.json`, else the current directory.
34
+ - **Writes:** only `.flisk/project.json`, with `"orchestration": { "source": "tiltfile", "path": "<path from the project root>" }`. Every other key is kept. Without a `project.json`, it creates the same minimal one as the Compose import, with only the proxy stack. It never edits the Tiltfile.
35
+ - **Refuses (exit 1):** a missing Tiltfile, a path inside `.flisk/`, a path with a quote or control character, and a `project.json` that already sets `orchestration` (pass `--force` to replace it).
36
+ - **Exit codes:** the same as the Compose import: `0` for `--dry-run` or a write, `1` for a refusal, `2` when the prompt is declined or closed (`Nothing written.`).
37
+ - **Needs** a Flisk core that runs an imported Tiltfile ([flisk#2849](https://github.com/flisk-sh/flisk/pull/2849)). The importer does not check the core version.
38
+
39
+ Plain `flisk-import [dir]` is still the Compose import. To import Compose from a folder named `tiltfile`, write `flisk-import ./tiltfile`.
40
+
22
41
  ```bash
23
42
  bun install && bun run build && bun run test
24
43
  ```
package/dist/cli.js CHANGED
@@ -5,9 +5,10 @@ import { createInterface } from "node:readline/promises";
5
5
  import { Command } from "commander";
6
6
  import "./detectors/index.js";
7
7
  import { ImportError } from "./errors.js";
8
+ import { MIN_CORE_VERSION, TILTFILE_CORE } from "./flisk.js";
8
9
  import { formatPlan } from "./format.js";
9
10
  import { buildPlan } from "./plan.js";
10
- import { MIN_CORE_VERSION } from "./tdk.js";
11
+ import { applyTiltfileImport, formatTiltfilePlan, planTiltfileImport } from "./tiltfile.js";
11
12
  import { applyWrites, manifestRelPath, planWrites } from "./write.js";
12
13
  async function confirm(message) {
13
14
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -26,6 +27,8 @@ async function confirm(message) {
26
27
  const program = new Command("flisk-import")
27
28
  .version(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version)
28
29
  .description("Import services from the root docker-compose.yml into Flisk")
30
+ // Options after `tiltfile` belong to it, so `tiltfile --dry-run` is not read as the Compose import's --dry-run.
31
+ .enablePositionalOptions()
29
32
  .argument("[dir]", "Directory containing docker-compose.yml", ".")
30
33
  .option("--dry-run", "Print the plan and write nothing", false)
31
34
  .option("--yes", "Write without prompting", false)
@@ -46,7 +49,7 @@ const program = new Command("flisk-import")
46
49
  return void console.log("Nothing to write: every service.json already exists.");
47
50
  }
48
51
  if (!options.yes &&
49
- !(await confirm(`Write ${pending.length} service.json file(s)${writes.projectConfig ? " and create .tdk/project.json" : ""}?`))) {
52
+ !(await confirm(`Write ${pending.length} service.json file(s)${writes.projectConfig ? " and create .flisk/project.json" : ""}?`))) {
50
53
  console.log("Nothing written.");
51
54
  process.exitCode = 2;
52
55
  return;
@@ -55,13 +58,48 @@ const program = new Command("flisk-import")
55
58
  for (const item of written) {
56
59
  console.log(` wrote ${relative(process.cwd(), item.path) || manifestRelPath(plan, item)}`);
57
60
  }
58
- console.log(`\nWrote ${written.length} service.json file(s)${writes.projectConfig ? " and created .tdk/project.json" : ""}.`);
61
+ console.log(`\nWrote ${written.length} service.json file(s)${writes.projectConfig ? " and created .flisk/project.json" : ""}.`);
59
62
  const envFile = relative(process.cwd(), join(writes.projectRoot, ".env")) || ".env";
60
63
  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.`);
64
+ const copied = Object.keys(writes.env.add);
65
+ if (copied.length)
66
+ console.log(` - Copied ${copied.length} environment value(s) from docker-compose.yml to ${envFile} (gitignored).`);
67
+ if (writes.items.some((item) => item.notes.some((note) => note.startsWith("no literal value"))))
68
+ console.log(` - Add the values listed above as missing to ${envFile}.`);
63
69
  console.log(` - Run \`flisk up\` with Flisk core ${MIN_CORE_VERSION} or later. The import did not start anything.`);
64
70
  });
71
+ program
72
+ .command("tiltfile")
73
+ .description("Register an existing Tiltfile in .flisk/project.json so flisk up runs it unchanged (never edits the Tiltfile)")
74
+ .argument("[path]", "The Tiltfile, or a directory holding one", "Tiltfile")
75
+ .option("--dry-run", "Print the plan and write nothing", false)
76
+ .option("--yes", "Write without prompting", false)
77
+ .option("--force", "Replace an orchestration already set in .flisk/project.json", false)
78
+ .action(async (path, local) => {
79
+ // Also honour the flags when they come before `tiltfile`, where the top-level command reads them.
80
+ const top = program.opts();
81
+ const options = {
82
+ dryRun: local.dryRun || top.dryRun,
83
+ yes: local.yes || top.yes,
84
+ force: local.force || top.force,
85
+ };
86
+ const plan = planTiltfileImport(path, { force: options.force });
87
+ console.log(formatTiltfilePlan(plan));
88
+ if (options.dryRun)
89
+ return void console.log("Dry run: nothing written.");
90
+ const target = plan.action === "create" ? "Create .flisk/project.json" : "Update .flisk/project.json";
91
+ if (!options.yes &&
92
+ !(await confirm(`${target} with orchestration ${plan.orchestration.path}?`))) {
93
+ console.log("Nothing written.");
94
+ process.exitCode = 2;
95
+ return;
96
+ }
97
+ applyTiltfileImport(plan);
98
+ console.log(` wrote ${relative(process.cwd(), plan.projectJson) || ".flisk/project.json"}`);
99
+ console.log(`\nRegistered ${plan.orchestration.path} as the orchestration source.`);
100
+ console.log("\nNext:");
101
+ console.log(` - Run \`flisk up\` with a Flisk core that runs an imported Tiltfile (${TILTFILE_CORE}). It runs ${plan.orchestration.path} unchanged; the import did not start anything.`);
102
+ });
65
103
  try {
66
104
  await program.parseAsync();
67
105
  }
@@ -1,8 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, normalize } from "node:path/posix";
3
3
  import { isAlias, isMap, parseDocument, visit } from "yaml";
4
+ import { HEALTH_CHECK_PATH_PATTERN } from "../flisk.js";
4
5
  import { registerDetector } from "../registry.js";
5
- import { HEALTH_CHECK_PATH_PATTERN } from "../tdk.js";
6
6
  const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
7
7
  /** Container-side port of one `ports:` entry, in short or long syntax. */
8
8
  function containerPort(entry) {
@@ -41,6 +41,26 @@ function envNames(environment) {
41
41
  }
42
42
  return isObj(environment) ? Object.keys(environment) : [];
43
43
  }
44
+ /** Literal environment values; interpolated ones (`${VAR}`) and empty entries are left for the user. */
45
+ function envValues(environment) {
46
+ const pairs = Array.isArray(environment)
47
+ ? environment
48
+ .filter((e) => typeof e === "string" && e.includes("="))
49
+ .map((e) => [e.slice(0, e.indexOf("=")), e.slice(e.indexOf("=") + 1)])
50
+ : isObj(environment)
51
+ ? Object.entries(environment)
52
+ : [];
53
+ const out = {};
54
+ for (const [key, raw] of pairs) {
55
+ if (raw === null || raw === undefined || typeof raw === "object")
56
+ continue;
57
+ const value = String(raw);
58
+ if (value.includes("${") || value.includes("\n"))
59
+ continue;
60
+ out[key] = value;
61
+ }
62
+ return out;
63
+ }
44
64
  function commandOf(command) {
45
65
  if (typeof command === "string")
46
66
  return command;
@@ -176,6 +196,7 @@ registerDetector({
176
196
  : [];
177
197
  const command = commandOf(raw.command);
178
198
  const envKeys = envNames(raw.environment);
199
+ const env = envValues(raw.environment);
179
200
  const foundHealthPath = healthPathOf(raw.healthcheck);
180
201
  const healthCheckPath = foundHealthPath && HEALTH_CHECK_PATH_PATTERN.test(foundHealthPath)
181
202
  ? foundHealthPath
@@ -208,6 +229,7 @@ registerDetector({
208
229
  ...(healthCheckPath ? { healthCheckPath } : {}),
209
230
  exposeViaProxy,
210
231
  ...(envKeys.length ? { envKeys } : {}),
232
+ ...(Object.keys(env).length ? { env } : {}),
211
233
  ...(dependsOn.length ? { dependsOn } : {}),
212
234
  confidence: "high",
213
235
  sources: [base],
package/dist/flisk.js ADDED
@@ -0,0 +1,72 @@
1
+ import { existsSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ /** Manifest constants of the Flisk 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 Flisk core that starts an imported project: 1.3.104 added `buildContext`, 1.3.145
8
+ * (flisk#1018) bootstraps a project that has `.flisk/project.json` but no Tiltfile yet, and the
9
+ * release after 1.4.10 (flisk#2646) accepts a bring-your-own port outside 4000-5999 and two-letter
10
+ * names, which this importer now writes. Stated, not checked: it does not read the installed version.
11
+ */
12
+ export const MIN_CORE_VERSION = "1.4.11";
13
+ /**
14
+ * The core change that runs a registered Tiltfile (`orchestration.source: "tiltfile"`) from `flisk up`. Stated, not
15
+ * checked, like MIN_CORE_VERSION.
16
+ */
17
+ export const TILTFILE_CORE = "flisk-sh/flisk#2849";
18
+ /** Flisk's bring-your-own port range. */
19
+ export const BYO_PORT_RANGE = { min: 4000, max: 5999, label: "4000-5999" };
20
+ /**
21
+ * Values Flisk copies into generated Compose and Traefik config must match these, or `flisk up` refuses
22
+ * the manifest (flisk `cli/src/utils/service-manifest.ts`, engine `manifest/parser.star`).
23
+ */
24
+ export const HEALTH_CHECK_PATH_PATTERN = /^\/[a-z0-9/-]*$/;
25
+ export const IMAGE_PATTERN = /^[A-Za-z0-9._/:@-]+$/;
26
+ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
27
+ export function validateName(label, name) {
28
+ return KEBAB.test(name)
29
+ ? { valid: true }
30
+ : {
31
+ valid: false,
32
+ error: `${label} "${name}" must use lowercase letters, numbers, and hyphens only`,
33
+ };
34
+ }
35
+ /** Nearest directory at or above `start` holding `.flisk/project.json`. */
36
+ export function findProjectRoot(start) {
37
+ let dir = resolve(start);
38
+ while (true) {
39
+ if (existsSync(join(dir, ".flisk", "project.json")))
40
+ return dir;
41
+ const parent = dirname(dir);
42
+ if (parent === dir)
43
+ return null;
44
+ dir = parent;
45
+ }
46
+ }
47
+ /**
48
+ * The minimal `.flisk/project.json` an import writes when the repo is not a Flisk project yet: the given stacks in
49
+ * Pre-Alpha, no optional infrastructure, and discovery of every `services/<stack>/<name>` folder. A copy of the minimum
50
+ * Flisk accepts today, so it can drift from flisk.
51
+ */
52
+ export function defaultProjectConfig(projectRoot, preAlpha) {
53
+ return {
54
+ version: "1.0",
55
+ project: { name: basename(projectRoot), version: "1.0.0" },
56
+ phases: {
57
+ pre_alpha: { name: "Pre-Alpha", ...preAlpha },
58
+ alpha: { name: "Alpha", description: "", enabledStacks: [] },
59
+ beta: { name: "Beta", description: "", enabledStacks: [] },
60
+ out_of_scope: { name: "Out of Scope", description: "", enabledStacks: [] },
61
+ },
62
+ optional_infra: {
63
+ monitoring: false,
64
+ elk: false,
65
+ debezium: false,
66
+ golden_image: false,
67
+ verdaccio: false,
68
+ },
69
+ discovery: { paths: ["services/*/*"] },
70
+ overrides: {},
71
+ };
72
+ }
package/dist/format.js CHANGED
@@ -10,7 +10,7 @@ export function formatPlan(plan, writes) {
10
10
  const jobs = plan.services.filter((s) => s.kind === "job").length;
11
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
12
  if (writes.projectConfig)
13
- out.push(` .tdk/project.json [create Flisk project]`, "");
13
+ out.push(` .flisk/project.json [create Flisk project]`, "");
14
14
  for (const service of plan.services) {
15
15
  const item = writes.items.find((i) => i.service === service);
16
16
  out.push(` ${service.name} (${service.kind}${service.dir ? `, ${service.dir}/` : ""})`);
package/dist/merge.js CHANGED
@@ -8,10 +8,11 @@ function slug(raw) {
8
8
  }
9
9
  export function toResourceName(raw) {
10
10
  let name = slug(raw) || "svc";
11
- // Resource names must start with a letter and be at least 3 characters (service schema).
11
+ // Resource names must start with a letter and be at least 2 characters (service schema), so a
12
+ // Compose name such as `db` is kept and stays the container's hostname.
12
13
  if (!/^[a-z]/.test(name))
13
14
  name = `svc-${name}`;
14
- if (name.length < 3)
15
+ if (name.length < 2)
15
16
  name = `${name}-svc`;
16
17
  return name;
17
18
  }
@@ -57,6 +58,7 @@ function mergeGroup(name, dir, members, resolve) {
57
58
  dir,
58
59
  kind: ordered[0].kind,
59
60
  envKeys: [...new Set(ordered.flatMap((c) => c.envKeys ?? []))].sort(),
61
+ env: Object.assign({}, ...[...ordered].reverse().map((c) => c.env ?? {})),
60
62
  dependsOn: [...new Set(ordered.flatMap((c) => (c.dependsOn ?? []).map(resolve)))].sort(),
61
63
  exposeViaProxy: ordered.find((c) => c.exposeViaProxy !== undefined)?.exposeViaProxy,
62
64
  procfileCommands: ordered.flatMap((c) => c.sources.some((source) => source.detector === "procfile") && c.command ? [c.command] : []),
package/dist/scan.js CHANGED
@@ -8,7 +8,7 @@ export const HARD_SKIPS = new Set([
8
8
  "dist",
9
9
  ".autogenerated",
10
10
  // Output of `flisk project`: never a source of services.
11
- ".tdk",
11
+ ".flisk",
12
12
  "shared-platform-engineering",
13
13
  ]);
14
14
  export const MAX_DEPTH = 8;
@@ -84,8 +84,8 @@ export function scanTree(root, limits = { depth: MAX_DEPTH, files: MAX_FILES })
84
84
  let depthCapped = false;
85
85
  let fileCapped = false;
86
86
  // In a Flisk project, `services/` holds manifests and generated compose files, not sources.
87
- const isTdkProject = existsSync(join(root, ".tdk", "project.json"));
88
- if (isTdkProject)
87
+ const isFliskProject = existsSync(join(root, ".flisk", "project.json"));
88
+ if (isFliskProject)
89
89
  notes.push("Flisk project detected: services/ was not scanned");
90
90
  const walk = (rel, depth, inherited) => {
91
91
  const abs = join(root, rel);
@@ -106,7 +106,7 @@ export function scanTree(root, limits = { depth: MAX_DEPTH, files: MAX_FILES })
106
106
  if (entry.isDirectory()) {
107
107
  if (HARD_SKIPS.has(entry.name) || isIgnored(rules, entryRel, entry.name, true))
108
108
  continue;
109
- if (isTdkProject && entryRel === "services")
109
+ if (isFliskProject && entryRel === "services")
110
110
  continue;
111
111
  if (depth >= limits.depth) {
112
112
  depthCapped = true;
@@ -0,0 +1,109 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { ImportError } from "./errors.js";
4
+ import { defaultProjectConfig, findProjectRoot } from "./flisk.js";
5
+ const RERUN = "flisk import tiltfile <path>";
6
+ /** Describes an `orchestration` value for a message, for example `tiltfile: Tiltfile`. */
7
+ export function describeOrchestration(value) {
8
+ if (value && typeof value === "object" && "source" in value) {
9
+ const { source, path } = value;
10
+ return `${String(source)}: ${typeof path === "string" && path ? path : "default"}`;
11
+ }
12
+ return JSON.stringify(value);
13
+ }
14
+ /**
15
+ * Plans registering an existing Tiltfile as the project's orchestration source, so `flisk up` runs it unchanged.
16
+ * `pathArg` is a file or a directory holding `Tiltfile`, resolved from `cwd`. Nothing is written here.
17
+ */
18
+ export function planTiltfileImport(pathArg = "Tiltfile", options = {}) {
19
+ const cwd = resolve(options.cwd ?? process.cwd());
20
+ // flisk up puts this path in a generated Tiltfile as a Starlark string, so refuse it before touching the disk.
21
+ const hasUnsafeChar = (value) => [...value].some((char) => char === '"' || char === "'" || char.charCodeAt(0) < 0x20);
22
+ if (hasUnsafeChar(pathArg)) {
23
+ throw new ImportError(`Tiltfile path ${JSON.stringify(pathArg)} has a quote or control character`, ["Rename the file or folder, then run: flisk import tiltfile <path>"]);
24
+ }
25
+ let tiltfile = resolve(cwd, pathArg);
26
+ if (statSync(tiltfile, { throwIfNoEntry: false })?.isDirectory())
27
+ tiltfile = join(tiltfile, "Tiltfile");
28
+ const projectRoot = findProjectRoot(cwd) ?? cwd;
29
+ const path = relative(projectRoot, tiltfile).split("\\").join("/");
30
+ if (hasUnsafeChar(path)) {
31
+ throw new ImportError(`Tiltfile path ${JSON.stringify(path)} has a quote or control character`, ["Rename the file or folder, then run: flisk import tiltfile <path>"]);
32
+ }
33
+ if (path === ".flisk" || path.startsWith(".flisk/")) {
34
+ throw new ImportError(`${path} is inside .flisk/, where Flisk writes its generated files; import your own Tiltfile instead`, [`Point it at the Tiltfile you maintain: ${RERUN}`]);
35
+ }
36
+ if (!statSync(tiltfile, { throwIfNoEntry: false })?.isFile()) {
37
+ throw new ImportError(`No Tiltfile found at ${pathArg}`, [
38
+ `Pass the path of an existing Tiltfile, or a folder that holds one: ${RERUN}`,
39
+ ]);
40
+ }
41
+ const projectJson = join(projectRoot, ".flisk", "project.json");
42
+ const orchestration = { source: "tiltfile", path };
43
+ if (!existsSync(projectJson)) {
44
+ const config = {
45
+ ...defaultProjectConfig(projectRoot, {
46
+ description: "Flisk proxy; flisk up runs the project's own Tiltfile",
47
+ enabledStacks: ["proxy"],
48
+ }),
49
+ orchestration,
50
+ };
51
+ return {
52
+ tiltfile,
53
+ projectRoot,
54
+ projectJson,
55
+ orchestration,
56
+ action: "create",
57
+ content: `${JSON.stringify(config, null, 2)}\n`,
58
+ };
59
+ }
60
+ let existing;
61
+ try {
62
+ existing = JSON.parse(readFileSync(projectJson, "utf8"));
63
+ }
64
+ catch (err) {
65
+ throw new ImportError(`Cannot read ${projectJson}: ${err instanceof Error ? err.message : String(err)}`, ["Fix the JSON in .flisk/project.json, then run the import again"]);
66
+ }
67
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
68
+ throw new ImportError(`${projectJson} is not a JSON object`, [
69
+ "Fix .flisk/project.json, then run the import again",
70
+ ]);
71
+ }
72
+ const config = existing;
73
+ const replaced = config.orchestration;
74
+ if (replaced !== undefined && !options.force) {
75
+ throw new ImportError(`.flisk/project.json already sets orchestration (${describeOrchestration(replaced)})`, ["Pass --force to replace it; every other key is kept"]);
76
+ }
77
+ // Spreading keeps every key, and keeps `orchestration` where it was when --force replaces it.
78
+ const next = { ...config, orchestration };
79
+ return {
80
+ tiltfile,
81
+ projectRoot,
82
+ projectJson,
83
+ orchestration,
84
+ action: "update",
85
+ ...(replaced !== undefined ? { replaced } : {}),
86
+ content: `${JSON.stringify(next, null, 2)}\n`,
87
+ };
88
+ }
89
+ /** Writes `.flisk/project.json`, and nothing else. The Tiltfile is never touched. */
90
+ export function applyTiltfileImport(plan) {
91
+ mkdirSync(dirname(plan.projectJson), { recursive: true });
92
+ writeFileSync(plan.projectJson, plan.content, plan.action === "create" ? { flag: "wx" } : {});
93
+ }
94
+ /** The printed plan for `flisk-import tiltfile`. */
95
+ export function formatTiltfilePlan(plan) {
96
+ const status = plan.action === "create"
97
+ ? "create Flisk project"
98
+ : plan.replaced !== undefined
99
+ ? `update, replaces orchestration (${describeOrchestration(plan.replaced)}) (--force)`
100
+ : "update, other keys kept";
101
+ return [
102
+ `Found ${plan.orchestration.path} in ${plan.projectRoot}`,
103
+ "The Tiltfile is not changed: flisk up runs it as it is.",
104
+ "",
105
+ ` .flisk/project.json [${status}]`,
106
+ ` orchestration: ${JSON.stringify(plan.orchestration)}`,
107
+ "",
108
+ ].join("\n");
109
+ }
package/dist/write.js CHANGED
@@ -1,9 +1,25 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, resolve } from "node:path";
3
3
  import { ImportError } from "./errors.js";
4
+ import { BYO_PORT_RANGE, defaultProjectConfig, findProjectRoot, IMAGE_PATTERN, SERVICE_MANIFEST_SCHEMA_URL, SERVICE_MANIFEST_SCHEMA_VERSION, validateName, } from "./flisk.js";
4
5
  import { toResourceName } from "./merge.js";
5
6
  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
+ /** Secret names the service schema accepts (`secrets.required`). */
8
+ const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
9
+ function readEnvKeys(path) {
10
+ if (!existsSync(path))
11
+ return new Set();
12
+ return new Set(readFileSync(path, "utf8")
13
+ .split(/\r?\n/)
14
+ .map((line) => line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1])
15
+ .filter((key) => key !== undefined));
16
+ }
17
+ /** A .env value, quoted when it holds characters a dotenv parser would split or strip. */
18
+ function envLine(key, value) {
19
+ return /^[A-Za-z0-9_./:@+-]*$/.test(value)
20
+ ? `${key}=${value}`
21
+ : `${key}=${JSON.stringify(value)}`;
22
+ }
7
23
  function existingPort(path) {
8
24
  try {
9
25
  const port = JSON.parse(readFileSync(path, "utf8")).port;
@@ -29,35 +45,19 @@ export function planWrites(plan, force) {
29
45
  ]);
30
46
  }
31
47
  const servicesDir = join(projectRoot, "services");
32
- const projectConfigPath = join(projectRoot, ".tdk", "project.json");
48
+ const projectConfigPath = join(projectRoot, ".flisk", "project.json");
33
49
  const projectConfig = existsSync(projectConfigPath)
34
50
  ? undefined
35
51
  : {
36
52
  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: "Flisk 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`,
53
+ content: `${JSON.stringify(defaultProjectConfig(projectRoot, {
54
+ description: "Flisk proxy and imported Compose services",
55
+ enabledStacks: ["proxy", stack],
56
+ }), null, 2)}\n`,
60
57
  };
58
+ const envPath = join(projectRoot, ".env");
59
+ const envKept = readEnvKeys(envPath);
60
+ const envAdd = {};
61
61
  const used = usedManifestPorts(servicesDir);
62
62
  const items = [];
63
63
  const rejected = [];
@@ -97,8 +97,9 @@ export function planWrites(plan, force) {
97
97
  // Never written, so no port is needed; keep the one on disk for the plan.
98
98
  port = existingPort(path) ?? BYO_PORT_RANGE.min;
99
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.
100
+ else if (service.port !== undefined) {
101
+ // A bring-your-own port is the port inside its own container (Traefik reaches it on the stack
102
+ // network; nothing is published on the host), so the Compose port is kept as written.
102
103
  port = service.port;
103
104
  }
104
105
  else if (keep !== undefined && free(keep)) {
@@ -112,11 +113,8 @@ export function planWrites(plan, force) {
112
113
  }
113
114
  }
114
115
  }
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`);
116
+ if (status !== "exists" && service.port === undefined && !isPrivate && port !== undefined)
117
+ notes.push(`port ${port} assigned: Compose gives no single container port; the container must listen on $PORT (${port})`);
120
118
  if (port === undefined) {
121
119
  rejected.push({ name: service.name, reason: `no free port in ${BYO_PORT_RANGE.label}` });
122
120
  continue;
@@ -135,8 +133,23 @@ export function planWrites(plan, force) {
135
133
  notes.push("no health path: the service does not serve HTTP (non-HTTP image or job)");
136
134
  if (!isPrivate && !nonHttp && !service.healthCheckPath)
137
135
  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");
136
+ // Names go into service.json as secrets, so Flisk injects them; values go to the gitignored project .env.
137
+ const secretNames = service.envKeys.filter((key) => ENV_NAME.test(key));
138
+ const skippedNames = service.envKeys.filter((key) => !ENV_NAME.test(key));
139
+ if (skippedNames.length)
140
+ notes.push(`environment names not written (Flisk accepts A-Z, 0-9 and _): ${skippedNames.join(", ")}`);
141
+ const missing = [];
142
+ for (const key of secretNames) {
143
+ const value = service.env[key];
144
+ if (envKept.has(key) || key in envAdd)
145
+ continue;
146
+ if (value === undefined)
147
+ missing.push(key);
148
+ else
149
+ envAdd[key] = value;
150
+ }
151
+ if (missing.length)
152
+ notes.push(`no literal value in docker-compose.yml for ${missing.join(", ")}; add it to the project .env`);
140
153
  if (service.dockerfile && service.image)
141
154
  notes.push(`image "${service.image}" is not written: with build and image, Flisk builds from ${service.dockerfile} and does not pull the tag`);
142
155
  // With both build and image, Compose builds the Dockerfile and tags the result as `image`.
@@ -158,10 +171,18 @@ export function planWrites(plan, force) {
158
171
  ...source,
159
172
  ...(isPrivate ? { exposeViaProxy: false } : {}),
160
173
  ...(dependsOn.length ? { dependsOn } : {}),
174
+ ...(secretNames.length ? { secrets: { required: secretNames } } : {}),
161
175
  };
162
176
  items.push({ service, path, manifest, status, notes });
163
177
  }
164
- return { projectRoot, stack, items, projectConfig, rejected };
178
+ return {
179
+ projectRoot,
180
+ stack,
181
+ items,
182
+ projectConfig,
183
+ rejected,
184
+ env: { path: envPath, add: envAdd },
185
+ };
165
186
  }
166
187
  export function applyWrites(writes) {
167
188
  const written = [];
@@ -176,8 +197,24 @@ export function applyWrites(writes) {
176
197
  writeFileSync(item.path, `${JSON.stringify(item.manifest, null, 2)}\n`);
177
198
  written.push(item);
178
199
  }
200
+ const keys = Object.keys(writes.env.add);
201
+ if (keys.length) {
202
+ const existing = existsSync(writes.env.path) ? readFileSync(writes.env.path, "utf8") : "";
203
+ const lines = keys.map((key) => envLine(key, writes.env.add[key]));
204
+ const block = `${existing && !existing.endsWith("\n") ? "\n" : ""}# From docker-compose.yml (flisk import)\n${lines.join("\n")}\n`;
205
+ writeFileSync(writes.env.path, existing + block);
206
+ ensureGitignored(writes.projectRoot, ".env");
207
+ }
179
208
  return written;
180
209
  }
181
210
  export function manifestRelPath(plan, item) {
182
211
  return relative(plan.root, item.path).split("\\").join("/");
183
212
  }
213
+ /** Keeps the project .env out of git: it now holds values copied from docker-compose.yml. */
214
+ function ensureGitignored(projectRoot, entry) {
215
+ const path = join(projectRoot, ".gitignore");
216
+ const content = existsSync(path) ? readFileSync(path, "utf8") : "";
217
+ if (content.split(/\r?\n/).some((line) => line.trim() === entry || line.trim() === `/${entry}`))
218
+ return;
219
+ writeFileSync(path, `${content}${content && !content.endsWith("\n") ? "\n" : ""}${entry}\n`);
220
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@flisk-sh/flisk-import",
3
- "version": "0.2.4",
4
- "description": "Import services from a repository's root docker-compose.yml into Flisk service.json files.",
3
+ "version": "0.4.0",
4
+ "description": "Import a repository's root docker-compose.yml into Flisk service.json files, or register its Tiltfile.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
package/dist/tdk.js DELETED
@@ -1,40 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
3
- /** Manifest constants of the Flisk 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 Flisk 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
- /** Flisk's bring-your-own port range. */
13
- export const BYO_PORT_RANGE = { min: 4000, max: 5999, label: "4000-5999" };
14
- /**
15
- * Values Flisk copies into generated Compose and Traefik config must match these, or `flisk 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
- }