Skip to content

Commit

Permalink
feat: fhub cli
Browse files Browse the repository at this point in the history
  • Loading branch information
dalechyn committed Nov 9, 2024
1 parent 684fcd3 commit 179689a
Show file tree
Hide file tree
Showing 17 changed files with 915 additions and 13 deletions.
1 change: 0 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
"generated.ts",
"vectors/*.json",
"vectors/**/*.test.ts",
"wagmi",
"site/dist"
]
},
Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm clean && pnpm build:cjs && pnpm build:esm && pnpm build:types",
"build:cjs": "tsc --project ./tsconfig.build.json --module commonjs --outDir ./src/_cjs --removeComments --verbatimModuleSyntax false && printf '{\"type\":\"commonjs\"}' > ./src/_cjs/package.json",
"build": "pnpm clean && pnpm build:esm && pnpm build:types",
"build:esm": "tsc --project ./tsconfig.build.json --module es2020 --outDir ./src/_esm && printf '{\"type\": \"module\",\"sideEffects\":false}' > ./src/_esm/package.json",
"build:types": "tsc --project ./tsconfig.build.json --module esnext --declarationDir ./src/_types --emitDeclarationOnly --declaration --declarationMap",
"changeset:prepublish": "pnpm version:update && bun scripts/prepublishOnly.ts && pnpm build",
"changeset:publish": "pnpm changeset:prepublish && changeset publish",
"changeset:version": "changeset version && pnpm install --lockfile-only && pnpm version:update && pnpm format",
"clean": "rm -rf *.tsbuildinfo src/*.tsbuildinfo src/_esm src/_cjs src/_types",
"clean": "rm -rf *.tsbuildinfo src/*.tsbuildinfo src/_esm src/_types",
"format": "biome format --write",
"lint": "biome check --fix",
"lint:repo": "sherif",
Expand Down
309 changes: 309 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions src/Cli/Bin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env node
import { cac } from 'cac'

import { version } from '../version.js'
import { type Generate, generate } from './Commands/Generate.js'
import { type Init, init } from './Commands/Init.js'
import * as Logger from './Logger.js'

const cli = cac('fhub')

cli
.command('generate', 'generate code based on configuration')
.option('-c, --config <path>', '[string] path to config file')
.option('-r, --root <path>', '[string] root path to resolve config from')
// .option('-w, --watch', '[boolean] watch for changes')
.example((name) => `${name} generate`)
.action(async (options: Generate) => await generate(options))

cli
.command('init', 'create configuration file')
.option('-c, --config <path>', '[string] path to config file')
.option('-r, --root <path>', '[string] root path to resolve config from')
.example((name) => `${name} init`)
.action(async (options: Init) => await init(options))

cli.help()
cli.version(version)

void (async () => {
try {
process.title = 'node (fhub)'
} catch {}

try {
// Parse CLI args without running command
cli.parse(process.argv, { run: false })
if (!cli.matchedCommand) {
if (cli.args.length === 0) {
if (!cli.options.help && !cli.options.version) cli.outputHelp()
} else throw new Error(`Unknown command: ${cli.args.join(' ')}`)
}
await cli.runMatchedCommand()
process.exit(0)
} catch (error) {
Logger.error(`\n${(error as Error).message}`)
process.exit(1)
}
})()
222 changes: 222 additions & 0 deletions src/Cli/Commands/Generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { default as dedent } from 'dedent'
import { default as fs } from 'fs-extra'
import { basename, dirname, resolve } from 'pathe'
import pc from 'picocolors'
import { z } from 'zod'

import { fileURLToPath } from 'node:url'
import { fromZodError } from '../Errors.js'
import * as logger from '../Logger.js'
import { findConfig } from '../Utils/findConfig.js'
import { format } from '../Utils/format.js'
import { getIsUsingTypeScript } from '../Utils/getIsUsingTypeScript.js'
import { resolveConfig } from '../Utils/resolveConfig.js'

const Generate = z.object({
/** Path to config file */
config: z.string().optional(),
/** Directory to search for config file */
root: z.string().optional(),
})
export type Generate = z.infer<typeof Generate>

