Skip to content

feat(add): init Guided provider setup #7

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jun 14, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions commands/add.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @ts-check

import * as y from "yoctocolors"
import open from "open"
import clipboard from "clipboardy"
import { select, input, password } from "@inquirer/prompts"
import { requireFramework } from "../lib/detect.js"
import { updateEnvFile } from "../lib/write-env.js"
import { providers, frameworks } from "../lib/meta.js"
import { secret } from "./index.js"
import { link, markdownToAnsi } from "../lib/markdown.js"

const choices = Object.entries(providers)
.filter(([, { setupUrl }]) => !!setupUrl)
.map(([value, { name }]) => ({ name, value }))

/** @param {string | undefined} providerId */
export async function action(providerId) {
try {
if (!providerId) {
providerId = await select({
message: "What provider do you want to set up?",
choices: choices,
})
}

const provider = providers[providerId]
if (!provider?.setupUrl) {
console.error(
y.red(
`Missing instructions for ${
provider?.name ?? providerId
}.\nInstructions are available for: ${y.bold(
choices.map((choice) => choice.name).join(", ")
)}`
)
)
return
}

const frameworkId = await requireFramework()
const framework = frameworks[frameworkId]

console.log(
y.dim(
`Setting up OAuth provider in your ${framework.name} app (${link(
"more info",
"https://authjs.dev/getting-started/authentication/oauth"
)})...\n`
)
)

const url = new URL(
`${framework.path}/callback/${providerId}`,
`http://localhost:${framework.port}`
)

clipboard.writeSync(url.toString())
console.log(
`\
${y.bold("Setup URL")}: ${provider.setupUrl}
${y.bold("Callback URL (copied to clipboard)")}: ${url}`
)

console.log("_________________________")
if (provider.instructions) {
console.log(y.dim("\nFollow the instructions:\n"))
console.log(markdownToAnsi(provider.instructions))
}
console.log(
y.dim(
`\nProvider documentation: https://providers.authjs.dev/${providerId}\n`
)
)
console.log("‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾")
console.log(y.dim("Opening setup URL in your browser...\n"))
await new Promise((resolve) => setTimeout(resolve, 3000))

await open(provider.setupUrl)

const clientId = await input({
message: `Paste ${y.magenta("Client ID")}:`,
validate: (value) => !!value,
})
const clientSecret = await password({
message: `Paste ${y.magenta("Client secret")}:`,
mask: true,
validate: (value) => !!value,
})

console.log(y.dim(`Updating environment variable file...`))

const varPrefix = `AUTH_${providerId.toUpperCase()}`

await updateEnvFile({
[`${varPrefix}_ID`]: clientId,
[`${varPrefix}_SECRET`]: clientSecret,
})

console.log(
y.dim(
`\nEnsuring that ${link(
"AUTH_SECRET",
"https://authjs.dev/getting-started/installation#setup-environment"
)} is set...`
)
)

await secret.action({})

console.log("\n🎉 Done! You can now use this provider in your app.")
} catch (error) {
if (!(error instanceof Error)) return
if (error.message.startsWith("User force closed")) return
throw error
}
}
58 changes: 1 addition & 57 deletions commands/ask.js
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import { input } from "@inquirer/prompts"
import { InkeepAI } from "@inkeep/ai-api"
import { ChatModeOptions } from "@inkeep/ai-api/models/components/index.js"
import * as ora from "ora"
import { link, markdownToAnsi, breakStringToLines } from "../lib/markdown.js"

const INKEEP_API_KEY = "e32967a320a48a2cd933922099e1f38f6ebb4ab62ff98343"
const INKEEP_INTEGRATION_ID = "clvn0fdez000cip0e5w2oaobw"
@@ -87,60 +88,3 @@ export async function action({ stream = false, raw = false }) {
spinner.stop().clear()
}
}

// double char markdown matchers
const BOLD_REGEX = /\*{2}([^*]+)\*{2}/g
const UNDERLINE_REGEX = /_{2}([^_]+)_{2}/g
const STRIKETHROUGH_REGEX = /~{2}([^~]+)~{2}/g
const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g

