tina4-nodejs 3.13.124 → 3.13.130

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.
@@ -6,6 +6,7 @@ import { migrateStatus } from "./commands/migrateStatus.js";
6
6
  import { migrateRollback } from "./commands/migrateRollback.js";
7
7
  import { listRoutes } from "./commands/routes.js";
8
8
  import { runTests } from "./commands/test.js";
9
+ import { runLint } from "./commands/lint.js";
9
10
  import { generate, GENERATORS, RESOLUTION_ENVELOPE_VERSION } from "./commands/generate.js";
10
11
  import { runSeeds } from "./commands/seed.js";
11
12
  import { queueCommand, QUEUE_SUBCOMMAND_NAMES } from "./commands/queue.js";
@@ -370,6 +371,11 @@ export const COMMANDS: Record<string, CommandSpec> = {
370
371
  usage: "[file]",
371
372
  summary: "Run project tests",
372
373
  },
374
+ lint: {
375
+ handler: (a) => { runLint(a); },
376
+ usage: "[--fix] [--no-install]",
377
+ summary: "Lint the project (eslint, installed dev-only on demand; else tsc/node --check baseline)",
378
+ },
373
379
  queue: {
374
380
  handler: async (a) => { await queueCommand(a); },
375
381
  usage: "<work|stats|retry|clear> [topic]",
@@ -37,8 +37,8 @@ import { join, relative, resolve, sep } from "node:path";
37
37
 
38
38
  // ── Field type mapping ──────────────────────────────────────────────
39
39
  const FIELD_TYPE_MAP: Record<string, { orm: string; sql: string; defaultVal: string }> = {
40
- string: { orm: '"string"', sql: "TEXT", defaultVal: "''" },
41
- str: { orm: '"string"', sql: "TEXT", defaultVal: "''" },
40
+ string: { orm: '"string"', sql: "VARCHAR(255)", defaultVal: "''" },
41
+ str: { orm: '"string"', sql: "VARCHAR(255)", defaultVal: "''" },
42
42
  int: { orm: '"integer"', sql: "INTEGER", defaultVal: "0" },
43
43
  integer: { orm: '"integer"', sql: "INTEGER", defaultVal: "0" },
44
44
  float: { orm: '"number"', sql: "REAL", defaultVal: "0" },
@@ -48,7 +48,7 @@ const FIELD_TYPE_MAP: Record<string, { orm: string; sql: string; defaultVal: str
48
48
  bool: { orm: '"boolean"', sql: "INTEGER", defaultVal: "0" },
49
49
  boolean: { orm: '"boolean"', sql: "INTEGER", defaultVal: "0" },
50
50
  text: { orm: '"string"', sql: "TEXT", defaultVal: "''" },
51
- datetime: { orm: '"datetime"', sql: "TEXT", defaultVal: "NULL" },
51
+ datetime: { orm: '"datetime"', sql: "TIMESTAMP", defaultVal: "NULL" },
52
52
  blob: { orm: '"string"', sql: "BLOB", defaultVal: "NULL" },
53
53
  };
54
54
 
@@ -131,13 +131,78 @@ export function toTableName(name: string): string {
131
131
  from: raw,
132
132
  to: safe,
133
133
  reason: `SQL reserved word '${raw}' would break CREATE TABLE`,
134
- override: `--table ${raw} --quote (requires quoted-identifier mode, not yet implemented)`,
134
+ override: `--table-name <name> (table names interpolate unquoted; forcing a reserved name is yours to quote in raw SQL)`,
135
135
  });
136
136
  return safe;
137
137
  }
138
138
  return raw;
139
139
  }
140
140
 
