@tdk-landscape/tdk-cli-core 1.3.108 → 1.3.110

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/CHANGELOG.md CHANGED
@@ -4,6 +4,8 @@ Notable changes to the `tdk` CLI. Versions match [npm](https://www.npmjs.com/pac
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ - `tdk config verify` no longer fails on a root `.tiltignore` the repository already had. `tdk project` never overwrote it, but verify compared it with the generated one and reported `Out of sync: .tiltignore`; it now warns `not generated by TDK, left as is`. A `.tiltignore` carrying TDK's generated header that was edited is still reported. Refs #568.
8
+ - A repo can pin the oldest CLI it works with: add `"minTdkVersion": "1.3.80"` to `.tdk/project.json`, and `tdk doctor` fails with `Run: tdk upgrade` on an older `tdk` (a value that is not `MAJOR.MINOR.PATCH` also fails). It is a floor, not an exact pin. Documented in `docs/upgrading.md`. Refs #564.
7
9
  - `tdk doctor` warns when a backend or worker applies database migrations from its own start-up (a `start`/`dev` script, a Dockerfile `CMD`/`ENTRYPOINT`, a shell entrypoint or its source: Prisma, Drizzle, Knex, TypeORM, MikroORM, Sequelize, dbmate, Atlas, Alembic). It is a warning, not a failure: such services work, but migrations belong in a `migrator` resource that runs once before the services that depend on it. `migrator` and bring-your-own resources are not checked.
8
10
  - Go services can opt in to a live development loop with `"dev": { "liveReload": true }` in `service.json`: edits are copied into the running container and recompiled without an image rebuild, while `go.mod`, `go.sum` and the generated Dockerfile still rebuild the image. The watcher compiles into a staging binary while the old build keeps serving and swaps only on success, so a compile error shows in the Tilt log and the last good build keeps answering (Air, tried first, stopped the app before compiling and left it down for the whole compile; in 2 of 6 edits a Traefik health check then returned 503 for 10 s). Measured on one loaded Mac: median edit-to-ready 1.63 s for the scaffold. The default Go image (`golang:1.23`) now sets `GOTOOLCHAIN=auto`, so a `go.mod` that needs a newer Go, such as Gin 1.12 (`go 1.25`), downloads that toolchain instead of stopping with `requires go >= 1.25`. Offline or air-gapped builds of a go.mod newer than 1.23 still fail, but now at the toolchain download rather than at the version check. `scripts/e2e/go-dev-loop.sh` checks all of this through a real `tdk up`. Refs #369.
9
11
  - `tdk up` keeps each smoke step's status and response body under `.tdk/smoke/<service>/<step>/` (`latest.json` and `body.txt`, body capped at 64 KiB), plus `last-success.json` and `last-success-body.txt` that a later failure never overwrites, so a regression can be diffed against the last good response. The failure line ends with the record path, and `tdk project` gitignores `.tdk/smoke/`. A new `smoke-e2e` workflow runs `scripts/verify-smoke.sh` and uploads the records. Before its first step a smoke check now waits for the service's health path (`healthCheckPath`, default `/health` for backends) with GETs until it answers something other than a connection error, 404 or 5xx, so a write is no longer the first request to hit a service that is still building; write retry rules are unchanged.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@tdk-landscape/tdk-cli-core.svg?style=flat&color=blue)](https://www.npmjs.com/package/@tdk-landscape/tdk-cli-core)
4
4
  [![unpacked size](https://img.shields.io/npm/unpacked-size/@tdk-landscape/tdk-cli-core)](https://packagephobia.com/result?p=@tdk-landscape/tdk-cli-core)
5
- [![install size](https://packagephobia.com/badge?p=@tdk-landscape/tdk-cli-core)](https://packagephobia.com/result?p=@tdk-landscape/tdk-cli-core)
5
+ [![install size](https://badgen.net/packagephobia/install/@tdk-landscape/tdk-cli-core)](https://packagephobia.com/result?p=@tdk-landscape/tdk-cli-core)
6
6
  [![CI](https://github.com/tdk-landscape/tdk-cli-core/actions/workflows/ci.yml/badge.svg)](https://github.com/tdk-landscape/tdk-cli-core/actions/workflows/ci.yml)
7
7
  [![Quickstart E2E](https://github.com/tdk-landscape/tdk-cli-core/actions/workflows/quickstart-e2e.yml/badge.svg)](https://github.com/tdk-landscape/tdk-cli-core/actions/workflows/quickstart-e2e.yml)
8
8
  [![ERP fixture scale E2E](https://github.com/tdk-landscape/tdk-cli-core/actions/workflows/erp-scale-e2e.yml/badge.svg)](https://github.com/tdk-landscape/tdk-cli-core/actions/workflows/erp-scale-e2e.yml)
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { renderToString } from "ink";
3
+ import { describe, expect, it } from "vitest";
4
+ import { LoadingScreen } from "../ui.js";
5
+ describe("ui LoadingScreen", () => {
6
+ it("shows honest text with no percentage", () => {
7
+ const out = renderToString(_jsx(LoadingScreen, { animated: false }));
8
+ expect(out).toContain("Discovering resources...");
9
+ expect(out).not.toContain("%");
10
+ });
11
+ it("renders a spinner frame only when animated", () => {
12
+ expect(renderToString(_jsx(LoadingScreen, { animated: true }))).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Discovering/);
13
+ expect(renderToString(_jsx(LoadingScreen, { animated: false }))).not.toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/);
14
+ });
15
+ });
16
+ //# sourceMappingURL=ui-loading-screen.test.js.map
@@ -15,7 +15,7 @@ import { execAsync, isExecTimeout } from "../utils/exec-async.js";
15
15
  import { formatCount } from "../utils/formatting.js";
16
16
  import { getHostPortPlan } from "../utils/host-port-config.js";
17
17
  import { createHostPortPlan } from "../utils/host-port-plan.js";
18
- import { findProjectRoot } from "../utils/paths.js";
18
+ import { findProjectRoot, getPackageVersion } from "../utils/paths.js";
19
19
  import { isApiServiceType } from "../utils/resource-kind.js";
20
20
  import { buildHealthTargets, pingHealthTargets } from "../utils/service-urls.js";
21
21
  import { discoverResourcesFromRoot } from "../utils/services.js";
@@ -416,6 +416,60 @@ function checkMasterConfigs() {
416
416
  fix: "Run: tdk project",
417
417
  };
418
418
  }
419
+ /**
420
+ * A repo can pin the oldest CLI it works with: `"minTdkVersion": "1.3.80"` in
421
+ * .tdk/project.json. An older `tdk` fails here, so a team sees one clear line
422
+ * instead of a half-working `tdk up`.
423
+ */
424
+ export function checkTdkVersion(currentVersion = getPackageVersion()) {
425
+ const projectRoot = findProjectRoot();
426
+ if (!projectRoot) {
427
+ return {
428
+ name: "TDK version",
429
+ didPass: true,
430
+ isSkipped: true,
431
+ message: "Not in a project - skipping TDK version check",
432
+ };
433
+ }
434
+ let minimum;
435
+ try {
436
+ const parsed = JSON.parse(readFileSync(join(projectRoot, ".tdk", "project.json"), "utf-8"));
437
+ minimum = parsed?.minTdkVersion;
438
+ }
439
+ catch {
440
+ minimum = undefined;
441
+ }
442
+ if (minimum === undefined) {
443
+ return {
444
+ name: "TDK version",
445
+ didPass: true,
446
+ isSkipped: true,
447
+ message: `tdk ${currentVersion} (no minTdkVersion in .tdk/project.json)`,
448
+ };
449
+ }
450
+ const required = typeof minimum === "string" ? parseVersion(minimum) : null;
451
+ if (!required || !/^\d+\.\d+\.\d+$/.test(String(minimum).trim())) {
452
+ return {
453
+ name: "TDK version",
454
+ didPass: false,
455
+ message: `.tdk/project.json minTdkVersion ${JSON.stringify(minimum)} is not a version like "1.3.80"`,
456
+ fix: 'Set minTdkVersion to a MAJOR.MINOR.PATCH string, for example "1.3.80".',
457
+ };
458
+ }
459
+ if (!versionMeetsMinimum(currentVersion, required)) {
460
+ return {
461
+ name: "TDK version",
462
+ didPass: false,
463
+ message: `tdk ${currentVersion} is older than the ${required.join(".")} this project requires (minTdkVersion)`,
464
+ fix: "Run: tdk upgrade",
465
+ };
466
+ }
467
+ return {
468
+ name: "TDK version",
469
+ didPass: true,
470
+ message: `tdk ${currentVersion} meets minTdkVersion ${required.join(".")}`,
471
+ };
472
+ }
419
473
  /**
420
474
  * Tilt only builds resources under `discovery.paths`, but the CLI finds every service.json,
421
475
  * so a resource outside them is listed and given a URL by `tdk up` yet never started.
@@ -1092,6 +1146,7 @@ export const doctorCommand = new Command("doctor")
1092
1146
  () => checkDockerNetworkCapacity(),
1093
1147
  ];
1094
1148
  const projectChecks = [
1149
+ () => checkTdkVersion(),
1095
1150
  checkMasterConfigs,
1096
1151
  checkGeneratedProjectRuntimeAssets,
1097
1152
  checkStarlarkLoadExports,
@@ -5,16 +5,27 @@ import { useCallback, useEffect, useMemo, useState } from "react";
5
5
  import { DetailPanel, FileTree, ResourceSelectInput, ResourceTable, TabBar, } from "../components/index.js";
6
6
  import { errorFactories, requireProjectRoot } from "../utils/errors.js";
7
7
  import { findProjectRoot } from "../utils/paths.js";
8
+ import { describeSearch } from "../utils/search-status.js";
8
9
  import { clearMetadataCache, discoverResources, discoverStacks, getResourceMetadata, getStackMetadata, } from "../utils/services.js";
9
10
  import { createStatusMessageController } from "../utils/status-message.js";
10
11
  import { isTiltAvailable } from "../utils/tilt.js";
11
12
  // biome-ignore lint/correctness/noUnusedFunctionParameters: reserved callback prop kept in the component API
12
13
  const HelpPanel = ({ onClose }) => (_jsxs(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 2, paddingY: 1, flexDirection: "column", width: 60, children: [_jsx(Text, { bold: true, color: "cyan", children: "Keyboard Shortcuts" }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, underline: true, children: "Navigation" }), _jsx(Text, { children: " \u2191/\u2193 Navigate list items" }), _jsx(Text, { children: " Enter Select item / Open detail" }), _jsx(Text, { children: " Space Toggle expand (tree view)" }), _jsx(Text, { children: " Tab Next tab" }), _jsx(Text, { children: " 1-5 Direct tab access" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, underline: true, children: "Actions" }) }), _jsx(Text, { children: " m Toggle mouse support" }), _jsx(Text, { children: " t Toggle tooltips" }), _jsx(Text, { children: " e Toggle enabled/disabled services" }), _jsx(Text, { children: " r Refresh data" }), _jsx(Text, { children: " / Search/filter" }), _jsx(Text, { children: " ? Show this help" }), _jsx(Text, { children: " q Quit \u2502 Esc Back" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to close..." }) })] }));
13
- const LoadingScreen = ({ progress, message }) => (_jsxs(Box, { flexDirection: "column", padding: 2, children: [_jsx(Text, { bold: true, color: "cyan", children: "\u2593\u2592\u2591 TDK NEON EDITION \u2591\u2592\u2593" }), _jsx(Box, { marginY: 1 }), _jsxs(Text, { children: ["Loading: ", message] }), _jsxs(Box, { marginY: 1, borderStyle: "single", borderColor: "gray", width: 50, children: [_jsx(Box, { width: progress / 2, backgroundColor: "cyan", children: _jsx(Text, { children: " ".repeat(progress / 2) }) }), _jsxs(Text, { children: [" ", progress, "%"] })] })] }));
14
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
15
+ export const LoadingScreen = ({ message = "Discovering resources...", animated = true, }) => {
16
+ const [frame, setFrame] = useState(0);
17
+ useEffect(() => {
18
+ if (!animated)
19
+ return;
20
+ const timer = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
21
+ return () => clearInterval(timer);
22
+ }, [animated]);
23
+ return (_jsxs(Box, { flexDirection: "column", padding: 2, children: [_jsx(Text, { bold: true, color: "cyan", children: "\u2593\u2592\u2591 TDK NEON EDITION \u2591\u2592\u2593" }), _jsx(Box, { marginY: 1 }), _jsxs(Text, { children: [animated && _jsxs(Text, { color: "cyan", children: [SPINNER_FRAMES[frame], " "] }), message] })] }));
24
+ };
14
25
  // biome-ignore lint/correctness/noUnusedFunctionParameters: reserved callback prop kept in the component API
15
26
  const ErrorScreen = ({ error, onRetry }) => (_jsxs(Box, { flexDirection: "column", padding: 2, alignItems: "center", children: [_jsx(Text, { bold: true, color: "red", children: "Could Not Load Services" }), _jsx(Box, { marginY: 1 }), _jsxs(Text, { color: "red", children: ["\u2717 ", error] }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: "gray", children: "Troubleshooting:" }), _jsx(Text, { color: "gray", children: " 1. Check that every service.json is valid JSON" }), _jsx(Text, { color: "gray", children: " 2. Run tdk from your project (the folder with the Tiltfile)" }), _jsx(Text, { color: "gray", children: " 3. Try: tdk status --verbose" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: "cyan", children: "Press [r] to retry or [q] to quit" })] }));
16
27
  const EmptyState = ({ message }) => (_jsxs(Box, { flexDirection: "column", padding: 2, alignItems: "center", children: [_jsx(Text, { bold: true, color: "yellow", children: "No Services Found" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: "gray", children: "\u25C9 No service.json files found" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { children: "To get started:" }), _jsx(Text, { children: " 1. Run: tdk project" }), _jsx(Text, { children: " 2. Run: tdk resource api --type backend" }), _jsx(Box, { marginY: 1 }), _jsx(Text, { color: "cyan", children: "Press [r] to refresh or [q] to quit" }), message && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: "cyan", children: ["\u2593\u2592\u2591 ", message, " \u2591\u2592\u2593"] }) }))] }));
17
- const TUIApp = () => {
28
+ const TUIApp = ({ animated = true }) => {
18
29
  const { exit } = useApp();
19
30
  const { stdout } = useStdout();
20
31
  const { stdin, setRawMode } = useStdin();
@@ -149,6 +160,15 @@ const TUIApp = () => {
149
160
  return [];
150
161
  }, [activeTab, filteredStacks, filteredServices, selectedStackData, selectedServiceData]);
151
162
  const items = getItems();
163
+ // Only the stack and service lists are filtered by the query; drilled-in views are not.
164
+ const searchList = activeTab === "overview" || (activeTab === "resources" && !selectedStackData)
165
+ ? { total: stacks.length }
166
+ : (activeTab === "files" && !selectedServiceData) || activeTab === "config"
167
+ ? { total: services.length }
168
+ : null;
169
+ const searchStatus = searchList
170
+ ? describeSearch(searchQuery, items.length, searchList.total)
171
+ : null;
152
172
  useEffect(() => {
153
173
  if (highlightedIndex >= items.length && items.length > 0) {
154
174
  setHighlightedIndex(items.length - 1);
@@ -401,7 +421,7 @@ const TUIApp = () => {
401
421
  const compactTabBar = terminalWidth < 100;
402
422
  const compact = terminalWidth < 80;
403
423
  if (loading) {
404
- return _jsx(LoadingScreen, { progress: 100, message: "Initializing..." });
424
+ return _jsx(LoadingScreen, { message: "Discovering resources...", animated: animated });
405
425
  }
406
426
  if (error) {
407
427
  return _jsx(ErrorScreen, { error: error, onRetry: refresh });
@@ -409,7 +429,7 @@ const TUIApp = () => {
409
429
  if (services.length === 0) {
410
430
  return _jsx(EmptyState, { message: message });
411
431
  }
412
- return (_jsxs(Box, { flexDirection: "column", height: stdout.rows || 24, children: [_jsx(Box, { paddingX: 1, paddingY: 0, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\u2593\u2592\u2591 TDK NEON EDITION \u2591\u2592\u2593" }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "white", children: projectRoot }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsxs(Text, { color: "green", children: [services.length, " services ready"] })] }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "gray", children: "─".repeat(compact ? 60 : Math.min(terminalWidth - 4, 100)) }) }), isSearching && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: "yellow", children: ["Search: ", searchQuery, "_"] }) })), !isSearching && message && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: "cyan", children: ["\u2593\u2592\u2591 ", message, " \u2591\u2592\u2593"] }) })), !isSearching && !message && showTooltips && !showHelp && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: activeTab === "overview" && selectedStack
432
+ return (_jsxs(Box, { flexDirection: "column", height: stdout.rows || 24, children: [_jsx(Box, { paddingX: 1, paddingY: 0, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\u2593\u2592\u2591 TDK NEON EDITION \u2591\u2592\u2593" }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "white", children: projectRoot }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsxs(Text, { color: "green", children: [services.length, " services ready"] })] }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "gray", children: "─".repeat(compact ? 60 : Math.min(terminalWidth - 4, 100)) }) }), isSearching && (_jsxs(Box, { paddingX: 1, height: 1, children: [_jsxs(Text, { color: "yellow", children: ["Search: ", searchQuery, "_"] }), searchStatus && _jsx(Text, { color: "gray", dimColor: true, children: ` ${searchStatus.summary}` })] })), searchStatus?.emptyMessage && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: "gray", children: searchStatus.emptyMessage }) })), !isSearching && message && (_jsx(Box, { paddingX: 1, height: 1, children: _jsxs(Text, { color: "cyan", children: ["\u2593\u2592\u2591 ", message, " \u2591\u2592\u2593"] }) })), !isSearching && !message && showTooltips && !showHelp && (_jsx(Box, { paddingX: 1, height: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: activeTab === "overview" && selectedStack
413
433
  ? `Stack "${selectedStack}" selected. [Enter] view │ [Esc] back │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
414
434
  : activeTab === "overview" && !selectedStack
415
435
  ? `[↑/↓] Navigate │ [Enter] Select │ [e] ${showEnabledOnly ? "show all" : "enabled only"} │ [?] help`
@@ -431,12 +451,12 @@ export const uiCommand = new Command("ui")
431
451
  .option("-v, --verbose", "Enable verbose output", false)
432
452
  .option("--no-animations", "Disable animations")
433
453
  .option("--high-contrast", "Enable high contrast mode")
434
- .action(async () => {
454
+ .action(async (options) => {
435
455
  const tiltAvailable = await isTiltAvailable();
436
456
  if (!tiltAvailable) {
437
457
  errorFactories.tiltNotInstalled().exit();
438
458
  }
439
459
  requireProjectRoot();
440
- render(_jsx(TUIApp, {}));
460
+ render(_jsx(TUIApp, { animated: options.animations }));
441
461
  });
442
462
  //# sourceMappingURL=ui.js.map
@@ -2,7 +2,7 @@ import { connect } from "node:net";
2
2
  import { join } from "node:path";
3
3
  import chalk from "chalk";
4
4
  import { Command } from "commander";
5
- import { ensureProjectRuntimeAssets } from "../generator/template-engine.js";
5
+ import { ensureProjectRuntimeAssets, ProjectConfigNotFoundError, verifyMasterConfigs, } from "../generator/template-engine.js";
6
6
  import { DEVCONTAINER_DOCKER_FIX, detectHost, isContainerHost, WEBCONTAINER_UP_MESSAGE, } from "../utils/agent-host.js";
7
7
  import { handleDryRun } from "../utils/command-helpers.js";
8
8
  import { getDeferredResourceNames } from "../utils/doctor-runtime.js";
@@ -54,6 +54,48 @@ async function defaultDockerReachable() {
54
54
  execFile("docker", ["ps"], { timeout: 10_000 }, (error) => resolve(!error));
55
55
  });
56
56
  }
57
+ /** Exit status for `tdk up` when generated files no longer match service.json / project.json. */
58
+ export const DRIFT_EXIT_CODE = 2;
59
+ /**
60
+ * Generated service files edited by hand. Only these block `tdk up`: when service.json itself changed, `tdk up` regenerates
61
+ * the outputs, so stale outputs are the normal edit-then-up loop and not drift.
62
+ */
63
+ export function driftReport(projectRoot) {
64
+ let handEdited;
65
+ try {
66
+ ({ handEdited } = verifyMasterConfigs(projectRoot));
67
+ }
68
+ catch (error) {
69
+ // A project with no .tdk/project.json has no snapshots to compare. Anything else (a corrupt config) must not skip the gate.
70
+ if (error instanceof ProjectConfigNotFoundError)
71
+ return null;
72
+ throw error;
73
+ }
74
+ if (handEdited.length === 0)
75
+ return null;
76
+ return [
77
+ "Generated files were edited by hand and no longer match service.json:",
78
+ ...handEdited.map((file) => ` ${file}`),
79
+ "Run `tdk config regenerate` to discard the edits, or bypass with `tdk up --ignore-drift`.",
80
+ ];
81
+ }
82
+ /**
83
+ * Runs before Tilt is started: exits with DRIFT_EXIT_CODE on drift unless `ignoreDrift` is set. With `ignoreDrift` the
84
+ * warning is printed every time, whether or not anything drifted, because the check is skipped and cannot tell.
85
+ */
86
+ export function enforceDriftGate(projectRoot, options, exit = process.exit) {
87
+ if (options.ignoreDrift) {
88
+ console.warn(chalk.yellow("Warning: --ignore-drift set; generated files were not checked."));
89
+ return;
90
+ }
91
+ const drift = driftReport(projectRoot);
92
+ if (!drift)
93
+ return;
94
+ options.onDrift?.(drift.join("\n"));
95
+ for (const line of drift)
96
+ console.error(chalk.red(line));
97
+ exit(DRIFT_EXIT_CODE);
98
+ }
57
99
  function waitForTiltUi(port, timeoutMs = 30_000) {
58
100
  return new Promise((resolve) => {
59
101
  const deadline = Date.now() + timeoutMs;
@@ -84,6 +126,7 @@ export const upCommand = new Command("up")
84
126
  .option("-q, --quiet", "Suppress non-essential output", false)
85
127
  .option("--dry-run", "Show what would be started without starting", false)
86
128
  .option("-f, --force", "Kill existing Tilt process before starting", false)
129
+ .option("--ignore-drift", "Start even if generated files differ from service.json", false)
87
130
  .option("--only <services...>", "Start only these services plus what the Tiltfile enables for them (their dependsOn services and shared infrastructure). With a stack, names must belong to that stack; dependencies may cross stacks")
88
131
  .option("--json", "Print one JSON object on stdout when the stack is ready or the command fails; implies --quiet", false)
89
132
  .action(async (stackName, options) => {
@@ -109,9 +152,8 @@ export const upCommand = new Command("up")
109
152
  await assertMachineReadyOrExit();
110
153
  }
111
154
  const action = async () => {
112
- const projectRoot = options.dryRun
113
- ? requireProjectRoot()
114
- : (findProjectRoot() ?? process.cwd());
155
+ const foundRoot = options.dryRun ? requireProjectRoot() : findProjectRoot();
156
+ const projectRoot = foundRoot ?? process.cwd();
115
157
  const discoveredResources = discoverResourcesStrict();
116
158
  // Reject a bad request before anything below can write to the project (.env, runtime assets, .tdk/project.json).
117
159
  if (options.only) {
@@ -127,13 +169,20 @@ export const upCommand = new Command("up")
127
169
  showErrorAndExit(message, 2);
128
170
  }
129
171
  }
172
+ // Also under --dry-run: the check only reads, and a dry run should show what a real run would refuse.
173
+ if (foundRoot) {
174
+ enforceDriftGate(foundRoot, {
175
+ ignoreDrift: options.ignoreDrift,
176
+ onDrift: (message) => emit?.({ ok: false }, [{ code: "DRIFT_DETECTED", message }]),
177
+ });
178
+ }
130
179
  const hostPortPlan = await getHostPortPlan(projectRoot, {
131
180
  inspectDocker: !options.dryRun,
132
181
  });
133
182
  if (!options.dryRun) {
134
183
  // An older project's .env predates keys such as JWT_SECRET, which Compose now requires. Add what is missing (never
135
184
  // changing an existing value) before anything starts. Only inside a real project, so a stray run never writes a .env.
136
- if (findProjectRoot()) {
185
+ if (foundRoot) {
137
186
  const addedEnvKeys = completeEnvFile(projectRoot);
138
187
  if (addedEnvKeys.length > 0 && !options.quiet) {
139
188
  console.log(chalk.gray(`Added ${addedEnvKeys.join(", ")} to .env (generated; existing values unchanged)`));
@@ -349,10 +349,13 @@ function normalizeProjectConfig(value) {
349
349
  overrides: config.overrides,
350
350
  };
351
351
  }
352
+ /** The project has no .tdk/project.json yet, as opposed to one that exists but is unreadable or invalid. */
353
+ export class ProjectConfigNotFoundError extends Error {
354
+ }
352
355
  export function readProjectConfig(projectRoot) {
353
356
  const projectJsonPath = path.join(projectRoot, ".tdk", "project.json");
354
357
  if (!fs.existsSync(projectJsonPath)) {
355
- throw new Error(`Project config not found: ${projectJsonPath}. Run 'tdk project --yes' first.`);
358
+ throw new ProjectConfigNotFoundError(`Project config not found: ${projectJsonPath}. Run 'tdk project --yes' first.`);
356
359
  }
357
360
  const jsonContent = fs.readFileSync(projectJsonPath, "utf-8");
358
361
  const parsed = JSON.parse(jsonContent);
@@ -600,10 +603,12 @@ async function vendorTdkExtension(projectRoot) {
600
603
  console.warn(`⚠️ Premium overlay skipped: ${err.message}`);
601
604
  }
602
605
  }
606
+ const TDK_GENERATED_MARKER = "SYSTEM-GENERATED BY TDK CLI";
603
607
  export function verifyMasterConfigs(projectRoot) {
604
608
  const errors = [];
605
609
  const warnings = [];
606
610
  const diffs = [];
611
+ const handEdited = [];
607
612
  const addDrift = (displayPath, actual, expected) => {
608
613
  if (actual === expected)
609
614
  return;
@@ -622,16 +627,28 @@ export function verifyMasterConfigs(projectRoot) {
622
627
  const filePath = path.join(outputDir, filename);
623
628
  addDrift(`.tdk/.tdk-out/${filename}`, fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : undefined, expectedContent);
624
629
  }
630
+ // `tdk project` never overwrites a root .tiltignore the repo already has, so one
631
+ // without TDK's header belongs to the team: it is not drift.
625
632
  const rootTiltIgnore = path.join(projectRoot, ".tiltignore");
626
- addDrift(".tiltignore", fs.existsSync(rootTiltIgnore) ? fs.readFileSync(rootTiltIgnore, "utf-8") : undefined, expectedFiles[".tiltignore"]);
633
+ const rootTiltIgnoreText = fs.existsSync(rootTiltIgnore)
634
+ ? fs.readFileSync(rootTiltIgnore, "utf-8")
635
+ : undefined;
636
+ if (rootTiltIgnoreText !== undefined && !rootTiltIgnoreText.includes(TDK_GENERATED_MARKER)) {
637
+ warnings.push(".tiltignore: not generated by TDK, left as is");
638
+ }
639
+ else {
640
+ addDrift(".tiltignore", rootTiltIgnoreText, expectedFiles[".tiltignore"]);
641
+ }
627
642
  for (const manifestPath of discoverServiceManifestPaths(projectRoot)) {
628
643
  const displayPath = path.relative(projectRoot, manifestPath).split(path.sep).join("/");
629
644
  const validation = validateServiceManifestFile(manifestPath, displayPath);
630
645
  errors.push(...validation.errors);
631
646
  warnings.push(...validation.warnings);
632
- errors.push(...verifyGeneratedResourceFiles(projectRoot, manifestPath));
647
+ const generated = verifyGeneratedResourceFiles(projectRoot, manifestPath);
648
+ errors.push(...generated.errors);
649
+ handEdited.push(...generated.handEdited);
633
650
  }
634
- return { valid: errors.length === 0, errors, warnings, diffs };
651
+ return { valid: errors.length === 0, errors, warnings, diffs, handEdited };
635
652
  }
636
653
  function createUnifiedDiff(file, actual, expected) {
637
654
  const oldLines = actual.replace(/\n$/, "").split("\n");
@@ -651,7 +668,7 @@ function verifyGeneratedResourceFiles(projectRoot, serviceJsonPath) {
651
668
  const serviceDir = path.dirname(serviceJsonPath);
652
669
  const generatedDir = path.join(serviceDir, ".autogenerated");
653
670
  if (!fs.existsSync(generatedDir))
654
- return [];
671
+ return { errors: [], handEdited: [] };
655
672
  const generatedFiles = [];
656
673
  const collect = (directory, relativeDir = "") => {
657
674
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
@@ -667,10 +684,11 @@ function verifyGeneratedResourceFiles(projectRoot, serviceJsonPath) {
667
684
  };
668
685
  collect(generatedDir);
669
686
  if (generatedFiles.length === 0)
670
- return [];
687
+ return { errors: [], handEdited: [] };
671
688
  const contractDir = path.join(generatedDir, ".tdk-contract");
672
689
  const sourceSnapshot = path.join(contractDir, "service.json.source");
673
690
  const errors = [];
691
+ const handEdited = [];
674
692
  if (!fs.existsSync(sourceSnapshot)) {
675
693
  errors.push(path.relative(projectRoot, sourceSnapshot) +
676
694
  ": generated-file contract snapshot is missing (run tdk up to regenerate)");
@@ -691,9 +709,11 @@ function verifyGeneratedResourceFiles(projectRoot, serviceJsonPath) {
691
709
  errors.push(`${display}: generated-file snapshot is missing (run tdk up to regenerate)`);
692
710
  }
693
711
  else if (fs.readFileSync(generated.fullPath, "utf-8") !== fs.readFileSync(snapshotPath, "utf-8")) {
712
+ // Windows path.relative uses backslashes; report project-relative paths with forward slashes everywhere.
713
+ handEdited.push(display.split(path.sep).join("/"));
694
714
  errors.push(`${display}: generated file differs from its TDK snapshot (run tdk up to regenerate)`);
695
715
  }
696
716
  }
697
- return errors;
717
+ return { errors, handEdited };
698
718
  }
699
719
  //# sourceMappingURL=template-engine.js.map
@@ -0,0 +1,13 @@
1
+ /** Describes a `/` search result for the `tdk ui` header; null when there is no query. */
2
+ export function describeSearch(query, matches, total) {
3
+ if (!query)
4
+ return null;
5
+ if (matches === 0) {
6
+ return {
7
+ summary: "0 matches",
8
+ emptyMessage: `No resources match "${query}". Press Esc to clear.`,
9
+ };
10
+ }
11
+ return { summary: `${matches} of ${total}` };
12
+ }
13
+ //# sourceMappingURL=search-status.js.map
package/cli/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tdk-cli-internal",
3
3
  "private": true,
4
- "version": "1.3.108",
4
+ "version": "1.3.110",
5
5
  "description": "TDK CLI — start services on your laptop.",
6
6
  "keywords": [
7
7
  "tdk",
@@ -178,7 +178,7 @@ FROM l4_backend_bun AS l4_backend_golden
178
178
  # =============================================================================
179
179
  # L4-FRONTEND: FRONTEND PRODUCTION RUNTIME
180
180
  # =============================================================================
181
- FROM nginx:alpine AS l4_frontend_golden
181
+ FROM nginx:1.27-alpine AS l4_frontend_golden
182
182
 
183
183
  LABEL layer="l4-frontend" description="Frontend production runtime with Nginx" maintainer="tdk-project"
184
184
 
@@ -16,6 +16,11 @@
16
16
  "version": {
17
17
  "type": "string"
18
18
  },
19
+ "minTdkVersion": {
20
+ "type": "string",
21
+ "pattern": "^\\d+\\.\\d+\\.\\d+$",
22
+ "description": "Oldest tdk CLI this project works with, as MAJOR.MINOR.PATCH. tdk doctor fails on an older CLI."
23
+ },
19
24
  "project": {
20
25
  "type": "object",
21
26
  "additionalProperties": true,
@@ -227,7 +227,7 @@ FROM l4_backend_bun AS l4_backend_golden
227
227
  # =============================================================================
228
228
  # L4-FRONTEND: FRONTEND PRODUCTION RUNTIME
229
229
  # =============================================================================
230
- FROM nginx:alpine AS l4_frontend_golden
230
+ FROM nginx:1.27-alpine AS l4_frontend_golden
231
231
 
232
232
  LABEL layer="l4-frontend" \
233
233
  description="Frontend production runtime with Nginx" \
@@ -1,6 +1,6 @@
1
1
  # === INLINED CONSTANTS for pure extension loading ===
2
2
  WEB_FRAMEWORK = "hono"
3
- RUNTIME_CONFIG = {"bun": {"image_base": "oven/bun:latest", "package_manager": "bun", "lockfile": "bun.lock", "backend_start_command": "bun run start", "frontend_start_command": "bun run dev"}, "node": {"image_base": "node:20-alpine", "package_manager": "npm", "lockfile": "package-lock.json", "backend_start_command": "node dist/index.js"}}
3
+ RUNTIME_CONFIG = {"bun": {"image_base": "oven/bun:1.3.11-alpine", "package_manager": "bun", "lockfile": "bun.lock", "backend_start_command": "bun run start", "frontend_start_command": "bun run dev"}, "node": {"image_base": "node:20-alpine", "package_manager": "npm", "lockfile": "package-lock.json", "backend_start_command": "node dist/index.js"}}
4
4
  OUTPUT = struct(AUTOGENERATED_FOLDER = ".autogenerated")
5
5
  # === END INLINED CONSTANTS ===
6
6
 
@@ -215,7 +215,7 @@ def L4_generate_frontend_runtime(res_path, target_path = '/usr/share/nginx/html'
215
215
 
216
216
  if use_nginx:
217
217
  # Use golden L4-frontend image if enabled (Nginx pre-configured)
218
- base_image = GOLDEN_L4_FRONTEND_IMAGE if use_golden else "nginx:alpine"
218
+ base_image = GOLDEN_L4_FRONTEND_IMAGE if use_golden else "nginx:1.27-alpine"
219
219
 
220
220
  # If using golden image, skip curl installation (already there)
221
221
  healthcheck_setup = "" if use_golden else "RUN apk add --no-cache curl\n"
@@ -195,7 +195,7 @@ DEFAULT_SYNCS = {
195
195
 
196
196
  RUNTIME_CONFIGS = {
197
197
  'bun': {
198
- 'image_base': 'oven/bun:latest',
198
+ 'image_base': 'oven/bun:1.3.11-alpine',
199
199
  'package_manager': 'bun',
200
200
  'lockfile': 'bun.lock',
201
201
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tdk-landscape/tdk-cli-core",
3
- "version": "1.3.108",
3
+ "version": "1.3.110",
4
4
  "description": "TDK CLI — start services on your laptop.",
5
5
  "homepage": "https://tdk-landscape.github.io/tdk-website",
6
6
  "repository": {