// single char markdown matchers
const ITALIC_REGEX = /(?<!\\)\*(.+)(?<!\\)\*|(?<!\\)_(.+)(?<!\\)_/g

function link(text, url) {
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`
}

/**
* @param {string} input
* @returns {string}
*/
function markdownToAnsi(input) {
input = input.replace(BOLD_REGEX, (...args) => y.bold(args[1]))
input = input.replace(UNDERLINE_REGEX, (...args) => y.underline(args[1]))
input = input.replace(STRIKETHROUGH_REGEX, (...args) =>
y.strikethrough(args[1])
)
input = input.replace(ITALIC_REGEX, (...args) => y.italic(args[1] || args[2]))
input = input.replace(/(?<!\\)\\/g, "")

// @ts-expect-error
input = input.replaceAll(LINK_REGEX, (...args) =>
y.blue(" " + link(args[2], args[1]))
)
return input
}

/**
* @param {string} str
* @param {number} maxLineLength
* @returns {string}
*/
function breakStringToLines(str, maxLineLength) {
let result = ""
let line = ""

str.split(" ").forEach((word) => {
if (line.length + word.length + 1 > maxLineLength) {
result += line + "\n"
line = word
} else {
if (line) line += " "
line += word
}
})

if (line) result += line

return result
}
1 change: 1 addition & 0 deletions commands/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * as ask from "./ask.js"
export * as init from "./init.js"
export * as secret from "./secret.js"
export * as add from './add.js'
13 changes: 9 additions & 4 deletions commands/init.js
Original file line number Diff line number Diff line change
@@ -36,7 +36,7 @@ export async function action(framework, options) {
const providers = await checkbox({
instructions: false,
message: "Select one or multiple providers",
choices: Object.entries(meta.providers).map(([value, name]) => ({
choices: Object.entries(meta.providers).map(([value, { name }]) => ({
name,
value,
})),
@@ -76,7 +76,7 @@ async function createFromExample(framework, dir) {
})
) {
execSync(`cd ${dir}`)
await secretAction({ write: true, path: dir })
await secretAction({ path: dir })
}

await install()
@@ -110,7 +110,12 @@ async function scaffoldProject(options) {
for (const provider of providers) {
const id = `AUTH_${provider.toUpperCase()}_ID`
const secret = `AUTH_${provider.toUpperCase()}_SECRET`
await updateEnvFile(dir, id, "")
await updateEnvFile(dir, secret, "")
await updateEnvFile(
{
[id]: "",
[secret]: "",
},
dir
)
}
}
21 changes: 5 additions & 16 deletions commands/secret.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
// @ts-check

import * as y from "yoctocolors"
import { write } from "../lib/clipboard/index.js"
import { detectFramework } from "../lib/detect.js"
import { join } from "node:path"
import clipboard from "clipboardy"
import { requireFramework } from "../lib/detect.js"
import { updateEnvFile } from "../lib/write-env.js"
import { frameworks } from "../lib/meta.js"

/** Web compatible method to create a random string of a given length */
function randomString(size = 32) {
@@ -19,7 +17,6 @@ function randomString(size = 32) {
* copy?: boolean
* path?: string
* raw?: boolean
* write?:boolean
* }} options
*/
export async function action(options) {
@@ -38,7 +35,7 @@ export async function action(options) {

if (options.copy) {
try {
write(line)
clipboard.writeSync(line)
console.log(message.introClipboard)
} catch (error) {
console.error(y.red(error))
@@ -50,16 +47,8 @@ export async function action(options) {
}

try {
const framework = await detectFramework(options.path)
if (framework === "unknown") {
return console.log(
`No framework detected. Currently supported frameworks are: ${y.bold(
Object.keys(frameworks).join(", ")
)}`
)
}

await updateEnvFile(options.path, key, value)
await requireFramework(options.path)
await updateEnvFile({ [key]: value }, options.path, true)
} catch (error) {
console.error(y.red(error))
}
8 changes: 7 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@

import { Command, InvalidArgumentError } from "commander"
import * as y from "yoctocolors"
import { ask, init, secret } from "./commands/index.js"
import { ask, init, secret, add } from "./commands/index.js"

// import pkg from "./package.json" assert { type: "json" }

@@ -52,6 +52,12 @@ program
.description("Generate a random string and add it to the .env file.")
.action(secret.action)

program
.command("add")
.argument("[provider]", "The authentication provider.")
.description('Register a new authentication provider')
.action(add.action)

program.parse()

export { program }
14 changes: 0 additions & 14 deletions lib/clipboard/index.js

This file was deleted.

33 changes: 0 additions & 33 deletions lib/clipboard/linux.js

This file was deleted.

11 changes: 0 additions & 11 deletions lib/clipboard/macos.js

This file was deleted.

8 changes: 0 additions & 8 deletions lib/clipboard/windows.js

This file was deleted.

19 changes: 19 additions & 0 deletions lib/detect.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @ts-check
import { readFile } from "node:fs/promises"
import { join } from "node:path"
import { frameworks } from "../lib/meta.js"
import * as y from "yoctocolors"

/**
* When this function runs in a framework directory we support,
@@ -35,3 +37,20 @@ export async function detectFramework(path = "") {
return "unknown"
}
}

export async function requireFramework(path = "") {
const framework = await detectFramework(path)

if (framework === "unknown") {
console.error(
y.red(`No framework detected. Currently supported frameworks are: ${y.bold(
Object.keys(frameworks).join(", ")
)}`)
)

process.exit(0)
}

return framework
}

59 changes: 59 additions & 0 deletions lib/markdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// @ts-check
import * as y from "yoctocolors"

// double char markdown matchers
const BOLD_REGEX = /\*{2}([^*]+)\*{2}/g
const UNDERLINE_REGEX = /_{2}([^_]+)_{2}/g
const STRIKETHROUGH_REGEX = /~{2}([^~]+)~{2}/g
const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g

// single char markdown matchers
const ITALIC_REGEX = /(?<!\\)\*(.+)(?<!\\)\*|(?<!\\)_(.+)(?<!\\)_/g

export function link(text, url) {
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`
}

