From 75020237a917234de9fc2b4b713ec195e7302bb9 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 22 Jul 2026 16:36:56 +0900 Subject: [PATCH 1/2] Add Optique agent skill [ci skip] --- .agents/skills/optique/SKILL.md | 260 ++++++++++++++++++++ .agents/skills/optique/SKILL.test.ts | 353 +++++++++++++++++++++++++++ .oxfmtrc.json | 6 +- .oxlintrc.json | 7 + skills-lock.json | 6 + 5 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/optique/SKILL.md create mode 100644 .agents/skills/optique/SKILL.test.ts diff --git a/.agents/skills/optique/SKILL.md b/.agents/skills/optique/SKILL.md new file mode 100644 index 0000000..ba6fd00 --- /dev/null +++ b/.agents/skills/optique/SKILL.md @@ -0,0 +1,260 @@ +--- +name: optique +description: > + Use this skill when writing any code that builds a command-line interface + with Optique in TypeScript or JavaScript. Covers the combinatorial parser + model, choosing @optique/core vs @optique/run, value parsers, structured + messages, optional()/withDefault()/multiple(), subcommands with command() + and or(), shell completion, async parsing, the integration packages, and + common mistakes to avoid. Trigger whenever the user is parsing command-line + arguments, building a CLI, or adding options or subcommands to a tool. +license: MIT +--- + +Optique is a type-safe combinatorial CLI parser. Use it by describing the CLI +grammar with parsers and combinators, not by manually walking `argv`. + +If web access is available, start from for the +maintained documentation index. Keep the rules below in mind even when offline; +they cover the parts agents most often get wrong. + + +Core rules +---------- + + - Use `run()` from *@optique/run* for real CLI applications. It reads + `process.argv`/`Deno.args`, handles help, version output, errors, exit + codes, colors, terminal width, and shell completion. Use `parse()` from + `@optique/core/parser` or `runParser()` from `@optique/core/facade` when + embedding Optique in tests, libraries, tools, or custom runtimes. + - Compose parsers with `object()`, `tuple()`, `seq()`, `or()`, `merge()`, and + modifiers. Do not write imperative `if`/`else` argument scanners around + Optique parsers. + - Let TypeScript infer the parsed value type from the parser. Do not + hand-maintain a separate interface for the result unless another API + boundary requires it. + - Most parsers are required until you wrap them. `optional(p)` yields + `undefined`; `withDefault(p, value)` yields a fallback value. For Boolean + flags, use `withDefault(flag("--name"), false)` when absence should mean + `false`. + - Use `message` from *@optique/core/message* for descriptions, help text, and + custom errors. Prefer semantic message helpers such as `optionName()` and + `metavar()` over string concatenation when naming CLI elements. + - Use value parsers such as `integer()`, `choice()`, `biject()`, `url()`, + and `uuid()` instead of validating raw strings after parsing. Use + `biject()` for one-to-one string-to-value choices, and use `transform()` + when an existing value parser describes the accepted CLI spelling but your + app needs a different result type. Use `path()` from + `@optique/run/valueparser` for file-system paths. Write a custom + `{ mode, metavar, parse, format }` value parser only when the catalog does + not cover the domain. + - Async value parsers make the containing parser async. If you use packages + such as *@optique/git*, remember to `await run(...)`, `await parse(...)`, or + `await runParser(...)` as appropriate. + - Build subcommands with `command()` combined by `or()`. Put a literal field + such as `command: constant("serve")` in each branch when you want a + discriminated union. + - Enable completion through `run(parser, { completion: "both" })` for CLI + apps. Do not hand-write completion scripts from parser metadata. + - Use `showUsage: false` in runner options when full help should show the + brief and command or option sections without the `Usage:` synopsis. + For deeply nested command trees, add `commandList: "top-level"` when root + help should list only first-level command groups. + + +Canonical app shape +------------------- + +~~~~ typescript +import { object } from "@optique/core/constructs"; +import { message } from "@optique/core/message"; +import { withDefault } from "@optique/core/modifiers"; +import { argument, flag, option } from "@optique/core/primitives"; +import { integer, string } from "@optique/core/valueparser"; +import { run } from "@optique/run"; + +const parser = object({ + input: argument(string({ metavar: "FILE" }), { + description: message`Input file to process.`, + }), + port: withDefault( + option("--port", integer({ min: 1, max: 65535 }), { + description: message`Port to listen on.`, + }), + 3000, + ), + verbose: withDefault( + flag("-v", "--verbose", { description: message`Enable verbose logging.` }), + false, + ), +}); + +const config = run(parser, { + brief: message`Process a file.`, + completion: "both", + showDefault: true, +}); + +console.log(`Processing ${config.input} on port ${config.port}.`); +~~~~ + + +Subcommands +----------- + +Use `command()` for each branch and `or()` to require exactly one matching +subcommand. Use `optional(or(...))` only when no subcommand is valid. + +~~~~ typescript +import { object, or } from "@optique/core/constructs"; +import { withDefault } from "@optique/core/modifiers"; +import { parse } from "@optique/core/parser"; +import { command, constant, flag, option } from "@optique/core/primitives"; +import { integer } from "@optique/core/valueparser"; + +const parser = or( + command("build", object({ + command: constant("build"), + watch: withDefault(flag("--watch"), false), + })), + command("serve", object({ + command: constant("serve"), + port: withDefault(option("--port", integer({ min: 1 })), 3000), + })), +); + +const result = parse(parser, ["serve", "--port", "8080"]); + +if (result.success) { + switch (result.value.command) { + case "build": + result.value.watch; + break; + case "serve": + result.value.port; + break; + } +} +~~~~ + + +Custom value parsers +-------------------- + +Prefer the built-in catalog first. If a one-to-one dictionary can describe the +input tokens and domain values, use `biject()`. If an existing parser already +accepts the right input syntax, wrap it with `transform()` before writing a +custom parser: + +~~~~ typescript +import { biject, choice, transform } from "@optique/core/valueparser"; + +const exitCode = biject({ + ok: 0, + warning: 1, + error: 2, +}); + +const logLevel = transform(choice(["debug", "info", "warn", "error"] as const), { + map(value) { + return value.toUpperCase() as "DEBUG" | "INFO" | "WARN" | "ERROR"; + }, + unmap(value) { + return value.toLowerCase() as "debug" | "info" | "warn" | "error"; + }, +}); +~~~~ + +When a custom domain is needed, keep the validation in a value parser so help, +errors, defaults, prompts, and completion all see the same typed value. + +~~~~ typescript +import { message } from "@optique/core/message"; +import type { ValueParser, ValueParserResult } from "@optique/core/valueparser"; + +const levels = ["debug", "info", "warn", "error"] as const; +type Level = typeof levels[number]; + +function isLevel(input: string): input is Level { + return (levels as readonly string[]).includes(input); +} + +function logLevel(): ValueParser<"sync", Level> { + return { + mode: "sync", + metavar: "LEVEL", + placeholder: "info", + parse(input: string): ValueParserResult { + if (isLevel(input)) return { success: true, value: input }; + return { success: false, error: message`Invalid log level: ${input}.` }; + }, + format(value: Level): string { + return value; + }, + }; +} + +const parser = logLevel(); +~~~~ + + +Common mistakes checklist +------------------------- + + - Do not parse `process.argv` manually before calling Optique. Pass the parser + to `run()` for applications, or pass explicit argument arrays to `parse()` + in tests and embedded use. + - Do not treat `or(a, b)` as “zero or more alternatives.” It requires one + matching branch unless the whole `or()` is wrapped in `optional()` or + `withDefault()`. + - Do not use `object()` for mutually exclusive subcommands. Use + `or(command(...), command(...))`. + - Do not forget that `flag("--x")` is required. Wrap it in `optional()` or + `withDefault(..., false)` for ordinary optional flags. + - Do not expect `multiple(p)` to fail when absent; it returns `[]`. Wrap with + `nonEmpty()` when at least one value is required. + - Do not confuse free-order parsing with `seq()`. Most constructs let child + parsers compete by priority; use `seq()` only when the grammar is truly + ordered. + - Do not concatenate plain strings for errors or descriptions. Use structured + `message` values. + - Do not forget to register source contexts when using `bindEnv()`, + `bindConfig()`, or `bindDerivedDefault()`. + +For the detailed maintained guide, use . + + +Reference links +--------------- + + - Documentation index for agents: + - Runners and entry points: + - Primitive parsers: + - Construct combinators: + - Modifiers: + - Value parser catalog: + - Structured messages: + - Shell completion: + - Command discovery: + - Man pages: + + +Integration packages +-------------------- + +| Package | Use for | Docs | +| --------------------------- | ------------------------------------------- | ----------------------------------------------------- | +| `@optique/env` | Environment variable fallbacks | | +| `@optique/config` | Configuration file fallbacks | | +| `@optique/derived-defaults` | Defaults computed from first-pass results | | +| `@optique/prompt` | Generic prompt adapter foundation | | +| `@optique/clack` | Clack interactive fallback prompts | | +| `@optique/inquirer` | Inquirer.js interactive fallback prompts | | +| `@optique/standard-schema` | Portable schema-backed value parsing | | +| `@optique/zod` | Zod-backed value parsing | | +| `@optique/valibot` | Valibot-backed value parsing | | +| `@optique/temporal` | Temporal date and time parsers | | +| `@optique/git` | Async Git reference validation | | +| `@optique/logtape` | LogTape levels and formatter/output options | | +| `@optique/man` | Man page generation | | +| `@optique/discover` | File-based command discovery | | diff --git a/.agents/skills/optique/SKILL.test.ts b/.agents/skills/optique/SKILL.test.ts new file mode 100644 index 0000000..095e876 --- /dev/null +++ b/.agents/skills/optique/SKILL.test.ts @@ -0,0 +1,353 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const root = fileURLToPath(new URL("../../../../", import.meta.url)); +const packageJsonPath = join(root, "packages/core/package.json"); +const skillPath = fileURLToPath(new URL("SKILL.md", import.meta.url)); +const compilerOptions: ts.CompilerOptions = { + allowImportingTsExtensions: true, + lib: ["lib.esnext.d.ts", "lib.dom.d.ts"], + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2022, +}; + +type DenoManifest = { + readonly name?: string; + readonly exports?: string | Record; +}; + +type PackageManifest = { + readonly exports?: string | Record; +}; + +const nodeStubs = ` +declare module "node:fs" { + export interface Stats { + isDirectory(): boolean; + isFile(): boolean; + } + export function existsSync(path: string): boolean; + export function statSync(path: string): Stats; +} + +declare module "node:path" { + declare const path: { + basename(path: string, suffix?: string): string; + dirname(path: string): string; + extname(path: string): string; + }; + export function basename(path: string, suffix?: string): string; + export function dirname(path: string): string; + export function extname(path: string): string; + export default path; +} + +declare module "node:process" { + interface WritableStreamLike { + readonly columns?: number; + readonly isTTY?: boolean; + write(chunk: string): boolean; + } + + declare const process: { + argv: string[]; + stdout: WritableStreamLike; + stderr: WritableStreamLike; + exit(code?: number): never; + }; + export default process; +} +`; + +function extractTypeScriptBlocks(markdown: string): string[] { + const blocks: string[] = []; + const lines = markdown.replaceAll("\r\n", "\n").replaceAll("\r", "\n") + .split("\n"); + + for (let index = 0; index < lines.length; index++) { + const opening = getFenceOpening(lines[index]); + if (opening == null) continue; + + const body: string[] = []; + for (index++; index < lines.length; index++) { + if (isFenceClosing(lines[index], opening.marker, opening.length)) { + break; + } + body.push(lines[index]); + } + + if (opening.language === "typescript" || opening.language === "ts") { + blocks.push(body.join("\n").trim()); + } + } + + return blocks; +} + +function getFenceOpening( + line: string, +): { + readonly marker: "`" | "~"; + readonly length: number; + readonly language: string; +} | undefined { + const indent = countLeadingSpaces(line); + if (indent > 3) return undefined; + + const marker = line[indent]; + if (marker !== "`" && marker !== "~") return undefined; + + const length = countRepeated(line, indent, marker); + if (length < 3) return undefined; + + const info = line.slice(indent + length).trim(); + if (marker === "`" && info.includes("`")) return undefined; + + return { + marker, + length, + language: getFirstWord(info).toLowerCase(), + }; +} + +function isFenceClosing( + line: string, + marker: "`" | "~", + openingLength: number, +): boolean { + const indent = countLeadingSpaces(line); + if (indent > 3) return false; + + const length = countRepeated(line, indent, marker); + return length >= openingLength && line.slice(indent + length).trim() === ""; +} + +function countLeadingSpaces(line: string): number { + let count = 0; + while (line[count] === " ") count++; + return count; +} + +function countRepeated(line: string, start: number, char: string): number { + let count = 0; + while (line[start + count] === char) count++; + return count; +} + +function getFirstWord(text: string): string { + for (let index = 0; index < text.length; index++) { + const char = text[index]; + if (char === " " || char === "\t") return text.slice(0, index); + } + return text; +} + +async function readWorkspaceExports(): Promise> { + const result = new Map(); + const packagesDir = join(root, "packages"); + const packages = await readdir(packagesDir, { withFileTypes: true }); + + for (const entry of packages) { + if (!entry.isDirectory()) continue; + + const packageDir = join(packagesDir, entry.name); + let denoManifest: DenoManifest; + try { + denoManifest = JSON.parse( + await readFile(join(packageDir, "deno.json"), "utf-8"), + ) as DenoManifest; + } catch (error) { + if (isFileNotFoundError(error)) continue; + throw error; + } + + if (denoManifest.name == null || denoManifest.exports == null) continue; + + const packageManifest = JSON.parse( + await readFile(join(packageDir, "package.json"), "utf-8"), + ) as PackageManifest; + const denoExports = normalizeDenoExports(denoManifest.exports); + + for (const [subpath, target] of Object.entries(denoExports)) { + if (!hasPackageExport(packageManifest.exports, subpath)) { + throw new Error( + `${denoManifest.name} exports ${subpath} from deno.json but not package.json.`, + ); + } + + const specifier = subpath === "." + ? denoManifest.name + : `${denoManifest.name}${subpath.slice(1)}`; + result.set(specifier, join(packageDir, target)); + } + } + + return result; +} + +function normalizeDenoExports( + exports: string | Record, +): Record { + return typeof exports === "string" ? { ".": exports } : exports; +} + +function hasPackageExport( + exports: string | Record | undefined, + subpath: string, +): boolean { + if (exports == null) return false; + if (typeof exports === "string") return subpath === "."; + return Object.hasOwn(exports, subpath); +} + +function isFileNotFoundError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const { code } = error as { readonly code?: unknown }; + return code === "ENOENT"; +} + +function typeCheckSnippets( + blocks: readonly string[], + workspaceExports: ReadonlyMap, +): readonly ts.Diagnostic[] { + const snippets = new Map(); + snippets.set( + normalizePath(join(root, "tmp/skill-snippets/node-stubs.d.ts")), + nodeStubs, + ); + for (const [index, block] of blocks.entries()) { + snippets.set( + normalizePath( + join(root, "tmp/skill-snippets", `snippet-${index + 1}.ts`), + ), + `${block}\n`, + ); + } + + const host = ts.createCompilerHost(compilerOptions, true); + const defaultFileExists = host.fileExists.bind(host); + const defaultReadFile = host.readFile.bind(host); + + host.fileExists = (fileName) => + snippets.has(normalizePath(fileName)) || defaultFileExists(fileName); + host.readFile = (fileName) => + snippets.get(normalizePath(fileName)) ?? defaultReadFile(fileName); + host.resolveModuleNames = (moduleNames, containingFile) => + moduleNames.map((moduleName) => { + const workspaceFile = workspaceExports.get(moduleName); + if (workspaceFile != null) { + return { resolvedFileName: workspaceFile, extension: ts.Extension.Ts }; + } + + return ts.resolveModuleName( + moduleName, + containingFile, + compilerOptions, + host, + ) + .resolvedModule; + }); + + const program = ts.createProgram([...snippets.keys()], compilerOptions, host); + return ts.getPreEmitDiagnostics(program); +} + +function normalizePath(path: string): string { + return path.replace(/\\/g, "/"); +} + +function formatDiagnostic(diagnostic: ts.Diagnostic): string { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + if (diagnostic.file == null || diagnostic.start == null) return message; + + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition( + diagnostic.start, + ); + const normalizedFileName = normalizePath(diagnostic.file.fileName); + const normalizedRoot = normalizePath(root); + const relativePath = normalizedFileName.startsWith(normalizedRoot) + ? normalizedFileName.slice(normalizedRoot.length) + : diagnostic.file.fileName; + return `${relativePath}:${line + 1}:${character + 1}: ${message}`; +} + +describe("Optique agent skill", () => { + it("should extract TypeScript examples from Markdown fences", () => { + const markdown = [ + "~~~~ ts twoslash", + "const short = true;", + "~~~~", + "```` typescript", + "const nested = `not a fence`;", + "```", + "````", + "``` javascript", + "const ignored = true;", + "```", + ].join("\n"); + + assert.deepEqual(extractTypeScriptBlocks(markdown), [ + "const short = true;", + "const nested = `not a fence`;\n```", + ]); + }); + + it("should be declared in the core package metadata", async () => { + const packageJson = JSON.parse( + await readFile(packageJsonPath, "utf-8"), + ) as { + readonly agents?: { + readonly skills?: readonly { + readonly name: string; + readonly path: string; + }[]; + }; + readonly files?: readonly string[]; + }; + + assert.deepEqual(packageJson.agents?.skills, [ + { name: "optique", path: "./skills/optique" }, + ]); + assert.ok(packageJson.files?.includes("skills/optique/SKILL.md")); + }); + + it("should keep the skill concise and point agents to maintained references", async () => { + const skill = await readFile(skillPath, "utf-8"); + const lineCount = skill.split("\n").length; + + assert.ok( + lineCount <= 300, + `Expected SKILL.md to be concise, got ${lineCount} lines.`, + ); + assert.match(skill, /^---\r?\nname: optique\r?\n/); + assert.match(skill, /https:\/\/optique\.dev\/llms\.txt/); + assert.match(skill, /https:\/\/optique\.dev\/pitfalls\.md/); + assert.match(skill, /https:\/\/optique\.dev\/concepts\/valueparsers\.md/); + assert.match(skill, /LogTape levels and formatter\/output options/); + }); + + it("should type-check TypeScript examples in the skill", async () => { + const skill = await readFile(skillPath, "utf-8"); + const blocks = extractTypeScriptBlocks(skill); + + assert.ok(blocks.length > 0, "Expected at least one TypeScript example."); + + const diagnostics = typeCheckSnippets(blocks, await readWorkspaceExports()); + + assert.equal( + diagnostics.length, + 0, + `Skill TypeScript examples failed to type-check:\n${ + diagnostics.map(formatDiagnostic).join("\n") + }`, + ); + }); +}); diff --git a/.oxfmtrc.json b/.oxfmtrc.json index abb763b..dfd6973 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -5,7 +5,9 @@ "ignorePatterns": [ "*.md", "**/*.md", - "plans/", - "**/dist/*.{cjs,d.cts,d.mts,d.ts,js,mjs}" + "**/dist/*.{cjs,d.cts,d.mts,d.ts,js,mjs}", + ".agents/skills/", + ".claude/skills/", + "plans/" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 90fce46..8207720 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,4 +1,11 @@ { + "ignorePatterns": [ + "**/dist/*.{cjs,d.cts,d.mts,d.ts,js,mjs}", + ".agents/skills/", + ".claude/skills/", + "node_modules/", + "plans/" + ], "options": { "typeAware": true, "typeCheck": true diff --git a/skills-lock.json b/skills-lock.json index f6f1c34..2184fc3 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -6,6 +6,12 @@ "sourceType": "github", "skillPath": "skills/graphql-schema-design/SKILL.md", "computedHash": "daa7be0631ea37c02ea9e000cfabb867c84fae2e958445beb1f63521f602e448" + }, + "optique": { + "source": "dahlia/optique", + "sourceType": "github", + "skillPath": "packages/core/skills/optique/SKILL.md", + "computedHash": "857ce1e2d28c8ee9623588e9fca3a9bebec300044b24f32397e0b0aac8910d8b" } } } From c738477f79e1b074939db8be6d6a456033f45709 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 22 Jul 2026 17:21:48 +0900 Subject: [PATCH 2/2] Add --generate-graphql-schema CLI option to print the GraphQL schema This adds a new mode to the drfed-server CLI that generates and outputs the GraphQL schema (via graphql's printSchema) instead of starting a server. The schema can be written to a file (--output-file) or printed to stdout (the default). Changes include: - Refactored the parser into separate serverParser and schemaGeneratorParser sub-parsers, combined via merge() and or() from @optique/core. - Extracted the server startup logic into a runServer() function. - Added runSchemaGenerator() that calls printSchema() and either writes to a file or prints to stdout. - Added a "generate:graphql-schema" mise task that runs the command and accepts an --output-file flag. - Added a "drfed-server" mise task for invoking the server directly. - Made graphql a catalog dependency in pnpm-workspace.yaml and referenced it from @drfed/drfed; updated @drfed/graphql to use the catalog reference as well. --- mise.toml | 19 +++++++++ packages/drfed/package.json | 1 + packages/drfed/src/index.ts | 78 ++++++++++++++++++++++++----------- packages/drfed/src/parser.ts | 36 ++++++++++++---- packages/graphql/package.json | 2 +- pnpm-lock.yaml | 8 +++- pnpm-workspace.yaml | 1 + 7 files changed, 111 insertions(+), 34 deletions(-) diff --git a/mise.toml b/mise.toml index d213465..57b7b4d 100644 --- a/mise.toml +++ b/mise.toml @@ -66,6 +66,7 @@ run = "mise fmt" [tasks.build] description = "Build the project" run = "pnpm run --parallel --recursive build" +silent = "stdout" [tasks.test] description = "Run tests" @@ -110,6 +111,18 @@ pnpm exec drizzle-kit generate ...$name ...$custom """ depends_post = ["fmt"] +[tasks."generate:graphql-schema"] +description = "Generate GraphQL schema" +usage = """ +flag "-o --output-file " help="The output file for the generated GraphQL schema. - for stdout. Defaults to -" +""" +depends = ["build"] +run = """ +#!/usr/bin/env nu +let output_file = (if "usage_output_file" in $env { $env.usage_output_file } else { "-" }) +node packages/drfed/bin/drfed-server.mjs --generate-graphql-schema --output-file $output_file +""" + [tasks.dev] description = "Run the development server" usage = ''' @@ -128,3 +141,9 @@ raw_args = true description = "Run SolidStart frontend server" dir = "./packages/web" run = "pnpm run dev" + +[tasks."drfed-server"] +description = "Invoke drfed-server command" +depends = ["build"] +raw_args = true +run = "node packages/drfed/bin/drfed-server.mjs" diff --git a/packages/drfed/package.json b/packages/drfed/package.json index da44db7..4bb98f7 100644 --- a/packages/drfed/package.json +++ b/packages/drfed/package.json @@ -77,6 +77,7 @@ "@upyo/smtp": "catalog:", "@upyo/logtape": "catalog:", "drizzle-orm": "catalog:", + "graphql": "catalog:", "pg": "catalog:", "srvx": "^0.11.16" } diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 5818810..077b548 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -14,22 +14,68 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { AsyncLocalStorage } from "node:async_hooks"; +import { writeFile } from "node:fs/promises"; import process from "node:process"; import { createYogaServer } from "@drfed/graphql"; +import { schema } from "@drfed/graphql/schema"; import { migrate } from "@drfed/models"; import { configure, getConsoleSink } from "@logtape/logtape"; import { createLoggingConfig } from "@optique/logtape"; import { run } from "@optique/run"; import { SmtpTransport } from "@upyo/smtp"; +import { printSchema } from "graphql"; import { serve } from "srvx"; // oxlint-disable-next-line import/no-relative-parent-imports import metadata from "../package.json" with { type: "json" }; -import type { Options } from "./parser.ts"; +import type { + Options, + SchemaGeneratorOptions, + ServerOptions, +} from "./parser.ts"; import program from "./program.ts"; import seedData from "./seed.ts"; +async function runServer(options: ServerOptions) { + if (options.drizzle.migrate) { + await migrate({ credentials: options.drizzle.credentials }); + } + if (options.seed) { + await seedData(options.drizzle.db); + } + const { mailer } = options; + const yogaServer = createYogaServer(options.drizzle.db, { mailer }); + const server = serve({ + fetch: yogaServer.fetch.bind(yogaServer), + hostname: options.address.host, + manual: true, + port: options.address.port, + }); + function shutdown() { + if (mailer instanceof SmtpTransport) { + mailer.closeAllConnections(); + } + // oxlint-disable-next-line promise/catch-or-return promise/prefer-await-to-then no-magic-numbers + server.close().then(() => process.exit(0)); + } + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + await server.serve(); +} + +async function runSchemaGenerator( + options: SchemaGeneratorOptions, +): Promise { + const schemaCode = printSchema(schema); + if (options.outputFile === "-") { + // oxlint-disable-next-line no-console + console.log(schemaCode); + return; + } + await writeFile(options.outputFile, schemaCode, { encoding: "utf-8" }); +} + // oxlint-disable-next-line max-lines-per-function export async function main(): Promise { const options: Options = run(program, { @@ -67,30 +113,12 @@ export async function main(): Promise { ], }, ); + await configure(loggingConfig); - if (options.drizzle.migrate) { - await migrate({ credentials: options.drizzle.credentials }); - } - if (options.seed) { - await seedData(options.drizzle.db); - } - const yogaServer = createYogaServer(options.drizzle.db, { - mailer: options.mailer, - }); - const server = serve({ - fetch: yogaServer.fetch.bind(yogaServer), - hostname: options.address.host, - manual: true, - port: options.address.port, - }); - function shutdown() { - if (options.mailer instanceof SmtpTransport) { - options.mailer.closeAllConnections(); - } - // oxlint-disable-next-line promise/catch-or-return promise/prefer-await-to-then no-magic-numbers - server.close().then(() => process.exit(0)); + + if ("generateGraphqlSchema" in options) { + await runSchemaGenerator(options); + } else { + await runServer(options); } - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); - await server.serve(); } diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts index 1c0f5b3..0828e36 100644 --- a/packages/drfed/src/parser.ts +++ b/packages/drfed/src/parser.ts @@ -21,7 +21,7 @@ import { merge, object, or } from "@optique/core/constructs"; import { message, optionNames } from "@optique/core/message"; import { map, optional, withDefault } from "@optique/core/modifiers"; import type { InferValue } from "@optique/core/parser"; -import { option } from "@optique/core/primitives"; +import { flag, option } from "@optique/core/primitives"; import { socketAddress, url } from "@optique/core/valueparser"; import { loggingOptions } from "@optique/logtape"; import { path } from "@optique/run/valueparser"; @@ -105,12 +105,7 @@ const seedParser = option("--dev-seed", { hidden: true, }); -export const parser = object({ - logging: loggingOptions({ - level: "option", - short: "-L", - formatter: "--log-format", - }), +const serverParser = object("DrFed server", { address: withDefault( option("--listen", "-l", socketAddress({ requirePort: true }), { description: message`The address to listen on.`, @@ -135,6 +130,33 @@ export const parser = object({ seed: seedParser, }); +export type ServerOptions = InferValue; + +const schemaGeneratorParser = object("GraphQL schema generation", { + generateGraphqlSchema: flag("--generate-graphql-schema", { + description: message`Generate GraphQL schema and exit.`, + }), + outputFile: withDefault( + option("--output-file", "-o", path({ type: "file", allowCreate: true }), { + description: message`The output file for the generated GraphQL schema. ${"-"} for stdout.`, + }), + "-", + ), +}); + +export type SchemaGeneratorOptions = InferValue; + +export const parser = merge( + object({ + logging: loggingOptions({ + level: "option", + short: "-L", + formatter: "--log-format", + }), + }), + or(serverParser, schemaGeneratorParser), +); + export type Options = InferValue; export default parser; diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 46f0f26..f3bcf6a 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -103,7 +103,7 @@ "@upyo/core": "catalog:", "@upyo/mock": "catalog:", "drizzle-orm": "catalog:", - "graphql": "^16.14.2", + "graphql": "catalog:", "graphql-scalars": "^1.25.0", "graphql-yoga": "^5.21.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60ed779..2fad828 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ catalogs: drizzle-orm: specifier: 1.0.0-beta.22 version: 1.0.0-beta.22 + graphql: + specifier: ^16.14.2 + version: 16.14.2 pg: specifier: ^8.21.0 version: 8.21.0 @@ -98,6 +101,9 @@ importers: drizzle-orm: specifier: 'catalog:' version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) + graphql: + specifier: 'catalog:' + version: 16.14.2 pg: specifier: 'catalog:' version: 8.21.0 @@ -157,7 +163,7 @@ importers: specifier: 'catalog:' version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) graphql: - specifier: ^16.14.2 + specifier: 'catalog:' version: 16.14.2 graphql-scalars: specifier: ^1.25.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4724f08..07b068f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,7 @@ catalog: "@upyo/mock": 0.6.0-dev.263+e633e1e6 "@upyo/smtp": 0.6.0-dev.263+e633e1e6 drizzle-orm: 1.0.0-beta.22 + graphql: ^16.14.2 pg: ^8.21.0 tsdown: ^0.22.3 typescript: ^6.0.3