export async function generate(options: Generate = {}) {
// Validate command line options
try {
await Generate.parseAsync(options)
} catch (error) {
if (error instanceof z.ZodError)
throw fromZodError(error, { prefix: 'Invalid option' })
throw error
}

// Get cli config file
const configPath = await findConfig(options)
if (!configPath) {
if (options.config)
throw new Error(`Config not found at ${pc.gray(options.config)}`)
throw new Error('Config not found')
}

const resolvedConfigs = await resolveConfig({ configPath })
const isTypeScript = await getIsUsingTypeScript()

const outNames = new Set<string>()
const isArrayConfig = Array.isArray(resolvedConfigs)
const configs = isArrayConfig ? resolvedConfigs : [resolvedConfigs]
for (const config of configs) {
if (isArrayConfig)
logger.log(`Using config ${pc.gray(basename(configPath))}`)
if (!config.out) throw new Error('out is required.')
if (outNames.has(config.out))
throw new Error(`out "${config.out}" must be unique.`)
outNames.add(config.out)

const spinner = logger.spinner()

// Generate client file
spinner.start('Generating client file')
await writeClientFile({
isTypeScript,
rpcUrl: config.rpcUrl,
out: config.out,
})
spinner.succeed()

// Get the filenames of available top level actions
spinner.start('Generating fhub Action Hooks')
const actionsPath = resolve(
dirname(fileURLToPath(import.meta.url)),
'../..',
'Actions',
)
const files = await fs.readdir(actionsPath)

await Promise.all(
files.map(async (file) => {
// Reading function names
const fileContents = await fs.readFile(
resolve(actionsPath, file),
'utf8',
)

// cutting of .ts or .js
const namespaceName = file.slice(0, -3)

// Idk how to generate server actions that return an async iterator – not sure this is possible
if (namespaceName === 'Watch') return

const functionNames = (() => {
const matches = [
...fileContents.matchAll(/as (?<functionName>.*) }/gm),
]
return matches.map((match) => {
const functionName = match.groups?.functionName
if (!functionName)
throw new Error('Unexpected – cant retrieve function name')
return functionName
})
})()

// Creating sub directories with server actions

await Promise.all(
functionNames.map(async (functionName) => {
const hookType = functionName === 'create' ? 'Mutation' : 'Query'
const outputHook = `use${namespaceName}${functionName.slice(0, 1).toUpperCase()}${functionName.slice(1)}${hookType}`
await fs.ensureDir(resolve(config.out, outputHook))

// Creating action.ts file
const actionFileContent = await format(dedent`
'use server'
import { fhubClient } from '../client.${isTypeScript ? 'ts' : 'js'}'
import { Actions } from 'fhub'
export function action(parameters${isTypeScript ? `: Actions.${namespaceName}.${functionName}.ParametersType` : ''}): ${isTypeScript ? `Promise<Actions.${namespaceName}.${functionName}.ReturnType>` : ''} {
return Actions.${namespaceName}.${functionName}(fhubClient, parameters)
}
`)
await fs.writeFile(
resolve(
config.out,
outputHook,
`action.${isTypeScript ? 'ts' : 'js'}`,
),
actionFileContent,
)

// Creating hook file
const hookContents = await (async () => {
if (hookType === 'Mutation') {
return await format(dedent`
import { action } from './action.${isTypeScript ? 'ts' : 'js'}'
${
isTypeScript
? dedent`
import type { Actions } from 'fhub'
import { useMutation, type MutationOptions } from '@tanstack/react-query'
`
: "import { useMutation } from '@tanstack/react-query'"
}
export function ${outputHook}${hookType}({mutation = {}}${
isTypeScript
? `: {
mutation?: MutationOptions<Actions.${namespaceName}.${functionName}.ReturnType, Actions.${namespaceName}.${functionName}.ErrorType, Actions.${namespaceName}.${functionName}.ParametersType> | undefined
}`
: ''
}) {
return useMutation({ ...mutation, mutationKey: ['${namespaceName}.${functionName}'], mutationFn: (args)=> action(args) })
}
`)
}

return format(dedent`
import { action } from './action.${isTypeScript ? 'ts' : 'js'}'
${
isTypeScript
? dedent`
import type { Actions } from 'fhub'
import { useQuery, type QueryOptions } from '@tanstack/react-query'
`
: "import { useQuery } from '@tanstack/react-query'"
}
${isTypeScript ? `type QueryKey = ['${namespaceName}.${functionName}', Actions.${namespaceName}.${functionName}.ParametersType | undefined]` : ''}
function queryKey(parameters${isTypeScript ? `: Actions.${namespaceName}.${functionName}.ParametersType | undefined` : ''})${isTypeScript ? ': QueryKey' : ''} {
return ['${namespaceName}.${functionName}', parameters] as const
}
export function ${outputHook}${hookType}({query = {}, args}${
isTypeScript
? `: {
query?: QueryOptions<Actions.${namespaceName}.${functionName}.ReturnType, Actions.${namespaceName}.${functionName}.ErrorType, Actions.${namespaceName}.${functionName}.ReturnType, QueryKey> | undefined
args?: Actions.${namespaceName}.${functionName}.ParametersType | undefined
}`
: ''
}) {
const enabled = Boolean(args && (query.enabled ?? true))
return useQuery({ ...query, queryKey: queryKey(args), queryFn: ({queryKey:[_, args]})=> action(args), enabled })
}
`)
})()

await fs.writeFile(
resolve(
config.out,
outputHook,
`index.${isTypeScript ? 'ts' : 'js'}`,
),
hookContents,
)
}),
)
}),
)

spinner.succeed()
spinner.stop()
}
}