/**
* @param {string} input
* @returns {string}
*/
export function markdownToAnsi(input) {
input = input.replace(BOLD_REGEX, (...args) => y.bold(args[1]))
input = input.replace(UNDERLINE_REGEX, (...args) => y.underline(args[1]))
input = input.replace(STRIKETHROUGH_REGEX, (...args) =>
y.strikethrough(args[1])
)
input = input.replace(ITALIC_REGEX, (...args) => y.italic(args[1] || args[2]))
input = input.replace(/(?<!\\)\\/g, "")

// @ts-expect-error
input = input.replaceAll(LINK_REGEX, (...args) =>
y.blue(" " + link(args[2], args[1]))
)
return input
}

/**
* @param {string} str
* @param {number} maxLineLength
* @returns {string}
*/
export function breakStringToLines(str, maxLineLength) {
let result = ""
let line = ""

str.split(" ").forEach((word) => {
if (line.length + word.length + 1 > maxLineLength) {
result += line + "\n"
line = word
} else {
if (line) line += " "
line += word
}
})

if (line) result += line

return result
}
198 changes: 115 additions & 83 deletions lib/meta.js
Original file line number Diff line number Diff line change
@@ -11,106 +11,138 @@ export const frameworks = {
name: "Next.js",
src: "https://github.com/nextauthjs/next-auth-example",
demo: "https://next-auth-example.vercel.app",
path: "/api/auth",
port: 3000,
envFile: ".env.local",
},
sveltekit: {
name: "SvelteKit",
src: "https://github.com/nextauthjs/sveltekit-auth-example",
demo: "https://sveltekit-auth-example.vercel.app",
path: "/auth",
port: 5173,
envFile: ".env",
},
express: {
name: "Express",
src: "https://github.com/nextauthjs/express-auth-example",
demo: "https://express-auth-example.vercel.app",
path: "/auth",
port: 3000,
envFile: ".env",
},
}

