tina4-nodejs 3.13.97 → 3.13.99

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.
Files changed (96) hide show
  1. package/CLAUDE.md +60 -25
  2. package/package.json +1 -2
  3. package/packages/cli/dist/bin.js +20620 -18995
  4. package/packages/cli/src/bin.ts +28 -71
  5. package/packages/cli/src/commands/migrate.ts +36 -75
  6. package/packages/cli/src/commands/migrateRollback.ts +10 -1
  7. package/packages/cli/src/commands/test.ts +92 -21
  8. package/packages/core/dist/index.js +20459 -18815
  9. package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
  10. package/packages/core/src/ai.ts +28 -12
  11. package/packages/core/src/api.ts +13 -5
  12. package/packages/core/src/background.ts +9 -3
  13. package/packages/core/src/devAdmin.ts +135 -20
  14. package/packages/core/src/dispatchPipeline.ts +185 -1
  15. package/packages/core/src/docs.ts +33 -5
  16. package/packages/core/src/env.ts +1 -1
  17. package/packages/core/src/errorOverlay.ts +39 -48
  18. package/packages/core/src/fakeData.ts +15 -0
  19. package/packages/core/src/index.ts +17 -6
  20. package/packages/core/src/logger.ts +892 -572
  21. package/packages/core/src/mcp.ts +9 -1
  22. package/packages/core/src/messenger.ts +31 -4
  23. package/packages/core/src/middleware.ts +169 -43
  24. package/packages/core/src/portTakeover.ts +232 -0
  25. package/packages/core/src/request.ts +57 -8
  26. package/packages/core/src/response.ts +67 -0
  27. package/packages/core/src/router.ts +35 -7
  28. package/packages/core/src/server.ts +450 -190
  29. package/packages/core/src/static.ts +81 -12
  30. package/packages/core/src/testClient.ts +126 -137
  31. package/packages/core/src/testing.ts +16 -12
  32. package/packages/core/src/types.ts +21 -9
  33. package/packages/core/src/version.ts +66 -0
  34. package/packages/core/src/websocket.ts +2 -2
  35. package/packages/core/src/websocketBackplane.ts +2 -2
  36. package/packages/frond/dist/index.js +31 -13
  37. package/packages/frond/src/engine.ts +39 -7
  38. package/packages/orm/dist/index.js +10879 -9258
  39. package/packages/orm/src/adapters/firebird.ts +200 -27
  40. package/packages/orm/src/adapters/mongodb.ts +160 -10
  41. package/packages/orm/src/adapters/mssql.ts +38 -11
  42. package/packages/orm/src/adapters/mysql.ts +24 -1
  43. package/packages/orm/src/adapters/odbc.ts +127 -29
  44. package/packages/orm/src/adapters/postgres.ts +18 -0
  45. package/packages/orm/src/adapters/sqlite.ts +93 -14
  46. package/packages/orm/src/autoCrud.ts +72 -8
  47. package/packages/orm/src/baseModel.ts +323 -71
  48. package/packages/orm/src/cachedDatabase.ts +48 -1
  49. package/packages/orm/src/database.ts +162 -59
  50. package/packages/orm/src/fakeData.ts +6 -2
  51. package/packages/orm/src/index.ts +4 -1
  52. package/packages/orm/src/migration.ts +95 -52
  53. package/packages/orm/src/query.ts +16 -4
  54. package/packages/orm/src/seeder.ts +43 -25
  55. package/packages/orm/src/sqlTranslator.ts +104 -19
  56. package/packages/orm/src/types.ts +97 -21
  57. package/packages/orm/src/validation.ts +5 -1
  58. package/packages/swagger/dist/index.js +3 -2
  59. package/packages/swagger/src/generator.ts +19 -4
  60. package/packages/swagger/src/ui.ts +6 -4
  61. package/types/cli/src/bin.d.ts +0 -22
  62. package/types/core/src/api.d.ts +11 -4
  63. package/types/core/src/background.d.ts +5 -2
  64. package/types/core/src/devAdmin.d.ts +35 -0
  65. package/types/core/src/dispatchPipeline.d.ts +41 -1
  66. package/types/core/src/errorOverlay.d.ts +13 -13
  67. package/types/core/src/index.d.ts +9 -6
  68. package/types/core/src/logger.d.ts +111 -185
  69. package/types/core/src/middleware.d.ts +40 -5
  70. package/types/core/src/portTakeover.d.ts +50 -0
  71. package/types/core/src/request.d.ts +15 -0
  72. package/types/core/src/response.d.ts +29 -0
  73. package/types/core/src/server.d.ts +92 -0
  74. package/types/core/src/testClient.d.ts +29 -3
  75. package/types/core/src/testing.d.ts +16 -12
  76. package/types/core/src/types.d.ts +21 -9
  77. package/types/core/src/version.d.ts +11 -0
  78. package/types/core/src/websocketBackplane.d.ts +1 -1
  79. package/types/frond/src/engine.d.ts +10 -0
  80. package/types/orm/src/adapters/firebird.d.ts +61 -2
  81. package/types/orm/src/adapters/mongodb.d.ts +20 -0
  82. package/types/orm/src/adapters/mssql.d.ts +11 -0
  83. package/types/orm/src/adapters/mysql.d.ts +11 -0
  84. package/types/orm/src/adapters/odbc.d.ts +35 -4
  85. package/types/orm/src/adapters/postgres.d.ts +11 -0
  86. package/types/orm/src/adapters/sqlite.d.ts +23 -4
  87. package/types/orm/src/baseModel.d.ts +45 -25
  88. package/types/orm/src/cachedDatabase.d.ts +27 -1
  89. package/types/orm/src/database.d.ts +56 -6
  90. package/types/orm/src/index.d.ts +3 -2
  91. package/types/orm/src/migration.d.ts +23 -5
  92. package/types/orm/src/query.d.ts +3 -0
  93. package/types/orm/src/seeder.d.ts +15 -2
  94. package/types/orm/src/sqlTranslator.d.ts +17 -4
  95. package/types/orm/src/types.d.ts +75 -16
  96. package/packages/core/src/errorOverlay.test.ts +0 -122
