From 894b99a13b6814d068669a991596a9dfc13ffd54 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Tue, 29 Oct 2024 18:23:56 -0700 Subject: [PATCH] Add package metadata files and get tests running This also tweaks the API so it follows the JS iterator protocol. --- .eslintignore | 3 ++ .eslintrc | 14 ++++++ .github/dependabot.yml | 10 +++++ .github/workflows/ci.yml | 94 ++++++++++++++++++++++++++++++++++++++++ .gitignore | 15 +++++++ .prettierrc.js | 3 ++ CODE_OF_CONDUCT.md | 10 +++++ CONTRIBUTING.md | 38 ++++++++++++++++ LICENSE | 20 +++++++++ README.md | 55 +++++++++++++++++++++++ jest.config.ts | 7 +++ lib/index.test.ts | 62 ++++++++++++++++---------- lib/index.ts | 78 +++++++++++++++------------------ lib/worker.ts | 5 ++- package.json | 42 ++++++++++++++++++ tsconfig.build.json | 7 +++ tsconfig.json | 15 +++++++ 17 files changed, 410 insertions(+), 68 deletions(-) create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .prettierrc.js create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 jest.config.ts create mode 100644 package.json create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..f1073fd --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +build/ +dist/ +**/*.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..3b5b912 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,14 @@ +{ + "extends": "./node_modules/gts/", + "rules": { + "@typescript-eslint/explicit-function-return-type": [ + "error", + {"allowExpressions": true} + ], + "func-style": ["error", "declaration"], + "prefer-const": ["error", {"destructuring": "all"}], + // It would be nice to sort import declaration order as well, but that's not + // autofixable and it's not worth the effort of handling manually. + "sort-imports": ["error", {"ignoreDeclarationSort": true}], + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..782a0ad --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..907cf78 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +defaults: + run: {shell: bash} + +env: + PROTOC_VERSION: 3.x + +on: + push: + branches: [main, feature.*] + tags: ['**'] + pull_request: + +jobs: + static_analysis: + name: Static analysis + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + check-latest: true + - run: npm install + - run: npm run check + + tests: + name: 'Tests | Node ${{ matrix.node-version }} | ${{ matrix.os }}' + runs-on: ${{ matrix.os }}-latest + + strategy: + matrix: + os: [ubuntu, macos, windows] + node-version: ['lts/*', 'lts/-1', 'lts/-2'] + fail-fast: false + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + check-latest: true + - run: npm install + - run: npm run test + + deploy: + name: Deploy + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/sync-process'" + needs: [static_analysis, tests] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + check-latest: true + registry-url: 'https://registry.npmjs.org' + - run: npm install + - run: npm publish + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + + typedoc: + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/sync-process'" + needs: [deploy] + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + permissions: + pages: write + id-token: write + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + check-latest: true + registry-url: 'https://registry.npmjs.org' + - run: npm install + - run: npm run doc + + - name: Upload static files as artifact + uses: actions/upload-pages-artifact@v3 + with: {path: docs} + + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f345cd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.DS_Store +build +dist +node_modules +npm-debug.log* +package-lock.json + +# Editors +.idea +.vscode +*.njsproj +*.ntvs* +*.sln +*.suo +*.sw? diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..c5166c2 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('gts/.prettierrc.json'), +}; diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..dfc4c84 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,10 @@ +Sass is more than a technology; Sass is driven by the community of individuals +that power its development and use every day. As a community, we want to embrace +the very differences that have made our collaboration so powerful, and work +together to provide the best environment for learning, growing, and sharing of +ideas. It is imperative that we keep Sass a fun, welcoming, challenging, and +fair place to play. + +[The full community guidelines can be found on the Sass website.][link] + +[link]: http://sass-lang.com/community-guidelines diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..96c5c3b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +* [Contributor License Agreement](#contributor-license-agreement) +* [Code Reviews](#code-reviews) +* [Large Language Models](#large-language-models) + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement. You (or your employer) retain the copyright to your contribution; +this simply gives us permission to use and redistribute your contributions as +part of the project. Head over to to see +your current agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code Reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. + +## Large Language Models + +Do not submit any code or prose written or modified by large language models or +"artificial intelligence" such as GitHub Copilot or ChatGPT to this project. +These tools produce code that looks plausible, which means that not only is it +likely to contain bugs those bugs are likely to be difficult to notice on +review. In addition, because these models were trained indiscriminately and +non-consensually on open-source code with a variety of licenses, it's not +obvious that we have the moral or legal right to redistribute code they +generate. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63ff5ce --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2024, Google LLC + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a446a5f --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# `sync-process` + +This package exposes a `SyncProcess` class that allows Node.js to run +a subprocess synchronously *and* interactively. + +## Usage + +Use `new SyncProcess()` to start running a subprocess. This supports the same +API as [`child_process.spawn()`] other than a few options. You can send input to +the process using `process.stdin`, and receive events from it (stdout, stderr, +or exit) using `process.next()`. This implements [the iterator protocol], but +*not* the iterable protocol because it's intrinsically stateful. + +[the iterator protocol]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterator_protocol +[`child_process.spawn()`]: https://nodejs.org/api/child_process.html#child_processspawncommand-args-options + +```js +import {SyncProcess} from 'sync-process'; +// or +// const {SyncProcess} = require('sync-process'); + +const node = new SyncProcess('node', ['--interactive']); + +for (;;) { + if (node.next().value.data.toString().endsWith('> ')) break; +} + +node.stdin.write("41 * Math.pow(2, 5)\n"); +console.log((node.next().value.data.toString().split("\n")[0])); +node.stdin.write(".exit\n"); +console.log(`Node exited with exit code ${node.next().value.code}`); +``` + +## Why synchrony? + +See [the `sync-message-port` documentation] for an explanation of why running +code synchronously can be valuable even in an asynchronous ecosystem like +Node.js + +[the `sync-message-port` documentation]: https://github.com/sass/sync-message-port?tab=readme-ov-file#why-synchrony + +### Why not `child_process.spawnSync()`? + +Although Node's built-in [`child_process.spawnSync()`] function does run +synchronously, it's not interactive. It only returns once the process has run to +completion and exited, which means it's not suitable for any long-lived +subprocess that interleaves sending and receiving data, such as when using the +[embedded Sass protocol]. + +[`child_process.spawnSync()`]: https://nodejs.org/api/child_process.html#child_processspawnsynccommand-args-options +[embedded Sass protocol]: https://github.com/sass/sass/blob/main/spec/embedded-protocol.md + +--- + +Disclaimer: this is not an official Google product. diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..a2539f4 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,7 @@ +const config = { + roots: ['/lib/'], + preset: 'ts-jest', + testEnvironment: 'node', +}; + +export default config; diff --git a/lib/index.test.ts b/lib/index.test.ts index b538b93..aafbf9a 100644 --- a/lib/index.test.ts +++ b/lib/index.test.ts @@ -4,21 +4,20 @@ import * as fs from 'fs'; import * as p from 'path'; -import * as del from 'del'; -import {Event, StderrEvent, StdoutEvent, SyncProcess} from './index'; +import {ExitEvent, StderrEvent, StdoutEvent, SyncProcess} from './index'; describe('SyncProcess', () => { describe('stdio', () => { it('emits stdout', () => { withJSProcess('console.log("hello, world!");', node => { - expectStdout(node.yield(), 'hello, world!\n'); + expectStdout(node.next(), 'hello, world!\n'); }); }); it('emits stderr', () => { withJSProcess('console.error("hello, world!");', node => { - expectStderr(node.yield(), 'hello, world!\n'); + expectStderr(node.next(), 'hello, world!\n'); }); }); @@ -27,10 +26,10 @@ describe('SyncProcess', () => { 'process.stdin.on("data", (data) => process.stdout.write(data));', node => { node.stdin.write('hi there!\n'); - expectStdout(node.yield(), 'hi there!\n'); + expectStdout(node.next(), 'hi there!\n'); node.stdin.write('fblthp\n'); - expectStdout(node.yield(), 'fblthp\n'); - } + expectStdout(node.next(), 'fblthp\n'); + }, ); }); @@ -42,8 +41,8 @@ describe('SyncProcess', () => { `, node => { node.stdin.end(); - expectStdout(node.yield(), 'closed!\n'); - } + expectStdout(node.next(), 'closed!\n'); + }, ); }); }); @@ -51,20 +50,20 @@ describe('SyncProcess', () => { describe('emits exit', () => { it('with code 0 by default', () => { withJSProcess('', node => { - expectExit(node.yield(), 0); + expectExit(node.next(), 0); }); }); it('with a non-0 code', () => { withJSProcess('process.exit(123);', node => { - expectExit(node.yield(), 123); + expectExit(node.next(), 123); }); }); it('with a signal code', () => { withJSProcess('for (;;) {}', node => { node.kill('SIGINT'); - expectExit(node.yield(), 'SIGINT'); + expectExit(node.next(), 'SIGINT'); }); }); }); @@ -74,14 +73,22 @@ describe('SyncProcess', () => { const node = new SyncProcess(process.argv0, [file], { env: {...process.env, SYNC_PROCESS_TEST: 'abcdef'}, }); - expectStdout(node.yield(), 'abcdef\n'); + expectStdout(node.next(), 'abcdef\n'); node.kill(); }); }); }); -/** Asserts that `event` is a `StdoutEvent` with text `text`. */ -function expectStdout(event: Event, text: string): void { +type NextResult = IteratorResult< + StdoutEvent | StderrEvent, + ExitEvent | undefined +>; + +/** Asserts that `result` contains a `StdoutEvent` with text `text`. */ +function expectStdout(result: NextResult, text: string): void { + expect(result.done).toBeFalsy(); + const event = (result as IteratorYieldResult) + .value; if (event.type === 'stderr') { throw `Expected stdout event, was stderr event: ${event.data.toString()}`; } @@ -90,8 +97,11 @@ function expectStdout(event: Event, text: string): void { expect((event as StdoutEvent).data.toString()).toEqual(text); } -/** Asserts that `event` is a `StderrEvent` with text `text`. */ -function expectStderr(event: Event, text: string): void { +/** Asserts that `result` contains a `StderrEvent` with text `text`. */ +function expectStderr(result: NextResult, text: string): void { + expect(result.done).toBeFalsy(); + const event = (result as IteratorYieldResult) + .value; if (event.type === 'stdout') { throw `Expected stderr event, was stdout event: ${event.data.toString()}`; } @@ -101,10 +111,15 @@ function expectStderr(event: Event, text: string): void { } /** - * Asserts that `event` is an `ExitEvent` with either the given exit code (if - * `codeOrSignal` is a number) or signal (if `codeOrSignal` is a string). + * Asserts that `result` contains an `ExitEvent` with either the given exit code + * (if `codeOrSignal` is a number) or signal (if `codeOrSignal` is a string). */ -function expectExit(event: Event, codeOrSignal: number | NodeJS.Signals): void { +function expectExit( + result: NextResult, + codeOrSignal: number | NodeJS.Signals, +): void { + expect(result.done).toBe(true); + const event = result.value!; if (event.type !== 'exit') { throw ( `Expected exit event, was ${event.type} event: ` + event.data.toString() @@ -114,7 +129,7 @@ function expectExit(event: Event, codeOrSignal: number | NodeJS.Signals): void { expect(event).toEqual( typeof codeOrSignal === 'number' ? {type: 'exit', code: codeOrSignal} - : {type: 'exit', signal: codeOrSignal} + : {type: 'exit', signal: codeOrSignal}, ); } @@ -124,7 +139,7 @@ function expectExit(event: Event, codeOrSignal: number | NodeJS.Signals): void { */ function withJSProcess( contents: string, - callback: (process: SyncProcess) => void + callback: (process: SyncProcess) => void, ): void { return withJSFile(contents, file => { const node = new SyncProcess(process.argv0, [file]); @@ -151,7 +166,6 @@ function withJSFile(contents: string, callback: (file: string) => void): void { try { callback(file); } finally { - // TODO(awjin): Change this to rmSync once we drop support for Node 12. - del.sync(testDir, {force: true}); + fs.rmSync(testDir, {force: true, recursive: true}); } } diff --git a/lib/index.ts b/lib/index.ts index 426dbb1..acde417 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,19 +6,33 @@ import * as fs from 'fs'; import * as p from 'path'; import * as stream from 'stream'; import {Worker, WorkerOptions} from 'worker_threads'; +import * as worker_threads from 'worker_threads'; -import {SyncMessagePort} from './sync-message-port'; -import {Event, InternalEvent} from './event'; +import {SyncMessagePort} from 'sync-message-port'; -export {Event, ExitEvent, StderrEvent, StdoutEvent} from './event'; +import {ExitEvent, InternalEvent, StderrEvent, StdoutEvent} from './event'; -// TODO(nex3): Factor this out into its own package. +export {ExitEvent, StderrEvent, StdoutEvent} from './event'; + +/** Whether {@link object} can't be transferred between threads, only cloned. */ +function isMarkedAsUntransferable(object: unknown): boolean { + // TODO: Remove this check when we no longer support Node v20 (after + // 2026-04-30). + return 'isMarkedAsUntransferable' in worker_threads + ? // TODO - DefinitelyTyped/DefinitelyTyped#71033: Remove this cast + (worker_threads.isMarkedAsUntransferable as (object: unknown) => boolean)( + object, + ) + : false; +} /** * A child process that runs synchronously while also allowing the user to * interact with it before it shuts down. */ -export class SyncProcess { +export class SyncProcess + implements Iterator +{ /** The port that communicates with the worker thread. */ private readonly port: SyncMessagePort; @@ -35,7 +49,7 @@ export class SyncProcess { constructor( command: string, argsOrOptions?: string[] | Options, - options?: Options + options?: Options, ) { let args: string[]; if (Array.isArray(argsOrOptions)) { @@ -63,56 +77,36 @@ export class SyncProcess { type: 'stdin', data: chunk as Buffer, }, - [chunk.buffer] + isMarkedAsUntransferable(chunk.buffer) ? undefined : [chunk.buffer], ); callback(); }, + final: () => this.port.postMessage({type: 'stdinClosed'}), }); - - // Unfortunately, there's no built-in event or callback that will reliably - // *synchronously* notify us that the stdin stream has been closed. (The - // `final` callback works in Node v16 but not v14.) Instead, we wrap the - // methods themselves that are used to close the stream. - const oldEnd = this.stdin.end.bind(this.stdin) as ( - a1?: unknown, - a2?: unknown, - a3?: unknown - ) => void; - this.stdin.end = ((a1?: unknown, a2?: unknown, a3?: unknown) => { - oldEnd(a1, a2, a3); - this.port.postMessage({type: 'stdinClosed'}); - }) as typeof this.stdin.end; - - const oldDestroy = this.stdin.destroy.bind(this.stdin) as ( - a1?: unknown - ) => void; - this.stdin.destroy = ((a1?: unknown) => { - oldDestroy(a1); - this.port.postMessage({type: 'stdinClosed'}); - }) as typeof this.stdin.destroy; } /** * Blocks until the child process is ready to emit another event, then returns - * that event. + * that event. This will return an [IteratorReturnResult] with an [ExitEvent] + * once when the process exits. If it's called again after that, it will + * return `{done: true}` without a value. * * If there's an error running the child process, this will throw that error. - * This may not be called after it emits an `ExitEvent` or throws an error. */ - yield(): Event { - if (this.stdin.destroyed) { - throw new Error( - "Can't call SyncProcess.yield() after the process has exited." - ); - } + next(): IteratorResult { + if (this.stdin.destroyed) return {done: true, value: undefined}; const message = this.port.receiveMessage() as InternalEvent; switch (message.type) { case 'stdout': - return {type: 'stdout', data: Buffer.from(message.data.buffer)}; + return { + value: {type: 'stdout', data: Buffer.from(message.data.buffer)}, + }; case 'stderr': - return {type: 'stderr', data: Buffer.from(message.data.buffer)}; + return { + value: {type: 'stderr', data: Buffer.from(message.data.buffer)}, + }; case 'error': this.close(); @@ -120,7 +114,7 @@ export class SyncProcess { case 'exit': this.close(); - return message; + return {done: true, value: message}; } } @@ -152,7 +146,7 @@ export class SyncProcess { */ function spawnWorker( fileWithoutExtension: string, - options: WorkerOptions + options: WorkerOptions, ): Worker { // The released version always spawns the JS worker. The TS worker is only // used for development. @@ -166,7 +160,7 @@ function spawnWorker( require('ts-node').register(); require(${JSON.stringify(tsFile)}); `, - {...options, eval: true} + {...options, eval: true}, ); } diff --git a/lib/worker.ts b/lib/worker.ts index 8954296..02a9f3d 100644 --- a/lib/worker.ts +++ b/lib/worker.ts @@ -11,7 +11,8 @@ import { import {SpawnOptionsWithoutStdio, spawn} from 'child_process'; import {strict as assert} from 'assert'; -import {SyncMessagePort} from './sync-message-port'; +import {SyncMessagePort} from 'sync-message-port'; + import {InternalEvent} from './event'; const port = new SyncMessagePort(workerData.port as MessagePort); @@ -24,7 +25,7 @@ function emit(event: InternalEvent, transferList?: TransferListItem[]): void { const process = spawn( workerData.command as string, workerData.args as string[], - workerData.options as SpawnOptionsWithoutStdio | undefined + workerData.options as SpawnOptionsWithoutStdio | undefined, ); port.on('message', message => { diff --git a/package.json b/package.json new file mode 100644 index 0000000..a1480b3 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "sync-process", + "version": "1.0.0", + "description": "Run a subprocess synchronously and interactively in Node.js", + "repository": "sass/sync-process", + "author": "Google Inc.", + "license": "MIT", + "exports": { + "types": "./dist/lib/index.d.ts", + "default": "./dist/lib/index.js" + }, + "main": "dist/lib/index.js", + "types": "dist/lib/index.d.ts", + "files": [ + "dist/**/*" + ], + "engines": { + "node": ">=16.0.0" + }, + "scripts": { + "check": "npm-run-all check:gts check:tsc", + "check:gts": "gts check", + "check:tsc": "tsc --noEmit", + "clean": "gts clean", + "compile": "tsc -p tsconfig.build.json", + "fix": "gts fix", + "test": "jest" + }, + "devDependencies": { + "@types/jest": "^29.4.0", + "@types/node": "^22.0.0", + "gts": "^6.0.2", + "jest": "^29.4.1", + "npm-run-all": "^4.1.5", + "ts-jest": "^29.0.5", + "ts-node": "^10.2.1", + "typescript": "^5.0.2" + }, + "dependencies": { + "sync-message-port": "^1.0.0" + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..a99ca90 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "jest.config.ts", + "**/*.test.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b5d630b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./node_modules/gts/tsconfig-google.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "lib": ["DOM"] + }, + "include": [ + "*.ts", + "bin/*.ts", + "lib/**/*.ts", + "tool/**/*.ts", + "test/**/*.ts" + ] +}