@flisk-sh/flisk-import 0.15.0 → 0.17.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/README.md CHANGED
@@ -10,10 +10,11 @@ flisk import . --yes # write service.json files and project setup
10
10
 
11
11
  ## What to expect
12
12
 
13
- - **Reads one file:** the root `docker-compose.yml`, or `compose.yaml`, `compose.yml` or `docker-compose.yaml` when that is absent. Not override files, nested Compose files, Dockerfiles on their own, `package.json`, or Procfiles.
13
+ - **Reads one project:** the root `docker-compose.yml`, or `compose.yaml`, `compose.yml` or `docker-compose.yaml` when that is absent, through `docker compose config --no-interpolate --no-env-resolution --no-path-resolution` when docker compose is installed, so `include:`, `extends` and anchors are resolved as `docker compose up` resolves them (wger keeps db, cache and powersync in `services/*.yaml`), `${VAR}` templates stay as written, and an included file's paths stay relative to its own folder. Without docker compose (or with `FLISK_IMPORT_DOCKER=0`) the file is read as YAML, and the plan says when that leaves `include` or `extends` unresolved. Not override files, Dockerfiles on their own, `package.json`, or Procfiles.
14
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
15
  - **Keeps the install's data:** each named volume gets `name`, the Docker volume `docker compose up` created (`<project>_<volume>`, its own `name:`, or an `external` volume), so `flisk up` mounts the same data with no copy (needs the Flisk release with flisk#2964).
16
16
  - **Keeps:** `profiles` (the service starts only with `flisk up --profile`), `env_file` as `envFile`, `tmpfs` and `deploy.resources.limits` (`container.tmpfs`, `cpus`, `memory`).
