diff --git a/cli/build-run-args.ts b/cli/commands/build-run-args.ts similarity index 96% rename from cli/build-run-args.ts rename to cli/commands/build-run-args.ts index 48d246d..cac0b78 100644 --- a/cli/build-run-args.ts +++ b/cli/commands/build-run-args.ts @@ -1,6 +1,6 @@ import type { ExecOptions } from "@effectionx/process"; import process from "node:process"; -import type { RunConfig } from "./config.ts"; +import type { RunType } from "./run.ts"; export type Runtime = "node" | "deno" | "bun"; @@ -12,7 +12,7 @@ function hasLoaderSpecified(packageName: string) { export function buildProcessOptions( runtime: Runtime, - config: RunConfig, + config: RunType["config"], passthroughArgs: string[], ): ExecOptions { let env = { ...process.env } as Record; @@ -104,7 +104,7 @@ export function buildProcessOptions( * hence the default is still "node". If the executable name contains * "deno" or "bun" we return the corresponding runtime. */ -export function resolveRuntime(config: RunConfig): Runtime { +export function resolveRuntime(config: RunType["config"]): Runtime { if (config.inspectRuntime) { return config.inspectRuntime as Runtime; } diff --git a/cli/commands/call.ts b/cli/commands/call.ts new file mode 100644 index 0000000..820a91f --- /dev/null +++ b/cli/commands/call.ts @@ -0,0 +1,43 @@ +import { until } from "effection"; +import { writeFile } from "node:fs/promises"; +import { createSSEClient } from "../../lib/sse-client.ts"; +import { log } from "../logger.ts"; +import type { Program } from "../config.ts"; +import type { CommandType } from "configliere"; + +// We need better helper types to destructure config +export type CallType = Extract< + Extract, "call">, { help?: false }>["config"], + { help?: false } +>["config"]; + +export function* call(config: CallType) { + const { name, out, host, protocol } = config; + + const argsList = [] as never[]; + const handle = createSSEClient(protocol, { url: host }); + if (!(name in handle.protocol.methods)) { + yield* log.error(`unknown command: ${name}`); + } + let results: unknown[] = []; + let subscription = yield* handle.invoke({ + name: name as any, + args: argsList, + }); + let next = yield* subscription.next(); + // log progress values and collect everything, including final return + while (!next.done) { + results.push(next.value); + yield* log.info(JSON.stringify(next.value)); + next = yield* subscription.next(); + } + + if (out) { + try { + yield* until(writeFile(out, JSON.stringify(results, null, 2))); + } catch (e) { + let msg = e instanceof Error ? e.message : String(e); + yield* log.error(`failed to write ${out}: ${msg}`); + } + } +} diff --git a/cli/commands/run.ts b/cli/commands/run.ts new file mode 100644 index 0000000..d71014a --- /dev/null +++ b/cli/commands/run.ts @@ -0,0 +1,89 @@ +import { type Operation, each, resource, sleep, spawn, until, withResolvers } from "effection"; +import { exec } from "@effectionx/process"; +import { writeFile } from "node:fs/promises"; +import { createSSEClient } from "../../lib/sse-client.ts"; +import { log } from "../logger.ts"; +import { resolveRuntime, buildProcessOptions } from "./build-run-args.ts"; +import type { Protocol } from "../../lib/types.ts"; +import type { CommandType } from "configliere"; +import type { Program } from "../config.ts"; + +// We need better helper types to destructure config +export type RunType = Extract, "run">, { help?: false }>; + +export function run(config: RunType["config"], passthroughArgs: string[]) { + return resource(function* (provide) { + try { + let runtime = resolveRuntime(config); + let host = `http://localhost:${config.inspectPort}`; + + let processOptions = buildProcessOptions(runtime, config, passthroughArgs); + + let ready = withResolvers(); + let childSpawned = withResolvers(); + + yield* spawn(function* () { + yield* childSpawned.operation; + for (let chunk of yield* each(child.stdout)) { + yield* log.info(chunk.toString()); + yield* each.next(); + } + }); + + yield* spawn(function* () { + yield* childSpawned.operation; + for (let chunk of yield* each(child.stderr)) { + if (chunk.toString().includes("effection inspector")) { + ready.resolve(); + } + yield* log.error(chunk.toString()); + yield* each.next(); + } + }); + + yield* spawn(function* () { + yield* ready.operation; + if (config.inspectRecord) { + yield* recordNodeMapToFile(host, config.inspectRecord, config.protocol); + } + }); + let child = yield* exec(runtime, processOptions); + childSpawned.resolve(); + + yield* spawn(function* () { + yield* sleep(15000); + ready.reject(new Error("timeout waiting for program to start")); + }); + + let status = yield* child.join(); + + yield* provide(status.code); + } finally { + // runProgram() exiting + } + }); +} + +function* recordNodeMapToFile( + host: string, + filePath: string, + protocol: Protocol, +): Operation { + let handle = createSSEClient(protocol, { url: host }); + let values: unknown[] = []; + try { + let subscription = yield* handle.invoke({ name: "recordNodeMap", args: [] }); + + let next = yield* subscription.next(); + while (!next.done) { + values.push(next.value); + next = yield* subscription.next(); + } + } catch (error) { + let message = error instanceof Error ? error.message : String(error); + yield* log.error(`record NodeMap interrupted: ${message}`); + } finally { + // always attempt to write whatever we have collected (possibly empty) + yield* until(writeFile(filePath, JSON.stringify(values, null, 2))); + } +} diff --git a/cli/config.ts b/cli/config.ts index 9e20fdb..ea9ca95 100644 --- a/cli/config.ts +++ b/cli/config.ts @@ -1,112 +1,104 @@ -import { commands, field, object, program, help } from "configliere"; +import { commands, field, object, program, inject, type ProgramType, constant } from "configliere"; import packageJSON from "../package.json" with { type: "json" }; import { type } from "arktype"; -import { scope, player } from "../lib/protocols.ts"; -import { combine } from "../mod.ts"; -import type { ParsedConfig } from "./types.ts"; - -export const inspector = combine.protocols(scope.protocol, player.protocol); - -const commandBase = object({ - out: { - description: "write out the response to a file", - ...field(type("string | undefined"), field.default(undefined)), - }, - host: { - description: "inspector base URL (overrides default)", - ...field(type("string"), field.default("http://localhost:41000")), - }, -}); -type InspectorProtocolCommandBase = Record< - keyof typeof inspector.methods, - { description: string } & typeof commandBase ->; -const inspectorProtocolEntries = ( - Object.keys(inspector.methods) as (keyof typeof inspector.methods)[] -).reduce((base, current) => { - base[current] = { - description: `/${current} API`, - ...commandBase, - }; - return base satisfies InspectorProtocolCommandBase; -}, {} as InspectorProtocolCommandBase); - -const protocolCommands = commands(inspectorProtocolEntries); - -export type ProtocolCommandConfig = ParsedConfig; - -const runBase = object({ - // TODO this throws an error if we have a remainder of more than one arg, - // which is a problem for the `run` command since we want to support passing - // through args to the program being run. - // entrypoint: { - // description: "entrypoint file", - // ...field(type("string"), cli.argument()), - // }, - inspectRecord: { - description: "write inspector recording to the given file", - ...field(type("string | undefined"), field.default(undefined)), - }, - inspectRuntime: { - description: - "which JavaScript runtime to launch (node, deno, bun).\n" + - "If omitted we infer from the executable that invoked the CLI", - ...field(type("'node'|'deno'|'bun'"), field.default("node")), - }, - inspectPause: { - description: "start program paused until resumed by inspector", - ...field(type("boolean"), field.default(false)), - }, - inspectPort: { - description: "port number to give to the inspector loader", - ...field(type("number"), field.default(41000)), - }, - inspectPackage: { - description: "package spec to preload/import/require (defaults to @effectionx/inspector)", - ...field(type("string"), field.default("@effectionx/inspector")), - }, - import: { - description: "tracking loader passed in from the user", - ...field(type("string[]"), field.array(), field.default([])), - }, - preload: { - description: "tracking loader passed in from the user", - ...field(type("string[]"), field.array(), field.default([])), - }, - require: { - description: "tracking loader passed in from the user", - aliases: ["-r"], - ...field(type("string[]"), field.array(), field.default([])), - }, -}); +import { type Methods, type Protocol } from "../mod.ts"; export const config = program({ name: "inspector", version: packageJSON.version, - config: commands( - { - help, - ui: { - description: "start up the inspector UI", - ...object({ - inspectPort: { - description: "port number to give to the inspector loader", - ...field(type("number"), field.default(41000)), + config: inject((protocol: Protocol) => { + let methods = commands( + Object.fromEntries( + Object.keys(protocol.methods).map((name) => [ + name, + { + description: `invoke method ${name}`, + ...object({ + name: constant(name), + protocol: constant(protocol), + out: { + description: "write out the response to a file", + ...field(type("string | undefined"), field.default(undefined)), + }, + host: { + description: "inspector base URL (overrides default)", + ...field(type("string"), field.default("http://localhost:41000")), + }, + }), }, - }), - }, - call: { - description: "invoke a inspector protocol method directly (low level)", - aliases: ["c"], - ...protocolCommands, - }, - run: { - description: "inspect a CLI program", - ...runBase, + ]), + ), + ); + + return commands( + { + ui: { + description: "start up the inspector UI", + ...object({ + inspectPort: { + description: "port number to give to the inspector loader", + ...field(type("number"), field.default(41000)), + }, + }), + }, + call: { + description: "invoke a inspector protocol method directly (low level)", + aliases: ["c"], + ...methods, + }, + run: { + description: "inspect a CLI program", + ...object({ + // TODO this throws an error if we have a remainder of more than one arg, + // which is a problem for the `run` command since we want to support passing + // through args to the program being run. + // entrypoint: { + // description: "entrypoint file", + // ...field(type("string"), cli.argument()), + // }, + protocol: constant(protocol), + inspectRecord: { + description: "write inspector recording to the given file", + ...field(type("string | undefined"), field.default(undefined)), + }, + inspectRuntime: { + description: + "which JavaScript runtime to launch (node, deno, bun).\n" + + "If omitted we infer from the executable that invoked the CLI", + ...field(type("'node'|'deno'|'bun'"), field.default("node")), + }, + inspectPause: { + description: "start program paused until resumed by inspector", + ...field(type("boolean"), field.default(false)), + }, + inspectPort: { + description: "port number to give to the inspector loader", + ...field(type("number"), field.default(41000)), + }, + inspectPackage: { + description: + "package spec to preload/import/require (defaults to @effectionx/inspector)", + ...field(type("string"), field.default("@effectionx/inspector")), + }, + import: { + description: "tracking loader passed in from the user", + ...field(type("string[]"), field.array(), field.default([])), + }, + preload: { + description: "tracking loader passed in from the user", + ...field(type("string[]"), field.array(), field.default([])), + }, + require: { + description: "tracking loader passed in from the user", + aliases: ["-r"], + ...field(type("string[]"), field.array(), field.default([])), + }, + }), + }, }, - }, - { default: "run" }, - ), + { default: "run" }, + ); + }), }); -export type RunConfig = ParsedConfig; +export type Program = ProgramType; diff --git a/cli/index.ts b/cli/index.ts index 4ab3447..3c8b921 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -1,190 +1,84 @@ #!/usr/bin/env node -import { - type Operation, - each, - main, - resource, - sleep, - spawn, - suspend, - until, - withResolvers, -} from "effection"; -import { inspector, type ProtocolCommandConfig, config, type RunConfig } from "./config.ts"; -import { exec } from "@effectionx/process"; +import { main, suspend } from "effection"; +import { config } from "./config.ts"; +import { call } from "./commands/call.ts"; +import { run } from "./commands/run.ts"; import process from "node:process"; -import { writeFile } from "node:fs/promises"; -import { createSSEClient } from "../lib/sse-client.ts"; import { useSSEServer } from "../lib/sse-server.ts"; import { log } from "./logger.ts"; -import { resolveRuntime, buildProcessOptions } from "./build-run-args.ts"; +import { player, scope } from "../lib/protocols.ts"; +import { combine } from "../lib/combine.ts"; -function runProgram(config: RunConfig, passthroughArgs: string[]) { - return resource(function* (provide) { - try { - let runtime = resolveRuntime(config); - let host = `http://localhost:${config.inspectPort}`; - - let processOptions = buildProcessOptions(runtime, config, passthroughArgs); - - let ready = withResolvers(); - let childSpawned = withResolvers(); - - yield* spawn(function* () { - yield* childSpawned.operation; - for (let chunk of yield* each(child.stdout)) { - yield* log.info(chunk.toString()); - yield* each.next(); - } - }); +await main(function* () { + const parser = config.parse({ + args: process.argv.slice(2).filter((arg) => arg !== "--"), + envs: [{ name: "ENV", value: process.env as Record }], + }); + if (!parser.ok) { + yield* log.info(parser.error); + return; + } - yield* spawn(function* () { - yield* childSpawned.operation; - for (let chunk of yield* each(child.stderr)) { - if (chunk.toString().includes("effection inspector")) { - ready.resolve(); - } - yield* log.error(chunk.toString()); - yield* each.next(); - } - }); + let program = parser.value; - yield* spawn(function* () { - yield* ready.operation; - if (config.inspectRecord) { - yield* recordNodeMapToFile(host, config.inspectRecord); - } - }); - let child = yield* exec(runtime, processOptions); - childSpawned.resolve(); + // TODO: load protocol dynamically + let protocol = combine.protocols(scope.protocol, player.protocol); - yield* spawn(function* () { - yield* sleep(15000); - ready.reject(new Error("timeout waiting for program to start")); - }); + // get phase 2 parser + let app = program.config(protocol); - let status = yield* child.join(); + // parse second phase + let result = app.parse(); - yield* provide(status.code); - } finally { - // runProgram() exiting - } - }); -} - -function* recordNodeMapToFile(host: string, filePath: string): Operation { - let handle = createSSEClient(inspector, { url: host }); - let values: unknown[] = []; - try { - let subscription = yield* handle.invoke({ name: "recordNodeMap", args: [] }); - - let next = yield* subscription.next(); - while (!next.done) { - values.push(next.value); - next = yield* subscription.next(); - } - } catch (error) { - let message = error instanceof Error ? error.message : String(error); - yield* log.error(`record NodeMap interrupted: ${message}`); - } finally { - // always attempt to write whatever we have collected (possibly empty) - yield* until(writeFile(filePath, JSON.stringify(values, null, 2))); + if (!result.ok) { + yield* log.error(result.error); + return; } -} -function* callMethod(config: ProtocolCommandConfig) { - const { - name, - config: { out, host }, - } = config; - const argsList = [] as never[]; - const handle = createSSEClient(inspector, { url: host }); - if (!(name in handle.protocol.methods)) { - yield* log.error(`unknown command: ${name}`); + if (program.help) { + yield* log.info(app.help()); + return; } - let results: unknown[] = []; - let subscription = yield* handle.invoke({ - name, - args: argsList, - }); - let next = yield* subscription.next(); - // log progress values and collect everything, including final return - while (!next.done) { - results.push(next.value); - yield* log.info(JSON.stringify(next.value)); - next = yield* subscription.next(); + + const command = result.value; + + if (command.help) { + yield* log.info(command.text); + return; } - if (out) { - try { - yield* until(writeFile(out, JSON.stringify(results, null, 2))); - } catch (e) { - let msg = e instanceof Error ? e.message : String(e); - yield* log.error(`failed to write ${out}: ${msg}`); + switch (command.name) { + case "ui": { + let address = yield* useSSEServer({ protocol: { methods: {} } } as any, { + port: command.config.inspectPort, + }); + yield* log.info(`serving inspector UI at ${address}`); + yield* suspend(); + break; + } + case "call": { + let method = command.config; + if (method.help) { + yield* log.info(method.text); + } else { + yield* call(method.config); + } + break; } - } -} -await main(function* () { - try { - const parser = config.createParser({ - args: process.argv.slice(2).filter((arg) => arg !== "--"), - envs: [{ name: "ENV", value: process.env as Record }], - }); + case "run": { + let { remainder } = result; - switch (parser.type) { - case "help": - case "version": - yield* log.info(parser.print()); - break; - case "main": { - const result = parser.parse(); - if (result.ok) { - let { value: command, remainder } = result; - switch (command.name) { - case "help": - yield* log.info(command.config.text); - break; - case "ui": { - let address = yield* useSSEServer({ protocol: { methods: {} } } as any, { - port: command.config.inspectPort, - }); - yield* log.info(`serving inspector UI at ${address}`); - yield* suspend(); - break; - } - case "call": - yield* callMethod(command.config); - break; - case "run": - if (!remainder.args || (remainder.args && remainder.args.length === 0)) { - const configForGettingHelp = config.createParser({ args: ["--help"] }); - if (configForGettingHelp.type !== "help") { - yield* log.error("failed to run, refer to help command"); - break; - } - const helpText = configForGettingHelp.print(); - yield* log.info(helpText); - } else { - yield* runProgram(command.config, remainder.args); - } - break; - default: - // An exhaustiveness check using 'never' can be added here - const _exhaustiveCheck: never = command; - break; - } - } else { - yield* log.error(result.error.message); - } - break; + if (!remainder.args || (remainder.args && remainder.args.length === 0)) { + yield* log.info(app.help({ args: ["run", "--help"] })); + } else { + yield* run(command.config, remainder.args); } - default: - // An exhaustiveness check using 'never' can be added here - const _exhaustiveCheck: never = parser; - break; + break; } - } finally { - // "inspector exiting" + default: + // An exhaustiveness check using 'never' can be added here + //const _exhaustiveCheck: never = command; + break; } }); diff --git a/cli/types.ts b/cli/types.ts deleted file mode 100644 index 6ad4f05..0000000 --- a/cli/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { CommandsParser, ObjectParser } from "configliere"; - -export type ParsedConfig> = Extract< - ReturnType, - { ok: true } ->["value"]; diff --git a/package.json b/package.json index f3d8b1e..3ef8645 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@effectionx/stream-helpers": "^0.8.0", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.27", - "configliere": "~0.2.3", + "configliere": "^0.3.0", "h3": "2.0.1-rc.14", "parse-sse": "^0.1.0", "remeda": "^2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d27502e..754b8b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,8 +32,8 @@ importers: specifier: ^2.1.27 version: 2.1.29 configliere: - specifier: ~0.2.3 - version: 0.2.3 + specifier: ^0.3.0 + version: 0.3.0 h3: specifier: 2.0.1-rc.14 version: 2.0.1-rc.14 @@ -962,8 +962,8 @@ packages: peerDependencies: '@floating-ui/utils': ^0.2.5 - configliere@0.2.3: - resolution: {integrity: sha512-yXhYYx89F+8UV4tJBP/N0Jb3SujLea3rp7p47fSAlj6wLl8Acve2pUZwcklgmb3ifJZvDGQWT9EiQmilEY1D9g==} + configliere@0.3.0: + resolution: {integrity: sha512-4W3UipMaFyoMdt6ZQtx0tPsuEKdVJD1kPmrJuHSrakyJ0XPxJ1QR5HC+vGb/FSY+OGCeO50eEUq8I5faJs2Oqw==} engines: {node: '>= 16'} cross-spawn@7.0.6: @@ -2174,8 +2174,9 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.10 - configliere@0.2.3: + configliere@0.3.0: dependencies: + '@standard-schema/spec': 1.1.0 ts-case-convert: 2.1.0 cross-spawn@7.0.6: diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 81485ea..ef61c39 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,16 +1,18 @@ import { describe, it } from "@effectionx/bdd"; import assert from "node:assert/strict"; -import { buildProcessOptions } from "../cli/build-run-args.ts"; +import { buildProcessOptions } from "../cli/commands/build-run-args.ts"; import { config } from "../cli/config.ts"; function parseRunArgs(raw: string[]) { - const parser = config.createParser({ args: raw, envs: [] }); - assert.equal(parser.type, "main"); - const result = parser.parse(); - assert.ok(result.ok, `failed to parse: ${JSON.stringify(result, null, 2)}`); - const { value, remainder, data } = result; + const parser = config.parse({ args: raw, envs: [] }); + assert(parser.ok && !parser.value.help && !parser.value.version); + const app = parser.value.config({ methods: {} }); + const result = app.parse(); + assert.ok(result.ok, `failed to parse: ${JSON.stringify(app, null, 2)}`); + const { value, remainder } = result; assert.equal(value.name, "run"); - return { config: value.config, remainder: remainder.args ?? [], data }; + assert(!value.help); + return { config: value.config, remainder: remainder.args ?? [] }; } describe("generate loader env", () => {