diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e284c7cd4..e5ba73227 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,10 @@ jobs: filters: | kilocode_backend: - 'apps/web/src/**' + - 'apps/web/.env' + - 'apps/web/.env.test' + - 'apps/web/.env.development.local.example' + - '.env.local.example' - 'apps/web/package.json' - 'apps/web/tsconfig.json' - 'apps/web/tsconfig.*.json' diff --git a/.github/workflows/setup-smoke.yml b/.github/workflows/setup-smoke.yml index 2f46dd0d0..b7544e8f5 100644 --- a/.github/workflows/setup-smoke.yml +++ b/.github/workflows/setup-smoke.yml @@ -4,6 +4,16 @@ on: schedule: - cron: '17 * * * *' workflow_dispatch: + pull_request: + branches: [main] + paths: + - '.env.local.example' + - 'apps/web/.env' + - 'apps/web/.env.development.local.example' + - 'dev/local/setup-env.ts' + - 'dev/local/env-sync/**' + - 'scripts/dev.sh' + - '.github/workflows/setup-smoke.yml' permissions: contents: read @@ -147,7 +157,7 @@ jobs: retention-days: 7 - name: Notify setup smoke failure - if: failure() + if: failure() && github.event_name != 'pull_request' env: GH_TOKEN: ${{ github.token }} ISSUE_NUMBER: '3791' diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9dff2cebc..8b7ccf516 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -150,6 +150,28 @@ The setup covers: `NEXTAUTH_SECRET`, `NEXTAUTH_URL`, `POSTGRES_URL`, `CALLBACK_T These changes will allow you to do local testing with a fake account. +#### c. Add or rotate shared web environment variables + +Use the repository workflow instead of editing Vercel projects independently. It updates `kilocode-app` and `kilocode-global-app` together for Development, Staging, and Production: + +```bash +pnpm web:env set EXAMPLE_API_TOKEN +``` + +Prerequisites: + +- Sign in with `vercel login` and have access to both projects in the `kilocode` scope. +- Install the 1Password CLI and have write access to the `Kilo Web ENV Production` vault. If needed, the CLI prompts you to sign in with Touch ID. +- Have `pnpm` available; the command runs the pinned Vercel CLI with `pnpm dlx`. + +The command asks whether the variable is sensitive, defaulting to yes. Sensitive Production and Staging values use Vercel's sensitive type, while Development remains encrypted but exportable through `vercel env pull`. The Production value is also stored as a concealed, exact-name item in `Kilo Web ENV Production`; its notes identify the local user and computer that last updated it. + +Answer no for public or otherwise non-secret configuration. `NEXT_PUBLIC_*` variables must be non-sensitive because Next.js exposes them to browsers. Non-sensitive values are not copied to 1Password. + +The command prompts for single-line values without echoing them, then asks for a default value for each tracked root and `apps/web` dotenv file. Enter a value directly, or press Return to skip that file. If every file is skipped, the command warns that the application must work without the variable so external contributors can still run it. A tracked default cannot match a remote value; use a non-secret local default instead. Invalid yes/no answers and empty remote values are prompted again instead of terminating the command. For multiline values, use `--development-file`, `--staging-file`, and `--production-file`. Use `--dry-run` to preview the redacted plan. + +Remote updates are sequential rather than transactional. If a provider fails partway through, fix the problem and rerun the same command; it safely upserts every target. The workflow does not deploy, so trigger the appropriate deployment separately. + ### 4. Start the database The project uses PostgreSQL 18 with pgvector, running via Docker. The compose file is at `dev/docker-compose.yml`: diff --git a/package.json b/package.json index 0f5f615dc..a4755d1d2 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,12 @@ "dev:setup-env": "tsx dev/local/setup-env.ts", "dev:seed": "tsx dev/seed/index.ts", "dev:discord-gateway-cron": "tsx dev/discord-gateway-cron.ts", - "dev:kiloclaw-fly-instances": "tsx services/kiloclaw/scripts/dev-fly-instances.ts" + "dev:kiloclaw-fly-instances": "tsx services/kiloclaw/scripts/dev-fly-instances.ts", + "web:env": "tsx scripts/web-env/index.ts" }, "packageManager": "pnpm@11.1.2", "devDependencies": { + "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "husky": "9.1.7", "ink": "6.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b3aecd4d..c4421b024 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: .: devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.12.4 '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260514.1 @@ -22563,7 +22566,7 @@ snapshots: '@slack/logger': 4.0.1 '@slack/web-api': 7.15.0 '@types/jsonwebtoken': 9.0.10 - '@types/node': 25.5.2 + '@types/node': 24.12.4 jsonwebtoken: 9.0.3 transitivePeerDependencies: - debug @@ -22609,7 +22612,7 @@ snapshots: dependencies: '@slack/logger': 4.0.1 '@slack/types': 2.21.1 - '@types/node': 25.5.2 + '@types/node': 24.12.4 '@types/retry': 0.12.0 axios: 1.16.1 eventemitter3: 5.0.4 @@ -23811,7 +23814,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 25.5.2 + '@types/node': 24.12.4 '@types/jsrsasign@10.5.15': {} diff --git a/scripts/lint-all.sh b/scripts/lint-all.sh index 88c85a31d..a2b65908f 100755 --- a/scripts/lint-all.sh +++ b/scripts/lint-all.sh @@ -6,7 +6,7 @@ set -euo pipefail PATH="node_modules/.bin:$PATH" -lint_dirs=(apps/web/src) +lint_dirs=(apps/web/src scripts/web-env) mobile_lint_dirs=() # Resolve workspace directories using pnpm (handles glob expansion) diff --git a/scripts/typecheck-all.sh b/scripts/typecheck-all.sh index f9d8da334..412881e6a 100755 --- a/scripts/typecheck-all.sh +++ b/scripts/typecheck-all.sh @@ -42,8 +42,9 @@ else pnpm --filter @kilocode/trpc run build fi -# 2. Root typecheck (always — it's fast with incremental tsgo) +# 2. Root typechecks (always — they are fast with incremental tsgo) tsgo --noEmit -p apps/web/tsconfig.json +tsgo --noEmit -p scripts/web-env/tsconfig.json # 3. Workspace typecheck if ! $changes_only; then diff --git a/scripts/web-env/index.ts b/scripts/web-env/index.ts new file mode 100644 index 000000000..ab7c31078 --- /dev/null +++ b/scripts/web-env/index.ts @@ -0,0 +1,220 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + ENVIRONMENTS, + PROJECTS, + confirm, + findRepoRoot, + question, + readSecret, + resolveVault, + resolveVercelContexts, + setEnvDefault, + setVariable, + setVaultValue, + trackedEnvFiles, + type Environment, + type Values, +} from './shared.js'; + +type Options = { + name: string; + dryRun: boolean; + valueFiles: Partial>; +}; + +function usage(): never { + throw new Error( + [ + 'Usage: pnpm web:env set VARIABLE [--dry-run]', + ' [--development-file PATH] [--staging-file PATH] [--production-file PATH]', + ].join('\n') + ); +} + +function parseOptions(args: string[]): Options { + if (args[0] !== 'set' || !args[1]) usage(); + const name = args[1]; + const valueFiles: Partial> = {}; + let dryRun = false; + + for (let index = 2; index < args.length; index += 1) { + const argument = args[index]; + if (argument === '--dry-run') dryRun = true; + else { + const match = argument?.match(/^--(development|staging|production)-file(?:=(.*))?$/); + if (!match) usage(); + const environment = match[1] as Environment; + const nextArgument = args[index + 1]; + const file = match[2] || nextArgument; + if (!file) usage(); + if (!match[2]) index += 1; + valueFiles[environment] = file; + } + } + + if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) { + throw new Error('Variable names must contain only uppercase letters, digits, and underscores.'); + } + return { name, dryRun, valueFiles }; +} + +async function askSensitivity(name: string): Promise { + while (true) { + const answer = (await question(`Is ${name} sensitive? [Y/n] `)).trim().toLowerCase(); + if (!['', 'y', 'yes', 'n', 'no'].includes(answer)) { + console.warn('Please answer yes or no.'); + continue; + } + const sensitive = !['n', 'no'].includes(answer); + if (sensitive && name.startsWith('NEXT_PUBLIC_')) { + console.warn('NEXT_PUBLIC_* values are browser-visible; answer no.'); + continue; + } + return sensitive; + } +} + +function normalizeFileValue(value: string): string { + const trailingNewlineLength = value.endsWith('\r\n') ? 2 : value.endsWith('\n') ? 1 : 0; + if (trailingNewlineLength === 0) return value; + const valueWithoutTrailingNewline = value.slice(0, -trailingNewlineLength); + return /[\r\n]/.test(valueWithoutTrailingNewline) ? value : valueWithoutTrailingNewline; +} + +async function collectValues(options: Options): Promise { + const values: Partial = {}; + for (const environment of ENVIRONMENTS) { + const file = options.valueFiles[environment]; + if (file) { + const value = normalizeFileValue(readFileSync(path.resolve(file), 'utf8')); + if (!value) throw new Error(`${environment} value file cannot be empty.`); + values[environment] = value; + continue; + } + + while (!values[environment]) { + const value = await readSecret(`${environment} value: `); + if (value) values[environment] = value; + else console.warn(`${environment} value cannot be empty. Please try again.`); + } + } + return values as Values; +} + +async function collectDefaults(repoRoot: string, name: string): Promise> { + const defaults = new Map(); + for (const relativeFile of trackedEnvFiles(repoRoot)) { + const value = await question( + `${relativeFile}: default value for ${name} (press Return to skip): ` + ); + if (!value) continue; + defaults.set(relativeFile, value); + } + return defaults; +} + +function warnAboutMissingTrackedDefault(name: string): void { + const border = '='.repeat(78); + console.warn(` +\x1b[1;33m${border} +NO TRACKED ENV DEFAULT WILL BE ADDED + +Make sure the application can start and run without ${name}. If the code requires +this variable, external contributors without access to shared secrets will run +into setup, test, or build failures. +${border}\x1b[0m +`); +} + +function assignmentValue(content: string, name: string): string | undefined { + const assignment = content.split('\n').find(line => line.startsWith(`${name}=`)); + if (!assignment) return undefined; + const value = assignment.slice(name.length + 1); + try { + const parsed: unknown = JSON.parse(value); + return typeof parsed === 'string' ? parsed : value; + } catch { + return value; + } +} + +function rejectMatchingTrackedValues( + repoRoot: string, + name: string, + values: Values, + defaults: Map +): void { + for (const relativeFile of trackedEnvFiles(repoRoot)) { + const content = readFileSync(path.join(repoRoot, relativeFile), 'utf8'); + const trackedValue = defaults.get(relativeFile) ?? assignmentValue(content, name); + const matchesRemoteValue = Object.values(values).some(value => trackedValue === value); + if (matchesRemoteValue) { + throw new Error( + `${relativeFile} contains or would contain a remote environment value. Use a non-secret local default instead.` + ); + } + } +} + +async function main(): Promise { + const options = parseOptions(process.argv.slice(2)); + const sensitive = await askSensitivity(options.name); + const repoRoot = findRepoRoot(); + const tempDirectory = mkdtempSync(path.join(os.tmpdir(), 'kilo-web-env-')); + + try { + console.log('Checking Vercel and 1Password access...'); + const contexts = resolveVercelContexts(tempDirectory); + const vaultId = sensitive ? resolveVault() : undefined; + const values = await collectValues(options); + const defaults = await collectDefaults(repoRoot, options.name); + if (defaults.size === 0) warnAboutMissingTrackedDefault(options.name); + rejectMatchingTrackedValues(repoRoot, options.name, values, defaults); + + console.log('\nPlan'); + for (const environment of ENVIRONMENTS) { + const type = sensitive && environment !== 'development' ? 'sensitive' : 'encrypted'; + for (const project of PROJECTS) console.log(`- ${project}/${environment}: ${type}`); + } + for (const [file, value] of defaults) + console.log(`- ${file}: ${options.name}=${JSON.stringify(value)}`); + console.log(`- 1Password: ${sensitive ? 'update Production copy' : 'skip'}`); + console.log('- Deployments: not triggered'); + + if (options.dryRun) { + console.log('\nDry run complete; nothing changed.'); + return; + } + if (!(await confirm('\nApply these changes?'))) { + console.log('Cancelled; nothing changed.'); + return; + } + + for (const [relativeFile, value] of defaults) { + setEnvDefault(path.join(repoRoot, relativeFile), options.name, value); + } + + for (const environment of ENVIRONMENTS) { + for (const context of contexts) { + console.log(`Setting ${context.project}/${environment}...`); + setVariable(context, environment, options.name, values[environment], sensitive); + } + } + if (vaultId) { + console.log('Updating 1Password Production copy...'); + setVaultValue(vaultId, options.name, values.production); + } + + console.log('\nDone. Rerun the same command if a provider failed partway through.'); + console.log('Deploy Staging or Production separately when the new value should take effect.'); + } finally { + rmSync(tempDirectory, { recursive: true, force: true }); + } +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : 'Environment update failed.'); + process.exitCode = 1; +}); diff --git a/scripts/web-env/shared.ts b/scripts/web-env/shared.ts new file mode 100644 index 000000000..9c7e27746 --- /dev/null +++ b/scripts/web-env/shared.ts @@ -0,0 +1,342 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import * as readline from 'node:readline'; + +export const PROJECTS = ['kilocode-app', 'kilocode-global-app'] as const; +export const ENVIRONMENTS = ['development', 'staging', 'production'] as const; +export const VAULT = 'Kilo Web ENV Production'; + +export type Project = (typeof PROJECTS)[number]; +export type Environment = (typeof ENVIRONMENTS)[number]; +export type Values = Record; +export type VercelContext = { + project: Project; + orgId: string; + cwd: string; +}; + +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseJson(value: string, operation: string): JsonRecord { + try { + const parsed: unknown = JSON.parse(value); + if (isRecord(parsed)) return parsed; + } catch { + // The provider output is intentionally omitted because it may contain secrets. + } + throw new Error(`${operation} returned an unexpected response.`); +} + +function records(value: unknown): JsonRecord[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function stringValue(record: JsonRecord, key: string): string | undefined { + return typeof record[key] === 'string' ? record[key] : undefined; +} + +export function run( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv; input?: string } = {} +): string { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env, + input: options.input, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + }); + if (result.status !== 0) { + const operation = `${command} ${args.slice(0, 3).join(' ')}`; + if (command === 'op') { + const output = [result.stderr, result.stdout, result.error?.message] + .filter(Boolean) + .join('\n') + .trim(); + throw new Error(`${operation} failed${output ? `:\n${output}` : '.'}`); + } + throw new Error(`${operation} failed; provider output was redacted.`); + } + return result.stdout; +} + +function vercel(context: VercelContext | undefined, args: string[], input?: string): string { + return run( + 'pnpm', + [ + 'dlx', + 'vercel@53.3.1', + ...args, + '--scope', + 'kilocode', + '--non-interactive', + '--no-color', + ...(context ? ['--cwd', context.cwd] : []), + ], + { + cwd: context?.cwd, + env: context + ? { + ...process.env, + VERCEL_ORG_ID: context.orgId, + VERCEL_PROJECT_ID: context.project, + } + : process.env, + input, + } + ); +} + +export function resolveVercelContexts(tempDirectory: string): VercelContext[] { + const whoami = parseJson(vercel(undefined, ['whoami', '--format=json']), 'Vercel login'); + const team = isRecord(whoami.team) ? whoami.team : undefined; + const orgId = team ? stringValue(team, 'id') : undefined; + if (!orgId || stringValue(team ?? {}, 'slug') !== 'kilocode') { + throw new Error('Sign in to the kilocode Vercel team with `vercel login`.'); + } + + return PROJECTS.map(project => ({ project, orgId, cwd: tempDirectory })); +} + +export function setVariable( + context: VercelContext, + environment: Environment, + name: string, + value: string, + sensitive: boolean +): void { + const shouldBeSensitive = sensitive && environment !== 'development'; + vercel( + context, + [ + 'env', + 'add', + name, + environment, + '--force', + shouldBeSensitive ? '--sensitive' : '--no-sensitive', + '--yes', + ], + value + ); +} + +export function resolveVault(): string { + const vault = parseJson(run('op', ['vault', 'get', VAULT, '--format=json']), 'Resolve vault'); + const vaultId = stringValue(vault, 'id'); + if (!vaultId) throw new Error(`Could not resolve 1Password vault ${VAULT}.`); + return vaultId; +} + +function findVaultItem(vaultId: string, name: string): JsonRecord | undefined { + const items = JSON.parse( + run('op', ['item', 'list', '--vault', vaultId, '--format=json']) + ) as unknown; + const matches = records(items).filter(item => item.title === name); + if (matches.length > 1) throw new Error(`More than one 1Password item is named ${name}.`); + return matches[0]; +} + +const AUDIT_NOTE_PREFIX = 'Managed by pnpm web:env. Last updated by '; + +function auditNote(): string { + return `${AUDIT_NOTE_PREFIX}${os.userInfo().username} on ${os.hostname()} at ${new Date().toISOString()}.`; +} + +function setAuditNote(item: JsonRecord, note: string): void { + const fields = item.fields; + if (!Array.isArray(fields)) throw new Error('1Password item does not have editable fields.'); + const notes = records(fields).find(field => field.id === 'notesPlain'); + if (!notes) { + fields.push({ + id: 'notesPlain', + label: 'notesPlain', + type: 'STRING', + purpose: 'NOTES', + value: note, + }); + return; + } + const existing = stringValue(notes, 'value') ?? ''; + const preserved = existing + .split('\n') + .filter(line => !line.startsWith(AUDIT_NOTE_PREFIX)) + .join('\n') + .trimEnd(); + notes.value = preserved ? `${preserved}\n${note}` : note; +} + +export function setVaultValue(vaultId: string, name: string, value: string): void { + const note = auditNote(); + const existing = findVaultItem(vaultId, name); + if (!existing) { + const item = { + title: name, + category: 'PASSWORD', + fields: [ + { + id: 'password', + label: 'password', + type: 'CONCEALED', + purpose: 'PASSWORD', + value, + }, + { + id: 'notesPlain', + label: 'notesPlain', + type: 'STRING', + purpose: 'NOTES', + value: note, + }, + ], + sections: [], + }; + const created = parseJson( + run('op', ['item', 'create', '-', '--vault', vaultId, '--format=json'], { + input: JSON.stringify(item), + }), + `Create ${name}` + ); + const createdPassword = records(created.fields).find(field => field.id === 'password'); + const createdNotes = records(created.fields).find(field => field.id === 'notesPlain'); + if (createdPassword?.value !== value || createdNotes?.value !== note) { + throw new Error(`1Password did not persist the new ${name} value and audit note.`); + } + return; + } + + const id = stringValue(existing, 'id'); + if (!id) throw new Error(`1Password item ${name} has no ID.`); + const item = parseJson( + run('op', ['item', 'get', id, '--vault', vaultId, '--format=json']), + `Read ${name}` + ); + const password = records(item.fields).find(field => field.id === 'password'); + if (!password || password.type !== 'CONCEALED') { + throw new Error(`1Password item ${name} does not have a concealed password field.`); + } + password.value = value; + setAuditNote(item, note); + const expectedNotes = stringValue( + records(item.fields).find(field => field.id === 'notesPlain') ?? {}, + 'value' + ); + const updated = parseJson( + run('op', ['item', 'edit', id, '--vault', vaultId, '--format=json'], { + input: JSON.stringify(item), + }), + `Update ${name}` + ); + const updatedPassword = records(updated.fields).find(field => field.id === 'password'); + const updatedNotes = records(updated.fields).find(field => field.id === 'notesPlain'); + if (updatedPassword?.value !== value || updatedNotes?.value !== expectedNotes) { + throw new Error(`1Password did not persist the updated ${name} value and audit note.`); + } +} + +export function findRepoRoot(): string { + let directory = process.cwd(); + while (path.dirname(directory) !== directory) { + const packageFile = path.join(directory, 'package.json'); + if (existsSync(packageFile)) { + const packageJson = JSON.parse(readFileSync(packageFile, 'utf8')) as { name?: string }; + if (packageJson.name === 'kilocode-monorepo') return directory; + } + directory = path.dirname(directory); + } + throw new Error('Run this command inside the kilocode-monorepo checkout.'); +} + +export function trackedEnvFiles(repoRoot: string): string[] { + return run('git', ['ls-files', '-z', '--', '.env*', 'apps/web/.env*'], { cwd: repoRoot }) + .split('\0') + .filter(file => { + if (!file) return false; + const inScope = !file.includes('/') || file.startsWith('apps/web/'); + const basename = path.basename(file); + return ( + inScope && + basename.startsWith('.env') && + basename !== '.envrc' && + (!basename.includes('.local') || basename.includes('.example')) + ); + }); +} + +export function setEnvDefault(file: string, name: string, value: string): void { + const content = readFileSync(file, 'utf8'); + const lines = content.split('\n'); + const matches = lines.flatMap((line, index) => + new RegExp(`^${name}=`).test(line) ? [index] : [] + ); + if (matches.length > 1) throw new Error(`${file} declares ${name} more than once.`); + const assignment = `${name}=${JSON.stringify(value)}`; + if (matches.length === 1) lines[matches[0] ?? 0] = assignment; + else lines.push(assignment); + writeFileSync(file, lines.join('\n')); +} + +export function question(prompt: string): Promise { + const interface_ = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + interface_.question(prompt, answer => { + interface_.close(); + resolve(answer); + }); + }); +} + +export async function confirm(prompt: string): Promise { + while (true) { + const answer = (await question(`${prompt} [y/N] `)).trim().toLowerCase(); + if (!answer || answer === 'n' || answer === 'no') return false; + if (answer === 'y' || answer === 'yes') return true; + console.warn('Please answer yes or no.'); + } +} + +export function readSecret(prompt: string): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error( + 'Secret prompts require an interactive terminal; use the --*-file options instead.' + ); + } + process.stdout.write(prompt); + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding('utf8'); + return new Promise((resolve, reject) => { + let value = ''; + const finish = () => { + process.stdin.off('data', onData); + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write('\n'); + }; + const onData = (chunk: string) => { + for (const character of chunk) { + if (character === '\u0003') { + finish(); + reject(new Error('Cancelled.')); + return; + } + if (character === '\r' || character === '\n') { + finish(); + resolve(value); + return; + } + if (character === '\u007f') value = value.slice(0, -1); + else value += character; + } + }; + process.stdin.on('data', onData); + }); +} diff --git a/scripts/web-env/tsconfig.json b/scripts/web-env/tsconfig.json new file mode 100644 index 000000000..732a7df9c --- /dev/null +++ b/scripts/web-env/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["*.ts"] +}