tina4-nodejs 3.13.64 → 3.13.67

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/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.59)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.67)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.13.59 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.67 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
@@ -757,7 +757,7 @@ User.create(data); // construct + save
757
757
  User.all(where?, params?, include?);
758
758
  User.select(sql, params?);
759
759
  User.selectOne(sql, params?, include?);
760
- User.where(conditions, params?, limit?, offset?, include?);
760
+ User.where(conditions, params?, limit?, offset?, include?, orderBy?);
761
761
  User.count(conditions?, params?);
762
762
  User.withTrashed(conditions?, params?, limit?, offset?);
763
763
  User.scope(name, filterSql, params?); // registers a reusable named method
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.64",
3
+ "version": "3.13.67",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -6,66 +6,42 @@ 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 { generate } from "./commands/generate.js";
9
+ import { generate, GENERATORS } from "./commands/generate.js";
10
10
  import { runSeeds } from "./commands/seed.js";
11
11
  import { runMetrics } from "./commands/metrics.js";
12
+ import { queueCommand, QUEUE_SUBCOMMAND_NAMES } from "./commands/queue.js";
13
+ import { buildImage } from "./commands/build.js";
12
14
  import { execSync } from "node:child_process";
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
18
 
14
- const args = process.argv.slice(2);
15
- const command = args[0];
16
-
17
- const HELP = `
18
- tina4nodejs The Intelligent Native Application 4ramework
19
-
20
- Usage:
21
- tina4nodejs init [dir] Create a new Tina4 project (default: current directory)
22
- tina4nodejs serve Start the dev server with hot-reload
23
- tina4nodejs migrate Run pending SQL migrations
24
- tina4nodejs migrate:create <desc> Create a new migration file pair (.sql + .down.sql)
25
- tina4nodejs migrate:status Show completed and pending migrations
26
- tina4nodejs migrate:rollback Roll back the last batch of migrations
27
- tina4nodejs routes List all registered routes
28
- tina4nodejs test [file] Run project tests
29
- tina4nodejs seed [file] Run database seed files from src/seeds/
30
- tina4nodejs metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]
31
- Rank top code-quality offenders
32
- tina4nodejs console Open an interactive REPL with the framework loaded
33
- tina4nodejs ai Install AI coding assistant context files
34
- tina4nodejs help Show this help message
35
-
36
- Generators:
37
- tina4nodejs generate model <Name> [--fields "name:string,price:float"]
38
- tina4nodejs generate route <name> [--model Name] [--public] Writes secure by default; --public opens them
39
- tina4nodejs generate crud <Name> [--fields "..."] [--public] Model + migration + routes + form + view + test
40
- tina4nodejs generate migration <description>
41
- tina4nodejs generate middleware <Name>
42
- tina4nodejs generate test <name>
43
- tina4nodejs generate form <Name> [--fields "..."] Form template with inputs matching model fields
44
- tina4nodejs generate view <Name> [--fields "..."] List + detail templates for viewing records
45
- tina4nodejs generate auth Login/register routes (public) + User model + templates
46
- tina4nodejs generate service <Name> [--every 5m | --cron "..."] Scheduled ServiceRunner task (src/services/)
47
- tina4nodejs generate queue <topic> Producer + consumer daemon worker (src/services/)
48
- tina4nodejs generate validator <Name> Request-body Validator (src/validators/)
49
- tina4nodejs generate seeder <Model> FakeData + seedOrm seeder (src/seeds/)
50
- tina4nodejs generate websocket <path> websocket() handler (src/routes/)
51
- tina4nodejs generate listener <event> Events.on(event) listener (src/listeners/)
52
-
53
- Scaffolding-first: logic-shaped generators (route without --model, service,
54
- queue, validator, seeder, websocket, listener) emit real wiring + an AI-FILL
55
- placeholder (throws until filled); CRUD-shaped ones emit working code. Writes
56
- are secure by default — use --public to open them.
57
-
58
- Field types: string, int, float, bool, text, datetime
59
- Table names: singular by default (Product → product)
60
-
61
- Options:
62
- --port <number> Server port (default: 7148)
63
- --no-browser Don't open the browser on serve
64
- --no-reload Disable file watcher / live-reload on serve
65
- --all Install AI context for all tools (with ai command)
66
- --force Overwrite existing AI context files (with ai command)
67
- --help Show this help message
68
- `;
19
+ // ── Version (cheap, side-effect-free) ───────────────────────────────
20
+ //
21
+ // Walk up from this file to the nearest package.json and read its version.
22
+ // From the source tree that is packages/cli/package.json; from the built
23
+ // dist/bin.js (or the published `tina4nodejs` package) it is the CLI package's
24
+ // own package.json — the framework version in every layout. Only touches the
25
+ // filesystem (JSON reads); it never bootstraps the app or opens a DB.
26
+
27
+ function readCliVersion(): string {
28
+ let dir = dirname(fileURLToPath(import.meta.url));
29
+ for (let i = 0; i < 6; i++) {
30
+ const pkgPath = join(dir, "package.json");
31
+ if (existsSync(pkgPath)) {
32
+ try {
33
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
34
+ if (typeof pkg.version === "string" && pkg.version) return pkg.version;
35
+ } catch {
36
+ // keep walking — a malformed package.json isn't ours
37
+ }
38
+ }
39
+ const parent = dirname(dir);
40
+ if (parent === dir) break;
41
+ dir = parent;
42
+ }
43
+ return "0.0.0";
44
+ }
69
45
 
