@flisk-sh/flisk-import 0.3.0 → 0.4.1

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
@@ -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
@@ -20,6 +20,24 @@ flisk import . --yes # write service.json files and project setup
20
20
 
21
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.
22
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/` (also through a symlink), a path with a quote or control character, a `project.json` that is not a JSON object or lacks the fields `flisk up` needs, 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
+
23
41
  ```bash
24
42
  bun install && bun run build && bun run test
25
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 } from "./flisk.js";
8
+ import { MIN_CORE_VERSION, TILTFILE_CORE } from "./flisk.js";
9
9
  import { formatPlan } from "./format.js";
10
10
  import { buildPlan } from "./plan.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)
@@ -65,6 +68,38 @@ const program = new Command("flisk-import")
65
68
  console.log(` - Add the values listed above as missing to ${envFile}.`);
66
69
  console.log(` - Run \`flisk up\` with Flisk core ${MIN_CORE_VERSION} or later. The import did not start anything.`);
67
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
+ });
68
103
  try {
69
104
  await program.parseAsync();
70
105
  }
package/dist/flisk.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
3
  /** Manifest constants of the Flisk service.json this tool writes. */
4
4
  export const SERVICE_MANIFEST_SCHEMA_VERSION = 1;
5
5
  export const SERVICE_MANIFEST_SCHEMA_URL = "https://flisk.sh/schema.service.json";
@@ -10,6 +10,11 @@ export const SERVICE_MANIFEST_SCHEMA_URL = "https://flisk.sh/schema.service.json
10
10
  * names, which this importer now writes. Stated, not checked: it does not read the installed version.
11
11
  */
12
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";
13
18
  /** Flisk's bring-your-own port range. */
14
19
  export const BYO_PORT_RANGE = { min: 4000, max: 5999, label: "4000-5999" };