141
+ /**
142
+ * The table name a generator uses (issue #123) — honours `--table-name` and
143
+ * speaks up instead of renaming SILENTLY. Mirrors the Python master's
144
+ * `_resolve_table` (tina4-python/tina4_python/cli/__init__.py).
145
+ *
146
+ * `announce` prints the note/warning; it is TRUE only for `generateModel` (where
147
+ * the table is born). Composite generators (crud) let the model sub-call
148
+ * announce, and generators that target an EXISTING table (route/seeder/form/view/
149
+ * migration) still honour `--table-name` but stay quiet so the note is not
150
+ * repeated — the note prints exactly once per `generate`.
151
+ *
152
+ * • `--table-name <name>` wins verbatim. If that name is ITSELF a reserved word,
153
+ * warn loudly (when announcing): Tina4 interpolates table names UNQUOTED, so
154
+ * the ORM's generated SQL will fail on it — quoting it in raw SQL + migrations
155
+ * is now the developer's job (we do NOT silently quote; identifier quoting is a
156
+ * global storage invariant, not a local fix).
157
+ * • Otherwise fall back to `toTableName` (snake + reserved-word pluralise). When
158
+ * that auto-pluralises a reserved-word class name (`Order` -> `orders`), print a
159
+ * one-line NOTE (when announcing) naming the rename and the `--table-name`
160
+ * escape hatch, so the developer is informed rather than surprised.
161
+ *
162
+ * The note/warning goes to STDERR (console.error) so a `generate … --json` run
163
+ * keeps its stdout envelope pristine for a downstream `| jq`. `toTableName`'s
164
+ * `reserved_word_pluralize` envelope transformation is UNCHANGED (this ADDS the
165
+ * announce path; it does not touch the envelope contract).
166
+ */
167
+ export function resolveTable(
168
+ name: string,
169
+ flags: Record<string, string | boolean> | undefined,
170
+ opts: { announce?: boolean } = {},
171
+ ): string {
172
+ const announce = opts.announce ?? false;
173
+ const override = (flags ?? {})["table-name"];
174
+
175
+ // `--table-name <name>` wins verbatim. A bare `--table-name` (no value) parses
176
+ // to `true` — ignore it, exactly like the Python master (falls through to the
177
+ // pluralise path), rather than letting the boolean become the table name.
178
+ if (typeof override === "string" && override) {
179
+ if (announce && SQL_RESERVED_TABLE_NAMES.has(toSnake(override))) {
180
+ console.error(
181
+ ` ! table_name '${override}' is a SQL reserved word. Tina4 interpolates ` +
182
+ `table names UNQUOTED, so the ORM's generated SQL will fail on it -- ` +
183
+ `quote it yourself in raw SQL and migrations.`,
184
+ );
185
+ }
186
+ return override;
187
+ }
188
+
189
+ const bare = toSnake(name);
190
+ if (!SQL_RESERVED_TABLE_NAMES.has(bare)) {
191
+ return bare; // non-reserved: singular, silent, no envelope transformation
192
+ }
193
+
194
+ // Reserved: pluralise via toTableName (which records the envelope
195
+ // transformation), then announce the rename so it is never a surprise.
196
+ const table = toTableName(name);
197
+ if (announce) {
198
+ console.error(
199
+ ` · '${bare}' is a SQL reserved word; using table_name '${table}' ` +
200
+ `(Tina4 interpolates table names unquoted). Override with --table-name <name>.`,
201
+ );
202
+ }
203
+ return table;
204
+ }
205
+
141
206
  // ── Resolution surface — the machine-readable envelope every generator ─
142
207
  // populates so `--json` can print it and a human run can print the same
143
208
  // facts to stderr. See the JSDoc on `printResolution` below for the envelope
@@ -385,10 +450,11 @@ function printResolution(): void {
385
450
  lines.push(` migration ${b.migration_path}`);
386
451
  }
387
452
  const reserved = b.transformations.find((t) => t.kind === "reserved_word_pluralize");
388
- if (reserved && reserved.from && reserved.override) {
453
+ if (reserved && reserved.from) {
389
454
  lines.push("");
390
- lines.push(` To keep the raw name '${reserved.from}' as the table:`);
391
- lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name} ${reserved.override}`);
455
+ lines.push(` To set the table name yourself:`);
456
+ lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name} --table-name <name>`);
457
+ lines.push(` Tina4 interpolates table names unquoted; if you force the reserved '${reserved.from}', you own the quoting in raw SQL.`);
392
458
  }
393
459
  // v1.1 (ADR-0063): surface the already-populated test_paths[], and the two
