Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
brentvatne committed Mar 20, 2024
0 parents commit d7134ad
Show file tree
Hide file tree
Showing 15 changed files with 434 additions and 0 deletions.
19 changes: 19 additions & 0 deletions .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
28 changes: 28 additions & 0 deletions .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
15 changes: 15 additions & 0 deletions .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
11 changes: 11 additions & 0 deletions README.md
@@ -0,0 +1,11 @@
# tutorial

Quick access to Expo and React Native tutorials from your terminal.

```
npx tutorial
```

<hr />

Thank you [Gabe](https://github.com/garbles) for the package name!
Binary file added bun.lockb
Binary file not shown.
50 changes: 50 additions & 0 deletions 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 <brent@expo.dev>, Cedric van Putten <github@cedric.dev>",
"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
}
}
30 changes: 30 additions & 0 deletions 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);
61 changes: 61 additions & 0 deletions 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);
}
19 changes: 19 additions & 0 deletions 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
`;
}
7 changes: 7 additions & 0 deletions src/utils/env.ts
@@ -0,0 +1,7 @@
import { boolish } from 'getenv';

export const env = {
get CI() {
return boolish('CI', false);
},
};
31 changes: 31 additions & 0 deletions 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;
}
}
111 changes: 111 additions & 0 deletions 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<ReturnType<typeof github.rest.repos.getContent>>;

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}`;
}

0 comments on commit d7134ad

Please sign in to comment.