@@ -11,7 +11,7 @@ import { runSeeds } from "./commands/seed.js";
11
11
  import { runMetrics } from "./commands/metrics.js";
12
12
  import { queueCommand, QUEUE_SUBCOMMAND_NAMES } from "./commands/queue.js";
13
13
  import { buildImage } from "./commands/build.js";
14
- import { execSync, spawnSync } from "node:child_process";
14
+ import { spawnSync } from "node:child_process";
15
15
  import { existsSync, readFileSync, statSync } from "node:fs";
16
16
  import { delimiter, dirname, join } from "node:path";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -43,81 +43,34 @@ function readCliVersion(): string {
43
43
  return "0.0.0";
44
44
  }
45
45
 
46
- // ── Port-kill helper ────────────────────────────────────────────────
47
-
48
- /**
49
- * Whether this process is running inside a container.
50
- *
51
- * Reclaiming a port makes sense on a dev machine, where a previous serve may
52
- * still hold it. Inside a container the server IS the container, so there is no
53
- * stale sibling to reclaim from -- and trying is dangerous (see below).
54
- */
55
- function inContainer(): boolean {
56
- if (existsSync("/.dockerenv") || existsSync("/run/.containerenv")) return true;
57
- try {
58
- const blob = readFileSync("/proc/1/cgroup", "utf-8");
59
- return blob.includes("docker") || blob.includes("containerd") || blob.includes("kubepods");
60
- } catch {
61
- return false;
62
- }
63
- }
46
+ // ── Port-takeover helper ────────────────────────────────────────────
47
+ //
48
+ // The identity check, PID safety filter, container guard, dev gate and opt-out
49
+ // all live in ONE shared module (@tina4/core portTakeover) so this CLI path and
50
+ // the runtime bind-failure fallback in core/server.ts cannot diverge
51
+ // (TAKEOVER-DEC-02). Loaded lazily (mirroring how serve.ts pulls in core) so a
52
+ // quick `tina4nodejs --help` never pays to import it.
64
53
 