70
46
  // ── Port-kill helper ────────────────────────────────────────────────
71
47
 
@@ -90,125 +66,324 @@ function killProcessOnPort(port: number): boolean {
90
66
  return false;
91
67
  }
92
68
 
93
- async function main(): Promise<void> {
94
- switch (command) {
95
- case "init": {
96
- const name = args[1] || ".";
97
- await initProject(name);
98
- break;
99
- }
100
- case "serve": {
101
- const portIndex = args.indexOf("--port");
102
- const port = portIndex !== -1 ? parseInt(args[portIndex + 1], 10) : 7148;
103
- const noBrowser = args.includes("--no-browser");
104
- const noReload = args.includes("--no-reload");
69
+ // ── Self-describing command surface ─────────────────────────────────
105
70
 
106
- // Kill any existing process on the port
107
- killProcessOnPort(port);
71
+ export interface CommandManifestEntry {
72
+ name: string;
73
+ summary: string;
74
+ args?: string[];
75
+ subcommands?: string[];
76
+ }
108
77
 
109
- await serveProject({ port, noBrowser, noReload });
110
- break;
111
- }
112
- case "migrate": {
113
- await runMigrations(args[1]);
114
- break;
115
- }
116
- case "migrate:create": {
117
- const description = args.slice(1).join(" ");
118
- await createMigration(description || undefined);
119
- break;
120
- }
121
- case "migrate:status": {
122
- await migrateStatus(args[1]);
123
- break;
124
- }
125
- case "migrate:rollback": {
126
- await migrateRollback(args[1]);
127
- break;
128
- }
129
- case "routes": {
130
- await listRoutes();
131
- break;
132
- }
133
- case "test": {
134
- await runTests(args[1]);
135
- break;
136
- }
137
- case "generate": {
138
- const what = args[1];
139
- const genName = args[2] || "";
140
- const extraArgs = args.slice(3);
141
- await generate(what, genName, extraArgs);
142
- break;
143
- }
144
- case "seed": {
145
- await runSeeds(args[1]);
146
- break;
147
- }
148
- case "metrics": {
149
- const code = runMetrics(args.slice(1));
150
- process.exit(code);
78
+ export interface CommandManifest {
79
+ framework: string;
80
+ version: string;
81
+ commands: CommandManifestEntry[];
82
+ }
83
+
84
+ /**
85
+ * Build the machine-readable manifest of the CLI's command surface.
86
+ *
87
+ * Pure data: reads the module-level COMMANDS registry plus the framework
88
+ * version — no bootstrap, no database, no migrations, no app imports. This is
89
+ * exactly what `commands --json` serialises and what the tina4 Rust client
90
+ * consumes to discover which commands this framework supports.
91
+ *
92
+ * Shape (identical keys to the Python master):
93
+ * { framework: "nodejs", version: "<x.y.z>",
94
+ * commands: [{ name, summary, args?, subcommands? }, ...] }
95
+ */
96
+ export function buildCommandManifest(): CommandManifest {
97
+ const commands: CommandManifestEntry[] = Object.entries(COMMANDS).map(([name, spec]) => {
98
+ const entry: CommandManifestEntry = { name, summary: spec.summary };
99
+ if (spec.args && spec.args.length) entry.args = [...spec.args];
100
+ if (spec.subcommands && spec.subcommands.length) entry.subcommands = [...spec.subcommands];
101
+ return entry;
102
+ });
103
+ return { framework: "nodejs", version: readCliVersion(), commands };
104
+ }
105
+
106
+ /**
107
+ * Emit the CLI's own command surface — the self-describing manifest.
108
+ *
109
+ * tina4nodejs commands human-readable list
110
+ * tina4nodejs commands --json machine-readable manifest (for the tina4 CLI)
111
+ *
112
+ * CHEAP + side-effect-free by contract: it only prints the static COMMANDS
113
+ * registry plus the framework version. It MUST NOT bootstrap the framework,
114
+ * open a database, run migrations, or import app modules — the Rust client
115
+ * calls this on `tina4 --help`, in any directory, so it must be instant and
116
+ * safe to run anywhere.
117
+ */
118
+ export function runCommands(args: string[] = []): void {
119
+ const manifest = buildCommandManifest();
120
+
121
+ if (args.includes("--json")) {
122
+ console.log(JSON.stringify(manifest, null, 2));
123
+ return;
124
+ }
125
+
126
+ console.log(`\n Tina4 ${manifest.framework} — ${manifest.version}\n`);
127
+ const width = Math.max(...manifest.commands.map((c) => c.name.length));
128
+ for (const c of manifest.commands) {
129
+ console.log(` ${c.name.padEnd(width)} ${c.summary}`);
130
+ if (c.subcommands && c.subcommands.length) {
131
+ console.log(` ${" ".repeat(width)} ${c.subcommands.join(", ")}`);
151
132
  }
152
- case "console": {
153
- const repl = await import("node:repl");
154
- const { loadEnv, Router, Log } = await import("../../core/src/index.js");
155
- const { initDatabase, Database } = await import("../../orm/src/index.js");
133
+ }
134
+ console.log("");
135
+ }
156
136
 
157
- loadEnv();
137
+ /**
138
+ * Print the human-readable command reference.
139
+ *
140
+ * Generated from the COMMANDS and GENERATORS registries — the SAME single
141
+ * source of truth that drives dispatch (`main`) and the `commands --json`
142
+ * manifest — so the help text can never drift from what the CLI actually does.
143
+ */
144
+ function printHelp(): void {
145
+ const commandRows: [string, string][] = Object.entries(COMMANDS).map(
146
+ ([name, spec]) => [`${name}${spec.usage ? " " + spec.usage : ""}`, spec.summary],
147
+ );
148
+ const generatorRows: [string, string][] = Object.entries(GENERATORS).map(
149
+ ([name, spec]) => [`generate ${name}${spec.usage ? " " + spec.usage : ""}`, spec.summary],
150
+ );
158
151
 
159
- const dbUrl = process.env.TINA4_DATABASE_URL;
160
- let db: unknown = null;
161
- if (dbUrl) {
162
- try {
163
- db = await initDatabase({ url: dbUrl });
164
- } catch {
165
- console.warn(" Warning: could not connect to database — db will be null");
166
- }
167
- }
152
+ // Align summaries in a column; a left cell longer than the cap overflows
153
+ // cleanly (2-space gap) rather than pushing every other summary out.
154
+ const pad = Math.min(46, Math.max(...[...commandRows, ...generatorRows].map(([left]) => left.length)));
155
+ const row = (left: string, summary: string): string => {
156
+ const gap = left.length <= pad ? pad : left.length;
157
+ return ` ${left.padEnd(gap)} ${summary}`;
158
+ };
159
+
160
+ const lines: string[] = [
161
+ "",
162
+ " tina4nodejs — The Intelligent Native Application 4ramework",
163
+ "",
164
+ " Usage: tina4nodejs <command> [options]",
165
+ "",
166
+ " Commands:",
167
+ ...commandRows.map(([left, summary]) => row(left, summary)),
168
+ "",
169
+ " Generators:",
170
+ ...generatorRows.map(([left, summary]) => row(left, summary)),
171
+ "",
172
+ " Scaffolding-first: logic-shaped generators (route without --model, service,",
173
+ " queue, validator, seeder, websocket, listener) emit real wiring + an AI-FILL",
174
+ " placeholder (throws until filled); CRUD-shaped ones emit working code. Writes",
175
+ " are secure by default — use --public to open them.",
176
+ "",
177
+ " Field types: string, int, float, bool, text, datetime",
178
+ " Table names: singular by default (Product → product)",
179
+ "",
180
+ " Options:",
181
+ " --port <number> Server port (default: 7148)",
182
+ " --no-browser Don't open the browser on serve",
183
+ " --no-reload Disable file watcher / live-reload on serve",
184
+ " --all Install AI context for all tools (with ai command)",
185
+ " --force Overwrite existing AI context files (with ai command)",
186
+ " --help Show this help message",
187
+ "",
188
+ " https://tina4.com",
189
+ "",
190
+ ];
191
+ console.log(lines.join("\n"));
192
+ }
168
193
 
169
- console.log("\n Tina4 Node.js Console");
170
- console.log(" Type JavaScript. Framework is loaded.");
171
- console.log(" Available: db, Router, Database, Log");
172
- console.log(" Exit: Ctrl+D or .exit\n");
194
+ // ── Console REPL (heavy imports the framework lazily on demand) ────
173
195
 
174
- const r = repl.start({ prompt: "tina4> " });
196
+ async function openConsole(): Promise<void> {
197
+ const repl = await import("node:repl");
198
+ const { loadEnv, Router, Log } = await import("../../core/src/index.js");
199
+ const { initDatabase, Database } = await import("../../orm/src/index.js");
175
200
 
176
- r.context.Router = Router;
177
- r.context.Database = Database;
178
- r.context.Log = Log;
179
- r.context.db = db;
201
+ loadEnv();
180
202
 
181
- await new Promise<void>((resolve) => r.on("exit", resolve));
182
- break;
203
+ const dbUrl = process.env.TINA4_DATABASE_URL;
204
+ let db: unknown = null;
205
+ if (dbUrl) {
206
+ try {
207
+ db = await initDatabase({ url: dbUrl });
208
+ } catch {
209
+ console.warn(" Warning: could not connect to database — db will be null");
183
210
  }
184
- case "ai": {
185
- const { showMenu, installSelected, installAll } = await import("../../core/src/ai.js");
186
- const root = args[1] || ".";
187
-
188
- if (args.includes("--all")) {
189
- installAll(root);
190
- } else {
191
- const selection = await showMenu(root);
192
- if (selection) {
193
- installSelected(root, selection);
194
- }
195
- }
196
- break;
211
+ }
212
+
213
+ console.log("\n Tina4 Node.js Console");
214
+ console.log(" Type JavaScript. Framework is loaded.");
215
+ console.log(" Available: db, Router, Database, Log");
216
+ console.log(" Exit: Ctrl+D or .exit\n");
217
+
218
+ const r = repl.start({ prompt: "tina4> " });
219
+
220
+ r.context.Router = Router;
221
+ r.context.Database = Database;
222
+ r.context.Log = Log;
223
+ r.context.db = db;
224
+
225
+ await new Promise<void>((resolve) => r.on("exit", resolve));
226
+ }
227
+
228
+ async function installAiContext(args: string[]): Promise<void> {
229
+ const { showMenu, installSelected, installAll } = await import("../../core/src/ai.js");
230
+ const root = args[0] || ".";
231
+
232
+ if (args.includes("--all")) {
233
+ installAll(root);
234
+ } else {
235
+ const selection = await showMenu(root);
236
+ if (selection) {
237
+ installSelected(root, selection);
197
238
  }
198
- case "help":
199
- case "--help":
200
- case "-h":
201
- case undefined:
202
- console.log(HELP);
203
- break;
204
- default:
205
- console.error(`Unknown command: ${command}`);
206
- console.log(HELP);
207
- process.exit(1);
208
239
  }
209
240
  }
210
241
 
211
- main().catch((err) => {
212
- console.error(err);
242
+ // ── Command registry — the single source of truth ───────────────────
243
+ //
244
+ // One entry per command drives main() dispatch, the human help (printHelp),
245
+ // AND the machine-readable manifest (commands --json). Add a command in ONE
246
+ // place and it appears in dispatch, help, and discovery — there is no second
247
+ // list to sync. Mirrors the Python master's COMMANDS registry
248
+ // (tina4_python/cli/__init__.py).
249
+ //
250
+ // COMMANDS[name] = {
251
+ // handler: (cmdArgs) => …, // args AFTER the command name
252
+ // summary: string,
253
+ // usage?: string, // arg/flag hint for printHelp (human only)
254
+ // args?: string[], // positional args for the manifest ("x?" = optional)
255
+ // subcommands?: string[], // sub-names for the manifest (generate)
256
+ // }
257
+
258
+ export interface CommandSpec {
259
+ handler: (cmdArgs: string[]) => void | Promise<void>;
260
+ summary: string;
261
+ usage?: string;
262
+ args?: string[];
263
+ subcommands?: string[];
264
+ }
265
+
266
+ export const COMMANDS: Record<string, CommandSpec> = {
267
+ init: {
268
+ handler: async (a) => { await initProject(a[0] || "."); },
269
+ usage: "[dir]",
270
+ args: ["dir?"],
271
+ summary: "Create a new Tina4 project (default: current directory)",
272
+ },
273
+ serve: {
274
+ handler: async (a) => {
275
+ const portIndex = a.indexOf("--port");
276
+ const port = portIndex !== -1 ? parseInt(a[portIndex + 1], 10) : 7148;
277
+ const noBrowser = a.includes("--no-browser");
278
+ const noReload = a.includes("--no-reload");
279
+ killProcessOnPort(port);
280
+ await serveProject({ port, noBrowser, noReload });
281
+ },
282
+ usage: "[--port P] [--no-browser] [--no-reload]",
283
+ summary: "Start the dev server with hot-reload (default: 0.0.0.0:7148)",
284
+ },
285
+ migrate: {
286
+ handler: async (a) => { await runMigrations(a[0]); },
287
+ summary: "Run pending SQL migrations",
288
+ },
289
+ "migrate:create": {
290
+ handler: async (a) => { await createMigration(a.join(" ") || undefined); },
291
+ usage: "<desc>",
292
+ args: ["description"],
293
+ summary: "Create a new migration file",
294
+ },
295
+ "migrate:status": {
296
+ handler: async (a) => { await migrateStatus(a[0]); },
297
+ summary: "Show completed and pending migrations",
298
+ },
299
+ "migrate:rollback": {
300
+ handler: async (a) => { await migrateRollback(a[0]); },
301
+ summary: "Roll back the last batch of migrations",
302
+ },
303
+ routes: {
304
+ handler: async () => { await listRoutes(); },
305
+ summary: "List all registered routes",
306
+ },
307
+ test: {
308
+ handler: async (a) => { await runTests(a[0]); },
309
+ usage: "[file]",
310
+ summary: "Run project tests",
311
+ },
312
+ queue: {
313
+ handler: async (a) => { await queueCommand(a); },
314
+ usage: "<work|stats|retry|clear> [topic]",
315
+ subcommands: QUEUE_SUBCOMMAND_NAMES,
316
+ summary: "Run queue workers and manage jobs",
317
+ },
318
+ build: {
319
+ handler: (a) => { buildImage(a); },
320
+ usage: "[--tag NAME] [--file PATH]",
321
+ summary: "Build the deployable Docker image",
322
+ },
323
+ generate: {
324
+ handler: async (a) => { await generate(a[0], a[1] || "", a.slice(2)); },
325
+ usage: "<what> <name> [options]",
326
+ subcommands: Object.keys(GENERATORS),
327
+ summary: "Generate scaffolding (see Generators below)",
328
+ },
329
+ seed: {
330
+ handler: async (a) => { await runSeeds(a[0]); },
331
+ usage: "[file]",
332
+ summary: "Run database seed files from src/seeds/",
333
+ },
334
+ metrics: {
335
+ handler: (a) => { process.exit(runMetrics(a)); },
336
+ usage: "[--top N] [--json] [--fail-on warn|error] [--path DIR]",
337
+ summary: "Rank top code-quality offenders",
338
+ },
339
+ console: {
340
+ handler: async () => { await openConsole(); },
341
+ summary: "Open an interactive REPL with the framework loaded",
342
+ },
343
+ ai: {
344
+ handler: async (a) => { await installAiContext(a); },
345
+ usage: "[--all]",
346
+ summary: "Install AI coding assistant context files",
347
+ },
348
+ commands: {
349
+ handler: (a) => { runCommands(a); },
350
+ usage: "[--json]",
351
+ summary: "List available commands (add --json for the machine manifest)",
352
+ },
353
+ help: {
354
+ handler: () => { printHelp(); },
355
+ summary: "Show this help message",
356
+ },
357
+ };
358
+
359
+ // ── Dispatch ────────────────────────────────────────────────────────
360
+
361
+ async function main(): Promise<void> {
362
+ const args = process.argv.slice(2);
363
+ let command = args[0];
364
+ const cmdArgs = args.slice(1);
365
+
366
+ // No command or a bare help flag → the help command.
367
+ if (command === undefined || command === "--help" || command === "-h") {
368
+ command = "help";
369
+ }
370
+
371
+ const spec = COMMANDS[command];
372
+ if (spec) {
373
+ await spec.handler(cmdArgs);
374
+ return;
375
+ }
376
+
377
+ console.error(`Unknown command: ${command}`);
378
+ printHelp();
213
379
  process.exit(1);
214
- });
380
+ }
381
+
382
+ // Run only when invoked as the entrypoint — importing this module (e.g. in a
383
+ // test to inspect COMMANDS / buildCommandManifest) must NOT dispatch a command.
384
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
385
+ main().catch((err) => {
386
+ console.error(err);
387
+ process.exit(1);
388
+ });
389
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * CLI command: build — build the deployable Docker image for this Tina4 app.
3
+ *
4
+ * A Tina4 app deploys as a container — `tina4nodejs init` and `tina4 deploy
5
+ * docker` both scaffold a Dockerfile — so `build` produces THAT artifact: the
6
+ * image. It shells out to the `docker` CLI (no new npm dependency) and fails
7
+ * loud with guidance when there is no Dockerfile or docker is not on PATH,
8
+ * instead of silently packaging the framework as a library.
9
+ *
10
+ * tina4nodejs build # docker build -t <dir>:latest .
11
+ * tina4nodejs build --tag myapp:1.2 # explicit image tag
12
+ * tina4nodejs build --file docker/Dockerfile
13
+ *
14
+ * Mirrors the Python master's _build (tina4_python/cli/__init__.py).
15
+ */
16
+ import { accessSync, constants, existsSync, statSync } from "node:fs";
17
+ import { basename, delimiter, join } from "node:path";
18
+ import { spawnSync } from "node:child_process";
19
+
20
+ /** Parse --key value / bare --flag (build only needs --tag and --file values). */
21
+ function parseFlags(args: string[]): Record<string, string | boolean> {
22
+ const flags: Record<string, string | boolean> = {};
23
+ let i = 0;
24
+ while (i < args.length) {
25
+ const arg = args[i];
26
+ if (arg.startsWith("--")) {
27
+ const key = arg.slice(2);
28
+ if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
29
+ flags[key] = args[i + 1];
30
+ i += 2;
31
+ } else {
32
+ flags[key] = true;
33
+ i += 1;
34
+ }
35
+ } else {
36
+ i += 1;
37
+ }
38
+ }
39
+ return flags;
40
+ }
41
+
42
+ /**
43
+ * Resolve `docker` on PATH, or null when it is genuinely absent.
44
+ *
45
+ * Scans PATH directly (zero-dep, cross-platform) rather than shelling out, so a
46
+ * deliberately emptied PATH really yields null — mirroring Python's
47
+ * shutil.which("docker"), which the fail-loud guard relies on.
48
+ */
49
+ function whichDocker(): string | null {
50
+ const pathValue = process.env.PATH || process.env.Path || "";
51
+ if (!pathValue) return null;
52
+ const exts =
53
+ process.platform === "win32"
54
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
55
+ : [""];
56
+ for (const dir of pathValue.split(delimiter)) {
57
+ if (!dir) continue;
58
+ for (const ext of exts) {
59
+ const candidate = join(dir, `docker${ext}`);
60
+ try {
61
+ accessSync(candidate, constants.X_OK);
62
+ if (statSync(candidate).isFile()) return candidate;
63
+ } catch {
64
+ // not here / not executable — keep looking
65
+ }
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** Build the deployable Docker image. Fails loud (exit 1+) on missing prerequisites. */
72
+ export function buildImage(args: string[]): void {
73
+ const flags = parseFlags(args);
74
+
75
+ let tag = typeof flags.tag === "string" ? flags.tag : "";
76
+ if (!tag) {
77
+ // Default tag: <project-folder>:latest, lower-cased (docker repo names must
78
+ // be lowercase). Fall back to a sane name for an unnamed cwd.
79
+ const dirName = basename(process.cwd()).toLowerCase();
80
+ tag = `${dirName || "tina4app"}:latest`;
81
+ }
82
+
83
+ const dockerfile = typeof flags.file === "string" && flags.file ? flags.file : "Dockerfile";
84
+ if (!existsSync(dockerfile) || !statSync(dockerfile).isFile()) {
85
+ console.log(` ✗ No ${dockerfile} found.`);
86
+ console.log(" A Tina4 app deploys as a container. Scaffold a Dockerfile first:");
87
+ console.log(" tina4 deploy docker (or: tina4nodejs init)");
88
+ process.exit(1);
89
+ }
90
+
91
+ const docker = whichDocker();
92
+ if (!docker) {
93
+ console.log(" ✗ docker was not found on PATH.");
94
+ console.log(" Install Docker to build the deployable image, or build manually:");
95
+ console.log(` docker build -t ${tag} -f ${dockerfile} .`);
96
+ process.exit(1);
97
+ }
98
+
99
+ console.log(` Building image ${tag} from ${dockerfile} ...`);
100
+ const result = spawnSync(docker, ["build", "-t", tag, "-f", dockerfile, "."], {
101
+ stdio: "inherit",
102
+ });
103
+ const code = result.status ?? 1;
104
+ if (code !== 0) {
105
+ console.log(` ✗ docker build failed (exit ${code})`);
106
+ process.exit(code);
107
+ }
108
+ console.log(` ✓ Built image ${tag}`);
109
+ console.log(` Run: docker run -p 7148:7148 ${tag}`);
110
+ }