15
20
  /**
@@ -39,3 +44,78 @@ export function findProjectRoot(start) {
39
44
  dir = parent;
40
45
  }
41
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
+ }
73
+ const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
74
+ const PHASES = ["pre_alpha", "alpha", "beta", "out_of_scope"];
75
+ const OPTIONAL_INFRA = ["monitoring", "elk", "debezium", "golden_image", "verdaccio"];
76
+ /**
77
+ * The fields of an existing `.flisk/project.json` that Flisk core would refuse, missing or of the wrong type, as dotted
78
+ * names such as `project.name`. A whole object that is absent is named once (`optional_infra`), not field by field.
79
+ * Empty when `flisk up` can read the file. A copy of core's `normalizeProjectConfig` (flisk
80
+ * `cli/src/generator/template-engine.ts`), so it can drift from flisk.
81
+ */
82
+ export function missingProjectFields(config) {
83
+ const missing = [];
84
+ if (typeof config.version !== "string")
85
+ missing.push("version");
86
+ if (!isObject(config.project))
87
+ missing.push("project");
88
+ else
89
+ for (const key of ["name", "version"])
90
+ if (typeof config.project[key] !== "string")
91
+ missing.push(`project.${key}`);
92
+ // Core also reads the older `stacks` shape, with `services` in place of `enabledStacks`.
93
+ const phaseGaps = (value, label, field) => {
94
+ if (!isObject(value))
95
+ return [label];
96
+ return PHASES.filter((phase) => {
97
+ const entry = value[phase];
98
+ return !isObject(entry) || !Array.isArray(entry[field]);
99
+ }).map((phase) => `${label}.${phase}.${field}`);
100
+ };
101
+ const phases = phaseGaps(config.phases, "phases", "enabledStacks");
102
+ if (phases.length && phaseGaps(config.stacks, "stacks", "services").length)
103
+ missing.push(...phases);
104
+ if (!isObject(config.optional_infra))
105
+ missing.push("optional_infra");
106
+ else {
107
+ const infra = config.optional_infra;
108
+ for (const key of OPTIONAL_INFRA)
109
+ if (typeof infra[key] !== "boolean")
110
+ missing.push(`optional_infra.${key}`);
111
+ }
112
+ if (!isObject(config.discovery))
113
+ missing.push("discovery");
114
+ else if (!Array.isArray(config.discovery.paths))
115
+ missing.push("discovery.paths");
116
+ if (config.capabilities !== undefined &&
117
+ !(isObject(config.capabilities) &&
118
+ Object.values(config.capabilities).every((flag) => typeof flag === "boolean")))
119
+ missing.push("capabilities");
120
+ return missing;
121
+ }
@@ -0,0 +1,141 @@
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { ImportError } from "./errors.js";
4
+ import { defaultProjectConfig, findProjectRoot, missingProjectFields } from "./flisk.js";
5
+ const RERUN = "flisk import tiltfile <path>";
6
+ const RECREATE = "Or recreate it with `flisk project --yes --force` (this resets .flisk/project.json), then run the import again";
7
+ /** The refusal for a Tiltfile inside `.flisk/`; `resolved` names where a symlink leads. */
8
+ function insideFliskError(path, resolved) {
9
+ const via = resolved ? ` (it resolves to ${resolved})` : "";
10
+ return new ImportError(`${path} is inside .flisk/${via}, where Flisk writes its generated files; import your own Tiltfile instead`, [`Point it at the Tiltfile you maintain: ${RERUN}`]);
11
+ }
12
+ /**
13
+ * Where `tiltfile` really is, relative to the real `<projectRoot>/.flisk`, when a symlink on the way leads into it.
14
+ * The lexical check misses `infra/Tiltfile -> ../.flisk/.flisk-out/Tiltfile`, and flisk up would then include its own
15
+ * generated Tiltfile. Undefined when it does not, or when there is no `.flisk/` yet.
16
+ */
17
+ function linkedIntoFlisk(tiltfile, projectRoot) {
18
+ const fliskDir = join(projectRoot, ".flisk");
19
+ if (!existsSync(fliskDir))
20
+ return undefined;
21
+ const realFlisk = realpathSync(fliskDir);
22
+ const inside = relative(realFlisk, realpathSync(tiltfile));
23
+ if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside))
24
+ return undefined;
25
+ return [".flisk", ...inside.split(sep)].join("/");
26
+ }
27
+ /** Describes an `orchestration` value for a message, for example `tiltfile: Tiltfile`. */
28
+ export function describeOrchestration(value) {
29
+ if (value && typeof value === "object" && "source" in value) {
30
+ const { source, path } = value;
31
+ return `${String(source)}: ${typeof path === "string" && path ? path : "default"}`;
32
+ }
33
+ return JSON.stringify(value);
34
+ }
35
+ /**
36
+ * Plans registering an existing Tiltfile as the project's orchestration source, so `flisk up` runs it unchanged.
37
+ * `pathArg` is a file or a directory holding `Tiltfile`, resolved from `cwd`. Nothing is written here.
38
+ */
39
+ export function planTiltfileImport(pathArg = "Tiltfile", options = {}) {
40
+ const cwd = resolve(options.cwd ?? process.cwd());
41
+ // flisk up puts this path in a generated Tiltfile as a Starlark string, so refuse it before touching the disk.
42
+ const hasUnsafeChar = (value) => [...value].some((char) => char === '"' || char === "'" || char.charCodeAt(0) < 0x20);
43
+ if (hasUnsafeChar(pathArg)) {
44
+ 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>"]);
45
+ }
46
+ let tiltfile = resolve(cwd, pathArg);
47
+ if (statSync(tiltfile, { throwIfNoEntry: false })?.isDirectory())
48
+ tiltfile = join(tiltfile, "Tiltfile");
49
+ const projectRoot = findProjectRoot(cwd) ?? cwd;
50
+ const path = relative(projectRoot, tiltfile).split("\\").join("/");
51
+ if (hasUnsafeChar(path)) {
52
+ 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>"]);
53
+ }
54
+ if (path === ".flisk" || path.startsWith(".flisk/"))
55
+ throw insideFliskError(path);
56
+ if (!statSync(tiltfile, { throwIfNoEntry: false })?.isFile()) {
57
+ throw new ImportError(`No Tiltfile found at ${pathArg}`, [
58
+ `Pass the path of an existing Tiltfile, or a folder that holds one: ${RERUN}`,
59
+ ]);
60
+ }
61
+ const resolved = linkedIntoFlisk(tiltfile, projectRoot);
62
+ if (resolved)
63
+ throw insideFliskError(path, resolved);
64
+ const projectJson = join(projectRoot, ".flisk", "project.json");
65
+ const orchestration = { source: "tiltfile", path };
66
+ if (!existsSync(projectJson)) {
67
+ const config = {
68
+ ...defaultProjectConfig(projectRoot, {
69
+ description: "Flisk proxy; flisk up runs the project's own Tiltfile",
70
+ enabledStacks: ["proxy"],
71
+ }),
72
+ orchestration,
73
+ };
74
+ return {
75
+ tiltfile,
76
+ projectRoot,
77
+ projectJson,
78
+ orchestration,
79
+ action: "create",
80
+ content: `${JSON.stringify(config, null, 2)}\n`,
81
+ };
82
+ }
83
+ let existing;
84
+ try {
85
+ existing = JSON.parse(readFileSync(projectJson, "utf8"));
86
+ }
87
+ catch (err) {
88
+ 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", RECREATE]);
89
+ }
90
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
91
+ throw new ImportError(`${projectJson} is not a JSON object`, [
92
+ "Fix .flisk/project.json, then run the import again",
93
+ RECREATE,
94
+ ]);
95
+ }
96
+ const config = existing;
97
+ // Writing orchestration into a file flisk up cannot read would report success and then fail at flisk up.
98
+ const missing = missingProjectFields(config);
99
+ if (missing.length) {
100
+ throw new ImportError(`.flisk/project.json is missing fields flisk up needs, or has them with the wrong type: ${missing.join(", ")}`, [
101
+ `Add ${missing.length === 1 ? "it" : "them"} to .flisk/project.json, then run the import again`,
102
+ RECREATE,
103
+ ]);
104
+ }
105
+ const replaced = config.orchestration;
106
+ if (replaced !== undefined && !options.force) {
107
+ throw new ImportError(`.flisk/project.json already sets orchestration (${describeOrchestration(replaced)})`, ["Pass --force to replace it; every other key is kept"]);
108
+ }
109
+ // Spreading keeps every key, and keeps `orchestration` where it was when --force replaces it.
110
+ const next = { ...config, orchestration };
111
+ return {
112
+ tiltfile,
113
+ projectRoot,
114
+ projectJson,
115
+ orchestration,
116
+ action: "update",
117
+ ...(replaced !== undefined ? { replaced } : {}),
118
+ content: `${JSON.stringify(next, null, 2)}\n`,
119
+ };
120
+ }
121
+ /** Writes `.flisk/project.json`, and nothing else. The Tiltfile is never touched. */
122
+ export function applyTiltfileImport(plan) {
123
+ mkdirSync(dirname(plan.projectJson), { recursive: true });
124
+ writeFileSync(plan.projectJson, plan.content, plan.action === "create" ? { flag: "wx" } : {});
125
+ }
126
+ /** The printed plan for `flisk-import tiltfile`. */
127
+ export function formatTiltfilePlan(plan) {
128
+ const status = plan.action === "create"
129
+ ? "create Flisk project"
130
+ : plan.replaced !== undefined
131
+ ? `update, replaces orchestration (${describeOrchestration(plan.replaced)}) (--force)`
132
+ : "update, other keys kept";
133
+ return [
134
+ `Found ${plan.orchestration.path} in ${plan.projectRoot}`,
135
+ "The Tiltfile is not changed: flisk up runs it as it is.",
136
+ "",
137
+ ` .flisk/project.json [${status}]`,
138
+ ` orchestration: ${JSON.stringify(plan.orchestration)}`,
139
+ "",
140
+ ].join("\n");
141
+ }
package/dist/write.js CHANGED
@@ -1,7 +1,7 @@
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, findProjectRoot, IMAGE_PATTERN, SERVICE_MANIFEST_SCHEMA_URL, SERVICE_MANIFEST_SCHEMA_VERSION, validateName, } from "./flisk.js";
4
+ import { BYO_PORT_RANGE, defaultProjectConfig, findProjectRoot, IMAGE_PATTERN, SERVICE_MANIFEST_SCHEMA_URL, SERVICE_MANIFEST_SCHEMA_VERSION, validateName, } from "./flisk.js";
5
5
  import { toResourceName } from "./merge.js";