65
54
  /**
66
- * Kill any process listening on `port`. Returns true if anything was killed.
67
- *
68
- * Every PID is validated first. `parseInt` on a non-numeric lsof field yields
69
- * 1, and SIGTERM to PID 1 is the container's own init -- which is exactly how a
70
- * production container logged "Killed existing process on port 7148 (PID: 1
71
- * ...)" and then exited 143, killing itself on startup.
72
- */
73
- /**
74
- * The PIDs from `lsof -ti` output that are safe to signal.
55
+ * Reclaim `port` from a stale Tina4 dev server, only when it is safe.
75
56
  *
76
- * Pure so the safety rule can be tested directly. An unvalidated parse is a
77
- * footgun with real teeth: where lsof prints a different shape than -ti
78
- * implies, a non-numeric field becomes 0, and signalling PID 0 hits EVERY
79
- * process in the caller's own process group -- the server kills itself. That
80
- * is what produced "Killed existing process on port 7148 (PID: 1 ...)" in a
81
- * real image, where the container then exited 143.
57
+ * Signals a holder ONLY when a Tina4 dev server recorded its PID in the per-port
58
+ * PID file (TAKEOVER-DEC-01). A foreign holder is left running and a clear
59
+ * message is printed; takeover is also skipped in a container, outside dev mode,
60
+ * and when opted out (`TINA4_NO_TAKEOVER` / `tina4 serve --no-kill`).
82
61
  *
83
- * Accepts only all-digit tokens; never PID 0 (our group), PID 1 (init),
84
- * ourselves, or our own process group.
62
+ * Returns true only when a Tina4 holder was actually signalled.
85
63
  */
