diff --git a/.github/actions/setup-project/action.yml b/.github/actions/setup-project/action.yml new file mode 100644 index 0000000..bf7892b --- /dev/null +++ b/.github/actions/setup-project/action.yml @@ -0,0 +1,19 @@ +name: Setup Project +description: Prepare the project in GitHub Actions + +inputs: + bun-version: + description: Version of Bun to use + default: latest + +runs: + using: composite + steps: + - name: 🏗 Setup Node + uses: oven-sh/setup-bun@v1 + with: + bun-version: ${{ inputs.bun-version }} + + - name: 📦 Install dependencies + run: bun install + shell: bash diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml new file mode 100644 index 0000000..30a6249 --- /dev/null +++ b/.github/workflows/review.yml @@ -0,0 +1,28 @@ +name: review + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + types: [opened, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + packages: + runs-on: ubuntu-latest + steps: + - name: 🏗 Setup repository + uses: actions/checkout@v3 + + - name: 🏗 Setup project + uses: ./.github/actions/setup-project + + # - name: ✅ Lint packages + # run: bun run lint --max-warnings 0 + + - name: 👷 Build packages + run: bun run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..689c2a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# project +build/ + +# dependencies +node_modules/ +npm-debug.* +yarn-debug.* +yarn-error.* +package-lock.json +yarn.lock + +# macOS +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..10b67d3 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# tutorial + +Quick access to Expo and React Native tutorials from your terminal. + +``` +npx tutorial +``` + +
+ +Thank you [Gabe](https://github.com/garbles) for the package name! \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..7117b04 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..9d8061b --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "tutorial", + "version": "0.1.4", + "description": "Open a tutorial for Expo, EAS, React, and React Native", + "keywords": [ + "tutorial", + "react-native", + "cli" + ], + "type": "module", + "main": "build/index.js", + "bin": "build/index.js", + "file": [ + "build" + ], + "scripts": { + "start": "bun build ./src/index.ts --outdir ./build --target node", + "build": "bun build ./src/index.ts --outdir ./build --target node --minify", + "clean": "git clean ./build -xdf", + "lint": "eslint ." + }, + "author": "Brent Vatne , Cedric van Putten ", + "license": "MIT", + "files": [ + "build" + ], + "devDependencies": { + "@types/getenv": "^1.0.3", + "@types/js-yaml": "^4.0.9", + "arg": "^5.0.2", + "chalk": "^5.3.0", + "eslint": "^8.56.0", + "eslint-config-universe": "^12.0.0", + "getenv": "^1.0.0", + "js-yaml": "^4.1.0", + "open": "^10.0.3", + "ora": "^8.0.1", + "prettier": "^3.2.4", + "prompts": "^2.4.2" + }, + "eslintConfig": { + "extends": "universe/node" + }, + "prettier": { + "printWidth": 100, + "tabWidth": 2, + "singleQuote": true, + "bracketSameLine": true + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..148c4fb --- /dev/null +++ b/src/index.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import arg from 'arg'; + +import { pickTutorial } from './pickTutorial'; +import { renderHelp } from './renderHelp'; +import { handleError } from './utils/errors'; + +export type Input = typeof args; + +const args = arg({ + '--help': Boolean, + '--version': Boolean, + '-h': '--help', + '-v': '--version', +}); + +if (args['--help']) { + console.log(renderHelp()); + process.exit(0); +} + +if (args['--version']) { + console.log(require('../package.json').version); + process.exit(0); +} + +process.on('SIGINT', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); + +await pickTutorial(args).catch(handleError); diff --git a/src/pickTutorial.ts b/src/pickTutorial.ts new file mode 100644 index 0000000..dd9ad51 --- /dev/null +++ b/src/pickTutorial.ts @@ -0,0 +1,61 @@ +import chalk from 'chalk'; +import open from 'open'; +import ora from 'ora'; + +import { type Input } from '.'; +import { prompt } from './utils/prompts'; + +type Choice = { + id: string; + title: string; + description: string; + value: { + url: string; + title: string; + }; +}; + +const choices: Choice[] = [ + { + id: '_blank', + title: 'The fundamentals of developing an app with Expo', + description: `A guided tutorial that walks you through the basics of creating a universal app that runs on Android, iOS and the web`, + value: { + url: 'https://docs.expo.dev/tutorial/', + title: '_blank', + }, + }, + { + id: '_blank', + title: 'Build and deploy your app with Expo Application Services (EAS)', + description: `Everything you need to know to get started with EAS and build and deploy your app to the app stores`, + value: { + url: 'https://egghead.io/courses/build-and-deploy-react-native-apps-with-expo-eas-85ab521e', + title: '_blank', + }, + }, + { + id: '_blank', + title: 'Introduction to React Native', + description: `Learn about the React fundamentals and basic APIs and components that you'll need for any React Native app`, + value: { + url: 'https://reactnative.dev/docs/getting-started', + title: '_blank', + }, + }, +]; + +export async function pickTutorial(arg: Input) { + if (!process.stdout.isTTY) { + await open(choices[0].value.url); + } + + const { choice } = await prompt({ + type: 'select', + name: 'choice', + message: 'Pick a tutorial', + choices, + }); + + await open(choice.url); +} diff --git a/src/renderHelp.ts b/src/renderHelp.ts new file mode 100644 index 0000000..0680b5e --- /dev/null +++ b/src/renderHelp.ts @@ -0,0 +1,19 @@ +import chalk from 'chalk'; + +import { detectPackageManager } from './utils/node'; + +export function renderHelp() { + const manager = detectPackageManager(); + + // Note, template literal is broken with bun build + // In the future, support: + // ${chalk.dim('$')} ${manager} [tool] + return ` + ${chalk.bold('Usage')} + ${chalk.dim('$')} ${manager} [tool] + + ${chalk.bold('Options')} + --version, -v Version number + --help, -h Usage info + `; +} diff --git a/src/utils/env.ts b/src/utils/env.ts new file mode 100644 index 0000000..3f73afd --- /dev/null +++ b/src/utils/env.ts @@ -0,0 +1,7 @@ +import { boolish } from 'getenv'; + +export const env = { + get CI() { + return boolish('CI', false); + }, +}; diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..654b308 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,31 @@ +import chalk from 'chalk'; + +export class AbortError extends Error { + readonly name = 'AbortError'; +} + +export class CommandError extends Error { + readonly name = 'CommandError'; + + constructor( + readonly code: string, + message: string = '', + ) { + super(message); + } +} + +export function handleError(error: any) { + switch (error?.name) { + case 'AbortError': + console.warn(chalk.red(`Command aborted: ${error.message}`)); + return process.exit(1); + + case 'CommandError': + console.warn(chalk.red(`Command failed: ${error.message} (${error.code})`)); + return process.exit(1); + + default: + throw error; + } +} diff --git a/src/utils/github.ts b/src/utils/github.ts new file mode 100644 index 0000000..04a15c7 --- /dev/null +++ b/src/utils/github.ts @@ -0,0 +1,111 @@ +import yaml from 'js-yaml'; +import { Octokit } from 'octokit'; + +export type GithubRepository = { + owner: string; + name: string; +}; + +type GithubIssueTemplate = { + id: string; + name: string; + description?: string; + labels?: string[]; + projects?: string[]; + assignees?: string[]; + body?: any; +}; + +/** @see https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#get-repository-content */ +async function fetchIssueTemplateFileNames(github: Octokit, repo: GithubRepository) { + let response: Awaited>; + + try { + response = await github.rest.repos.getContent({ + owner: repo.owner, + repo: repo.name, + path: '.github/ISSUE_TEMPLATE', + mediaType: { + format: 'raw', + }, + }); + } catch (error) { + if (error.status === 404) return []; + throw error; + } + + // TODO: figure out why this is a thing + if (!Array.isArray(response.data)) return []; + + return response.data + .filter( + (entity) => + entity.type === 'file' && (entity.path.endsWith('.yml') || entity.path.endsWith('.yaml')), + ) + .map((file) => ({ + name: file.name as string, + path: file.path as string, + sha: file.sha as string, + size: file.size as number, + downloadUrl: file.download_url as string, + })); +} + +/** + * Fetch the issue template, and configuration, from a GitHub repository. + * This checks the `.github/ISSUE_TEMPLATE` directory for files ending in `.yml` or `.yaml`. + * + * @see https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository#configuring-the-template-chooser + */ +export async function fetchIssueTemplates(github: Octokit, repo: GithubRepository) { + const files = await fetchIssueTemplateFileNames(github, repo); + const contents = await Promise.allSettled( + files.map((file) => + fetch(file.downloadUrl) + .then((response) => response.text()) + .then((content) => yaml.load(content) as any), + ), + ); + + const filesWithContent = contents.map((content, index) => ({ + ...files[index], + content: content.status === 'fulfilled' ? content.value : undefined, + })); + + const configFile = filesWithContent.find( + (file) => file.name === 'config.yml' || file.name === 'config.yaml', + ); + + return { + emptyIssuesEnabled: configFile?.content?.blank_issues_enabled ?? true, + links: configFile?.content?.contact_links ?? [], + templates: filesWithContent.filter((file) => file !== configFile), + }; +} + +/** + * Create the URL used to fill in a new issue, using optional template. + * + * @see https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository#creating-issue-forms + */ +export function createGithubIssueUrl(repo: GithubRepository, template?: GithubIssueTemplate) { + const url = new URL(`https://github.com/${repo.owner}/${repo.name}/issues/new`); + + if (!template) return `${url}`; + + url.searchParams.append('template', template.id); + + if (template.labels?.length) { + url.searchParams.append('labels', template.labels.join(',')); + } + + if (template.projects?.length) { + url.searchParams.append('projects', template.projects.join(',')); + } + + if (template.assignees?.length) { + url.searchParams.append('assignees', template.assignees.join(',')); + } + + return `${url}`; +} diff --git a/src/utils/node.ts b/src/utils/node.ts new file mode 100644 index 0000000..a8191b1 --- /dev/null +++ b/src/utils/node.ts @@ -0,0 +1,21 @@ +export type PackageManager = 'bun' | 'yarn' | 'npm' | 'pnpm'; + +/** + * Detect the currently used package manager. + * This looks up the `npm_config_user_agent` environment variable. + */ +export function detectPackageManager(): PackageManager { + const header = process.env.npm_config_user_agent; + + if (header?.startsWith('bun/')) { + return 'bun'; + } else if (header?.startsWith('yarn/')) { + return 'yarn'; + } else if (header?.startsWith('npm/')) { + return 'npm'; + } else if (header?.startsWith('pnpm/')) { + return 'pnpm'; + } + + return 'npm'; +} diff --git a/src/utils/prompts.ts b/src/utils/prompts.ts new file mode 100644 index 0000000..a7b194f --- /dev/null +++ b/src/utils/prompts.ts @@ -0,0 +1,24 @@ +import prompts, { type PromptObject } from 'prompts'; + +import { env } from './env'; +import { AbortError, CommandError } from './errors'; + +export function prompt(question: PromptObject) { + if (!isInteractive()) { + throw new CommandError( + 'NON_INTERACTIVE', + 'Input is required but process is in non-interactive mode.', + ); + } + + return prompts(question, { + onCancel() { + throw new AbortError('Question was not answered'); + }, + }); +} + +/** Determine if the current process can receive input */ +function isInteractive() { + return !env.CI && process.stdin.isTTY; +} diff --git a/src/utils/yaml.ts b/src/utils/yaml.ts new file mode 100644 index 0000000..25ccc85 --- /dev/null +++ b/src/utils/yaml.ts @@ -0,0 +1,7 @@ +import yaml from 'js-yaml'; + +export function fetchAndParseYaml(url: string): Promise { + return fetch(url) + .then((response) => response.text()) + .then((text) => yaml.load(text) as T); +}