├── .gitignore ├── examples ├── package.json ├── generate-react-component │ ├── index.ts │ ├── __name__.tsx │ ├── __name__.stories.tsx │ ├── __name__.test.tsx │ └── generate-react-component.ts ├── fetch.ts ├── tsconfig.json ├── shell.ts ├── prompts.ts ├── generate-react-component-inline.ts └── generate-single-file.ts ├── .husky ├── pre-commit └── commit-msg ├── src ├── e2e │ ├── Dockerfile │ ├── commands │ │ ├── index.ts │ │ └── list.ts │ ├── examples │ │ ├── index.ts │ │ ├── shell.ts │ │ ├── fetch.ts │ │ ├── generate-react-component.ts │ │ └── generate-react-component-inline.ts │ ├── docker.sh │ ├── utils.ts │ └── index.ts ├── globals │ ├── sleep.ts │ ├── shell.ts │ └── index.ts ├── utils │ ├── test.ts │ ├── logger.ts │ └── path.ts ├── commands │ ├── list.ts │ ├── repl.ts │ └── run.ts ├── loader-cjs.ts ├── loader-esm.ts ├── setup.ts ├── types.ts ├── Project.ts ├── Project.test.ts └── main.ts ├── commitlint.config.cts ├── tsconfig.json ├── release.config.cjs ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── LICENSE ├── package.json ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /examples/generate-react-component/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./__name__"; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn test 5 | npx lint-staged 6 | -------------------------------------------------------------------------------- /src/e2e/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | WORKDIR /root/source 4 | COPY . . 5 | 6 | CMD ["/root/source/src/e2e/docker.sh"] 7 | -------------------------------------------------------------------------------- /src/globals/sleep.ts: -------------------------------------------------------------------------------- 1 | export default (ms: number) => 2 | new Promise((resolve) => { 3 | setTimeout(resolve, ms); 4 | }); 5 | -------------------------------------------------------------------------------- /src/e2e/commands/index.ts: -------------------------------------------------------------------------------- 1 | import * as listCommands from "./list"; 2 | 3 | const commands = { 4 | ...listCommands, 5 | }; 6 | 7 | export { commands }; 8 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | NODE_OPTIONS='--import=tsx --no-warnings' npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /examples/generate-react-component/__name__.tsx: -------------------------------------------------------------------------------- 1 | export interface __name__Props {} 2 | 3 | export const __name__ = ({}: __name__Props) => { 4 | return
__name__
; 5 | }; 6 | -------------------------------------------------------------------------------- /src/e2e/examples/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./fetch"; 2 | export * from "./generate-react-component"; 3 | export * from "./generate-react-component-inline"; 4 | export * from "./shell"; 5 | -------------------------------------------------------------------------------- /src/utils/test.ts: -------------------------------------------------------------------------------- 1 | import sinon from "sinon"; 2 | 3 | const stub = >(target: T, key: keyof T) => { 4 | try { 5 | // @ts-ignore 6 | target[key].restore(); 7 | } catch {} 8 | return sinon.stub(target, key); 9 | }; 10 | 11 | export { stub }; 12 | -------------------------------------------------------------------------------- /examples/fetch.ts: -------------------------------------------------------------------------------- 1 | import "auto"; 2 | 3 | export default auto({ 4 | id: "fetch", 5 | title: "Fetch", 6 | run: async () => { 7 | const response = await fetch("http://localhost:9123"); 8 | const text = await response.text(); 9 | console.log(text); 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /commitlint.config.cts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | rules: { 4 | "type-enum": [2, "always", ["feat", "fix", "chore", "test", "docs", "revert"]], 5 | "body-max-line-length": [0, "always"], 6 | "footer-max-line-length": [0, "always"], 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | export const debug = (message: string, ...args: any[]) => { 4 | if (process.env.DEBUG) { 5 | console.log(chalk.yellow(`[DEBUG] ${message}`), ...args); 6 | } 7 | }; 8 | 9 | export const error = (message: string, ...args: any[]) => { 10 | console.error(`[ERROR] ${message}`, ...args); 11 | }; 12 | -------------------------------------------------------------------------------- /src/globals/shell.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | cwd() { 3 | return process.cwd(); 4 | }, 5 | cd(path: TemplateStringsArray | string) { 6 | process.chdir(typeof path === "string" ? path : path[0]); 7 | return process.cwd(); 8 | }, 9 | get pwd() { 10 | return process.cwd(); 11 | }, 12 | set pwd(path: string) { 13 | process.chdir(path); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /examples/generate-react-component/__name__.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryObj, Meta } from "@storybook/react"; 2 | import { __name__ } from "./__name__"; 3 | 4 | const meta: Meta = { 5 | title: "__name__", 6 | component: __name__, 7 | }; 8 | export default meta; 9 | 10 | type Story = StoryObj; 11 | 12 | export const Default: Story = { 13 | args: {}, 14 | }; 15 | -------------------------------------------------------------------------------- /examples/generate-react-component/__name__.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | 4 | import { __name__ } from "./__name__"; 5 | 6 | describe("__name__", () => { 7 | beforeEach(() => { 8 | jest.clearAllMocks(); 9 | }); 10 | 11 | it("renders", () => { 12 | render(<__name__/>); 13 | }) 14 | }); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "noEmit": true, 7 | "esModuleInterop": true, 8 | "isolatedModules": true, 9 | "resolveJsonModule": true, 10 | "strict": true, 11 | "lib": ["ESNext"], 12 | "types": ["node"], 13 | "skipLibCheck": true 14 | }, 15 | "include": ["./src"] 16 | } 17 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* This file should be auto-generated, don't copy-paste this. */ 2 | { 3 | "compilerOptions": { 4 | "strict": true, 5 | "lib": [], 6 | "jsx": "react-jsx", 7 | "baseUrl": ".", 8 | "typeRoots": [ 9 | "/home/user/.npm/.../globals" /* auto-generated */ 10 | ], 11 | "paths": { 12 | "auto": [ 13 | "/home/user/.npm/.../globals" /* auto-generated */ 14 | ] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/e2e/docker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -uf -o pipefail 3 | IFS=$'\n\t' 4 | 5 | npm install -g pnpm 6 | 7 | cd /root/source || exit 8 | HUSKY=0 pnpm i --frozen-lockfile --force 9 | pnpm run build 10 | 11 | mkdir /root/build 12 | npm pack --pack-destination /root/build 13 | 14 | cd /root/build || exit 15 | npm install -g andrei.fyi-auto-0.0.0-semantic-release.tgz 16 | 17 | cd /root/source || exit 18 | mkdir ~/.config 19 | npx tsx src/e2e 20 | -------------------------------------------------------------------------------- /src/e2e/examples/shell.ts: -------------------------------------------------------------------------------- 1 | import { execa } from "execa"; 2 | import type { Test } from "../index"; 3 | 4 | const shell: Test = { 5 | run: async (cwd) => { 6 | const { stdout } = await execa("auto", ["run", "shell"], { cwd }); 7 | 8 | return { stdout }; 9 | }, 10 | expected: { 11 | stdout: ` 12 | Info: Using main repository: ~/.config/auto 13 | Info: Running ~/.config/auto/shell.ts 14 | "license": "MIT", 15 | "Hello, root" 16 | "1" 17 | "2" 18 | [ '"1"', '"2"' ] 19 | 0`, 20 | }, 21 | }; 22 | 23 | export { shell }; 24 | -------------------------------------------------------------------------------- /src/e2e/examples/fetch.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | import { execa } from "execa"; 3 | import type { Test } from "../index"; 4 | 5 | const fetch: Test = { 6 | run: async (cwd) => { 7 | const server = http.createServer((_, res) => { 8 | res.writeHead(200, { "Content-Type": "text/plain" }); 9 | res.end("Hello"); 10 | }); 11 | server.listen(9123); 12 | 13 | const { stdout } = await execa("auto", ["run", "fetch"], { cwd }); 14 | 15 | server.close(); 16 | 17 | return { stdout }; 18 | }, 19 | expected: { 20 | stdout: ` 21 | Info: Using main repository: ~/.config/auto 22 | Info: Running ~/.config/auto/fetch.ts 23 | Hello`, 24 | }, 25 | }; 26 | 27 | export { fetch }; 28 | -------------------------------------------------------------------------------- /release.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: [ 3 | "master", 4 | { 5 | name: "beta", 6 | prerelease: true, 7 | }, 8 | ], 9 | plugins: [ 10 | "@semantic-release/commit-analyzer", 11 | "@semantic-release/release-notes-generator", 12 | [ 13 | "@semantic-release/changelog", 14 | { 15 | changelogFile: "CHANGELOG.md", 16 | }, 17 | ], 18 | "@semantic-release/github", 19 | [ 20 | "@semantic-release/git", 21 | { 22 | assets: ["CHANGELOG.md", "package.json"], 23 | message: "chore(release): set `package.json` to ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", 24 | }, 25 | ], 26 | "@semantic-release/npm", 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /src/commands/list.ts: -------------------------------------------------------------------------------- 1 | import { command } from "cleye"; 2 | import chalk from "chalk"; 3 | import { Project } from "../Project"; 4 | import { AutoReturnType } from "../types"; 5 | 6 | const createListCommand = (project: Project, scripts: AutoReturnType[]) => 7 | command({ name: "list", alias: "ls", flags: { all: Boolean } }, (argv) => { 8 | const filteredScripts = argv.flags.all ? scripts : scripts.filter((t) => !t.isValid || t.isValid(project)); 9 | for (const script of filteredScripts) { 10 | console.log( 11 | chalk.grey("-"), 12 | chalk.magenta(`<${script.id}>`), 13 | chalk.cyan(script.title ?? ""), 14 | script.isLocal ? chalk.blue("(local)") : chalk.yellow("(main)") 15 | ); 16 | } 17 | }); 18 | 19 | export { createListCommand }; 20 | -------------------------------------------------------------------------------- /src/loader-cjs.ts: -------------------------------------------------------------------------------- 1 | import _Module from "module"; 2 | import { resolve, dirname } from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | 5 | type ModuleType = { 6 | _resolveFilename: ResolveFilenameSignature; 7 | }; 8 | 9 | type ResolveFilenameSignature = ( 10 | request: string, 11 | parent: NodeJS.Module | null, 12 | isMain?: boolean, 13 | options?: any 14 | ) => string; 15 | 16 | const Module = _Module as unknown as ModuleType; 17 | const autoLoaderPath = resolve(dirname(fileURLToPath(import.meta.url)), "globals/index.cjs"); 18 | 19 | const resolveFilename = Module._resolveFilename; 20 | Module._resolveFilename = function (request, parent, isMain, options) { 21 | if (request === "auto") return autoLoaderPath; 22 | 23 | return resolveFilename.call(this, request, parent, isMain, options); 24 | }; 25 | -------------------------------------------------------------------------------- /src/loader-esm.ts: -------------------------------------------------------------------------------- 1 | import { resolve as nodeResolve, dirname } from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | import fs from "fs"; 4 | 5 | const autoLoaderPath = nodeResolve(dirname(fileURLToPath(import.meta.url)), "globals/index.mjs"); 6 | 7 | async function resolve(specifier: string, context: unknown, next: Function) { 8 | if (specifier === "auto") { 9 | return { url: `file://${autoLoaderPath}`, shortCircuit: true }; 10 | } 11 | return next(specifier, context); 12 | } 13 | 14 | async function load(url: string, context: unknown, next: Function) { 15 | if (url === autoLoaderPath) { 16 | const code = fs.readFileSync(autoLoaderPath, "utf8"); 17 | return { 18 | format: "module", 19 | source: code, 20 | }; 21 | } 22 | return next(url, context); 23 | } 24 | 25 | export { load, resolve }; 26 | -------------------------------------------------------------------------------- /src/setup.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | import fs from "fs-extra"; 4 | 5 | const setupTSConfig = (tsConfigPath: string) => { 6 | const pathToDistGlobals = resolve(dirname(fileURLToPath(import.meta.url)), "..", "dist", "globals"); 7 | return fs.writeFile( 8 | tsConfigPath, 9 | JSON.stringify( 10 | { 11 | compilerOptions: { 12 | strict: true, 13 | lib: [], 14 | jsx: "react-jsx", 15 | baseUrl: ".", 16 | typeRoots: [pathToDistGlobals], 17 | paths: { 18 | auto: [pathToDistGlobals], 19 | }, 20 | }, 21 | }, 22 | null, 23 | 2 24 | ) 25 | ); 26 | }; 27 | 28 | const setupPackage = (packagePath: string) => { 29 | return fs.writeFile(packagePath, JSON.stringify({ type: "module" }, null, 2)); 30 | }; 31 | 32 | export { setupPackage, setupTSConfig }; 33 | -------------------------------------------------------------------------------- /src/commands/repl.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | import { resolve } from "node:path"; 3 | import repl from "node:repl"; 4 | import { command } from "cleye"; 5 | import chalk from "chalk"; 6 | import envPaths from "env-paths"; 7 | import { Project } from "../Project"; 8 | import { AutoReturnType } from "../types"; 9 | import packageJson from "../../package.json"; 10 | 11 | const createReplCommand = (project: Project, scripts: AutoReturnType[]) => 12 | command({ name: "repl" }, async () => { 13 | (global as any).project = project; 14 | (global as any).scripts = scripts; 15 | const r = repl.start({ 16 | prompt: chalk.greenBright("> "), 17 | useGlobal: true, 18 | terminal: true, 19 | }); 20 | // eslint-disable-next-line @typescript-eslint/no-empty-function 21 | r.setupHistory(resolve(envPaths(packageJson.name, { suffix: "" }).cache, "history"), () => {}); 22 | }); 23 | 24 | export { createReplCommand }; 25 | -------------------------------------------------------------------------------- /src/utils/path.ts: -------------------------------------------------------------------------------- 1 | import os from "node:os"; 2 | import envPaths from "env-paths"; 3 | import { findUpSync } from "find-up"; 4 | import { resolve } from "node:path"; 5 | 6 | const rootMatchingConfigurations = [ 7 | { match: "package.json", type: "file" }, 8 | { match: "go.mod", type: "file" }, 9 | { match: "Makefile", type: "file" }, 10 | { match: ".git", type: "directory" }, 11 | ] as const; 12 | 13 | const tildify = (path: string) => path.replace(os.homedir(), "~"); 14 | 15 | const getGlobalRepositoryPath = () => { 16 | return envPaths("auto", { suffix: "" }).config; 17 | }; 18 | 19 | const resolveProjectRoot = (cwd: string) => { 20 | let root = cwd; 21 | for (const { match, type } of rootMatchingConfigurations) { 22 | const foundPath = findUpSync(match, { cwd: root, type }); 23 | if (foundPath) { 24 | root = resolve(foundPath, ".."); 25 | break; 26 | } 27 | } 28 | return root; 29 | }; 30 | 31 | export { getGlobalRepositoryPath, resolveProjectRoot, tildify }; 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | branches: ["master", "development"] 6 | push: 7 | branches: ["development"] 8 | jobs: 9 | test: 10 | name: Tests 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [18.x, 19.x, 20.x] 15 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: pnpm/action-setup@v2 19 | with: 20 | version: latest 21 | run_install: true 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: pnpm 27 | - run: pnpm install 28 | - run: pnpm tsc 29 | - run: pnpm test 30 | - run: pnpm build 31 | - run: docker run --rm $(docker build -f src/e2e/Dockerfile -q .) 32 | timeout-minutes: 10 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Andrei Neculaesei 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | 9 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release (semantic-release) 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: ["master"] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | issues: write 18 | pull-requests: write 19 | id-token: write 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: pnpm/action-setup@v2 23 | with: 24 | version: latest 25 | run_install: true 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: 21.x 29 | cache: pnpm 30 | - run: pnpm install 31 | - run: pnpm tsc 32 | - run: pnpm test 33 | - run: pnpm build 34 | - run: docker run --rm $(docker build -f src/e2e/Dockerfile -q .) 35 | timeout-minutes: 10 36 | - env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | run: npx semantic-release 40 | -------------------------------------------------------------------------------- /examples/shell.ts: -------------------------------------------------------------------------------- 1 | import "auto"; 2 | import { ExecaChildProcess } from "execa"; 3 | 4 | export default auto({ 5 | id: "shell", 6 | title: "Shell-like usage", 7 | run: async ({ project }) => { 8 | cd(project.rootDirectory); 9 | 10 | console.log((await execa("cat", ["package.json"]).pipeStdout?.(execa("grep", ["license"])))?.stdout); 11 | 12 | const whoami = await $`whoami`; 13 | await $`echo "Hello, ${whoami}"`.pipeStdout?.(process.stdout); 14 | 15 | console.log( 16 | ( 17 | await Promise.all( 18 | [ 19 | async () => { 20 | await sleep(100); 21 | return $`echo "1"`.pipeStdout?.(process.stdout); 22 | }, 23 | async () => { 24 | await sleep(200); 25 | return $`echo "2"`.pipeStdout?.(process.stdout); 26 | }, 27 | ].map((f) => f()) 28 | ) 29 | ).map((p) => p?.stdout) 30 | ); 31 | 32 | const name = "auto-foo-bar-baz"; 33 | await $`mkdir -p /tmp/${name}`; 34 | console.log((await $`ls /tmp/${name}`).exitCode); 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /examples/prompts.ts: -------------------------------------------------------------------------------- 1 | import "auto"; 2 | 3 | export default auto({ 4 | id: "prompts", 5 | title: "Auto prompts", 6 | params: { 7 | boolean: { 8 | title: "Boolean param", 9 | type: "boolean", 10 | }, 11 | number: { 12 | title: "Number param", 13 | type: "number", 14 | }, 15 | string: { 16 | title: "String param", 17 | type: "string", 18 | }, 19 | }, 20 | run: async ({ params }) => { 21 | console.log("Params:", params); 22 | 23 | const boolean = await prompt.confirm({ message: "On-demand boolean prompt" }); 24 | console.log("Boolean value:", boolean); 25 | 26 | const string = await prompt.input({ message: "On-demand string prompt" }); 27 | console.log("String value:", string); 28 | 29 | const choice = await prompt.select({ 30 | message: "Choose", 31 | choices: [ 32 | { 33 | name: "Blue pill", 34 | value: "blue", 35 | description: "Take the blue pill", 36 | }, 37 | { 38 | name: "Red pill", 39 | value: "red", 40 | description: "Take the red pill", 41 | }, 42 | new prompt.Separator(), 43 | { 44 | name: "Green pill", 45 | value: "green", 46 | description: "Take the green pill", 47 | }, 48 | ], 49 | }); 50 | console.log("Choice:", choice); 51 | }, 52 | }); 53 | -------------------------------------------------------------------------------- /src/e2e/commands/list.ts: -------------------------------------------------------------------------------- 1 | import { execa } from "execa"; 2 | import type { Test } from "../index"; 3 | 4 | const list: Test = { 5 | name: "list.global", 6 | run: async (cwd) => { 7 | const { stdout } = await execa("auto", ["ls"], { cwd }); 8 | 9 | return { stdout }; 10 | }, 11 | expected: { 12 | stdout: ` 13 | Info: Using main repository: ~/.config/auto 14 | - Shell-like usage (main) 15 | - Auto prompts (main) 16 | - Generate single file (main) 17 | - Fetch (main) 18 | `, 19 | }, 20 | }; 21 | 22 | const listLocal: Test = { 23 | name: "list.local", 24 | project: { 25 | "package.json": "{}", 26 | "auto/tsconfig.json": "{}", 27 | "auto/package.json": JSON.stringify({ type: "module" }), 28 | "auto/fetch.ts": ` 29 | import "auto"; 30 | export default auto({ 31 | id: "fetch", 32 | title: "Fetch", 33 | run: async () => { 34 | console.log("fetch"); 35 | }, 36 | }); 37 | `.trim(), 38 | }, 39 | run: async (cwd) => { 40 | const { stdout } = await execa("auto", ["ls"], { cwd }); 41 | return { stdout }; 42 | }, 43 | expected: { 44 | stdout: ({ cwd }) => ` 45 | Info: Using main repository: ~/.config/auto 46 | Info: Using local repository: ${cwd}/auto 47 | - Shell-like usage (main) 48 | - Auto prompts (main) 49 | - Generate single file (main) 50 | - Fetch (local) 51 | `, 52 | }, 53 | }; 54 | 55 | export { list, listLocal }; 56 | -------------------------------------------------------------------------------- /examples/generate-react-component/generate-react-component.ts: -------------------------------------------------------------------------------- 1 | import "auto"; 2 | 3 | export default auto({ 4 | id: "react-component", 5 | title: "React Component", 6 | params: { 7 | path: { 8 | title: "Component Path", 9 | type: "string", 10 | defaultValue: ({ project }) => { 11 | if (project.hasDirectory("src/components")) { 12 | return `src/components/`; 13 | } 14 | if (project.hasDirectory("components")) { 15 | return `components/`; 16 | } 17 | }, 18 | }, 19 | }, 20 | isValid: (project) => project.hasDependency("react"), 21 | run: async ({ project, params, fileMap, self, t }) => { 22 | console.log("Running:", self.id); 23 | 24 | const parts = params.path.split("/"); 25 | const name = parts.pop(); 26 | if (!name) throw new Error(`Invalid name: ${name}`); 27 | 28 | project.createDirectory(params.path); 29 | 30 | const template: typeof t = (text: string) => t(text, { ...params, name }); 31 | 32 | project.writeFile(template(`${params.path}/index.ts`), template(fileMap["index.ts"])); 33 | project.writeFile(template(`${params.path}/${name}.tsx`), template(fileMap["__name__.tsx"])); 34 | if (project.hasDependency("jest") || project.hasDependency("@testing-library/react")) { 35 | project.writeFile(template(`${params.path}/${name}.test.tsx`), template(fileMap["__name__.test.tsx"])); 36 | } 37 | if (project.hasDependency("storybook")) { 38 | project.writeFile(template(`${params.path}/${name}.stories.tsx`), template(fileMap["__name__.stories.tsx"])); 39 | } 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /src/e2e/examples/generate-react-component.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import os from "node:os"; 3 | import type { Test } from "../index"; 4 | import { lazyRead, runCommandWithInputs } from "../utils"; 5 | 6 | const exampleDir = path.join(os.homedir(), ".config/auto", "generate-react-component"); 7 | const files = Object.entries({ 8 | index: "index.ts", 9 | component: `__name__.tsx`, 10 | stories: `__name__.stories.tsx`, 11 | test: `__name__.test.tsx`, 12 | }).reduce>((acc, [k, v]) => { 13 | acc[k] = path.join(exampleDir, v); 14 | return acc; 15 | }, {}); 16 | 17 | const generateReactComponent: Test = { 18 | project: { 19 | "package.json": JSON.stringify({ 20 | name: "app", 21 | dependencies: { react: "*", jest: "*", storybook: "*" }, 22 | }), 23 | }, 24 | run: (cwd) => { 25 | return runCommandWithInputs( 26 | "auto run react-component", 27 | [{ on: "Component Path", value: "src/components/MyComponent" }], 28 | { cwd } 29 | ); 30 | }, 31 | expected: { 32 | files: { 33 | "src/components/MyComponent/index.ts": lazyRead(files.index, (v) => v.replace(/__name__/g, "MyComponent")), 34 | "src/components/MyComponent/MyComponent.tsx": lazyRead(files.component, (v) => 35 | v.replace(/__name__/g, "MyComponent") 36 | ), 37 | "src/components/MyComponent/MyComponent.stories.tsx": lazyRead(files.stories, (v) => 38 | v.replace(/__name__/g, "MyComponent") 39 | ), 40 | "src/components/MyComponent/MyComponent.test.tsx": lazyRead(files.test, (v) => 41 | v.replace(/__name__/g, "MyComponent") 42 | ), 43 | }, 44 | }, 45 | }; 46 | 47 | export { generateReactComponent }; 48 | -------------------------------------------------------------------------------- /src/globals/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @shopify/restrict-full-import */ 2 | import * as _chalk from "chalk"; 3 | import * as _execa from "execa"; 4 | import * as _glob from "glob"; 5 | import * as _fs_t from "fs-extra"; 6 | import * as _lodash_t from "lodash"; 7 | import * as _which_t from "which"; 8 | import * as _inquirer from "@inquirer/prompts"; 9 | import _fs from "fs-extra"; 10 | import _lodash from "lodash"; 11 | import _which from "which"; 12 | 13 | import * as types from "../types"; 14 | import _sleep from "./sleep"; 15 | import shell from "./shell"; 16 | 17 | Object.assign(global, { 18 | // core 19 | auto: types.auto, 20 | // internal utils 21 | ...shell, 22 | sleep: _sleep, 23 | // external utils 24 | $$: _execa.$({ verbose: true }), 25 | $: _execa.$, 26 | chalk: _chalk, 27 | prompt: _inquirer, 28 | inquirer: _inquirer, 29 | execa: _execa.execa, 30 | execaSync: _execa.execaSync, 31 | fs: _fs, 32 | glob: _glob, 33 | lodash: _lodash, 34 | which: _which, 35 | }); 36 | 37 | // accessors 38 | Object.defineProperty(globalThis, "pwd", { 39 | get() { 40 | return shell.cwd(); 41 | }, 42 | set(path: string) { 43 | shell.cd(path); 44 | }, 45 | }); 46 | 47 | declare global { 48 | const auto: types.AutoType; 49 | const cd: typeof shell.cd; 50 | const pwd: string; 51 | // @ts-ignore damn you tsserver 52 | const sleep: typeof _sleep; 53 | const $$: typeof _execa.$; 54 | const $: typeof _execa.$; 55 | const chalk: typeof _chalk; 56 | const prompt: typeof _inquirer; 57 | const inquirer: typeof _inquirer; 58 | const execa: typeof _execa.execa; 59 | const execaSync: typeof _execa.execaSync; 60 | const glob: typeof _glob; 61 | const fs: typeof _fs_t; 62 | const lodash: typeof _lodash_t; 63 | const which: typeof _which_t; 64 | } 65 | -------------------------------------------------------------------------------- /src/e2e/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import { resolve as resolvePath } from "path"; 3 | import { execa } from "execa"; 4 | 5 | const runCommandWithInputs = (command: string, inputs: { on: string; value: string }[], opts?: { cwd: string }) => { 6 | const [cmd, ...args] = command.split(" "); 7 | return new Promise<{ stdout: string }>((resolve, reject) => { 8 | const proc = execa(cmd, args, { ...opts, stdin: "pipe" }); 9 | proc.stdin!.setDefaultEncoding("utf8"); 10 | 11 | let stdout = ""; 12 | let stdoutChunk = ""; 13 | let currentInputIndex = 0; 14 | 15 | const loop = () => { 16 | if (currentInputIndex === inputs.length) proc.stdin!.end(); 17 | else if (stdoutChunk.includes(inputs[currentInputIndex].on)) { 18 | console.log(" - Simulating input:", inputs[currentInputIndex].value); 19 | stdoutChunk = ""; 20 | proc.stdin!.write(`${inputs[currentInputIndex].value}\n`); 21 | currentInputIndex++; 22 | } 23 | }; 24 | 25 | proc.stdout!.on("data", (chunk) => { 26 | stdout += chunk; 27 | stdoutChunk += chunk; 28 | loop(); 29 | }); 30 | 31 | proc.stderr!.on("data", (chunk) => { 32 | console.error(chunk.toString()); 33 | reject(new Error("Error in stderr")); 34 | }); 35 | 36 | proc.on("exit", () => resolve({ stdout })); 37 | }); 38 | }; 39 | 40 | const lazyRead = (filePath: string, modifier?: (value: string) => string) => () => { 41 | const value = fs.readFileSync(filePath, "utf8").trim(); 42 | return modifier ? modifier(value) : value; 43 | }; 44 | 45 | const generateMockProject = async (files: Record) => { 46 | const projectPath = await fs.mkdtemp("/tmp/auto-e2e"); 47 | // eslint-disable-next-line no-await-in-loop 48 | for (const [path, content] of Object.entries(files)) await fs.outputFile(resolvePath(projectPath, path), content); 49 | 50 | return projectPath; 51 | }; 52 | 53 | export { generateMockProject, lazyRead, runCommandWithInputs }; 54 | -------------------------------------------------------------------------------- /src/e2e/examples/generate-react-component-inline.ts: -------------------------------------------------------------------------------- 1 | import type { Test } from "../index"; 2 | import { runCommandWithInputs } from "../utils"; 3 | 4 | const expectedFiles = { 5 | index: 'export * from "./__name__";', 6 | component: ` 7 | import React from "react"; 8 | 9 | export interface __name__Props { 10 | } 11 | 12 | export const __name__ = (props: __name__Props) => { 13 | return ( 14 |
__name__
15 | ); 16 | };`.trim(), 17 | storybook: ` 18 | import { ComponentStory } from "@storybook/react"; 19 | import { __name__, __name__Props } from "./__name__"; 20 | 21 | export default { 22 | title: "__name__", 23 | component: __name__, 24 | }; 25 | 26 | const Template: ComponentStory = (props: __name__Props) => { 27 | return <__name__ {...props} />; 28 | }; 29 | 30 | export const Default = Template.bind({}); 31 | Default.args = {};`.trim(), 32 | }; 33 | 34 | const generateReactComponentInline: Test = { 35 | project: { 36 | "package.json": JSON.stringify({ 37 | name: "app", 38 | dependencies: { 39 | react: "*", 40 | "@storybook/react": "*", 41 | "@testing-library/react": "*", 42 | "@testing-library/user-event": "*", 43 | }, 44 | }), 45 | }, 46 | run: (cwd) => { 47 | return runCommandWithInputs( 48 | "auto run react-component-inline", 49 | [ 50 | { on: "Component Name", value: "MyComponent" }, 51 | { on: "Target path:", value: "src/components/MyComponent" }, 52 | ], 53 | { cwd } 54 | ); 55 | }, 56 | expected: { 57 | files: { 58 | "src/components/MyComponent/index.ts": expectedFiles.index.replace(/__name__/g, "MyComponent"), 59 | "src/components/MyComponent/MyComponent.tsx": expectedFiles.component.replace(/__name__/g, "MyComponent"), 60 | "src/components/MyComponent/MyComponent.stories.tsx": expectedFiles.storybook.replace(/__name__/g, "MyComponent"), 61 | "src/components/MyComponent/MyComponent.test.tsx": ` 62 | import { render, screen } from "@testing-library/react"; 63 | import userEvent from "@testing-library/user-event"; 64 | import { __name__ } from "./__name__"; 65 | 66 | describe("__name__", () => { 67 | beforeEach(() => { 68 | jest.clearAllMocks(); 69 | }); 70 | 71 | it.todo("should render"); 72 | });`.replace(/__name__/g, "MyComponent"), 73 | }, 74 | }, 75 | }; 76 | 77 | export { generateReactComponentInline }; 78 | -------------------------------------------------------------------------------- /examples/generate-react-component-inline.ts: -------------------------------------------------------------------------------- 1 | import "auto"; 2 | 3 | const files = { 4 | index: 'export * from "./__name__";', 5 | component: ` 6 | import React from "react"; 7 | 8 | export interface __name__Props { 9 | } 10 | 11 | export const __name__ = (props: __name__Props) => { 12 | return ( 13 |
__name__
14 | ); 15 | };`.trim(), 16 | storybook: ` 17 | import { ComponentStory } from "@storybook/react"; 18 | import { __name__, __name__Props } from "./__name__"; 19 | 20 | export default { 21 | title: "__name__", 22 | component: __name__, 23 | }; 24 | 25 | const Template: ComponentStory = (props: __name__Props) => { 26 | return <__name__ {...props} />; 27 | }; 28 | 29 | export const Default = Template.bind({}); 30 | Default.args = {};`.trim(), 31 | }; 32 | 33 | export default auto({ 34 | id: "react-component-inline", 35 | title: "React Component (inline)", 36 | params: { 37 | name: { 38 | title: "Component Name", 39 | type: "string", 40 | }, 41 | }, 42 | isValid: (project) => project.hasDependency("react"), 43 | run: async ({ project, params, self, t }) => { 44 | console.log("Running:", self.id); 45 | 46 | const componentDirectoryPath = await prompt.input({ 47 | message: "Target path:", 48 | default: `${project.hasDirectory("src/components") ? "src/components" : ""}/${params.name}`, 49 | }); 50 | 51 | // component directory 52 | console.log("Creating directory:", componentDirectoryPath); 53 | project.createDirectory(componentDirectoryPath); 54 | 55 | // component 56 | project.writeFile(t(`${componentDirectoryPath}/__name__.tsx`), t(files.component)); 57 | 58 | // index 59 | project.writeFile(t(`${componentDirectoryPath}/index.ts`), t(files.index)); 60 | 61 | // story (storybook) 62 | if (project.hasDependency("@storybook/react")) { 63 | project.writeFile(t(`${componentDirectoryPath}/__name__.stories.tsx`), t(files.storybook)); 64 | } 65 | 66 | // test (testing-library) 67 | if (project.hasDependency("@testing-library/react")) { 68 | const hasUserEvent = project.hasDependency("@testing-library/user-event"); 69 | const content = t( 70 | [ 71 | 'import { render, screen } from "@testing-library/react";', 72 | hasUserEvent && 'import userEvent from "@testing-library/user-event";', 73 | ` 74 | import { __name__ } from "./__name__"; 75 | 76 | describe("__name__", () => { 77 | beforeEach(() => { 78 | jest.clearAllMocks(); 79 | }); 80 | 81 | it.todo("should render"); 82 | });`.trim(), 83 | ] 84 | .filter(Boolean) 85 | .join("\n") 86 | ); 87 | project.writeFile(t(`${componentDirectoryPath}/__name__.test.tsx`), content); 88 | } 89 | }, 90 | }); 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@andrei.fyi/auto", 3 | "version": "0.0.0-semantic-release", 4 | "repository": "https://github.com/3rd/auto", 5 | "description": "Powerful TypeScript-based automation tool", 6 | "keywords": [ 7 | "typescript", 8 | "cli", 9 | "automation" 10 | ], 11 | "author": { 12 | "name": "Andrei Neculaesei", 13 | "email": "3rd@users.noreply.github.com" 14 | }, 15 | "license": "MIT", 16 | "type": "module", 17 | "files": [ 18 | "dist" 19 | ], 20 | "main": "./dist/main.mjs", 21 | "exports": { 22 | "./loader-esm": { 23 | "import": "./dist/loader-esm.mjs" 24 | }, 25 | "./loader-cjs": { 26 | "require": "./dist/loader-cjs.cjs" 27 | }, 28 | "./globals": { 29 | "import": "./dist/globals/index.mjs", 30 | "types": "./dist/globals/index.d.ts" 31 | } 32 | }, 33 | "bin": { 34 | "auto": "./dist/main.js" 35 | }, 36 | "engines": { 37 | "node": ">= 18.0.0" 38 | }, 39 | "scripts": { 40 | "dev": "pnpm test:watch", 41 | "build:watch": "pkgroll --watch", 42 | "build": "pnpm run clean && pkgroll --target=node18", 43 | "test": "NODE_OPTIONS='--import=tsx --no-warnings' ava", 44 | "test:watch": "NODE_OPTIONS='--import=tsx --no-warnings' ava --watch", 45 | "e2e": "docker run --rm -it $(docker build -f src/e2e/Dockerfile -q .)", 46 | "tsc": "tsc", 47 | "prepare": "husky install", 48 | "prepublishOnly": "pnpm run build && pnpm run tsc && pnpm run test", 49 | "clean": "rm -rf dist" 50 | }, 51 | "ava": { 52 | "extensions": { 53 | "ts": "commonjs" 54 | } 55 | }, 56 | "lint-staged": { 57 | "*": "prettier --ignore-unknown --write" 58 | }, 59 | "prettier": { 60 | "arrowParens": "always", 61 | "bracketSpacing": true, 62 | "jsxBracketSameLine": false, 63 | "printWidth": 120, 64 | "quoteProps": "as-needed", 65 | "semi": true, 66 | "singleQuote": false, 67 | "tabWidth": 2, 68 | "trailingComma": "es5", 69 | "useTabs": false 70 | }, 71 | "devDependencies": { 72 | "@commitlint/cli": "^18.4.4", 73 | "@commitlint/config-conventional": "^18.4.4", 74 | "@semantic-release/changelog": "^6.0.3", 75 | "@semantic-release/git": "^10.0.1", 76 | "@types/node": "^20.11.5", 77 | "@types/sinon": "^17.0.3", 78 | "ava": "^6.0.1", 79 | "husky": "^8.0.3", 80 | "lint-staged": "^15.2.0", 81 | "pkgroll": "^2.0.1", 82 | "prettier": "^3.2.4", 83 | "semantic-release": "^23.0.0", 84 | "sinon": "^17.0.1", 85 | "typescript": "^5.3.3" 86 | }, 87 | "dependencies": { 88 | "@inquirer/prompts": "^3.3.0", 89 | "@types/cross-spawn": "^6.0.6", 90 | "@types/fs-extra": "^11.0.4", 91 | "@types/lodash": "^4.14.202", 92 | "@types/which": "^3.0.3", 93 | "chalk": "^5.3.0", 94 | "cleye": "^1.3.2", 95 | "cross-spawn": "^7.0.3", 96 | "env-paths": "^3.0.0", 97 | "execa": "^8.0.1", 98 | "find-up": "^7.0.0", 99 | "fs-extra": "^11.2.0", 100 | "glob": "^10.3.10", 101 | "lodash": "^4.17.21", 102 | "tsx": "^4.7.0", 103 | "which": "^4.0.0" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Project } from "./Project"; 2 | import { debug } from "./utils/logger"; 3 | 4 | type ParamType = "boolean" | "number" | "string"; 5 | 6 | type ParamValueType = T extends "boolean" 7 | ? boolean 8 | : T extends "number" 9 | ? number 10 | : T extends "string" 11 | ? string 12 | : never; 13 | 14 | type ScriptParam> = { 15 | title: string; 16 | type: T; 17 | defaultValue?: 18 | | ParamValueType 19 | | ((args: { project: Project; params: { [K in keyof P]: ParamValueType } }) => ParamValueType | undefined); 20 | required?: boolean; 21 | }; 22 | 23 | type Params = Record> = { 24 | [K in keyof T]: ScriptParam & { type: T[K] }; 25 | }; 26 | 27 | type Script

> = { 28 | id: string; 29 | title?: string; 30 | params?: Params

; 31 | isValid?: (project: Project) => boolean; 32 | run: (args: { 33 | cwd: string; 34 | project: Project; 35 | self: Script

; 36 | params: { [K in keyof P]: ParamValueType }; 37 | files: { path: string; content: string }[]; 38 | fileMap: Record; 39 | t: (text: string, params?: Record) => string; 40 | }) => void; 41 | }; 42 | 43 | const getDefaultParamValue = (type: T) => { 44 | debug("Getting default value for param type:", type); 45 | const defaultValues: Record> = { 46 | boolean: false, 47 | number: 0, 48 | string: "", 49 | }; 50 | const value = defaultValues[type] as ParamValueType; 51 | debug("Default value:", value); 52 | return value; 53 | }; 54 | 55 | const autoSymbol = Symbol.for("auto"); 56 | 57 | const auto =

>(script: Script

) => { 58 | debug("Initializing auto script:", script.id); 59 | debug("Script configuration:", { 60 | id: script.id, 61 | title: script.title, 62 | params: script.params, 63 | isValid: script.isValid, 64 | }); 65 | 66 | return { 67 | [autoSymbol]: true, 68 | ...script, 69 | isLocal: false, 70 | path: "", 71 | bootstrapParams: () => { 72 | debug("Bootstrapping params for script:", script.id); 73 | const params = Object.fromEntries( 74 | Object.entries(script.params ?? {}).map(([key, param]) => { 75 | debug("Processing param:", { key, param }); 76 | let value = getDefaultParamValue(param.type); 77 | if (typeof param.defaultValue !== "function" && param.defaultValue !== undefined) { 78 | value = param.defaultValue; 79 | debug("Using static default value:", { key, value }); 80 | } 81 | return [key, { ...param, value }]; 82 | }) as [keyof P, ScriptParam & { value: ParamValueType }][] 83 | ); 84 | debug("Bootstrapped params:", params); 85 | return params; 86 | }, 87 | }; 88 | }; 89 | 90 | type AutoType = typeof auto; 91 | type AutoReturnType = ReturnType; 92 | 93 | export type { AutoReturnType, AutoType, Params, ParamType, ParamValueType, Script, ScriptParam }; 94 | export { auto, autoSymbol }; 95 | -------------------------------------------------------------------------------- /src/e2e/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | /* eslint-disable unicorn/no-await-expression-member */ 3 | import { resolve } from "node:path"; 4 | import fs from "fs-extra"; 5 | import assert from "node:assert"; 6 | import { setupPackage, setupTSConfig } from "../setup"; 7 | import { getGlobalRepositoryPath } from "../utils/path"; 8 | import { commands as commandTests } from "./commands"; 9 | import * as exampleTests from "./examples"; 10 | import { generateMockProject } from "./utils"; 11 | 12 | type Test = { 13 | name?: string; 14 | run: (cwd: string) => Promise<{ 15 | stdout?: string; 16 | // eslint-disable-next-line @typescript-eslint/no-invalid-void-type 17 | } | void>; 18 | project?: Record; 19 | prepare?: (cwd: string) => Promise; // cwd is the mocked project cwd if present, or the current pwd 20 | expected: { 21 | stdout?: string | ((args: { cwd?: string }) => string); 22 | files?: Record string)>; 23 | }; 24 | }; 25 | 26 | // global setup 27 | const globalRepositoryPath = getGlobalRepositoryPath(); 28 | console.log(`Setting up global repository at: ${globalRepositoryPath}`); 29 | await fs.mkdirp(globalRepositoryPath); 30 | await fs.copy("./examples", globalRepositoryPath); 31 | const tsConfigPath = resolve(globalRepositoryPath, "tsconfig.json"); 32 | await setupTSConfig(tsConfigPath); 33 | await setupPackage(resolve(globalRepositoryPath, "package.json")); 34 | 35 | // generate tsconfig 36 | assert(await fs.exists(tsConfigPath)); 37 | const tsConfig = await fs.readJson(tsConfigPath); 38 | assert.deepEqual( 39 | tsConfig, 40 | { 41 | compilerOptions: { 42 | strict: true, 43 | lib: [], 44 | jsx: "react-jsx", 45 | baseUrl: ".", 46 | typeRoots: ["/root/source/dist/globals"], 47 | paths: { 48 | auto: ["/root/source/dist/globals"], 49 | }, 50 | }, 51 | }, 52 | "Generated tsconfig.json is invalid." 53 | ); 54 | 55 | const tests = { ...commandTests, ...exampleTests }; 56 | for (const [name, test] of Object.entries(tests)) { 57 | let cwd = process.cwd(); 58 | console.log(`Testing: ${test.name ?? name}`); 59 | if (test.project) { 60 | const projectPath = await generateMockProject(test.project); 61 | cwd = projectPath; 62 | console.log(` - Generated mock project at: ${projectPath}`); 63 | } 64 | if (test.prepare) { 65 | await test.prepare(cwd); 66 | } 67 | const result = await test.run(cwd); 68 | if (test.expected.stdout) { 69 | if (!result?.stdout) throw new Error(`Test "${test.name ?? name}" doesn't provide stdout.`); 70 | const expectedStdout = 71 | typeof test.expected.stdout === "function" ? test.expected.stdout({ cwd }) : test.expected.stdout; 72 | assert.equal(result.stdout.trim(), expectedStdout.trim(), `Test "${test.name ?? name}" stdout is invalid.`); 73 | } 74 | if (test.expected.files) { 75 | const existingFiles = await fs.readdir(cwd); 76 | console.log("Files in cwd:", existingFiles); 77 | for (const [path, expectedContent] of Object.entries(test.expected.files)) { 78 | const filePath = resolve(cwd, path); 79 | const actualContent = await fs.readFile(filePath, "utf8"); 80 | assert.equal( 81 | actualContent.trim(), 82 | (typeof expectedContent === "function" ? expectedContent(actualContent).trim() : expectedContent).trim(), 83 | `Test "${test.name ?? name}" file ${path} is invalid.` 84 | ); 85 | } 86 | } 87 | } 88 | 89 | export type { Test }; 90 | -------------------------------------------------------------------------------- /examples/generate-single-file.ts: -------------------------------------------------------------------------------- 1 | import "auto"; 2 | 3 | export default auto({ 4 | id: "file", 5 | title: "Generate single file", 6 | run: async ({ project }) => { 7 | const type = await prompt.select({ 8 | message: "Select type", 9 | choices: [ 10 | { 11 | value: "tsconfig", 12 | }, 13 | ], 14 | }); 15 | 16 | if (type === "tsconfig") { 17 | if (project.hasFile("tsconfig.json")) { 18 | console.error("tsconfig.json already exists"); 19 | } 20 | 21 | const base = { 22 | $schema: "https://json.schemastore.org/tsconfig", 23 | compilerOptions: { 24 | strict: true, 25 | allowJs: false, 26 | checkJs: false, 27 | noEmit: true, 28 | esModuleInterop: true, 29 | skipLibCheck: true, 30 | }, 31 | }; 32 | 33 | const templates = { 34 | node: { 35 | module: "Node16", 36 | target: "ES2022", 37 | lib: ["ES2023"], 38 | moduleResolution: "node16", 39 | }, 40 | react: { 41 | module: "ESNext", 42 | target: "ESNext", 43 | lib: ["DOM", "DOM.Iterable", "ESNext"], 44 | moduleResolution: "bundler", 45 | jsx: "react-jsx", 46 | allowJs: false, 47 | allowSyntheticDefaultImports: true, 48 | esModuleInterop: false, 49 | isolatedModules: true, 50 | resolveJsonModule: true, 51 | skipLibCheck: true, 52 | useDefineForClassFields: true, 53 | }, 54 | }; 55 | 56 | const variant = await prompt.select({ 57 | message: "Select variant", 58 | choices: Object.keys(templates).map((key) => ({ value: key })), 59 | }); 60 | 61 | const template = templates[variant as keyof typeof templates]; 62 | 63 | const options = await prompt.checkbox({ 64 | message: "Select options", 65 | choices: [ 66 | new prompt.Separator("Features"), 67 | { value: "noEmit", checked: true }, 68 | { value: "allowJs" }, 69 | { value: "checkJs" }, 70 | new prompt.Separator("Rules"), 71 | { value: "allowUnreachableCode" }, 72 | { value: "allowUnusedLabels" }, 73 | { value: "exactOptionalPropertyTypes" }, 74 | { value: "forceConsistentCasingInFileNames" }, 75 | { value: "noFallthroughCasesInSwitch" }, 76 | { value: "noImplicitOverride" }, 77 | { value: "noImplicitReturns" }, 78 | { value: "noPropertyAccessFromIndexSignature" }, 79 | { value: "noUncheckedIndexedAccess" }, 80 | { value: "noUnusedLocals" }, 81 | { value: "noUnusedParameters" }, 82 | new prompt.Separator("Paths"), 83 | { name: "@/* -> src/*", value: "@src" }, 84 | ], 85 | }); 86 | 87 | const config = { 88 | ...base, 89 | compilerOptions: { 90 | ...base.compilerOptions, 91 | ...template, 92 | ...options.reduce( 93 | (acc, option) => { 94 | if (option === "@src") { 95 | acc.baseUrl = "."; 96 | acc.paths = { 97 | ...acc.paths, 98 | "@/*": ["src/*"], 99 | }; 100 | return acc; 101 | } 102 | acc[option] = true; 103 | return acc; 104 | }, 105 | {} as Record 106 | ), 107 | }, 108 | }; 109 | 110 | project.writeFile("tsconfig.json", JSON.stringify(config, null, 2)); 111 | console.log("tsconfig.json created"); 112 | } 113 | }, 114 | }); 115 | -------------------------------------------------------------------------------- /src/Project.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import { resolve } from "node:path"; 3 | import chalk from "chalk"; 4 | import { resolveProjectRoot } from "./utils/path"; 5 | 6 | type Dependency = { 7 | name: string; 8 | version?: string; 9 | }; 10 | 11 | class Project { 12 | rootDirectory: string; 13 | 14 | constructor(rootDirectory: string) { 15 | this.rootDirectory = rootDirectory; 16 | } 17 | 18 | static resolveFromPath(path: string = process.cwd()) { 19 | return new Project(resolveProjectRoot(path)); 20 | } 21 | 22 | get isGoProject() { 23 | return this.hasFile("go.mod"); 24 | } 25 | 26 | get isJavaScriptProject() { 27 | return this.hasFile("package.json"); 28 | } 29 | 30 | get isTypeScriptProject() { 31 | return this.isJavaScriptProject && this.hasFile("tsconfig.json"); 32 | } 33 | 34 | get isNodeProject() { 35 | if (!this.isJavaScriptProject) return false; 36 | if (this.hasDependency("@types/node")) return true; 37 | const packageJson = this.readJSON("package.json"); 38 | return packageJson?.engines?.node !== undefined; 39 | } 40 | 41 | get dependencies() { 42 | const dependencies: Dependency[] = []; 43 | if (this.isJavaScriptProject) { 44 | const packageJson = this.readJSON("package.json"); 45 | for (const [name, version] of Object.entries({ 46 | ...(packageJson.dependencies ?? []), 47 | ...(packageJson.devDependencies ?? []), 48 | ...(packageJson.peerDependencies ?? []), 49 | })) { 50 | dependencies.push({ name, version: typeof version === "string" ? version : undefined }); 51 | } 52 | } 53 | if (this.isGoProject) { 54 | const goMod = this.readFile("go.mod"); 55 | const requireLines = /require \(([\S\s]*?)\)/.exec(goMod)?.[1]; 56 | if (requireLines) { 57 | for (const module of requireLines.trim().split("\n")) { 58 | const [name, version] = module.trim().split(" "); 59 | dependencies.push({ name, version }); 60 | } 61 | } 62 | } 63 | return dependencies; 64 | } 65 | 66 | resolvePath(...paths: string[]) { 67 | return resolve(this.rootDirectory, ...paths); 68 | } 69 | 70 | hasPath(...paths: string[]) { 71 | return fs.existsSync(this.resolvePath(...paths)); 72 | } 73 | 74 | hasFile(...paths: string[]) { 75 | return this.hasPath(...paths) && fs.lstatSync(this.resolvePath(...paths)).isFile(); 76 | } 77 | 78 | readFile(...path: string[]) { 79 | return fs.readFileSync(this.resolvePath(...path), "utf8"); 80 | } 81 | 82 | writeFile(path: string, content: string, overwrite = false) { 83 | const resolvedPath = this.resolvePath(path); 84 | console.log(chalk.blue("Writing file:"), resolvedPath); 85 | if (!overwrite && fs.existsSync(resolvedPath)) { 86 | throw new Error(`File already exists: ${resolvedPath}`); 87 | } 88 | 89 | fs.outputFileSync(resolvedPath, content); 90 | } 91 | 92 | hasDirectory(path: string) { 93 | return this.hasPath(path) && fs.lstatSync(this.resolvePath(path)).isDirectory(); 94 | } 95 | 96 | createDirectory(path: string) { 97 | const resolvedPath = this.resolvePath(path); 98 | console.log(chalk.blue("Creating directory:"), resolvedPath); 99 | if (fs.existsSync(resolvedPath)) throw new Error(`Directory already exists: ${resolvedPath}`); 100 | 101 | fs.mkdirSync(resolvedPath, { recursive: true }); 102 | } 103 | 104 | readJSON(path: string) { 105 | return fs.readJsonSync(this.resolvePath(path)); 106 | } 107 | 108 | hasDependency(name: string, version?: string) { 109 | return this.dependencies.some((dependency) => { 110 | if (dependency.name !== name) return false; 111 | if (version && dependency.version !== version) return false; 112 | return true; 113 | }); 114 | } 115 | 116 | hasAnyDependency(names: string[]) { 117 | return this.dependencies.some((dependency) => names.includes(dependency.name)); 118 | } 119 | } 120 | 121 | export { Project }; 122 | -------------------------------------------------------------------------------- /src/Project.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import sinon from "sinon"; 3 | import { stub } from "./utils/test"; 4 | import { Project } from "./Project"; 5 | 6 | test.beforeEach(() => { 7 | sinon.restore(); 8 | }); 9 | 10 | test("detects Go project", async (t) => { 11 | const hasFile = stub(Project.prototype, "hasFile"); 12 | hasFile.returns(false); 13 | hasFile.withArgs("go.mod").returns(true); 14 | t.true(Project.resolveFromPath().isGoProject); 15 | }); 16 | 17 | test("detects JavaScript project", async (t) => { 18 | const hasFile = stub(Project.prototype, "hasFile"); 19 | hasFile.returns(false); 20 | hasFile.withArgs("package.json").returns(true); 21 | t.true(Project.resolveFromPath().isJavaScriptProject); 22 | }); 23 | 24 | test("detects TypeScript project", async (t) => { 25 | const hasFile = stub(Project.prototype, "hasFile"); 26 | hasFile.returns(false); 27 | hasFile.withArgs("package.json").returns(true); 28 | hasFile.withArgs("tsconfig.json").returns(true); 29 | t.true(Project.resolveFromPath().isJavaScriptProject); 30 | t.true(Project.resolveFromPath().isTypeScriptProject); 31 | }); 32 | 33 | test("detects Node project", async (t) => { 34 | const hasFile = stub(Project.prototype, "hasFile"); 35 | hasFile.returns(false); 36 | hasFile.withArgs("package.json").returns(true); 37 | const readJSON = stub(Project.prototype, "readJSON"); 38 | 39 | // not a Node project 40 | readJSON.returns({}); 41 | t.false(Project.resolveFromPath().isNodeProject); 42 | 43 | // Node project with engines.node 44 | readJSON.returns({ engines: { node: "x" } }); 45 | t.true(Project.resolveFromPath().isNodeProject); 46 | 47 | // Node project with @types/node 48 | readJSON.returns({ dependencies: { "@types/node": "x" } }); 49 | t.true(Project.resolveFromPath().isNodeProject); 50 | }); 51 | 52 | test("gets JavaScript dependencies", async (t) => { 53 | const hasFile = stub(Project.prototype, "hasFile"); 54 | hasFile.returns(false); 55 | hasFile.withArgs("package.json").returns(true); 56 | const readJSON = stub(Project.prototype, "readJSON"); 57 | readJSON.returns({ 58 | dependencies: { foo: "1.0.0" }, 59 | devDependencies: { bar: "2.0.0" }, 60 | peerDependencies: { baz: "3.0.0" }, 61 | }); 62 | t.deepEqual(Project.resolveFromPath().dependencies, [ 63 | { name: "foo", version: "1.0.0" }, 64 | { name: "bar", version: "2.0.0" }, 65 | { name: "baz", version: "3.0.0" }, 66 | ]); 67 | }); 68 | 69 | test("gets Go dependencies", async (t) => { 70 | const hasFile = stub(Project.prototype, "hasFile"); 71 | hasFile.returns(false); 72 | hasFile.withArgs("go.mod").returns(true); 73 | const readFile = stub(Project.prototype, "readFile"); 74 | readFile.returns(` 75 | module github.com/owner/repo 76 | 77 | require ( 78 | github.com/foo/bar v1.0.0 79 | github.com/baz/qux v2.0.0 80 | ) 81 | `); 82 | t.deepEqual(Project.resolveFromPath().dependencies, [ 83 | { name: "github.com/foo/bar", version: "v1.0.0" }, 84 | { name: "github.com/baz/qux", version: "v2.0.0" }, 85 | ]); 86 | }); 87 | 88 | test("gets multi-source dependencies", async (t) => { 89 | const hasFile = stub(Project.prototype, "hasFile"); 90 | hasFile.returns(false); 91 | hasFile.withArgs("package.json").returns(true); 92 | hasFile.withArgs("go.mod").returns(true); 93 | const readJSON = stub(Project.prototype, "readJSON"); 94 | readJSON.returns({ 95 | dependencies: { foo: "1.0.0" }, 96 | devDependencies: { bar: "2.0.0" }, 97 | peerDependencies: { baz: "3.0.0" }, 98 | }); 99 | const readFile = stub(Project.prototype, "readFile"); 100 | readFile.returns(` 101 | module github.com/owner/repo 102 | 103 | require ( 104 | github.com/foo/bar v1.0.0 105 | github.com/baz/qux v2.0.0 106 | ) 107 | `); 108 | t.deepEqual(Project.resolveFromPath().dependencies, [ 109 | { name: "foo", version: "1.0.0" }, 110 | { name: "bar", version: "2.0.0" }, 111 | { name: "baz", version: "3.0.0" }, 112 | { name: "github.com/foo/bar", version: "v1.0.0" }, 113 | { name: "github.com/baz/qux", version: "v2.0.0" }, 114 | ]); 115 | }); 116 | 117 | test("checks if dependency exists", async (t) => { 118 | const hasFile = stub(Project.prototype, "hasFile"); 119 | hasFile.returns(false); 120 | hasFile.withArgs("package.json").returns(true); 121 | const readJSON = stub(Project.prototype, "readJSON"); 122 | readJSON.returns({ 123 | dependencies: { foo: "1.0.0" }, 124 | devDependencies: { bar: "2.0.0" }, 125 | peerDependencies: { baz: "3.0.0" }, 126 | }); 127 | t.true(Project.resolveFromPath().hasDependency("foo")); 128 | t.true(Project.resolveFromPath().hasDependency("bar")); 129 | t.true(Project.resolveFromPath().hasDependency("baz")); 130 | t.false(Project.resolveFromPath().hasDependency("brr")); 131 | }); 132 | 133 | test("checks if any of dependency[] exists", async (t) => { 134 | const hasFile = stub(Project.prototype, "hasFile"); 135 | hasFile.returns(false); 136 | hasFile.withArgs("package.json").returns(true); 137 | const readJSON = stub(Project.prototype, "readJSON"); 138 | readJSON.returns({ 139 | dependencies: { foo: "1.0.0" }, 140 | }); 141 | t.true(Project.resolveFromPath().hasAnyDependency(["foo", "bar", "baz"])); 142 | t.false(Project.resolveFromPath().hasAnyDependency(["moo", "bar", "baz"])); 143 | }); 144 | -------------------------------------------------------------------------------- /src/commands/run.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | import * as fs from "node:fs"; 3 | import { command } from "cleye"; 4 | import chalk from "chalk"; 5 | import { globSync } from "glob"; 6 | import * as inquirer from "@inquirer/prompts"; 7 | import { Project } from "../Project"; 8 | import { AutoReturnType } from "../types"; 9 | import { tildify } from "../utils/path"; 10 | import { dirname, resolve } from "node:path"; 11 | import * as log from "../utils/logger"; 12 | 13 | const toKebabCase = (str: string) => { 14 | return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); 15 | }; 16 | 17 | const createRunCommand = (project: Project, scripts: AutoReturnType[]) => 18 | command( 19 | { 20 | name: "run", 21 | alias: "r", 22 | parameters: ["