@carlos-tzin/tzin 1.0.5 → 1.0.7

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
@@ -1,5 +1,92 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.7 — 2026-09-27
4
+
5
+ The `tzin` CLI was announced as a headline feature but was **not reachable** in
6
+ a published install. `npm run dev` in a freshly scaffolded app failed with
7
+ `sh: 1: tzin: not found`, and `npx tzin dev` in the docs 404'd because no
8
+ unscoped `tzin` package exists on npm. Fixed here.
9
+
10
+ ### Fixed
11
+
12
+ - **`bin` field added to `package.json`** — `@carlos-tzin/tzin` now installs a
13
+ `tzin` executable into `node_modules/.bin`, so the `create-tzin` node
14
+ template (`"dev": "tzin dev"`, `"build": "tzin build"`) works after
15
+ `npm install`.
16
+ - **Shebang in `src/cli.ts`** — `dist/cli.js` is now a runnable binary. `tsc`
17
+ preserves it on emit.
18
+ - **`dev` no longer resolves a `.ts` file from `dist/`** — the CLI pointed
19
+ `tsx watch` at `./dev-server.ts` relative to itself, which only exists in
20
+ `src/`. In the published package the watcher died with
21
+ `ERR_MODULE_NOT_FOUND: .../dist/dev-server.ts`. It now resolves the sibling
22
+ module for both layouts (`.js` when built, `.ts` when run from source).
23
+ - **`tzin build` / `tzin dev` no longer return before their child exits** —
24
+ both spawned `tsc` / `tsx watch` and then called `process.exit(0)`, so
25
+ `tzin build` reported success (exit 0) even with type errors and the dev
26
+ server was orphaned. The CLI now waits and forwards the child's exit code,
27
+ breaking a CI build on a type error instead of shipping stale output.
28
+ - **Tool resolution** — `tsx` / `tsc` / `wrangler` are now taken from the
29
+ project's own `node_modules/.bin` (walking up from cwd). The npx fallback
30
+ pins the right package (`--package typescript tsc`); previously `npx tsc`
31
+ without a local install ran an unrelated third-party `tsc` package.
32
+
33
+ ### Also fixed (found while verifying the above — both shipped broken in 1.0.6)
34
+
35
+ - **`signJwt` / `verifyJwt` threw in every consumer install.**
36
+ `src/auth.ts` used a bare `require('node:crypto')` inside an ESM package,
37
+ so `dist/auth.js` shipped as `require` + top-level `await` and blew up with
38
+ `ReferenceError: Cannot determine intended module format` /
39
+ `ERR_AMBIGUOUS_MODULE_SYNTAX` on the first call. The unit tests passed
40
+ because Vitest's module runner injects a `require` shim, so nothing
41
+ exercised the published artifact. Now a static `import`.
42
+ - **`loadConfig()` silently ignored every config file.** Same bare `require`
43
+ in `src/config.ts`, swallowed by the surrounding `try/catch` and turned into
44
+ a `null` return, so `port` / `openapi` / `routes` from `tzin.config.*` never
45
+ applied. Now uses `createRequire(import.meta.url)`, which also lets Node load
46
+ ESM `.js` and `.mjs` configs.
47
+
48
+ ### Docs
49
+
50
+ - Replaced every `npx tzin dev` / `npx tzin build` (which 404) with
51
+ `npm run dev` / `npm run build`, plus `npx --package @carlos-tzin/tzin tzin dev`
52
+ for one-off runs without a local install.
53
+
54
+ ### Tests
55
+
56
+ - `test/cli.test.ts`: asserts the `bin` mapping, the shebang in source and
57
+ build output, that `build` waits for `tsc` and propagates a non-zero exit
58
+ code, and that `dev` from the built layout finds `dev-server` instead of
59
+ crashing.
60
+ - `test/cli.test.ts`: runs the built `dist/` in a plain node process to assert
61
+ JWT signing, `loadConfig`, and a served app work outside Vitest's transform
62
+ (the gap that let the `require` bugs reach npm).
63
+
64
+ ## 1.0.6 — 2026-09-22
65
+
66
+ Deployment fixes after the 1.0.5 release. No API changes.
67
+
68
+ ### Templates
69
+
70
+ - `create-tzin/templates/node/src/bus.ts`: `import('ioredis' as string)`
71
+ — the dynamic import was being resolved by `tsc` at compile time, so a
72
+ freshly scaffolded app (without `ioredis` installed) failed to build with
73
+ `Cannot find module 'ioredis'`. Now it typechecks and runs with zero new
74
+ dependencies; `ioredis` is only fetched at runtime when `REDIS_URL` is set.
75
+
76
+ ### Docker
77
+
78
+ - `Dockerfile` HEALTHCHECK now hits `/openapi.json` instead of `/health`.
79
+ The node template mounts `bearerAuth` on every route, so `/health` returns
80
+ 401 and the healthcheck would always fail. `/openapi.json` is public.
81
+
82
+ ### Compose / docs
83
+
84
+ - `docker-compose.yml`: removed the `TZIN_BUS=redis` env var (the framework
85
+ never read it — clustering is enabled by `REDIS_URL` via `src/bus.ts`).
86
+ Added a comment that `build: .` points at the app root, not the framework
87
+ repo.
88
+ - `docs/deployment.md` env-var table: `TZIN_BUS` → `REDIS_URL`.
89
+
3
90
  ## 1.0.5 — 2026-09-21