6
6
  import { usedManifestPorts } from "./plan.js";
7
7
  /** Secret names the service schema accepts (`secrets.required`). */
@@ -50,29 +50,10 @@ export function planWrites(plan, force) {
50
50
  ? undefined
51
51
  : {
52
52
  path: projectConfigPath,
53
- content: `${JSON.stringify({
54
- version: "1.0",
55
- project: { name: basename(projectRoot), version: "1.0.0" },
56
- phases: {
57
- pre_alpha: {
58
- name: "Pre-Alpha",
59
- description: "Flisk proxy and imported Compose services",
60
- enabledStacks: ["proxy", stack],
61
- },
62
- alpha: { name: "Alpha", description: "", enabledStacks: [] },
63
- beta: { name: "Beta", description: "", enabledStacks: [] },
64
- out_of_scope: { name: "Out of Scope", description: "", enabledStacks: [] },
65
- },
66
- optional_infra: {
67
- monitoring: false,
68
- elk: false,
69
- debezium: false,
70
- golden_image: false,
71
- verdaccio: false,
72
- },
73
- discovery: { paths: ["services/*/*"] },
74
- overrides: {},
75
- }, 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`,
76
57
  };
77
58
  const envPath = join(projectRoot, ".env");
78
59
  const envKept = readEnvKeys(envPath);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@flisk-sh/flisk-import",
3
- "version": "0.3.0",
4
- "description": "Import services from a repository's root docker-compose.yml into Flisk service.json files.",
3
+ "version": "0.4.1",
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": {