async function writeClientFile({
isTypeScript,
rpcUrl,
out,
}: {
isTypeScript: boolean
rpcUrl: string
out: string
}) {
// Format and write output
const cwd = process.cwd()
const outPath = resolve(cwd, out, `client.${isTypeScript ? 'ts' : 'js'}`)
await fs.ensureDir(dirname(outPath))
const formatted = await format(dedent`
import { Client, Transport } from "fhub";
export const fhubClient = Client.create(Transport.grpcNode({ baseUrl: '${rpcUrl}', httpVersion: '2' }))
`)
await fs.writeFile(outPath, formatted)
}
95 changes: 95 additions & 0 deletions src/Cli/Commands/Init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import dedent from 'dedent'
import { default as fs } from 'fs-extra'
import { relative, resolve } from 'pathe'
import pc from 'picocolors'
import { z } from 'zod'

import { type Config, defaultConfig } from '../Config.js'
import { fromZodError } from '../Errors.js'
import * as logger from '../Logger.js'
import { findConfig } from '../Utils/findConfig.js'
import { format } from '../Utils/format.js'
import { getIsUsingTypeScript } from '../Utils/getIsUsingTypeScript.js'

export type Init = {
/** Path to config file */
config?: string
/** Watch for file system changes to config and plugins */
content?: Config
/** Directory to init config file */
root?: string
}

const Init = z.object({
config: z.string().optional(),
content: z.object({}).optional(),
root: z.string().optional(),
})

export async function init(options: Init = {}) {
// Validate command line options
try {
await Init.parseAsync(options)
} catch (error) {
if (error instanceof z.ZodError)
throw fromZodError(error, { prefix: 'Invalid option' })
throw error
}

// Check for existing config file
const configPath = await findConfig(options)
if (configPath) {
logger.info(
`Config already exists at ${pc.gray(
relative(process.cwd(), configPath),
)}`,
)
return configPath
}

const spinner = logger.spinner()
spinner.start('Creating config')
// Check if project is using TypeScript
const isUsingTypeScript = await getIsUsingTypeScript()
const rootDir = resolve(options.root || process.cwd())
let outPath: string
if (options.config) {
outPath = resolve(rootDir, options.config)
} else {
const extension = isUsingTypeScript ? 'ts' : 'js'
outPath = resolve(rootDir, `fhub.config.${extension}`)
}

let content: string
if (isUsingTypeScript) {
const config = options.content ?? defaultConfig
content = dedent(`
import { defineConfig } from 'fhub/Cli'
export default defineConfig(${JSON.stringify(config)})
`)
} else {
const config = options.content ?? {
...defaultConfig,
out: defaultConfig.out.replace('.ts', '.js'),
}
content = dedent(`
// @ts-check
/** @type {import('fhub/Cli').Config} */
export default ${JSON.stringify(config, null, 2).replace(
/"(\d*)":/gm,
'$1:',
)}
`)
}

const formatted = await format(content)
await fs.writeFile(outPath, formatted)
spinner.succeed()
logger.success(
`Config created at ${pc.gray(relative(process.cwd(), outPath))}`,
)

return outPath
}
Loading

0 comments on commit 179689a

Please sign in to comment.