export const providers = {
"42-school": "42 School",
apple: "Apple",
asgardeo: "Asgardeo",
auth0: "Auth0",
authentik: "Authentik",
"azure-ad-b2c": "Azure AD B2C",
"azure-ad": "Azure AD",
"azure-devops": "Azure DevOps",
battlenet: "Battlenet",
beyondidentity: "Beyond Identity",
box: "Box",
"boxyhq-saml": "Boxyhq SAML",
bungie: "Bungie",
"click-up": "Click-up",
cognito: "Cognito",
coinbase: "Coinbase",
credentials: "Credentials",
descope: "Descope",
discord: "Discord",
dribbble: "Dribbble",
dropbox: "Dropbox",
"duende-identity-server6": "Duende Identity Server 6",
email: "Email",
eveonline: "Eveonline",
facebook: "Facebook",
faceit: "Faceit",
foursquare: "Foursquare",
freshbooks: "Freshbooks",
fusionauth: "FusionAuth",
github: "GitHub",
gitlab: "GitLab",
google: "Google",
hubspot: "Hubspot",
"identity-server4": "Identity Server 4",
instagram: "Instagram",
kakao: "Kakao",
keycloak: "Keycloak",
line: "Line",
linkedin: "LinkedIn",
mailchimp: "Mailchimp",
mailru: "Mail.ru",
mastodon: "Mastodon",
mattermost: "Mattermost",
medium: "Medium",
"microsoft-entra-id": "Microsoft Entra ID",
naver: "Naver",
netlify: "Netlify",
netsuite: "Netsuite",
nodemailer: "Nodemailer",
notion: "Notion",
okta: "Okta",
onelogin: "Onelogin",
"ory-hydra": "Ory Hydra",
osso: "Osso",
osu: "Osu",
passage: "Passage",
passkey: "Passkey",
patreon: "Patreon",
pinterest: "Pinterest",
pipedrive: "Pipedrive",
postmark: "Postmark",
reddit: "Reddit",
resend: "Resend",
salesforce: "Salesforce",
sendgrid: "Sendgrid",
slack: "Slack",
spotify: "Spotify",
strava: "Strava",
tiktok: "Tiktok",
todoist: "Todoist",
trakt: "Trakt",
twitch: "Twitch",
twitter: "Twitter",
"united-effects": "United Effects",
vk: "Vk",
webex: "Webex",
wikimedia: "Wikimedia",
wordpress: "Wordpress",
workos: "WorkOS",
yandex: "Yandex",
zitadel: "Zitadel",
zoho: "Zoho",
zoom: "Zoom",
"42-school": { name: "42 School", setupUrl: undefined },
apple: {
name: "Apple",
setupUrl:
"https://developer.apple.com/account/resources/identifiers/list/serviceId",
},
asgardeo: { name: "Asgardeo", setupUrl: undefined },
auth0: { name: "Auth0", setupUrl: undefined },
authentik: { name: "Authentik", setupUrl: undefined },
"azure-ad-b2c": { name: "Azure AD B2C", setupUrl: undefined },
"azure-ad": { name: "Azure AD", setupUrl: undefined },
"azure-devops": { name: "Azure DevOps", setupUrl: undefined },
battlenet: { name: "Battlenet", setupUrl: undefined },
beyondidentity: { name: "Beyond Identity", setupUrl: undefined },
box: { name: "Box", setupUrl: undefined },
"boxyhq-saml": { name: "Boxyhq SAML", setupUrl: undefined },
bungie: { name: "Bungie", setupUrl: undefined },
"click-up": { name: "Click-up", setupUrl: undefined },
cognito: { name: "Cognito", setupUrl: undefined },
coinbase: { name: "Coinbase", setupUrl: undefined },
credentials: { name: "Credentials", setupUrl: undefined },
descope: { name: "Descope", setupUrl: undefined },
discord: { name: "Discord", setupUrl: undefined },
dribbble: { name: "Dribbble", setupUrl: undefined },
dropbox: { name: "Dropbox", setupUrl: undefined },
"duende-identity-server6": {
name: "Duende Identity Server 6",
setupUrl: undefined,
},
email: { name: "Email", setupUrl: undefined },
eveonline: { name: "Eveonline", setupUrl: undefined },
facebook: { name: "Facebook", setupUrl: undefined },
faceit: { name: "Faceit", setupUrl: undefined },
foursquare: { name: "Foursquare", setupUrl: undefined },
freshbooks: { name: "Freshbooks", setupUrl: undefined },
fusionauth: { name: "FusionAuth", setupUrl: undefined },
github: {
name: "GitHub",
setupUrl: "https://github.com/settings/applications/new",
instructions: `\
1. Set *Application name* (can be anything)
2. Set *Homepage URL* (your business/website, but can be anything)
3. Paste the redirect URI (on your clipboard) to *Authorization callback URL*
4. Click *Register application*
5. Paste the *Client ID* back here
6. Click *Generate a new client secret*
7. Paste the *Client secret* back here (Note: This is the only time you can see it)`,
},
gitlab: { name: "GitLab", setupUrl: undefined },
google: {
name: "Google",
setupUrl: "https://console.cloud.google.com/apis/credentials/oauthclient",
instructions: `\
1. Choose *Application Type: Web Application*
2. Paste the redirect URI (on your clipboard) to *Authorized redirect URIs*
3. Fill out the rest of the form
4. Click *Create*`,
},
hubspot: { name: "Hubspot", setupUrl: undefined },
"identity-server4": { name: "Identity Server 4", setupUrl: undefined },
instagram: { name: "Instagram", setupUrl: undefined },
kakao: { name: "Kakao", setupUrl: undefined },
keycloak: { name: "Keycloak", setupUrl: undefined },
line: { name: "Line", setupUrl: undefined },
linkedin: { name: "LinkedIn", setupUrl: undefined },
mailchimp: { name: "Mailchimp", setupUrl: undefined },
mailru: { name: "Mail.ru", setupUrl: undefined },
mastodon: { name: "Mastodon", setupUrl: undefined },
mattermost: { name: "Mattermost", setupUrl: undefined },
medium: { name: "Medium", setupUrl: undefined },
"microsoft-entra-id": { name: "Microsoft Entra ID", setupUrl: undefined },
naver: { name: "Naver", setupUrl: undefined },
netlify: { name: "Netlify", setupUrl: undefined },
netsuite: { name: "Netsuite", setupUrl: undefined },
nodemailer: { name: "Nodemailer", setupUrl: undefined },
notion: { name: "Notion", setupUrl: undefined },
okta: { name: "Okta", setupUrl: undefined },
onelogin: { name: "Onelogin", setupUrl: undefined },
"ory-hydra": { name: "Ory Hydra", setupUrl: undefined },
osso: { name: "Osso", setupUrl: undefined },
osu: { name: "Osu", setupUrl: undefined },
passage: { name: "Passage", setupUrl: undefined },
passkey: { name: "Passkey", setupUrl: undefined },
patreon: { name: "Patreon", setupUrl: undefined },
pinterest: { name: "Pinterest", setupUrl: undefined },
pipedrive: { name: "Pipedrive", setupUrl: undefined },
postmark: { name: "Postmark", setupUrl: undefined },
reddit: { name: "Reddit", setupUrl: undefined },
resend: { name: "Resend", setupUrl: undefined },
salesforce: { name: "Salesforce", setupUrl: undefined },
sendgrid: { name: "Sendgrid", setupUrl: undefined },
slack: { name: "Slack", setupUrl: undefined },
spotify: { name: "Spotify", setupUrl: undefined },
strava: { name: "Strava", setupUrl: undefined },
tiktok: { name: "Tiktok", setupUrl: undefined },
todoist: { name: "Todoist", setupUrl: undefined },
trakt: { name: "Trakt", setupUrl: undefined },
twitch: { name: "Twitch", setupUrl: undefined },
twitter: { name: "Twitter", setupUrl: undefined },
"united-effects": { name: "United Effects", setupUrl: undefined },
vk: { name: "Vk", setupUrl: undefined },
webex: { name: "Webex", setupUrl: undefined },
wikimedia: { name: "Wikimedia", setupUrl: undefined },
wordpress: { name: "Wordpress", setupUrl: undefined },
workos: { name: "WorkOS", setupUrl: undefined },
yandex: { name: "Yandex", setupUrl: undefined },
zitadel: { name: "Zitadel", setupUrl: undefined },
zoho: { name: "Zoho", setupUrl: undefined },
zoom: { name: "Zoom", setupUrl: undefined },
}