394
460
  // new arrays (edit_hints, next) when either is non-empty. Sections stay
@@ -591,7 +657,7 @@ export interface GeneratorSpec {
591
657
  }
592
658
 
593
659
  export const GENERATORS: Record<string, GeneratorSpec> = {
594
- model: { handler: generateModel, usage: '<Name> [--fields "name:string,price:float"]', summary: "ORM model + matching migration" },
660
+ model: { handler: generateModel, usage: '<Name> [--fields "name:string,price:float"] [--table-name <name>]', summary: "ORM model + matching migration" },
595
661
  route: { handler: generateRoute, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
596
662
  crud: { handler: generateCrud, usage: '<Name> [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" },
597
663
  migration: { handler: (n, f) => generateMigration(n, f, undefined, undefined, !f["no-test"]), usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
@@ -717,6 +783,7 @@ export async function generate(what: string, name: string, extraArgs: string[] =
717
783
  console.error(" Usage: tina4nodejs generate <what> <name> [options]");
718
784
  console.error(` Generators: ${GENERATOR_LIST}`);
719
785
  console.error(' Options: --fields "name:string,price:float" --model ModelName');
786
+ console.error(" --table-name <name> force the model's table name (else derived; reserved words auto-pluralise)");
720
787
  console.error(" --public open a route's writes (default: secure)");
721
788
  console.error(' --every 5m | --cron "…" service schedule');
722
789
  console.error(" --json emit machine-readable resolution envelope on stdout");
@@ -833,7 +900,9 @@ export async function generateProgrammatic(
833
900
 
834
901
  function generateModel(name: string, flags: Record<string, string | boolean>, emitTest = true): void {
835
902
  const fields = fieldsOrDefault((flags.fields as string) || "");
836
- const table = toTableName(name);
903
+ // The table is BORN here — announce=true, so a reserved-word rename (Order ->
904
+ // orders) or a forced-reserved --table-name is said out loud, not silent (#123).
905
+ const table = resolveTable(name, flags, { announce: true });
837
906
  const dir = resolve("src/models");
838
907
  ensureDir(dir);
839
908
  const path = join(dir, `${name}.ts`);
@@ -922,7 +991,8 @@ function generateRoute(name: string, flags: Record<string, string | boolean>, em
922
991
  setResolutionField("file_path", `src/routes/api/${routePath}/get.ts`);
923
992
  }
924
993
 
925
- const table = model ? toTableName(model) : "";
994
+ // Route targets an EXISTING table honour --table-name, stay quiet (announce=false).
995
+ const table = model ? resolveTable(model, flags, { announce: false }) : "";
926
996
  // Model import path is RELATIVE to the route file's directory. Files directly
927
997
  // under src/routes/api/<name>/ are 3 levels above src/models/; the [id]/ files
928
998
  // are one deeper (4 levels).
@@ -1161,7 +1231,9 @@ ${aiFill(`delete_${singular}`, {
1161
1231
  // ── CRUD ────────────────────────────────────────────────────────────
1162
1232
 
1163
1233
  function generateCrud(name: string, flags: Record<string, string | boolean>): void {
1164
- const table = toTableName(name);
1234
+ // Composite: quiet here (announce=false); the generateModel sub-call below is
1235
+ // the one that announces, so the reserved-word note prints exactly once.
1236
+ const table = resolveTable(name, flags, { announce: false });
1165
1237
  const routeName = toPlural(table);
1166
1238
  const isPublic = Boolean(flags.public);
1167
1239
 
@@ -1221,9 +1293,11 @@ export function generateMigration(
1221
1293
  .replace(/^create_/, "")
1222
1294
  .replace(/^add_/, "")
1223
1295
  .replace(/^drop_/, "");
1224
- // toTableName also records a `reserved_word_pluralize` transformation
1225
- // on the resolution when the raw form collides with a SQL reserved word.
1226
- table = toTableName(raw);
1296
+ // resolveTable honours --table-name and (via toTableName) records the
1297
+ // `reserved_word_pluralize` transformation when the raw form collides with a
1298
+ // SQL reserved word. Quiet (announce=false): a direct migration targets an
1299
+ // existing table, so it does not repeat the model's note.
1300
+ table = resolveTable(raw, flags, { announce: false });
1227
1301
  }
1228
1302
  // Only set table_name / migration_path when THIS is the top-level target.
1229
1303
  // A migration produced by generateModel is a side effect of `generate model`,
@@ -1269,7 +1343,7 @@ export function generateMigration(
1269
1343
  const defaultClause = info.defaultVal !== "NULL" ? ` DEFAULT ${info.defaultVal}` : "";
1270
1344
  colLines.push(` ${fname} ${info.sql}${defaultClause}`);
1271
1345
  }
1272
- colLines.push(" created_at TEXT DEFAULT CURRENT_TIMESTAMP");
1346
+ colLines.push(" created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP");
1273
1347
 
1274
1348
  upSql =
1275
1349
  `CREATE TABLE IF NOT EXISTS ${table} (\n` +
@@ -1508,7 +1582,7 @@ void test${titleName};
1508
1582
 
1509
1583
  function generateForm(name: string, flags: Record<string, string | boolean>): void {
1510
1584
  const fields = fieldsOrDefault((flags.fields as string) || "");
1511
- const table = toTableName(name);
1585
+ const table = resolveTable(name, flags, { announce: false });
1512
1586
  const routeName = toPlural(table);
1513
1587
 
1514
1588
  const inputTypes: Record<string, string> = {
@@ -1586,7 +1660,7 @@ function generateForm(name: string, flags: Record<string, string | boolean>): vo
1586
1660
 
1587
1661
  function generateView(name: string, flags: Record<string, string | boolean>): void {
1588
1662
  const fields = fieldsOrDefault((flags.fields as string) || "");
1589
- const table = toTableName(name);
1663
+ const table = resolveTable(name, flags, { announce: false });
1590
1664
  const routeName = toPlural(table);
1591
1665
 
1592
1666
  const cols = fields.map(([f]) => f);
@@ -2025,8 +2099,8 @@ ${rules} validator.required("name"); // starter rule (matches the model's def
2025
2099
  // overrides may be static values OR (fake) => value callables) and
2026
2100
  // packages/cli/src/commands/seed.ts (runs each src/seeds/*.ts as a script).
2027
2101
 
2028
- function generateSeeder(name: string, _flags: Record<string, string | boolean>): void {
2029
- const table = toTableName(name);
2102
+ function generateSeeder(name: string, flags: Record<string, string | boolean>): void {
2103
+ const table = resolveTable(name, flags, { announce: false });
2030
2104
  const dir = resolve("src/seeds");
2031
2105
  ensureDir(dir);
2032
2106
  const path = join(dir, `${table}_seeder.ts`);
@@ -0,0 +1,360 @@
1
+ /**
2
+ * CLI command: lint — lint the project's source. The FRAMEWORK ships no linter
3
+ * (a Tina4 app stays zero-dependency); `tina4nodejs lint` uses the project's own
4
+ * eslint and INSTALLS it as a DEV dependency of the PROJECT on demand when it is
5
+ * absent — running the command is the consent. Layers, in order:
6
+ *
7
+ * • eslint present (resolvable from the project's node_modules) AND a flat
8
+ * config present (`eslint.config.{js,mjs,cjs,ts,mts,cts}`): run it. `--fix`
9
+ * runs `eslint --fix` (safe autofixes). eslint reports syntax too, so it is
10
+ * the whole pass when present.
11
+ * • eslint absent (and NOT `--no-install`): silently `npm i -D eslint
12
+ * @eslint/js typescript-eslint` into the PROJECT (dev-only, never the app's
13
+ * runtime deps — typescript-eslint pulls its own `typescript` peer), and
14
+ * scaffold a minimal flat config at `eslint.config.js` when none exists that
15
+ * lints BOTH `.js` and `.ts`, then run eslint. A one-line ` · installing
16
+ * eslint...` notice is printed. If npm is missing or the install fails, a
17
+ * one-line notice is printed and the command falls through to the baseline.
18
+ * • Baseline (zero new dependency): `tsc --noEmit` when a `tsconfig.json`
19
+ * exists (the type+syntax check every tina4-nodejs project already owns,
20
+ * forced to emit nothing); otherwise stdlib `node --check` over `.js`/`.mjs`/
21
+ * `.cjs` files. Used with `--no-install`, or when the install cannot run.
22
+ *
23
+ * Contract (identical across all four frameworks): exit 0 = clean, non-zero =
24
+ * findings; the summary names the tool that ran in `[...]`; `--fix` only
25
+ * autofixes on the eslint path. Scope is the user's app (`src/` + the entrypoint
26
+ * `app.ts`/`app.js`), mirroring how `tina4nodejs test` runs the project's own
27
+ * tests — not the framework's code.
28
+ *
29
+ * tina4nodejs lint # eslint (installed dev-only on demand), else baseline
30
+ * tina4nodejs lint --fix # eslint --fix
31
+ * tina4nodejs lint --no-install # eslint if already present, else the baseline
32
+ *
33
+ * Mirrors the Python master's _lint (tina4_python/cli/__init__.py) — ruff there,
34
+ * eslint here.
35
+ */
36
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
37
+ import { createRequire } from "node:module";
38
+ import { delimiter, dirname, join, relative } from "node:path";
39
+ import { spawnSync } from "node:child_process";
40
+
41
+ // Source extensions we count as lintable app code. `.d.ts` declaration files are
42
+ // deliberately excluded — they carry no runnable code for `node --check` and are
43
+ // covered by the tsconfig for `tsc`.
44
+ const LINT_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"];
45
+ // The subset `node --check` can parse (it does not understand TypeScript).
46
+ const JS_EXTENSIONS = [".js", ".mjs", ".cjs"];
47
+ // Directories a source walk must never descend into.
48
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]);
49
+
50
+ /** True for a lintable source filename (excludes `.d.ts`). */
51
+ function isLintFile(name: string): boolean {
52
+ if (name.endsWith(".d.ts")) return false;
53
+ return LINT_EXTENSIONS.some((ext) => name.endsWith(ext));
54
+ }
55
+
56
+ /** Recursively collect lintable source files under `dir`. */
57
+ function walkSource(dir: string, out: string[]): void {
58
+ let entries: string[];
59
+ try {
60
+ entries = readdirSync(dir);
61
+ } catch {
62
+ return;
63
+ }
64
+ for (const name of entries) {
65
+ if (SKIP_DIRS.has(name)) continue;
66
+ const full = join(dir, name);
67
+ let st;
68
+ try {
69
+ st = statSync(full);
70
+ } catch {
71
+ continue;
72
+ }
73
+ if (st.isDirectory()) {
74
+ walkSource(full, out);
75
+ } else if (isLintFile(name)) {
76
+ out.push(full);
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The user's app source in lint scope: everything under `src/` plus the
83
+ * entrypoint (`app.ts`/`app.js`, with `.mts`/`.mjs` variants). Mirrors how the
84
+ * Python master lints `src/` + `app.py` — the developer's code, never the
85
+ * framework's.
86
+ */
87
+ function collectAppFiles(cwd: string): string[] {
88
+ const files: string[] = [];
89
+ const srcDir = join(cwd, "src");
90
+ try {
91
+ if (statSync(srcDir).isDirectory()) walkSource(srcDir, files);
92
+ } catch {
93
+ // no src/ — fall through to the entrypoint check
94
+ }
95
+ for (const entry of ["app.ts", "app.mts", "app.js", "app.mjs"]) {
96
+ const p = join(cwd, entry);
97
+ try {
98
+ if (statSync(p).isFile()) files.push(p);
99
+ } catch {
100
+ // not present
101
+ }
102
+ }
103
+ return files;
104
+ }
105
+
106
+ /**
107
+ * Flat-config filenames to DETECT (any one means the project already configured
108
+ * eslint, so we never scaffold over it). Legacy `.eslintrc*` is deliberately NOT
109
+ * detected: eslint 9 ignores it under flat config, so treating it as "configured"
110
+ * would make eslint run and then error "no flat config found". Flat config only.
111
+ */
112
+ const ESLINT_FLAT_CONFIGS = [
113
+ "eslint.config.js",
114
+ "eslint.config.mjs",
115
+ "eslint.config.cjs",
116
+ "eslint.config.ts",
117
+ "eslint.config.mts",
118
+ "eslint.config.cts",
119
+ ];
120
+
121
+ // The filename we SCAFFOLD when a project has no flat config. `.js` (not `.mjs`)
122
+ // per the shared design; every tina4-nodejs project is `"type": "module"`, so a
123
+ // `.js` flat config is ESM and loads as written.
124
+ const ESLINT_SCAFFOLD_FILE = "eslint.config.js";
125
+
126
+ // The minimal flat config we scaffold — eslint's own recommended rule set PLUS
127
+ // typescript-eslint's recommended (the non-type-checked / syntactic preset, so no
128
+ // tsconfig or parserOptions.project wiring is needed) so BOTH `.js` and `.ts` are
129
+ // linted. Without the typescript-eslint half, eslint reports every `.ts` file
130
+ // "File ignored" and a TS project lints vacuously. IDENTICAL content across the
131
+ // shared design.
132
+ const ESLINT_SCAFFOLD = `import js from "@eslint/js";
133
+ import tseslint from "typescript-eslint";
134
+ export default [js.configs.recommended, ...tseslint.configs.recommended];
135
+ `;
136
+
137
+ /** Absolute path of an eslint flat config in `cwd`, or null when none exists. */
138
+ function findEslintConfig(cwd: string): string | null {
139
+ for (const name of ESLINT_FLAT_CONFIGS) {
140
+ const p = join(cwd, name);
141
+ if (existsSync(p)) return p;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Resolve a package's bin script FROM the user's project, or null when the
148
+ * package is not installed there.
149
+ *
150
+ * The DIRECT `node_modules/<pkg>/<bin>` path is checked FIRST because it is
151
+ * immune to the CJS resolver's caches: `runLint` calls this both BEFORE and
152
+ * AFTER an in-process `npm install`, and Node can negatively-cache the pre-install
153
+ * "not found" so a post-install `require.resolve` still misses (the same
154
+ * dir-listing-cache footgun Python's importlib has). `existsSync` hits the real
155
+ * filesystem every call, so it sees the just-installed bin. The resolver is the
156
+ * fallback for hoisted / workspace layouts where the package is not a direct
157
+ * child of `cwd/node_modules`.
158
+ */
159
+ function resolvePackageBin(cwd: string, pkg: string, binRelative: string): string | null {
160
+ const direct = join(cwd, "node_modules", pkg, binRelative);
161
+ if (existsSync(direct)) return direct;
162
+
163
+ const require = createRequire(join(cwd, "package.json"));
164
+ let entry: string;
165
+ try {
166
+ entry = require.resolve(pkg);
167
+ } catch {
168
+ return null; // not installed / not resolvable from the project
169
+ }
170
+ let dir = dirname(entry);
171
+ for (let i = 0; i < 8; i++) {
172
+ const pkgJson = join(dir, "package.json");
173
+ if (existsSync(pkgJson)) {
174
+ try {
175
+ if (JSON.parse(readFileSync(pkgJson, "utf-8")).name === pkg) {
176
+ const bin = join(dir, binRelative);
177
+ return existsSync(bin) ? bin : null;
178
+ }
179
+ } catch {
180
+ // malformed package.json — keep walking up
181
+ }
182
+ }
183
+ const parent = dirname(dir);
184
+ if (parent === dir) break;
185
+ dir = parent;
186
+ }
187
+ return null;
188
+ }
189
+
190
+ /** eslint's runnable bin from the project (`bin/eslint.js`), or null. */
191
+ function resolveEslintBin(cwd: string): string | null {
192
+ return resolvePackageBin(cwd, "eslint", "bin/eslint.js");
193
+ }
194
+
195
+ /**
196
+ * True when `pkg` is installed directly under the project's node_modules (a
197
+ * `package.json` at `node_modules/<pkg>/`). Direct filesystem check, immune to
198
+ * the module-resolver cache — the scaffold imports `@eslint/js` and
199
+ * `typescript-eslint`, so it must not be written until both are on disk.
200
+ */
201
+ function hasPackage(cwd: string, pkg: string): boolean {
202
+ return existsSync(join(cwd, "node_modules", ...pkg.split("/"), "package.json"));
203
+ }
204
+
205
+ /**
206
+ * Absolute path of `npm` on PATH, or null. Scanned directly (no `which` shell-out)
207
+ * so it behaves the same on every platform — mirrors bin.ts's findClient().
208
+ */
209
+ function resolveNpm(): string | null {
210
+ const windows = process.platform === "win32";
211
+ const names = windows ? ["npm.cmd", "npm.exe", "npm"] : ["npm"];
212
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
213
+ if (!dir) continue;
214
+ for (const name of names) {
215
+ const candidate = join(dir, name);
216
+ try {
217
+ if (statSync(candidate).isFile()) return candidate;
218
+ } catch {
219
+ // not there — keep looking
220
+ }
221
+ }
222
+ }
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * Lint the project's source. Exits 0 = clean, 1 = findings (parity with the
228
+ * Python master and with `tina4nodejs test`'s exit-code contract).
229
+ */
230
+ export function runLint(args: string[]): void {
231
+ const fix = args.includes("--fix");
232
+ const noInstall = args.includes("--no-install");
233
+ const cwd = process.cwd();
234
+
235
+ const files = collectAppFiles(cwd);
236
+ if (files.length === 0) {
237
+ console.log(" lint: nothing to lint (no src/ or app.ts).");
238
+ process.exit(0);
239
+ }
240
+
241
+ let eslintBin = resolveEslintBin(cwd);
242
+ let eslintConfig = findEslintConfig(cwd);
243
+
244
+ // ── Silent on-demand bootstrap ──────────────────────────────────────
245
+ // Running `tina4 lint` is the consent to add eslint as a DEV dependency of the
246
+ // PROJECT and scaffold a minimal flat config. --no-install opts out (CI /
247
+ // offline) and falls through to the zero-dependency baseline. We only bootstrap
248
+ // what is missing, and only scaffold a config once eslint is actually available
249
+ // to use it (so a failed install never leaves a stray eslint.config.js).
250
+ if (!noInstall && (!eslintBin || !eslintConfig)) {
251
+ const npm = resolveNpm();
252
+ if (!npm) {
253
+ console.log(" · npm not found — using the zero-dependency baseline.");
254
+ } else {
255
+ if (!eslintBin) {
256
+ console.log(" · installing eslint (npm i -D eslint @eslint/js typescript-eslint)...");
257
+ const rc = spawnSync(npm, ["install", "-D", "eslint", "@eslint/js", "typescript-eslint"], {
258
+ cwd,
259
+ stdio: "inherit",
260
+ }).status;
261
+ if (rc === 0) {
262
+ eslintBin = resolveEslintBin(cwd);
263
+ } else {
264
+ console.log(" · could not install eslint — using the zero-dependency baseline.");
265
+ }
266
+ }
267
+ // Scaffold ONLY once all three the config imports are on disk (eslint bin +
268
+ // @eslint/js + typescript-eslint), so a partial/failed install never leaves
269
+ // an eslint.config.js that cannot load.
270
+ if (
271
+ eslintBin && !eslintConfig &&
272
+ hasPackage(cwd, "@eslint/js") && hasPackage(cwd, "typescript-eslint")
273
+ ) {
274
+ const scaffold = join(cwd, ESLINT_SCAFFOLD_FILE);
275
+ try {
276
+ writeFileSync(scaffold, ESLINT_SCAFFOLD, "utf-8");
277
+ eslintConfig = scaffold;
278
+ console.log(` · scaffolded ${ESLINT_SCAFFOLD_FILE} (@eslint/js + typescript-eslint recommended).`);
279
+ } catch (err) {
280
+ console.log(
281
+ ` · could not scaffold ${ESLINT_SCAFFOLD_FILE} (${err instanceof Error ? err.message : String(err)}).`,
282
+ );
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ // ── eslint: the project's own linter (installed dev-only on demand) ──
289
+ // Only when a config exists AND eslint resolves from the project. eslint reports
290
+ // syntax errors too, so when present it is the entire pass.
291
+ if (eslintBin && eslintConfig) {
292
+ const label = fix ? "eslint --fix" : "eslint";
293
+ const eslintArgs = [eslintBin, ...files, ...(fix ? ["--fix"] : [])];
294
+ const code = spawnSync(process.execPath, eslintArgs, { cwd, stdio: "inherit" }).status ?? 1;
295
+ if (code !== 0) {
296
+ console.log(` ✗ lint failed — ${files.length} file(s) [${label}]`);
297
+ process.exit(1);
298
+ }
299
+ console.log(` ✓ lint clean — ${files.length} file(s) [${label}]`);
300
+ process.exit(0);
301
+ }
302
+
303
+ // From here on nothing can autofix — say so once when --fix was asked for.
304
+ if (fix) {
305
+ console.log(" · --fix needs eslint — the baseline check has no autofix.");
306
+ }
307
+
308
+ // ── Baseline (TypeScript): the project's own `tsc --noEmit` ──────────
309
+ // Every tina4-nodejs project ships tsconfig.json + typescript. `--noEmit` forces
310
+ // a type+syntax check that writes NOTHING (the project's tsconfig may set outDir,
311
+ // so this must never emit). tsc reports syntax errors too. Run from cwd so tsc
312
+ // reads THIS tsconfig.json; diagnostics stream straight through.
313
+ const hasTsconfig = existsSync(join(cwd, "tsconfig.json"));
314
+ const tscBin = hasTsconfig ? resolvePackageBin(cwd, "typescript", "bin/tsc") : null;
315
+ if (tscBin) {
316
+ const code = spawnSync(process.execPath, [tscBin, "--noEmit"], { cwd, stdio: "inherit" }).status ?? 1;
317
+ if (code !== 0) {
318
+ console.log(` ✗ lint failed — ${files.length} file(s) [tsc]`);
319
+ process.exit(1);
320
+ }
321
+ console.log(` ✓ lint clean — ${files.length} file(s) [tsc]`);
322
+ process.exit(0);
323
+ }
324
+
325
+ // ── Baseline (plain JS): stdlib `node --check` over .js/.mjs/.cjs ────
326
+ // Ships with node — zero dependency. A full syntax parse that never runs the
327
+ // code. TypeScript files are out of its reach (they belong to the tsc path).
328
+ const jsFiles = files.filter((f) => JS_EXTENSIONS.some((ext) => f.endsWith(ext)));
329
+ if (jsFiles.length === 0) {
330
+ console.log(
331
+ " lint: no JavaScript files to check — add tsconfig.json + typescript to type-check .ts files.",
332
+ );
333
+ process.exit(0);
334
+ }
335
+
336
+ let syntaxErrors = 0;
337
+ for (const file of jsFiles) {
338
+ const result = spawnSync(process.execPath, ["--check", file], { cwd, encoding: "utf-8" });
339
+ if ((result.status ?? 1) !== 0) {
340
+ const stderr = (result.stderr || "").trim();
341
+ // node --check ends its stderr with a `SyntaxError: ...` line — surface it.
342
+ const detail =
343
+ stderr
344
+ .split("\n")
345
+ .reverse()
346
+ .find((line) => line.includes("Error:")) || "syntax error";
347
+ console.log(` ✗ ${relative(cwd, file)}: ${detail.trim()}`);
348
+ syntaxErrors++;
349
+ }
350
+ }
351
+
352
+ if (syntaxErrors > 0) {
353
+ console.log(
354
+ ` ✗ lint failed — ${syntaxErrors} syntax error(s) in ${jsFiles.length} file(s) [node --check]`,
355
+ );
356
+ process.exit(1);
357
+ }
358
+ console.log(` ✓ lint clean — ${jsFiles.length} file(s) [node --check]`);
359
+ process.exit(0);
360
+ }