4
91
 
5
92
  Deployment polish + release automation. No API changes to the framework core.
@@ -40,8 +127,8 @@ Deployment polish + release automation. No API changes to the framework core.
40
127
 
41
128
  - `scripts/release.mjs <patch|minor|major> [--dry-run]`: version bump,
42
129
  template sync, CHANGELOG skeleton, commit + tag
43
- - `.github/workflows/release.yml`: tag-triggered npm publish via
44
- Trusted Publishing (OIDC, `--provenance`), no `NPM_TOKEN` secret
130
+ - `.github/workflows/release.yml`: tag-triggered CI (build + tests only);
131
+ publishing is manual with the npm token in `~/.npmrc` (no repo secret)
45
132
 
46
133
  ## 1.0.4 — Node 20 compatibility fix
47
134
 
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  [![CI](https://github.com/Tzinny-dev/tzin/actions/workflows/ci.yml/badge.svg)](https://github.com/Tzinny-dev/tzin/actions/workflows/ci.yml)
9
9
  [![npm](https://img.shields.io/npm/v/@carlos-tzin/tzin)](https://www.npmjs.com/package/@carlos-tzin/tzin)
10
10
  [![Donate](https://img.shields.io/badge/Donate-PayPal-00457C?logo=paypal&logoColor=white)](https://paypal.me/carlostzin)
11
+ [![Buy Me A Coffee](https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/tzinny)
11
12
 
12
13
  **Status: stable (1.0.x).** The core works end-to-end (see [Benchmarks](#benchmarks));
13
14
  1.0.0 shipped the stable API, and 1.0.2 adds test coverage gates, a wider CI
@@ -25,6 +26,8 @@ npx create-tzin my-app
25
26
 
26
27
  ## Documentation
27
28
 
29
+ Hosted site: **[tzin.tzinny.com](https://tzin.tzinny.com)**
30
+
28
31
  - **[API Reference](docs/api-reference.md)** — all exports, types, and options
29
32
  - **[Architecture Guide](docs/architecture.md)** — request pipeline, design decisions, internals
30
33
  - **[Deployment Guide](docs/deployment.md)** — Docker, Cloudflare Workers, multi-node clustering
@@ -203,8 +206,15 @@ export const app = createApp([getUserRoute], {
203
206
  ### 4. Run
204
207
 
205
208
  ```bash
206
- npx tzin dev # dev server at http://localhost:3000
207
- npx tzin build # production build
209
+ npm run dev # tzin dev -> dev server at http://localhost:3000
210
+ npm run build # tzin build -> production build
211
+ ```
212
+
213
+ The `tzin` binary ships with the package, so these scripts work after `npm install`. Without
214
+ a local install, run it through npm exec:
215
+
216
+ ```bash
217
+ npx --package @carlos-tzin/tzin tzin dev
208
218
  ```
209
219
 
210
220
  ## CLI
package/dist/auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHmac } from 'node:crypto';
1
2
  import { defineContext } from './context.js';
2
3
  // ── Context Key ──────────────────────────────────────────────────────
3
4
  /** Typed key for the authenticated user */
@@ -10,7 +11,6 @@ function base64UrlDecode(data) {
10
11
  return Buffer.from(data, 'base64url').toString();
11
12
  }
12
13
  function hmacSign(data, secret) {
13
- const { createHmac } = require('node:crypto');
14
14
  return createHmac('sha256', secret).update(data).digest('base64url');
15
15
  }
16
16
  /**
package/dist/cli.d.ts CHANGED
@@ -1 +1,2 @@
1
+ #!/usr/bin/env node
1
2
  export {};
package/dist/cli.js CHANGED
@@ -1,8 +1,51 @@
1
+ #!/usr/bin/env node
1
2
  import { spawn } from 'node:child_process';
2
3
  import { fileURLToPath } from 'node:url';
3
- import { resolve } from 'node:path';
4
+ import { resolve, dirname } from 'node:path';
4
5
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
5
6
  const [, , cmd, ...rest] = process.argv;
7
+ // Published package ships dist/*.js, repo runs from src/*.ts — pick the one that exists.
8
+ function siblingModule(name) {
9
+ const js = fileURLToPath(new URL(`./${name}.js`, import.meta.url));
10
+ if (existsSync(js))
11
+ return js;
12
+ const ts = fileURLToPath(new URL(`./${name}.ts`, import.meta.url));
13
+ if (existsSync(ts))
14
+ return ts;
15
+ console.error(`tzin: cannot locate ${name} (looked for ${js} and ${ts})`);
16
+ process.exit(1);
17
+ }
18
+ // npm package that provides each binary (never assume bin name == package name:
19
+ // `npx tsc` without typescript installed runs an unrelated third-party `tsc`).
20
+ const TOOL_PACKAGE = { tsc: 'typescript' };
21
+ const isWin = process.platform === 'win32';
22
+ function localBin(name, cwd) {
23
+ const exe = isWin ? `${name}.cmd` : name;
24
+ let dir = cwd;
25
+ for (;;) {
26
+ const candidate = resolve(dir, 'node_modules', '.bin', exe);
27
+ if (existsSync(candidate))
28
+ return candidate;
29
+ const parent = dirname(dir);
30
+ if (parent === dir)
31
+ return null;
32
+ dir = parent;
33
+ }
34
+ }
35
+ // Prefer the project's own binary; only fall back to npx (pinned to the right
36
+ // package) when the tool is not installed locally.
37
+ function runTool(name, args, cwd) {
38
+ const local = localBin(name, cwd);
39
+ if (local)
40
+ return spawn(local, args, { cwd, stdio: 'inherit' });
41
+ const pkg = TOOL_PACKAGE[name] ?? name;
42
+ console.error(`tzin: ${name} not found in ${cwd} (install it to pin the version)`);
43
+ return spawn('npx', ['--yes', '--package', pkg, name, ...args], {
44
+ cwd,
45
+ stdio: 'inherit',
46
+ shell: isWin,
47
+ });
48
+ }
6
49
  let matched = false;
7
50
  function usage() {
8
51
  console.error(`tzin CLI
@@ -22,6 +65,7 @@ if (!cmd || cmd === '--help' || cmd === '-h')
22
65
  // ── dev ──────────────────────────────────────────────────────────────
23
66
  if (cmd === 'dev') {
24
67
  matched = true;
68
+ const cwd = process.cwd();
25
69
  let entry = '';
26
70
  let port = '3000';
27
71
  const portFlag = rest.indexOf('--port');
@@ -30,15 +74,17 @@ if (cmd === 'dev') {
30
74
  const entryArg = rest.find((a) => !a.startsWith('-'));
31
75
  if (entryArg)
32
76
  entry = entryArg;
33
- const devServer = fileURLToPath(new URL('./dev-server.ts', import.meta.url));
34
- const args = ['tsx', 'watch', '--clear-screen=false', devServer];
77
+ const devServer = siblingModule('dev-server');
78
+ const args = ['watch', '--clear-screen=false', devServer];
35
79
  if (entry)
36
80
  args.push(entry);
37
81
  args.push('--port', port);
38
- const child = spawn('npx', args, { stdio: 'inherit' });
82
+ const child = runTool('tsx', args, cwd);
39
83
  process.on('SIGINT', () => child.kill('SIGINT'));
40
- child.on('exit', (code) => process.exit(code ?? 0));
41
- process.exit(0);
84
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
85
+ // Stay alive until the watcher exits — the parent must not return the
86
+ // prompt (or npm's exit code) while the dev server is still running.
87
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
42
88
  }
43
89
  // ── build ─────────────────────────────────────────────────────────────
44
90
  if (cmd === 'build') {
@@ -55,17 +101,15 @@ if (cmd === 'build') {
55
101
  process.exit(1);
56
102
  }
57
103
  console.log('Building...');
58
- const child = spawn('npx', ['tsc', '-p', 'tsconfig.json'], {
59
- cwd,
60
- stdio: 'inherit',
61
- });
104
+ const child = runTool('tsc', ['-p', 'tsconfig.json'], cwd);
62
105
  child.on('exit', (code) => {
63
106
  if (code === 0) {
64
107
  console.log('\n✓ Build complete → dist/');
65
108
  }
66
109
  process.exit(code ?? 1);
67
110
  });
68
- process.exit(0);
111
+ // NOTE: no trailing process.exit(0) — it would return success to the
112
+ // caller before tsc finishes (and mask type errors).
69
113
  }
70
114
  // ── deploy ────────────────────────────────────────────────────────────
71
115
  if (cmd === 'deploy') {
@@ -86,105 +130,102 @@ if (cmd === 'deploy') {
86
130
  console.error('No wrangler.toml found. Run: npx wrangler init');
87
131
  process.exit(1);
88
132
  }
89
- const child = spawn('npx', ['wrangler', 'deploy'], {
90
- cwd,
91
- stdio: 'inherit',
92
- });
133
+ const child = runTool('wrangler', ['deploy'], cwd);
93
134
  child.on('exit', (code) => process.exit(code ?? 1));
94
- process.exit(0);
95
135
  }
96
- // Default: Node
97
- if (target !== 'node') {
136
+ else if (target === 'node') {
137
+ nodeDeploy();
138
+ }
139
+ else {
98
140
  console.error(`Unknown deploy target: ${target}`);
99
141
  console.error('Available: node, workers');
100
142
  process.exit(1);
101
143
  }
102
- // Check for tsconfig
103
- if (!existsSync(resolve(cwd, 'tsconfig.json'))) {
104
- console.error('No tsconfig.json found');
105
- process.exit(1);
106
- }
107
- console.log('Building for production...');
108
- const build = spawn('npx', ['tsc', '-p', 'tsconfig.json'], {
109
- cwd,
110
- stdio: 'inherit',
111
- });
112
- build.on('exit', (code) => {
113
- if (code !== 0)
114
- process.exit(code ?? 1);
115
- console.log('\n✓ Build complete');
116
- afterBuild();
117
- });
118
- // NOTE: no trailing process.exit(0) here — the parent must stay alive
119
- // until the build child exits and afterBuild() runs. Calling
120
- // process.exit() here would kill the process (and the child listener)
121
- // before the build finishes.
122
- function afterBuild() {
123
- const pkgPath = resolve(cwd, 'package.json');
124
- let name = 'app';
125
- let version = '0.0.0';
126
- try {
127
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
128
- name = String(pkg.name ?? 'app').replace(/^@/, '').replace(/\//g, '-');
129
- version = String(pkg.version ?? '0.0.0');
130
- }
131
- catch {
132
- console.error('No package.json found — is this a tzin project?');
144
+ function nodeDeploy() {
145
+ // Check for tsconfig
146
+ if (!existsSync(resolve(cwd, 'tsconfig.json'))) {
147
+ console.error('No tsconfig.json found');
133
148
  process.exit(1);
134
149
  }
135
- // --pack: runnable tarball (dist + manifest + container files when present).
136
- if (pack) {
137
- const out = resolve(cwd, `${name}-${version}.tgz`);
138
- const files = ['dist', 'package.json'];
139
- for (const f of ['Dockerfile', 'docker-compose.yml', '.dockerignore']) {
140
- if (existsSync(resolve(cwd, f)))
141
- files.push(f);
150
+ console.log('Building for production...');
151
+ const build = runTool('tsc', ['-p', 'tsconfig.json'], cwd);
152
+ build.on('exit', (code) => {
153
+ if (code !== 0)
154
+ process.exit(code ?? 1);
155
+ console.log('\n✓ Build complete');
156
+ afterBuild();
157
+ });
158
+ // NOTE: no trailing process.exit(0) here — the parent must stay alive
159
+ // until the build child exits and afterBuild() runs. Calling
160
+ // process.exit() here would kill the process (and the child listener)
161
+ // before the build finishes.
162
+ function afterBuild() {
163
+ const pkgPath = resolve(cwd, 'package.json');
164
+ let name = 'app';
165
+ let version = '0.0.0';
166
+ try {
167
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
168
+ name = String(pkg.name ?? 'app').replace(/^@/, '').replace(/\//g, '-');
169
+ version = String(pkg.version ?? '0.0.0');
142
170
  }
143
- console.log(`Packing ${files.join(', ')} → ${out}`);
144
- const tar = spawn('tar', ['-czf', out, ...files], { cwd, stdio: 'inherit' });
145
- tar.on('exit', (tarCode) => {
146
- if (tarCode !== 0)
147
- process.exit(tarCode ?? 1);
148
- console.log(`\n✓ Packed ${out}`);
149
- console.log(`Unpack + run: tar -xzf ${name}-${version}.tgz && node dist/index.js`);
150
- if (dockerTag)
151
- dockerBuild();
152
- else
153
- process.exit(0);
154
- });
155
- return;
156
- }
157
- if (dockerTag) {
158
- dockerBuild();
159
- return;
160
- }
161
- console.log('Run: node dist/index.js');
162
- console.log('Tip: tzin deploy --target node --pack [--docker <tag> [--push]] for a runnable artifact');
163
- process.exit(0);
164
- }
165
- function dockerBuild() {
166
- if (!dockerTag)
167
- return;
168
- if (!existsSync(resolve(cwd, 'Dockerfile'))) {
169
- console.error('No Dockerfile found. Copy the reference Dockerfile from the tzin repo (see docs/deployment.md).');
170
- process.exit(1);
171
+ catch {
172
+ console.error('No package.json found — is this a tzin project?');
173
+ process.exit(1);
174
+ }
175
+ // --pack: runnable tarball (dist + manifest + container files when present).
176
+ if (pack) {
177
+ const out = resolve(cwd, `${name}-${version}.tgz`);
178
+ const files = ['dist', 'package.json'];
179
+ for (const f of ['Dockerfile', 'docker-compose.yml', '.dockerignore']) {
180
+ if (existsSync(resolve(cwd, f)))
181
+ files.push(f);
182
+ }
183
+ console.log(`Packing ${files.join(', ')} → ${out}`);
184
+ const tar = spawn('tar', ['-czf', out, ...files], { cwd, stdio: 'inherit' });
185
+ tar.on('exit', (tarCode) => {
186
+ if (tarCode !== 0)
187
+ process.exit(tarCode ?? 1);
188
+ console.log(`\n✓ Packed ${out}`);
189
+ console.log(`Unpack + run: tar -xzf ${name}-${version}.tgz && node dist/index.js`);
190
+ if (dockerTag)
191
+ dockerBuild();
192
+ else
193
+ process.exit(0);
194
+ });
195
+ return;
196
+ }
197
+ if (dockerTag) {
198
+ dockerBuild();
199
+ return;
200
+ }
201
+ console.log('Run: node dist/index.js');
202
+ console.log('Tip: tzin deploy --target node --pack [--docker <tag> [--push]] for a runnable artifact');
203
+ process.exit(0);
171
204
  }
172
- console.log(`Building Docker image ${dockerTag}...`);
173
- const args = ['build', '-t', dockerTag, '.'];
174
- const child = spawn('docker', args, { cwd, stdio: 'inherit' });
175
- child.on('exit', (buildCode) => {
176
- if (buildCode !== 0)
177
- process.exit(buildCode ?? 1);
178
- console.log(`\n✓ Image built: ${dockerTag}`);
179
- if (!push) {
180
- console.log(`Run: docker run -p 3000:3000 --env PORT=3000 ${dockerTag}`);
181
- process.exit(0);
205
+ function dockerBuild() {
206
+ if (!dockerTag)
182
207
  return;
208
+ if (!existsSync(resolve(cwd, 'Dockerfile'))) {
209
+ console.error('No Dockerfile found. Copy the reference Dockerfile from the tzin repo (see docs/deployment.md).');
210
+ process.exit(1);
183
211
  }
184
- console.log(`Pushing ${dockerTag}...`);
185
- const pushChild = spawn('docker', ['push', dockerTag], { cwd, stdio: 'inherit' });
186
- pushChild.on('exit', (pushCode) => process.exit(pushCode ?? 1));
187
- });
212
+ console.log(`Building Docker image ${dockerTag}...`);
213
+ const args = ['build', '-t', dockerTag, '.'];
214
+ const child = spawn('docker', args, { cwd, stdio: 'inherit' });
215
+ child.on('exit', (buildCode) => {
216
+ if (buildCode !== 0)
217
+ process.exit(buildCode ?? 1);
218
+ console.log(`\n✓ Image built: ${dockerTag}`);
219
+ if (!push) {
220
+ console.log(`Run: docker run -p 3000:3000 --env PORT=3000 ${dockerTag}`);
221
+ process.exit(0);
222
+ return;
223
+ }
224
+ console.log(`Pushing ${dockerTag}...`);
225
+ const pushChild = spawn('docker', ['push', dockerTag], { cwd, stdio: 'inherit' });
226
+ pushChild.on('exit', (pushCode) => process.exit(pushCode ?? 1));
227
+ });
228
+ }
188
229
  }
189
230
  }
190
231
  // ── generate ─────────────────────────────────────────────────────────
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
3
4
  const CONFIG_FILES = [
4
5
  'tzin.config.ts',
5
6
  'tzin.config.js',
@@ -16,8 +17,9 @@ export function loadConfig(cwd = process.cwd()) {
16
17
  // Return null for now - will be handled by tsx loader
17
18
  return null;
18
19
  }
19
- // For .js/.mjs, import dynamically
20
- const config = require(path);
20
+ // createRequire, not a bare require(): the package is ESM, where a
21
+ // bare require() is undefined and would silently drop the config.
22
+ const config = createRequire(import.meta.url)(path);
21
23
  return config.default || config;
22
24
  }
23
25
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlos-tzin/tzin",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Contract-first TypeScript framework. Types that scale, realtime channels with presence, and an MCP server for every API.",
5
5
  "license": "MIT",
6
6
  "author": "The tzin authors",
@@ -26,6 +26,9 @@
26
26
  "type": "module",
27
27
  "main": "./dist/index.js",
28
28
  "types": "./dist/index.d.ts",
29
+ "bin": {
30
+ "tzin": "./dist/cli.js"
31
+ },
29
32
  "exports": {
30
33
  ".": {
31
34
  "types": "./dist/index.d.ts",