+157
-102
@@ -1,9 +0,20 @@ | ||
| #!/usr/bin/env node | ||
| // tekir CLI bin. Runs under both Node and Bun: `serve` and any | ||
| // provider-registered command import the user's entry in-process via | ||
| // `await import(pathToFileURL(...))`, so they work on whichever runtime | ||
| // is invoking the bin. `build` requires `Bun.build`; on Node we re-exec | ||
| // the same script under `bun` (or print an actionable error if Bun is | ||
| // not on PATH). | ||
| #!/usr/bin/env bun | ||
| // tekir CLI bin. Shipped with a Bun shebang so the bin shim | ||
| // (npm/bun-generated, including Bun's `tekir.bunx` hint on Windows) | ||
| // routes through the Bun runtime by default. Bun is required because | ||
| // `tekir build` runs `Bun.build` directly, and because Bun is | ||
| // position-strict on its own `--env-file` flag — `--env-file=...` | ||
| // tokens after the script path pass through to argv where this bin | ||
| // can filter them, while Node's runtime greedily parses the same flag | ||
| // everywhere in argv and hard-errors on a missing file before any user | ||
| // code runs. | ||
| // | ||
| // The bin file itself is plain Node-compatible ESM, so a Node-only user | ||
| // without Bun on PATH can still invoke it directly: | ||
| // `node node_modules/@tekir/cli/bin/tekir.mjs --envfile=path serve` | ||
| // On that path the `--envfile` (no hyphen) alias bypasses Node's CLI | ||
| // parser; `--env-file` (hyphenated) is intercepted by Node's own | ||
| // runtime flag and is therefore Bun-only in practice. `tekir build` | ||
| // always requires Bun no matter how the bin is invoked. | ||
| // | ||
| // Entry resolution order (single rule, applies to every command): | ||
@@ -19,10 +30,21 @@ // 1. `--entry <path>` flag, anywhere in argv. | ||
| // | ||
| // `--env-file <path>` (multi) is forwarded to the underlying runtime as | ||
| // its native `--env-file` flag, not parsed by tekir. The bin filters out | ||
| // missing files (with a warning) before forwarding, so monorepo dev | ||
| // scripts that chain optional per-package `.env` files work even when | ||
| // some of them don't exist yet. For non-watch in-process commands the | ||
| // bin re-execs itself once under the runtime to apply the flags; for | ||
| // `serve --dev` the existing `--watch` spawn already covers the same | ||
| // surface in a single hop. | ||
| // Env files load in two ways, both feeding the same manual dotenv loader: | ||
| // 1. `tekir.envFiles` (string array) in the cwd's `package.json` — | ||
| // recommended for monorepo dev scripts that need many `.env` files | ||
| // because the paths never enter the bin's argv. | ||
| // 2. `--env-file <path>` (multi) on the command line — convenient for | ||
| // one-off invocations. | ||
| // | ||
| // We deliberately do NOT defer to the runtime's native `--env-file` | ||
| // flag. Node 20.6+'s `--env-file` greedily parses the flag everywhere in | ||
| // argv (including after the script path) AND hard-errors on missing | ||
| // files — the combination breaks the moment a single optional `.env` in | ||
| // a monorepo dev script is absent, even when this bin would otherwise | ||
| // filter it out, because Node bails during its own argv parsing phase | ||
| // before the bin even runs. That failure mode is also why the | ||
| // recommended path for many `.env` files is `tekir.envFiles` in | ||
| // `package.json`: paths kept out of argv never trigger Node's parser. | ||
| // Bun is position-strict and silently skips missing files, but a tekir | ||
| // bin running under either runtime needs to behave the same way, so we | ||
| // own the loader. | ||
| import { existsSync, readFileSync } from 'node:fs' | ||
@@ -36,6 +58,14 @@ import { spawn, spawnSync } from 'node:child_process' | ||
| /** | ||
| * Pull `--entry <path>` / `--entry=path` and `--env-file <path>` / | ||
| * `--env-file=<path>` (multi) out of argv. Everything else stays in | ||
| * `rest` in original order so the in-app dispatcher and Bun.build see | ||
| * only their own flags. | ||
| * Pull `--entry`, `--envfile`, and `--env-file` (multi for the env | ||
| * variants) out of argv. Everything else stays in `rest` in original | ||
| * order so the in-app dispatcher and Bun.build see only their own flags. | ||
| * | ||
| * `--envfile` (no hyphen) is the recommended Node-host-safe spelling. | ||
| * Node's CLI parser does not recognize the un-hyphenated form and lets | ||
| * it pass through to the script's argv even when it appears after the | ||
| * script path. The hyphenated `--env-file` is also accepted and routes | ||
| * to the same loader, but on Node hosts Node's own `--env-file` parser | ||
| * intercepts it first and hard-errors on a missing file before the bin | ||
| * even runs — so it works mainly for Bun-direct invocations where the | ||
| * runtime is position-strict and missing-tolerant. | ||
| */ | ||
@@ -66,6 +96,6 @@ function extractTekirFlags(argv) { | ||
| } | ||
| if (tok === '--env-file') { | ||
| if (tok === '--env-file' || tok === '--envfile') { | ||
| const next = argv[i + 1] | ||
| if (next === undefined || next.startsWith('--')) { | ||
| console.error('[tekir] --env-file requires a value.') | ||
| console.error(`[tekir] ${tok} requires a value.`) | ||
| process.exit(1) | ||
@@ -77,6 +107,7 @@ } | ||
| } | ||
| if (tok.startsWith('--env-file=')) { | ||
| const v = tok.slice('--env-file='.length) | ||
| if (tok.startsWith('--env-file=') || tok.startsWith('--envfile=')) { | ||
| const eq = tok.indexOf('=') | ||
| const v = tok.slice(eq + 1) | ||
| if (!v) { | ||
| console.error('[tekir] --env-file requires a value.') | ||
| console.error(`[tekir] ${tok.slice(0, eq)} requires a value.`) | ||
| process.exit(1) | ||
@@ -93,43 +124,67 @@ } | ||
| /** | ||
| * Strip `--env-file` flags from argv when we're about to re-spawn ourselves | ||
| * under the runtime: the runtime applies them as native flags before | ||
| * loading our bin, so the bin must not see them again or it would loop. | ||
| * Minimal `.env` loader. Matches the dotenv conventions Bun and Node use | ||
| * for their own `--env-file` flag: | ||
| * - blank lines and `#` comments skipped | ||
| * - optional `export KEY=value` prefix | ||
| * - single- or double-quoted values are unwrapped | ||
| * - shell-provided env (anything already set when tekir started) wins; | ||
| * later files override earlier ones for the same key | ||
| * | ||
| * Caller passes `shellKeys` (a Set of keys that existed before any file | ||
| * load) so the precedence rule is independent of load order. | ||
| * | ||
| * Missing files are warned and skipped — see the `--env-file` comment at | ||
| * the top of this file for why we never delegate this to the runtime. | ||
| */ | ||
| function stripEnvFileFlags(argv) { | ||
| const out = [] | ||
| for (let i = 0; i < argv.length; i++) { | ||
| const t = argv[i] | ||
| if (t === '--env-file') { i++; continue } | ||
| if (t.startsWith('--env-file=')) continue | ||
| out.push(t) | ||
| function loadEnvFile(path, shellKeys) { | ||
| if (!existsSync(path)) { | ||
| console.warn(`[tekir] --env-file '${path}' not found, skipping`) | ||
| return | ||
| } | ||
| return out | ||
| let content | ||
| try { | ||
| content = readFileSync(path, 'utf8') | ||
| } catch (err) { | ||
| console.warn(`[tekir] Could not read --env-file '${path}': ${err.message}, skipping`) | ||
| return | ||
| } | ||
| for (const rawLine of content.split(/\r?\n/)) { | ||
| let line = rawLine.trim() | ||
| if (!line || line.startsWith('#')) continue | ||
| if (line.startsWith('export ')) line = line.slice('export '.length).trimStart() | ||
| const eq = line.indexOf('=') | ||
| if (eq === -1) continue | ||
| const key = line.slice(0, eq).trim() | ||
| if (!key) continue | ||
| let value = line.slice(eq + 1).trim() | ||
| if (value.length >= 2) { | ||
| const f = value[0] | ||
| const l = value[value.length - 1] | ||
| if ((f === '"' && l === '"') || (f === "'" && l === "'")) { | ||
| value = value.slice(1, -1) | ||
| } | ||
| } | ||
| if (!shellKeys.has(key)) process.env[key] = value | ||
| } | ||
| } | ||
| /** | ||
| * Filter user-supplied env-file paths to those that exist on disk. Both | ||
| * Bun and Node 20.6+'s native `--env-file` flag hard-error on a missing | ||
| * file, which would break monorepos that chain optional per-package | ||
| * `.env` files. Warn for each absent file so typos still surface. | ||
| * Read the `tekir` field of the cwd's `package.json` once. Returns an | ||
| * object with `entry?: string` and `envFiles?: string[]`, or null when | ||
| * no package.json or no `tekir` block. Lookups elsewhere reuse this | ||
| * cached value so we don't re-read the file three times in a row. | ||
| */ | ||
| function existingEnvFiles(paths) { | ||
| const out = [] | ||
| for (const f of paths) { | ||
| if (existsSync(f)) { | ||
| out.push(f) | ||
| } else { | ||
| console.warn(`[tekir] --env-file '${f}' not found, skipping`) | ||
| } | ||
| } | ||
| return out | ||
| function readTekirConfig() { | ||
| try { | ||
| const pkg = JSON.parse(readFileSync('package.json', 'utf8')) | ||
| const cfg = pkg?.tekir | ||
| if (cfg && typeof cfg === 'object') return cfg | ||
| } catch {} | ||
| return null | ||
| } | ||
| async function findEntry(explicit) { | ||
| async function findEntry(explicit, cfg) { | ||
| if (explicit) return explicit | ||
| if (cfg?.entry && typeof cfg.entry === 'string') return cfg.entry | ||
| try { | ||
| const pkg = JSON.parse(readFileSync('package.json', 'utf8')) | ||
| if (pkg?.tekir?.entry && typeof pkg.tekir.entry === 'string') return pkg.tekir.entry | ||
| } catch {} | ||
| for (const candidate of ['index.ts', 'api/index.ts', 'app/index.ts', 'src/index.ts', 'index.js']) { | ||
@@ -160,7 +215,13 @@ if (existsSync(candidate)) return candidate | ||
| console.error('') | ||
| console.error('Environment files: pass `--env-file <path>` (multi) to load `.env`-style files') | ||
| console.error(' via the runtime\'s own `--env-file` flag (Bun, or Node 20.6+). tekir filters') | ||
| console.error(' out missing files with a warning before forwarding, so optional per-package') | ||
| console.error(' `.env` files in a monorepo do not need to exist for the rest to load.') | ||
| console.error('Environment files: declare them in `package.json` (recommended for monorepo') | ||
| console.error(' dev scripts) and/or pass `--env-file <path>` flags. The bin loads them itself,') | ||
| console.error(' so paths in `package.json` never enter argv where Node\'s native `--env-file`') | ||
| console.error(' parser would bail on a missing file:') | ||
| console.error('') | ||
| console.error(' "tekir": { "envFiles": ["a/.env", "b/.env"] }') | ||
| console.error('') | ||
| console.error(' Shell-provided env wins; later files override earlier ones; missing files are') | ||
| console.error(' warned and skipped. `package.json` paths load first, then any `--env-file`') | ||
| console.error(' flags from the command line.') | ||
| console.error('') | ||
| console.error('Build flags: --target, --format esm|cjs|iife, --minify / --no-minify,') | ||
@@ -207,22 +268,16 @@ console.error(' granular --minify-syntax / --minify-whitespace / --minify-identifiers,') | ||
| /** | ||
| * Re-spawn this same bin under `runtime` with native `--env-file` flags | ||
| * applied first. The runtime parses the env-files into its own | ||
| * `process.env` before our bin's second invocation imports the entry, so | ||
| * we never have to ship a hand-rolled dotenv parser. Exits the parent | ||
| * with the child's exit code. | ||
| * `Bun.build` is Bun-only. On Node we re-spawn the same bin under `bun` | ||
| * with the original argv preserved; if Bun is not on PATH we print an | ||
| * actionable error. The child inherits `process.env`, which the parent | ||
| * has already populated from any `--env-file` flags, so env-bearing | ||
| * builds work without passing the flag through. | ||
| */ | ||
| function reexecUnder(runtime, envFilesToForward) { | ||
| const childArgv = [ | ||
| ...envFilesToForward.map(f => `--env-file=${f}`), | ||
| process.argv[1] ?? '', | ||
| ...stripEnvFileFlags(rawArgv), | ||
| ] | ||
| const result = spawnSync(runtime, childArgv, { stdio: 'inherit' }) | ||
| function ensureBunOrReexec() { | ||
| if (isBun) return | ||
| const result = spawnSync('bun', [process.argv[1] ?? '', ...process.argv.slice(2)], { | ||
| stdio: 'inherit', | ||
| }) | ||
| if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === 'ENOENT') { | ||
| if (runtime === 'bun') { | ||
| console.error('[tekir] `bun` is required for this command but is not on PATH.') | ||
| console.error(' Install Bun: https://bun.sh.') | ||
| } else { | ||
| console.error(`[tekir] '${runtime}' is not on PATH.`) | ||
| } | ||
| console.error('[tekir] `tekir build` requires Bun (Bun.build is the bundler).') | ||
| console.error(' Install Bun: https://bun.sh, or run via `bunx @tekir/cli build`.') | ||
| process.exit(1) | ||
@@ -234,11 +289,22 @@ } | ||
| const rawArgv = process.argv.slice(2) | ||
| const { entry: entryFlag, envFiles, rest: argv } = extractTekirFlags(rawArgv) | ||
| const { entry: entryFlag, envFiles: cliEnvFiles, rest: argv } = extractTekirFlags(rawArgv) | ||
| const command = argv[0] | ||
| const presentEnvFiles = existingEnvFiles(envFiles) | ||
| const tekirCfg = readTekirConfig() | ||
| // Preload env files into the parent process before any in-process import | ||
| // or subprocess spawn. `package.json.tekir.envFiles` loads first (so the | ||
| // JSON config sets the baseline), then any `--env-file` flags from the | ||
| // command line override / extend on top — matches the "later wins" | ||
| // dotenv precedence. Shell-provided env always wins both. | ||
| const jsonEnvFiles = Array.isArray(tekirCfg?.envFiles) | ||
| ? tekirCfg.envFiles.filter(x => typeof x === 'string') | ||
| : [] | ||
| const allEnvFiles = [...jsonEnvFiles, ...cliEnvFiles] | ||
| if (allEnvFiles.length > 0) { | ||
| const shellKeys = new Set(Object.keys(process.env)) | ||
| for (const f of allEnvFiles) loadEnvFile(f, shellKeys) | ||
| } | ||
| if (!command) { | ||
| // Bare `tekir`: behaves like `tekir serve`. If env-files were passed, | ||
| // re-exec under the current runtime so it loads them first. | ||
| if (presentEnvFiles.length > 0) reexecUnder(isBun ? 'bun' : 'node', presentEnvFiles) | ||
| const entry = await findEntry(entryFlag) | ||
| const entry = await findEntry(entryFlag, tekirCfg) | ||
| await runEntry(entry, undefined, []) | ||
@@ -250,11 +316,5 @@ } | ||
| if (command === 'build') { | ||
| // `Bun.build` is Bun-only. Re-exec under bun if we're on Node, OR if | ||
| // the user passed `--env-file` flags (so bun loads them as the build | ||
| // process boots and any custom plugin code that reads `process.env` | ||
| // sees them). | ||
| if (!isBun || presentEnvFiles.length > 0) { | ||
| reexecUnder('bun', presentEnvFiles) | ||
| } | ||
| // We are now guaranteed to be running under Bun with env applied. | ||
| const entry = await findEntry(entryFlag) | ||
| ensureBunOrReexec() | ||
| // From here on we are guaranteed to be running under Bun. | ||
| const entry = await findEntry(entryFlag, tekirCfg) | ||
| const flags = argv.slice(1) | ||
@@ -266,3 +326,3 @@ const { runBuild } = await import('@tekir/core') | ||
| const entry = await findEntry(entryFlag) | ||
| const entry = await findEntry(entryFlag, tekirCfg) | ||
| const rest = argv.slice(1) | ||
@@ -274,10 +334,9 @@ | ||
| // has to spawn a subprocess; in-app `tekir()` still receives `serve` as | ||
| // the command and starts the server normally inside it. `--env-file` | ||
| // flags are forwarded to the runtime alongside `--watch` so editing a | ||
| // loaded `.env` re-applies on the next restart. | ||
| // the command and starts the server normally inside it. The child | ||
| // inherits the parent's `process.env` (already populated from any | ||
| // `--env-file` flags), so `--env-file` is not forwarded to the runtime. | ||
| if (command === 'serve' && rest.includes('--dev')) { | ||
| const watchRest = rest.filter(a => a !== '--dev') | ||
| const runner = isBun ? 'bun' : 'node' | ||
| const watchFlags = ['--watch', ...presentEnvFiles.map(f => `--env-file=${f}`)] | ||
| const proc = spawn(runner, [...watchFlags, entry, 'serve', ...watchRest], { | ||
| const proc = spawn(runner, ['--watch', entry, 'serve', ...watchRest], { | ||
| stdio: 'inherit', | ||
@@ -288,7 +347,3 @@ env: { ...process.env, NODE_ENV: process.env.NODE_ENV || 'development' }, | ||
| } else { | ||
| // Any other command imports the entry in-process. If env-files were | ||
| // passed, re-exec under the runtime first so its native `--env-file` | ||
| // flag applies them before our second invocation runs the entry. | ||
| if (presentEnvFiles.length > 0) reexecUnder(isBun ? 'bun' : 'node', presentEnvFiles) | ||
| await runEntry(entry, command, rest) | ||
| } |
+1
-1
| { | ||
| "name": "@tekir/cli", | ||
| "version": "0.1.1", | ||
| "version": "0.1.2", | ||
| "description": "tekir command-line tool: serve, build, generate-key, and provider-registered commands.", | ||
@@ -5,0 +5,0 @@ "author": "dev@tekir.io", |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
17244
20.6%320
20.75%5
66.67%