86
- export function selectablePids(lsofOutput: string, me: number, myGroup?: number): number[] {
87
- const pids: number[] = [];
88
- for (const token of lsofOutput.split(/\s+/)) {
89
- if (!/^\d+$/.test(token)) continue; // never coerce junk into a PID
90
- const pid = Number(token);
91
- if (pid <= 1 || pid === me) continue; // 0 = our group, 1 = init, me = suicide
92
- if (myGroup !== undefined && pid === myGroup) continue;
93
- if (!pids.includes(pid)) pids.push(pid);
94
- }
95
- return pids;
96
- }
97
-
98
- function killProcessOnPort(port: number): boolean {
99
- if (inContainer()) return false;
100
- try {
101
- const result = execSync(`lsof -ti :${port}`, { encoding: "utf-8", timeout: 5000 }).trim();
102
- if (!result) return false;
103
-
104
- const killed: string[] = [];
105
- // Node core exposes no getpgrp(), so myGroup stays unset here. The pid <= 1
106
- // guard already covers the dangerous case (a junk field coercing to 0);
107
- // Python and Ruby pass their real process group in addition.
108
- for (const pid of selectablePids(result, process.pid)) {
109
- try {
110
- process.kill(pid, "SIGTERM");
111
- killed.push(String(pid));
112
- } catch {
113
- // ignore ProcessLookupError / PermissionError
114
- }
115
- }
116
- if (killed.length === 0) return false;
117
- console.log(` Killed existing process on port ${port} (PID: ${killed.join(", ")})`);
64
+ async function killProcessOnPort(port: number): Promise<boolean> {
65
+ const { takeOverPort, isDev, noTakeoverOptedOut, TAKEOVER_KILLED, TAKEOVER_REFUSALS } =
66
+ await import("../../core/src/portTakeover.js");
67
+ const result = takeOverPort(port, isDev(), noTakeoverOptedOut());
68
+ if (result.status === TAKEOVER_KILLED) {
69
+ console.log(` ${result.message}`);
118
70
  return true;
119
- } catch {
120
- // lsof not found or no process on port — that's fine
71
+ }
72
+ if (result.message && TAKEOVER_REFUSALS.includes(result.status)) {
73
+ console.log(` ${result.message}`);
121
74
  }
122
75
  return false;
123
76
  }
@@ -352,7 +305,11 @@ export const COMMANDS: Record<string, CommandSpec> = {
352
305
  const port = portIndex !== -1 ? parseInt(a[portIndex + 1], 10) : 7148;
353
306
  const noBrowser = a.includes("--no-browser");
354
307
  const noReload = a.includes("--no-reload");
355
- killProcessOnPort(port);
308
+ // --no-kill opts out of port takeover for the whole process, so the CLI
309
+ // path here AND the runtime bind-failure fallback both honour it
310
+ // (TAKEOVER-DEC-03).
311
+ if (a.includes("--no-kill")) process.env.TINA4_NO_TAKEOVER = "true";
312
+ await killProcessOnPort(port);
356
313
  await serveProject({ port, noBrowser, noReload });
357
314
  },
358
315
  usage: "[--port P] [--no-browser] [--no-reload]",
@@ -1,15 +1,25 @@
1
1
  /**
2
2
  * CLI command: migrate — Run pending SQL migration files.
3
3
  *
4
- * Scans the migrations/ directory for .sql files (excluding .down.sql),
5
- * executes them in order, and records each as applied with a batch number.
4
+ * MIG-NODE-CLI-DIVERGENT (feature 15, MIG-DEC-01): this used to be a SECOND,
5
+ * weaker migration implementation -- a naive `sql.split(";")` (breaks on a
6
+ * `;` inside a string/comment/proc block), no per-file transaction (a
7
+ * mid-file failure left earlier statements applied on every engine
8
+ * including PostgreSQL, with no rollback), no Firebird/MSSQL idempotency
9
+ * skips, and the ledger row recorded OUTSIDE any transaction. All untested.
10
+ *
11
+ * It now delegates to the SAME `migrate()` the ORM's programmatic API uses
12
+ * (`packages/orm/src/migration.ts`) -- ONE code path, so the CLI gets the
13
+ * transactional, robust-split, idempotent behaviour for free. The weaker
14
+ * re-implementation is deleted, not kept alongside (maintainability = less
15
+ * code).
6
16
  *
7
17
  * Supports both naming patterns:
8
18
  * - Sequential: 000001_name.sql
9
19
  * - Timestamp: YYYYMMDDHHMMSS_name.sql
10
20
  */
11
- import { existsSync, readdirSync, readFileSync } from "node:fs";
12
- import { join, resolve } from "node:path";
21
+ import { existsSync } from "node:fs";
22
+ import { resolve } from "node:path";
13
23
  import { loadEnv } from "../../../core/src/dotenv.js";
14
24
 
15
25
  export async function runMigrations(migrationDir?: string): Promise<void> {
@@ -25,33 +35,22 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
25
35
  return;
26
36
  }
27
37
 
28
- // Initialise the database so the adapter is available
29
38
  let initDatabase: typeof import("../../../orm/src/index.js").initDatabase;
30
- let ensureMigrationTable: typeof import("../../../orm/src/index.js").ensureMigrationTable;
31
- let isMigrationApplied: typeof import("../../../orm/src/index.js").isMigrationApplied;
32
- let recordMigration: typeof import("../../../orm/src/index.js").recordMigration;
33
- let getNextBatch: typeof import("../../../orm/src/index.js").getNextBatch;
34
- let getAdapter: typeof import("../../../orm/src/index.js").getAdapter;
35
- let adapterExecute: typeof import("../../../orm/src/index.js").adapterExecute;
39
+ let migrate: typeof import("../../../orm/src/index.js").migrate;
36
40
 
37
41
  try {
38
42
  const orm = await import("../../../orm/src/index.js");
39
43
  initDatabase = orm.initDatabase;
40
- ensureMigrationTable = orm.ensureMigrationTable;
41
- isMigrationApplied = orm.isMigrationApplied;
42
- recordMigration = orm.recordMigration;
43
- getNextBatch = orm.getNextBatch;
44
- getAdapter = orm.getAdapter;
45
- adapterExecute = orm.adapterExecute;
44
+ migrate = orm.migrate;
46
45
  } catch {
47
46
  console.error(" Error: @tina4/orm is required to run migrations.");
48
47
  process.exit(1);
49
48
  }
50
49
 
51
- // Ensure database is initialised (uses TINA4_DATABASE_URL/DATABASE_URL or
52
- // defaults to sqlite). initDatabase() is async — MUST be awaited, otherwise
53
- // setAdapter() has not run by the time ensureMigrationTable() asks for the
54
- // adapter and the whole CLI crashes with "No database adapter configured."
50
+ // Initialise the database so the adapter is available. initDatabase() is
51
+ // async — MUST be awaited, otherwise setAdapter() has not run by the time
52
+ // migrate() asks for the adapter and the whole CLI crashes with
53
+ // "No database adapter configured."
55
54
  try {
56
55
  await initDatabase();
57
56
  } catch (err) {
@@ -59,64 +58,26 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
59
58
  process.exit(1);
60
59
  }
61
60
 
62
- await ensureMigrationTable();
63
-
64
- // Collect .sql files, excluding .down.sql, sorted by numeric prefix
65
- const files = readdirSync(dir)
66
- .filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql"))
67
- .sort((a, b) => {
68
- const aMatch = a.match(/^(\d+)/);
69
- const bMatch = b.match(/^(\d+)/);
70
- if (aMatch && bMatch) {
71
- const aNum = BigInt(aMatch[1]);
72
- const bNum = BigInt(bMatch[1]);
73
- if (aNum < bNum) return -1;
74
- if (aNum > bNum) return 1;
75
- }
76
- return a.localeCompare(b);
77
- });
78
-
79
- if (files.length === 0) {
80
- console.log(" No .sql migration files found.");
81
- return;
82
- }
61
+ const result = await migrate(undefined, { migrationsDir: dir });
83
62
 
84
- const batch = await getNextBatch();
85
- let applied = 0;
86
-
87
- for (const file of files) {
88
- const name = file.replace(/\.sql$/, "");
89
-
90
- if (await isMigrationApplied(name)) {
91
- continue;
63
+ if (result.applied.length === 0 && result.failed.length === 0) {
64
+ console.log(" Nothing to migrate — all migrations already applied.");
65
+ } else {
66
+ for (const file of result.applied) {
67
+ console.log(` Migrated: ${file}`);
92
68
  }
93
-
94
- const sql = readFileSync(join(dir, file), "utf-8").trim();
95
- if (!sql) continue;
96
-
97
- console.log(` Migrating: ${file}`);
98
-
99
- const adapter = getAdapter();
100
- // Split on semicolons and execute each statement
101
- const statements = sql.split(";").map((s) => s.trim()).filter(Boolean);
102
-
103
- for (const stmt of statements) {
104
- try {
105
- await adapterExecute(adapter, stmt);
106
- } catch (err) {
107
- const msg = err instanceof Error ? err.message : String(err);
108
- console.error(` Error in ${file}: ${msg}`);
109
- process.exit(1);
110
- }
69
+ if (result.applied.length > 0) {
70
+ console.log(` Applied ${result.applied.length} migration(s).`);
111
71
  }
112
-
113
- await recordMigration(name, batch);
114
- applied++;
115
72
  }
116
73
 
117
- if (applied === 0) {
118
- console.log(" Nothing to migrate all migrations already applied.");
119
- } else {
120
- console.log(` Applied ${applied} migration(s) (batch ${batch}).`);
74
+ if (result.failed.length > 0) {
75
+ // migrate() has already logged the specific statement error for each
76
+ // failed file (console.error + Log.error) -- fail-fast so CI/CD actually
77
+ // fails when a migration breaks, matching the explicit `tina4 migrate`
78
+ // CLI contract in every other framework (the startup auto-migrate hook
79
+ // is the one that swallows; this command must not).
80
+ console.error(` ${result.failed.length} migration(s) failed: ${result.failed.join(", ")}`);
81
+ process.exit(1);
121
82
  }
122
83
  }
@@ -54,7 +54,16 @@ export async function migrateRollback(migrationDir?: string): Promise<void> {
54
54
 
55
55
  console.log(` Rolling back batch ${lastBatch[0].batch} (${lastBatch.length} migration(s))...`);
56
56
 
57
- const rolledBack = await rollbackFn(dir);
57
+ // rollback() is fail-safe (MIG-DEC-02): a missing/failed down artifact now
58
+ // THROWS instead of silently dropping the tracking record, so the CLI must
59
+ // catch it and exit non-zero rather than let it crash with a raw stack.
60
+ let rolledBack: string[];
61
+ try {
62
+ rolledBack = await rollbackFn(dir);
63
+ } catch (err) {
64
+ console.error(` Rollback failed: ${err instanceof Error ? err.message : String(err)}`);
65
+ process.exit(1);
66
+ }
58
67
 
59
68
  if (rolledBack.length === 0) {
60
69
  console.log(" Nothing was rolled back.");
@@ -1,17 +1,92 @@
1
1
  /**
2
2
  * CLI command: test — Run project tests.
3
3
  *
4
- * Looks for test files and executes them with tsx.
5
- * Supports: test/integration.ts, test/*.ts, tests/*.ts, *.test.ts patterns.
4
+ * Two stages, and the process exits non-zero if EITHER fails:
5
+ * 1. Inline @tests stage (INLINE-DEC-01) — discover functions decorated with the
6
+ * inline tests() builder under src/ and run them with a real exit code. Only
7
+ * files that call tests() are imported, so discovery never runs an arbitrary
8
+ * scanned source file's side effect (INLINE-DEC-02).
9
+ * 2. File-runner stage — execute test files with tsx (test/*.ts, tests/*.ts, an
10
+ * explicit file arg), propagating their exit codes (python#96 parity).
6
11
  */
7
- import { existsSync, readdirSync } from "node:fs";
12
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
13
  import { resolve, join } from "node:path";
14
+ import { pathToFileURL } from "node:url";
9
15
  import { execSync } from "node:child_process";
10
16
 
17
+ /** Collect every .ts/.js file under a directory, recursively. */
18
+ function walkSource(dir: string): string[] {
19
+ const out: string[] = [];
20
+ let entries: string[];
21
+ try {
22
+ entries = readdirSync(dir);
23
+ } catch {
24
+ return out;
25
+ }
26
+ for (const name of entries) {
27
+ const full = join(dir, name);
28
+ let st;
29
+ try {
30
+ st = statSync(full);
31
+ } catch {
32
+ continue;
33
+ }
34
+ if (st.isDirectory()) {
35
+ out.push(...walkSource(full));
36
+ } else if (name.endsWith(".ts") || name.endsWith(".js")) {
37
+ out.push(full);
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * Discover inline tests() under src/ and run them. Returns true if any inline
45
+ * test FAILED or ERRORED. Only files that call tests() are imported, so a source
46
+ * file without an inline test is never executed during discovery.
47
+ */
48
+ async function runInlineTests(cwd: string): Promise<boolean> {
49
+ const srcDir = resolve(cwd, "src");
50
+ if (!existsSync(srcDir)) return false;
51
+
52
+ const { runAll, reset } = await import("@tina4/core");
53
+ reset();
54
+
55
+ let discovered = 0;
56
+ for (const file of walkSource(srcDir)) {
57
+ let text: string;
58
+ try {
59
+ text = readFileSync(file, "utf-8");
60
+ } catch {
61
+ continue;
62
+ }
63
+ if (!/\btests\s*\(/.test(text)) continue; // only files using the inline decorator
64
+ discovered++;
65
+ try {
66
+ await import(pathToFileURL(file).href);
67
+ } catch (err) {
68
+ console.log(
69
+ ` ! could not import ${file}: ${err instanceof Error ? err.message : String(err)}`,
70
+ );
71
+ }
72
+ }
73
+
74
+ if (discovered === 0) return false;
75
+
76
+ const results = runAll();
77
+ return results.failed + results.errors > 0;
78
+ }
79
+
11
80
  export async function runTests(testPath?: string): Promise<void> {
12
81
  const cwd = process.cwd();
13
82
 
14
- // If a specific test file is provided, run it directly
83
+ // Stage 1 inline @tests discovered under src/, run with a real exit code.
84
+ const inlineFailed = await runInlineTests(cwd);
85
+
86
+ // Stage 2 — file runner (existing behaviour), capturing failure into fileFailed.
87
+ let fileFailed = false;
88
+
89
+ // If a specific test file is provided, run it directly.
15
90
  if (testPath) {
16
91
  const file = resolve(testPath);
17
92
  if (!existsSync(file)) {
@@ -22,17 +97,13 @@ export async function runTests(testPath?: string): Promise<void> {
22
97
  try {
23
98
  execSync(`npx tsx "${file}"`, { cwd, stdio: "inherit" });
24
99
  } catch {
25
- process.exit(1);
100
+ fileFailed = true;
26
101
  }
27
- return;
102
+ process.exit(inlineFailed || fileFailed ? 1 : 0);
28
103
  }
29
104
 
30
- // Auto-discover test files
31
- const candidates = [
32
- "test/integration.ts",
33
- "test",
34
- "tests",
35
- ];
105
+ // Auto-discover test files.
106
+ const candidates = ["test/integration.ts", "test", "tests"];
36
107
 
37
108
  let testFiles: string[] = [];
38
109
 
@@ -40,13 +111,13 @@ export async function runTests(testPath?: string): Promise<void> {
40
111
  const fullPath = resolve(cwd, candidate);
41
112
  if (!existsSync(fullPath)) continue;
42
113
 
43
- // If it's a file, run it
114
+ // If it's a file, run it.
44
115
  if (candidate.endsWith(".ts")) {
45
116
  testFiles.push(fullPath);
46
117
  break;
47
118
  }
48
119
 
49
- // If it's a directory, collect all .ts and .test.ts files
120
+ // If it's a directory, collect all .ts and .test.ts files.
50
121
  try {
51
122
  const files = readdirSync(fullPath)
52
123
  .filter((f) => f.endsWith(".ts"))
@@ -59,25 +130,25 @@ export async function runTests(testPath?: string): Promise<void> {
59
130
  }
60
131
 
61
132
  if (testFiles.length === 0) {
133
+ if (inlineFailed) {
134
+ process.exit(1);
135
+ }
62
136
  console.log(" No test files found.");
63
- console.log(" Looked in: test/integration.ts, test/*.ts, tests/*.ts");
64
- return;
137
+ console.log(" Looked in: src/**/*.ts (@tests), test/integration.ts, test/*.ts, tests/*.ts");
138
+ process.exit(0);
65
139
  }
66
140
 
67
141
  console.log(` Found ${testFiles.length} test file(s)\n`);
68
142
 
69
- let failed = false;
70
143
  for (const file of testFiles) {
71
144
  const relative = file.replace(cwd + "/", "");
72
145
  console.log(` Running: ${relative}`);
73
146
  try {
74
147
  execSync(`npx tsx "${file}"`, { cwd, stdio: "inherit" });
75
148
  } catch {
76
- failed = true;
149
+ fileFailed = true;
77
150
  }
78
151
  }
79
152
 
80
- if (failed) {
81
- process.exit(1);
82
- }
153
+ process.exit(inlineFailed || fileFailed ? 1 : 0);
83
154
  }