17
+ - **Keeps (Flisk 1.4.28):** each `depends_on` condition as a `{ name, condition }` entry (the list form as `service_started`), `build.args` as `buildArgs` (and, for a generated Dockerfile on an image with variables, each variable as a build argument, so `ARG POSTGRES_VERSION` gets the project's value), `logging` (options as strings), bind paths from the environment (`${OM_AGENTS_DIR:-./agents}`, the default rewritten relative to `service.json`), SELinux labels (`:z`, `:Z`, `bind.selinux`) as `selinux`, and images with `${NAME:?message}`. A bind path from a variable without a default (`${STORAGE_DIR}/redis`) and `extra_hosts` with a variable are still not carried over.
17
18
  - **Keeps:** `stop_grace_period` (`container.stopGracePeriod`, such as `30s`), the `aliases` of every network (`container.networkAliases`), `extra_hosts` in list or map form (`container.extraHosts`, such as `host.docker.internal:host-gateway`) and `ulimits` (`container.ulimits`). A service renamed to kebab-case (`lychee_api` becomes `lychee-api`) keeps its Compose name as a network alias, so `http://lychee_api:8000` in another service still resolves. `container_name` is dropped with a note: Flisk names containers itself. These fields, an image with variables and `on-failure:N` need the Flisk core release that reads them; an older core does not apply them.
18
19
  - **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.
19
20
  - **Runtime keys:** named volumes and bind mounts inside the project (`volumes`), `cap_add`, `cap_drop`, `init`, `read_only`, `user`, `security_opt` and `shm_size` (`container`), extra published ports (`extraPorts`, on the same host address as in Compose: `"80:80"` stays on every interface, `"127.0.0.1:5432:5432"` on loopback, and another address or a `${VAR:-default}` or `${VAR-default}` host side as `publish`), `network_mode: service:<name>` (`sidecarOf`) and `restart`.
@@ -22,7 +23,7 @@ flisk import . --yes # write service.json files and project setup
22
23
  - **A tracked `.env`:** a repo that commits its `.env` (Symfony) keeps it as it is, and it is not added to `.gitignore`; values that must stay local go to `.env.local` when git already ignores it, else to `.flisk/.env.import` (added to `.gitignore`), and each service that needs them reads that file as `envFile`.
23
24
  - **Keeps:** a `command` or `entrypoint` on an image as the `CMD`/`ENTRYPOINT` of a small `Dockerfile` next to `service.json` (Flisk sets no container command; a string is split into exec-form arguments as Compose splits it, with no shell, so `command: server /export` on `minio/minio` stays the arguments of its `ENTRYPOINT`; `$$` becomes `$` as Compose passes it to the container; only a string with Compose variables (`${VAR}`, filled from `.env` by Compose, by the container's shell here) or unbalanced quotes runs through `sh -c`, and the plan says so), a `healthcheck` as `healthCheck.command`, a published database port as `extraPorts`, and `restart: on-failure:N` with its retry limit (N from 1 to 100).
24
25
  - **Drops, and says so:** `command` on a service with a `build`, `env_file` and every other untranslated key; anonymous volumes and paths outside the project; `network_mode` other than `service:<name>`; services with invalid profile names, `extends`, an out-of-tree build, or an image with characters or variable forms other than `${NAME}`, `${NAME:-default}` and `${NAME-default}` (`postgres:${POSTGRES_VERSION:-18}-alpine` is written as it is).
25
- - **Does not start anything.** Run `flisk up` with Flisk core **1.4.26 or later** (`publish` needs it). The importer does not check the core version.
26
+ - **Does not start anything.** Run `flisk up` with Flisk core **1.4.28 or later** (dependsOn conditions, buildArgs, logging, bind paths from the environment, SELinux labels and `${NAME:?message}` images need it). The importer does not check the core version.
26
27
  - **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.`).
27
28
 
28
29
  See [docs/import.md](docs/import.md) for the full mapping and limits. `flisk import` is the core CLI command backed by this package.
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { basename, dirname, isAbsolute, join, normalize } from "node:path/posix";
3
4
  import { isAlias, isMap, parseDocument, visit } from "yaml";
@@ -51,6 +52,7 @@ const TRANSLATED_KEYS = new Set([
51
52
  "env_file",
52
53
  "tmpfs",
53
54
  "deploy",
55
+ "logging",
54
56
  "stop_grace_period",
55
57
  "extra_hosts",
56
58
  "ulimits",
@@ -152,7 +154,8 @@ function networkAliasesOf(value) {
152
154
  bad.push(alias);
153
155
  }
154
156
  }
155
- return { kept: [...new Set(kept)], bad };
157
+ // Sorted, so the YAML reader and docker compose config (which sorts networks) give the same list.
158
+ return { kept: [...new Set(kept)].sort(), bad };
156
159
  }
157
160
  /** stop_grace_period, network aliases, extra_hosts and ulimits, and a line for each value that is not carried over. */
158
161
  function networkOptionsOf(raw) {
@@ -271,10 +274,44 @@ function hostSideOf(port, hostIp, hostPort) {
271
274
  const publish = `${hostIp}:${hostNumber ?? port}`;
272
275
  return /^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(publish) ? { publish } : undefined;
273
276
  }
277
+ /**
278
+ * The project as `docker compose config` resolves it (include, extends, merges, anchors), with ${VAR} templates and env
279
+ * files left as written. Undefined when docker compose is not available, fails, or FLISK_IMPORT_DOCKER=0.
280
+ */
281
+ function composeConfigOf(absPath) {
282
+ if (process.env.FLISK_IMPORT_DOCKER === "0")
283
+ return undefined;
284
+ try {
285
+ const out = execFileSync("docker", [
286
+ "compose",
287
+ "--profile",
288
+ "*",
289
+ "-f",
290
+ absPath,
291
+ "config",
292
+ "--no-interpolate",
293
+ "--no-env-resolution",
294
+ // Paths stay as written in the root file; an included file's are rebased onto the project directory.
295
+ "--no-path-resolution",
296
+ "--format",
297
+ "json",
298
+ ], {
299
+ cwd: dirname(absPath),
300
+ encoding: "utf8",
301
+ stdio: ["ignore", "pipe", "ignore"],
302
+ timeout: 60_000,
303
+ });
304
+ const config = JSON.parse(out);
305
+ return isObj(config) ? config : undefined;
306
+ }
307
+ catch {
308
+ return undefined;
309
+ }
310
+ }
274
311
  /** Whether a `ports:` entry names a host port ("8000:8000", "127.0.0.1:8000:8000", `published:`), not only a container port. */
275
312
  function hasHostSide(entry) {
276
313
  if (isObj(entry))
277
- return entry.published !== undefined;
314
+ return entry.published !== undefined && entry.published !== "";
278
315
  return typeof entry === "string" && (entry.split("/")[0] ?? "").includes(":");
279
316
  }
280
317
  function publishedPort(entry) {
@@ -284,7 +321,7 @@ function publishedPort(entry) {
284
321
  let protocol = "tcp";
285
322
  let hostSide;
286
323
  if (isObj(entry)) {
287
- const published = entry.published === undefined ? undefined : String(entry.published);
324
+ const published = entry.published === undefined || entry.published === "" ? undefined : String(entry.published);
288
325
  const hostIp = typeof entry.host_ip === "string" ? entry.host_ip : undefined;
289
326
  if (entry.protocol === "udp")
290
327
  protocol = "udp";
@@ -318,33 +355,69 @@ function publishedPort(entry) {
318
355
  ...(protocol === "udp" ? { protocol } : {}),
319
356
  };
320
357
  }
358
+ /** A volume entry in short form for the plan, also when docker compose config wrote it in long form. */
359
+ function volumeText(entry) {
360
+ if (!isObj(entry) || typeof entry.target !== "string")
361
+ return entry;
362
+ const source = typeof entry.source === "string" && entry.source ? `${entry.source}:` : "";
363
+ return `${source}${entry.target}${entry.read_only === true ? ":ro" : ""}`;
364
+ }
321
365
  /** One `volumes:` entry. Bind sources are resolved against the Compose file's directory. */
322
366
  function volumeOf(entry, composeDir) {
323
367
  let source;
324
368
  let target;
325
369
  let readOnly = false;
326
370
  let type;
371
+ let selinux;
327
372
  if (typeof entry === "string") {
328
- const parts = entry.split(":");
329
- if (parts.length < 2 || parts.length > 3)
330
- return undefined;
331
- [source, target] = parts;
332
- readOnly = parts[2] === "ro";
333
- if (parts[2] !== undefined && parts[2] !== "ro" && parts[2] !== "rw")
373
+ // A `${NAME:-./path}` source has a colon of its own.
374
+ const match = entry.match(/^(\$\{[^}]*\}|[^:]*):([^:]*)(?::([^:]*))?$/);
375
+ if (!match)
334
376
  return undefined;
377
+ [, source, target] = match;
378
+ for (const option of match[3] === undefined ? [] : match[3].split(",")) {
379
+ if (option === "ro")
380
+ readOnly = true;
381
+ else if (option === "z" || option === "Z")
382
+ selinux = option;
383
+ else if (option !== "rw")
384
+ return undefined;
385
+ }
335
386
  }
336
387
  else if (isObj(entry)) {
337
388
  ({ source, target, type } = entry);
338
389
  readOnly = entry.read_only === true;
339
390
  if (type !== undefined && type !== "volume" && type !== "bind")
340
391
  return undefined;
392
+ const label = isObj(entry.bind) ? entry.bind.selinux : undefined;
393
+ if (label === "z" || label === "Z")
394
+ selinux = label;
395
+ else if (label !== undefined)
396
+ return undefined;
341
397
  }
342
398
  else {
343
399
  return undefined;
344
400
  }
345
401
  if (typeof source !== "string" || typeof target !== "string" || !CONTAINER_PATH.test(target))
346
402
  return undefined;
403
+ // `${NAME:-./path}`: a bind path from the environment, with a default inside the project.
404
+ const fromEnv = source.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)(:?-)(\.{1,2}(?:\/[A-Za-z0-9_.-]+)*\/?)\}$/);
405
+ if (fromEnv) {
406
+ const resolved = normalize(join(composeDir, fromEnv[3]));
407
+ if (resolved === ".." || resolved.startsWith("../"))
408
+ return undefined;
409
+ return {
410
+ source: resolved,
411
+ target,
412
+ ...(readOnly ? { readOnly } : {}),
413
+ bind: true,
414
+ fromEnv: { name: fromEnv[1], op: fromEnv[2] },
415
+ ...(selinux ? { selinux } : {}),
416
+ };
417
+ }
347
418
  const bind = type === "bind" || source.startsWith(".") || source.startsWith("/");
419
+ if (!bind && selinux)
420
+ return undefined;
348
421
  if (!bind) {
349
422
  return VOLUME_NAME.test(source)
350
423
  ? { source, target, ...(readOnly ? { readOnly } : {}), bind }
@@ -355,7 +428,62 @@ function volumeOf(entry, composeDir) {
355
428
  const resolved = normalize(join(composeDir, source));
356
429
  if (resolved === ".." || resolved.startsWith("../"))
357
430
  return undefined;
358
- return { source: resolved, target, ...(readOnly ? { readOnly } : {}), bind };
431
+ return {
432
+ source: resolved,
433
+ target,
434
+ ...(readOnly ? { readOnly } : {}),
435
+ bind,
436
+ ...(selinux ? { selinux } : {}),
437
+ };
438
+ }
439
+ const ARG_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
440
+ /** Compose build.args (a map, or a list of NAME=value) as strings; undefined when there are none or one is unusable. */
441
+ function buildArgsOf(build) {
442
+ if (!isObj(build) || build.args === undefined)
443
+ return undefined;
444
+ const pairs = Array.isArray(build.args)
445
+ ? build.args.map((a) => typeof a === "string" && a.includes("=")
446
+ ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)]
447
+ : [String(a), undefined])
448
+ : isObj(build.args)
449
+ ? Object.entries(build.args)
450
+ : [];
451
+ const out = {};
452
+ for (const [name, value] of pairs) {
453
+ if (!ARG_NAME.test(name) || value === undefined || value === null || typeof value === "object")
454
+ return undefined;
455
+ const text = String(value);
456
+ if (text.includes("\n"))
457
+ return undefined;
458
+ out[name] = text;
459
+ }
460
+ return Object.keys(out).length ? out : undefined;
461
+ }
462
+ /** Compose logging: a driver and string options (max-file: 3 becomes "3"). */
463
+ function loggingOf(value) {
464
+ if (!isObj(value))
465
+ return undefined;
466
+ const out = {};
467
+ if (value.driver !== undefined) {
468
+ if (typeof value.driver !== "string" || !/^[a-z0-9][a-z0-9_.-]*$/.test(value.driver))
469
+ return undefined;
470
+ out.driver = value.driver;
471
+ }
472
+ if (value.options !== undefined) {
473
+ if (!isObj(value.options))
474
+ return undefined;
475
+ const options = {};
476
+ for (const [key, v] of Object.entries(value.options)) {
477
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(key) || v === null || typeof v === "object")
478
+ return undefined;
479
+ const text = String(v);
480
+ if (/[\n\r]/.test(text))
481
+ return undefined;
482
+ options[key] = text;
483
+ }
484
+ out.options = options;
485
+ }
486
+ return Object.keys(out).length ? out : undefined;
359
487
  }
360
488
  /** Compose's project name: top-level `name`, else the directory name, lowercased with other characters dropped. */
361
489
  export function projectNameOf(root, absPath) {
@@ -784,9 +912,21 @@ registerDetector({
784
912
  reason: "uses YAML anchors/aliases; values were read after resolution",
785
913
  });
786
914
  }
787
- const root = doc.toJS();
915
+ const rawRoot = doc.toJS();
916
+ // `docker compose config` resolves include, extends and merges as `docker compose up` does; the YAML is the fallback.
917
+ const resolved = composeConfigOf(absPath);
918
+ const root = resolved ?? rawRoot;
919
+ const resolves = isObj(rawRoot) &&
920
+ (rawRoot.include !== undefined ||
921
+ (isObj(rawRoot.services) &&
922
+ Object.values(rawRoot.services).some((s) => isObj(s) && s.extends !== undefined)));
923
+ if (!resolved && resolves)
924
+ skips.push({
925
+ ...base,
926
+ reason: "uses include or extends, which only `docker compose config` resolves; docker compose was not available (or failed), so the file was read as YAML",
927
+ });
788
928
  const services = isObj(root) && isObj(root.services) ? root.services : null;
789
- if (!services || !isMap(doc.get("services", true))) {
929
+ if (!services || (!resolved && !isMap(doc.get("services", true)))) {
790
930
  return { candidates, skips: [...skips, { ...base, reason: "no `services:` map" }] };
791
931
  }
792
932
  const project = projectNameOf(root, absPath);
@@ -866,6 +1006,34 @@ registerDetector({
866
1006
  reason: `service "${name}" publishes extra container ports (${ports.filter((port) => port !== selectedPort).join(", ")}); Flisk routes only ${selectedPort} and keeps the others published as extraPorts`,
867
1007
  });
868
1008
  }
1009
+ // Every dependency keeps its Compose condition; the list form, and a map entry without one, mean service_started.
1010
+ const dependsOnConditions = {};
1011
+ const dependencyEntries = Array.isArray(raw.depends_on)
1012
+ ? raw.depends_on
1013
+ .filter((d) => typeof d === "string")
1014
+ .map((d) => [d, undefined])
1015
+ : isObj(raw.depends_on)
1016
+ ? Object.entries(raw.depends_on)
1017
+ : [];
1018
+ for (const [dep, value] of dependencyEntries) {
1019
+ const condition = isObj(value) ? value.condition : undefined;
1020
+ dependsOnConditions[dep] =
1021
+ condition === "service_healthy" || condition === "service_completed_successfully"
1022
+ ? condition
1023
+ : "service_started";
1024
+ }
1025
+ const buildArgs = buildArgsOf(raw.build);
1026
+ if (raw.build !== undefined && isObj(raw.build) && raw.build.args !== undefined && !buildArgs)
1027
+ skips.push({
1028
+ ...base,
1029
+ reason: `service "${name}" build.args ${JSON.stringify(raw.build.args)} has names or values Flisk does not accept; not used`,
1030
+ });
1031
+ const logging = loggingOf(raw.logging);
1032
+ if (raw.logging !== undefined && !logging)
1033
+ skips.push({
1034
+ ...base,
1035
+ reason: `service "${name}" logging ${JSON.stringify(raw.logging)} has a driver or options Flisk does not accept; not used`,
1036
+ });
869
1037
  const dependsOn = Array.isArray(raw.depends_on)
870
1038
  ? raw.depends_on.filter((d) => typeof d === "string")
871
1039
  : isObj(raw.depends_on)
@@ -968,7 +1136,7 @@ registerDetector({
968
1136
  else
969
1137
  skips.push({
970
1138
  ...base,
971
- reason: `service "${name}" volume ${JSON.stringify(entry)} is not translated (an anonymous volume, a path outside the imported directory, or Compose variables); not mounted`,
1139
+ reason: `service "${name}" volume ${JSON.stringify(volumeText(entry))} is not translated (an anonymous volume, a path outside the imported directory, or Compose variables); not mounted`,
972
1140
  });
973
1141
  }
974
1142
  // `on-failure:5` caps the retries at 5; service.json keeps the cap (1 to 100).
@@ -1013,6 +1181,9 @@ registerDetector({
1013
1181
  ...(Object.keys(env).length ? { env } : {}),
1014
1182
  ...(Object.keys(envRefs).length ? { envRefs } : {}),
1015
1183
  ...(dependsOn.length ? { dependsOn } : {}),
1184
+ ...(Object.keys(dependsOnConditions).length ? { dependsOnConditions } : {}),
1185
+ ...(buildArgs ? { buildArgs } : {}),
1186
+ ...(logging ? { logging } : {}),
1016
1187
  ...(container ? { container } : {}),
1017
1188
  ...(extraPorts.length ? { extraPorts } : {}),
1018
1189
  ...(sidecarOf ? { sidecarOf } : {}),
package/dist/flisk.js CHANGED
@@ -9,7 +9,7 @@ export const SERVICE_MANIFEST_SCHEMA_URL = "https://flisk.sh/schema.service.json
9
9
  * release after 1.4.10 (flisk#2646) accepts a bring-your-own port outside 4000-5999 and two-letter
10
10
  * names, which this importer now writes. Stated, not checked: it does not read the installed version.
11
11
  */
12
- export const MIN_CORE_VERSION = "1.4.26";
12
+ export const MIN_CORE_VERSION = "1.4.28";
13
13
  /**
14
14
  * The core change that runs a registered Tiltfile (`orchestration.source: "tiltfile"`) from `flisk up`. Stated, not
15
15
  * checked, like MIN_CORE_VERSION.
@@ -26,7 +26,7 @@ export const HEALTH_CHECK_PATH_PATTERN = /^\/[a-z0-9/-]*$/;
26
26
  * An image reference, which may hold Compose variables `${NAME}`, `${NAME:-default}` and `${NAME-default}` (postgres:${PG_VERSION:-18}-alpine);
27
27
  * Compose resolves them from the project .env. Nothing else: no quotes, spaces or newlines that could change generated YAML.
28
28
  */
29
- export const IMAGE_PATTERN = /^(?:[A-Za-z0-9._/:@-]|\$\{[A-Za-z_][A-Za-z0-9_]*(?::?-[A-Za-z0-9._/:@-]+)?\})+$/;
29
+ export const IMAGE_PATTERN = /^(?:[A-Za-z0-9._/:@-]|\$\{[A-Za-z_][A-Za-z0-9_]*(?::?-[A-Za-z0-9._/:@-]+|:?\?[A-Za-z0-9 !#%&()*+,./:;<=>?@[\]^_|~-]*)?\})+$/;
30
30
  /** A DNS name for a network alias: letters, digits, '-', '_' and '.'. */
31
31
  export const NETWORK_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,252}$/;
32
32
  const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -50,6 +50,17 @@ export function findProjectRoot(start) {
50
50
  dir = parent;
51
51
  }
52
52
  }
53
+ /**
54
+ * A project name Flisk accepts (flisk#1519): lowercase letters, digits, - and _, starting with a letter or digit.
55
+ * "Lychee" becomes "lychee", "My App" becomes "my-app".
56
+ */
57
+ export function projectNameFor(projectRoot) {
58
+ const name = basename(projectRoot)
59
+ .toLowerCase()
60
+ .replace(/[^a-z0-9_-]+/g, "-")
61
+ .replace(/^[-_]+|-+$/g, "");
62
+ return name || "project";
63
+ }
53
64
  /**
54
65
  * The minimal `.flisk/project.json` an import writes when the repo is not a Flisk project yet: the given stacks in
55
66
  * Pre-Alpha, no optional infrastructure, and discovery of every `services/<stack>/<name>` folder. A copy of the minimum
@@ -58,7 +69,7 @@ export function findProjectRoot(start) {
58
69
  export function defaultProjectConfig(projectRoot, preAlpha) {
59
70
  return {
60
71
  version: "1.0",
61
- project: { name: basename(projectRoot), version: "1.0.0" },
72
+ project: { name: projectNameFor(projectRoot), version: "1.0.0" },
62
73
  phases: {
63
74
  pre_alpha: { name: "Pre-Alpha", ...preAlpha },
64
75
  alpha: { name: "Alpha", description: "", enabledStacks: [] },
package/dist/merge.js CHANGED
@@ -76,6 +76,11 @@ function mergeGroup(name, dir, members, resolve) {
76
76
  ...firstOf(ordered, "models"),
77
77
  ...firstOf(ordered, "profiles"),
78
78
  ...firstOf(ordered, "envFile"),
79
+ ...firstOf(ordered, "buildArgs"),
80
+ ...firstOf(ordered, "logging"),
81
+ dependsOnConditions: Object.fromEntries([...ordered]
82
+ .reverse()
83
+ .flatMap((c) => Object.entries(c.dependsOnConditions ?? {}).map(([d, v]) => [resolve(d), v]))),
79
84
  ...firstOf(ordered, "healthCheck"),
80
85
  ...(ordered.find((c) => c.sidecarOf)?.sidecarOf
81
86
  ? { sidecarOf: resolve(ordered.find((c) => c.sidecarOf)?.sidecarOf) }
package/dist/write.js CHANGED
@@ -40,9 +40,15 @@ function looksSecret(value) {
40
40
  * `ARG` lines for the Compose variables in an image, so `FROM postgres:${POSTGRES_VERSION:-18}-alpine` can use them:
41
41
  * Docker substitutes only build arguments declared before FROM. The default is the expression's.
42
42
  */
43
+ function imageArgExpressions(image) {
44
+ const out = {};
45
+ for (const m of image.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}/g))
46
+ out[m[1]] ??= m[0];
47
+ return out;
48
+ }
43
49
  function buildArgLines(image) {
44
50
  const args = new Map();
45
- for (const m of image.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}/g))
51
+ for (const m of image.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*)|:?\?[^}]*)?\}/g))
46
52
  if (!args.has(m[1]))
47
53
  args.set(m[1], m[2]);
48
54
  return [...args]
@@ -179,7 +185,13 @@ export function planWrites(plan, force) {
179
185
  }
180
186
  if (status !== "exists")
181
187
  used.set(path, port);
182
- const dependsOn = service.dependsOn.filter((d) => d !== service.name && names.has(d));
188
+ // A Compose condition is kept as a { name, condition } entry (Flisk 1.4.28); a plain dependency stays a name.
189
+ const dependsOn = service.dependsOn
190
+ .filter((d) => d !== service.name && names.has(d))
191
+ .map((d) => {
192
+ const condition = service.dependsOnConditions[d];
193
+ return condition ? { name: d, condition } : d;
194
+ });
183
195
  const dropped = service.dependsOn.filter((d) => d !== service.name && !names.has(d));
184
196
  if (dropped.length)
185
197
  notes.push(`dependsOn not written, not in this import: ${dropped.join(", ")}`);
@@ -249,6 +261,20 @@ export function planWrites(plan, force) {
249
261
  // Omit `image` from service.json so Flisk builds from the Dockerfile instead of pulling the tag.
250
262
  // Flisk has no container `command`, so a Compose `command` on an image becomes the CMD of a one-line Dockerfile.
251
263
  const files = [];
264
+ // Build arguments: Compose build.args for a Dockerfile build, and, for a generated Dockerfile on an image with
265
+ // variables, each variable with its own expression, so the project's .env reaches `ARG NAME` before FROM.
266
+ const imageArgs = !service.dockerfile &&
267
+ service.image &&
268
+ (service.containerCommand || service.containerEntrypoint)
269
+ ? imageArgExpressions(service.image)
270
+ : {};
271
+ const buildArgsAll = {
272
+ ...(service.dockerfile ? (service.buildArgs ?? {}) : {}),
273
+ ...imageArgs,
274
+ };
275
+ const buildArgs = Object.keys(buildArgsAll).length ? buildArgsAll : undefined;
276
+ if (service.buildArgs && !service.dockerfile)
277
+ notes.push("build.args not written: the service has no build of its own");
252
278
  const wrapWithCommand = !service.dockerfile &&
253
279
  service.image &&
254
280
  (service.containerCommand || service.containerEntrypoint);
@@ -326,7 +352,14 @@ export function planWrites(plan, force) {
326
352
  return [];
327
353
  }
328
354
  const rel = relative(serviceDir, absolute).split("\\").join("/") || ".";
329
- return [{ ...volume, source: rel.startsWith(".") ? rel : `./${rel}` }];
355
+ const path = rel.startsWith(".") ? rel : `./${rel}`;
356
+ // `${NAME:-./path}` keeps reading NAME; its default is made relative to the service directory.
357
+ const source = volume.fromEnv
358
+ ? `\${${volume.fromEnv.name}${volume.fromEnv.op}${path}}`
359
+ : path;
360
+ if (volume.fromEnv)
361
+ notes.push(`volume ${volume.target} reads its path from ${volume.fromEnv.name}; the default is written relative to this service.json`);
362
+ return [{ ...volume, source }];
330
363
  });
331
364
  // env_file paths are relative to the service directory in service.json, and must stay inside the project.
332
365
  // With the project .env tracked by git, secrets live in another local file, which Flisk then reads as envFile.
@@ -354,6 +387,8 @@ export function planWrites(plan, force) {
354
387
  ...source,
355
388
  ...(isPrivate ? { exposeViaProxy: false } : {}),
356
389
  ...(dependsOn.length ? { dependsOn } : {}),
390
+ ...(buildArgs ? { buildArgs } : {}),
391
+ ...(service.logging ? { logging: service.logging } : {}),
357
392
  ...(Object.keys(env).length
358
393
  ? { env: Object.fromEntries(Object.entries(env).sort(([a], [b]) => a.localeCompare(b))) }
359
394
  : {}),
@@ -370,10 +405,11 @@ export function planWrites(plan, force) {
370
405
  ...(volumes.length
371
406
  ? {
372
407
  // `name` keeps the Docker volume the Compose install already has, so its data is used with no copy.
373
- volumes: volumes.map(({ source, target, readOnly, name }) => ({
408
+ volumes: volumes.map(({ source, target, readOnly, name, selinux }) => ({
374
409
  source,
375
410
  target,
376
411
  ...(readOnly ? { readOnly } : {}),
412
+ ...(selinux ? { selinux } : {}),
377
413
  ...(name ? { name } : {}),
378
414
  })),
379
415
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flisk-sh/flisk-import",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
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",