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);
+}