export const adapters = {
70 changes: 42 additions & 28 deletions lib/write-env.js
Original file line number Diff line number Diff line change
@@ -8,39 +8,53 @@ import { frameworks } from "./meta.js"
import { detectFramework } from "./detect.js"

/**
* Update a key-value pair to a .env file
* Add/update key-value pair(s) to a .env file
* @param {Record<string, string>} env
* @param {string|undefined} envPath
* @param {string} key
* @param {string} value
* @param {boolean} comment
*/
export async function updateEnvFile(envPath = "", key, value) {
export async function updateEnvFile(env, envPath = "", comment = false) {
const framework = await detectFramework(envPath)
const dotEnvFile = frameworks[framework]?.envFile
let content = ""
const line = `${key}="${value}" # Added by \`npx auth\`. Read more: https://cli.authjs.dev`
const file = join(process.cwd(), envPath, dotEnvFile)
try {
content = await readFile(file, "utf-8")
if (!content.includes(`${key}=`)) {
console.log(`➕ Added ${key} to ${y.italic(file)}.`)
content = `${line}\n${content}`
} else {
const { overwrite } = await prompt({
type: "confirm",
name: "overwrite",
message: `Overwrite existing ${key}?`,
initial: false,
})
if (!overwrite) return
console.log(`✨ Updated ${key} in ${y.italic(file)}.`)
content = content.replace(new RegExp(`${key}=(.*)`), `${line}`)
}
} catch (error) {
if (error.code === "ENOENT") {
console.log(`📝 Created ${y.italic(file)} with ${key}.`)
content = line
} else {
throw error
let content = ""
let read = false
let created = false
for (const [key, value] of Object.entries(env)) {
const line = `${key}="${value}"${
comment ? " # Added by `npx auth`. Read more: https://cli.authjs.dev" : ""
}`
try {
if (!read) {
content = await readFile(file, "utf-8")
read = true
}
if (!content.includes(`${key}=`)) {
console.log(`➕ Added \`${key}\` to ${y.italic(file)}.`)
content = content ? `${content}\n${line}` : line
} else {
const { overwrite } = await prompt({
type: "confirm",
name: "overwrite",
message: `Overwrite existing \`${key}\`?`,
initial: false,
})
if (!overwrite) continue
console.log(`✨ Updated \`${key}\` in ${y.italic(file)}.`)
content = content.replace(new RegExp(`${key}=(.*)`), `${line}`)
}
} catch (error) {
if (error.code === "ENOENT") {
if (!created) {
console.log(`📝 Created ${y.italic(file)} with \`${key}\`.`)
created = true
} else {
console.log(`➕ Added \`${key}\` to ${y.italic(file)}.`)
}
content = content ? `${content}\n${line}` : line
} else {
throw error
}
}
}
if (content) await writeFile(file, content)
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -36,7 +36,10 @@
"dependencies": {
"@inkeep/ai-api": "^0.7.2",
"@inquirer/prompts": "3.3.2",
"clipboardy": "^4.0.0",
"next": "0",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@balazsorban44 Is this imported anywhere?

"commander": "11.1.0",
"open": "^10.1.0",
"ora": "^8.0.1",
"prompts": "^2.4.2",
"yoctocolors": "1.0.0"
189 changes: 189 additions & 0 deletions pnpm-lock.yaml