refactor(cmdline): make command providers ordinary
This commit is contained in:
46 files changed
+400
-742
No files matched your search
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
@@ -28,7 +29,9 @@ export const name = 'web-app'
|
||||
/** This dsh installation's root, from either this package's source or built entry. */
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
|
||||
const HMR_ROW_ID = 'client-hmr'
|
||||
const CLIENT_ROSTER_SERVICE = 'webClientRoster'
|
||||
|
||||
/** Runtime service that releases Web rows after bind-dependent values resolve. */
|
||||
const WEB_RUNTIME_SERVICE = 'webRuntime'
|
||||
|
||||
/** Services required before the web runtime can mount. */
|
||||
export const inject = ['httpServer']
|
||||
@@ -36,7 +39,7 @@ export const inject = ['httpServer']
|
||||
/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */
|
||||
export type WebMode = 'production' | 'development'
|
||||
|
||||
/** Plugin config: composed deployment settings plus per-invocation startup values. */
|
||||
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
|
||||
export interface Config {
|
||||
/** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */
|
||||
mode: WebMode
|
||||
@@ -49,22 +52,25 @@ export interface Config {
|
||||
* orientation text would be false.
|
||||
*/
|
||||
surfaceContext: boolean
|
||||
/**
|
||||
* LAN IPv4 addresses sampled once by the app startup row when the effective bind
|
||||
* is all-interfaces — the exact snapshot the /api trust fence was
|
||||
* configured with, so the printed LAN URL can never name an address the
|
||||
* fence rejects. Empty on a loopback bind.
|
||||
*/
|
||||
lanAddresses: string[]
|
||||
/** Explicit `--trusted-host` authorities from this invocation. */
|
||||
trustedHosts: string[]
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
mode: z.union([z.const('production'), z.const('development')]).default('production'),
|
||||
printUrl: z.boolean().default(true),
|
||||
surfaceContext: z.boolean().default(true),
|
||||
lanAddresses: z.array(String).default([]),
|
||||
trustedHosts: z.array(String).default([]),
|
||||
})
|
||||
|
||||
/** Bind-dependent Web values shared by the trust fence and URL display. */
|
||||
export interface WebRuntimeValues {
|
||||
/** LAN IPv4 literals sampled once when the server binds all interfaces. */
|
||||
lanAddresses: string[]
|
||||
/** LAN literals followed by explicit invocation authorities. */
|
||||
trustedHosts: string[]
|
||||
}
|
||||
|
||||
/** Environment variable naming the canonical local URL of this Web GUI. */
|
||||
const DSH_WEB_URL = 'DSH_WEB_URL' as const
|
||||
/** Environment variable naming the Web runtime mode. */
|
||||
@@ -73,6 +79,27 @@ const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
|
||||
// Display-only mirror of the webserver schema's loopback host: the address the
|
||||
// local URL always prints. Not a source of truth — the schema is.
|
||||
const LOOPBACK_HOST = '127.0.0.1'
|
||||
/** The webserver schema's all-interfaces bind literal. */
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
/**
|
||||
* Resolve one LAN-trust snapshot from the active server bind.
|
||||
*
|
||||
* Derived entries are port-less IP literals: DNS rebinding needs an
|
||||
* attacker-controlled name, while an IP-literal Host is safe on any port and
|
||||
* an OS-assigned port is unknowable before bind.
|
||||
* @param bindHost - the active webserver bind host.
|
||||
* @param extra - explicit `--trusted-host` values, in argument order.
|
||||
* @returns the LAN display addresses and invocation-derived fence authorities.
|
||||
*/
|
||||
export function resolveLanTrust(bindHost: string, extra: readonly string[]): WebRuntimeValues {
|
||||
const lanAddresses = bindHost === ALL_INTERFACES_HOST
|
||||
? Object.values(networkInterfaces()).flat()
|
||||
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
|
||||
.map(iface => iface.address)
|
||||
: []
|
||||
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
|
||||
}
|
||||
|
||||
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
|
||||
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
|
||||
@@ -124,8 +151,10 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
// fiber. Otherwise its first browser graph omits the reload receiver, which
|
||||
// cannot use that receiver to discover itself later.
|
||||
if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID)
|
||||
// Release client discovery only after the optional row has a pending fiber.
|
||||
ctx.provide(CLIENT_ROSTER_SERVICE, true)
|
||||
const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts)
|
||||
// Release dependent rows only after the optional row has a pending fiber and
|
||||
// bind-dependent trust has been sampled once.
|
||||
ctx.provide(WEB_RUNTIME_SERVICE, runtime)
|
||||
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
|
||||
if (config.surfaceContext) {
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
@@ -153,9 +182,8 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
// sibling rows (the /api route owner) are still mounting. Await Loader
|
||||
// settlement first; a hand-built tree without a Loader prints at once.
|
||||
const printUrl = (): void => {
|
||||
// The startup row's boot-time LAN snapshot, not a fresh sample: the printed
|
||||
// LAN URL must name an address the /api trust fence was configured with.
|
||||
const lanCandidate = config.lanAddresses[0]
|
||||
// Reuse the exact LAN snapshot provided to the /api trust fence.
|
||||
const lanCandidate = runtime.lanAddresses[0]
|
||||
const port = ctx.httpServer.port
|
||||
console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
/**
|
||||
* The web app's startup row: it owns the `dsh --profile web` flag family
|
||||
* (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help` text,
|
||||
* turns those flags into changes on the rows that inject
|
||||
* {@link WEB_STARTUP_SERVICE}, and then provides it. Until it does, no
|
||||
* flag-configured web row starts, so `dsh --profile web --help` prints this
|
||||
* command's help and the server never binds.
|
||||
* The web app's command-line provider: it parses the `dsh --profile web` flag
|
||||
* family (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help`
|
||||
* text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}.
|
||||
* Ordinary rows inject that service before reading it from lazy config.
|
||||
* @module @deepseek-ai/dsh-web-app/startup
|
||||
*/
|
||||
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { Command } from 'commander'
|
||||
import type { Context } from 'cordis'
|
||||
import { interpolate, type EntryOptions } from '@cordisjs/plugin-loader'
|
||||
import { runStartup } from '@deepseek-ai/dsh-cmdline'
|
||||
import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'web-startup'
|
||||
@@ -20,11 +16,7 @@ export const name = 'web-startup'
|
||||
/** Services required before the flags can be resolved. */
|
||||
export const inject = ['cmdlineArgs']
|
||||
|
||||
/**
|
||||
* The service this row provides and every flag-configured web row reads. The
|
||||
* rows are listed in this bundle's `cordis.patch.yml`, where each names the key
|
||||
* it takes from here and the value it falls back to.
|
||||
*/
|
||||
/** Service provided by this ordinary plugin and injected by flag-configured rows. */
|
||||
export const WEB_STARTUP_SERVICE = 'webStartup'
|
||||
|
||||
/** What the web rows read from {@link WEB_STARTUP_SERVICE}. */
|
||||
@@ -35,63 +27,8 @@ export interface WebStartupValues {
|
||||
port?: number
|
||||
/** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */
|
||||
mode: 'production' | 'development'
|
||||
/**
|
||||
* The `/api` fence authorities for this invocation: the LAN literals an
|
||||
* all-interfaces bind derived, plus the `--trusted-host` extras, over what
|
||||
* the composition already configured.
|
||||
*/
|
||||
/** Explicit `--trusted-host` authorities, in argument order. */
|
||||
trustedHosts: string[]
|
||||
/** The LAN literals the fence was configured with, for display. */
|
||||
lanAddresses: string[]
|
||||
}
|
||||
|
||||
/** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
/**
|
||||
* Read the deployment trust list before its row mounts and validates config.
|
||||
* @param config - the connection row's config resolved before `webStartup` exists.
|
||||
* @returns its configured authorities, or an empty list when absent.
|
||||
* @throws when the file-backed config is not an array of strings.
|
||||
*/
|
||||
function configuredTrustedHosts(config: unknown): string[] {
|
||||
const value = (config as { trustedHosts?: unknown } | undefined)?.trustedHosts
|
||||
if (value === undefined) return []
|
||||
const valid = Array.isArray(value) && value.every((entry: unknown) => typeof entry === 'string')
|
||||
if (!valid) throw new Error('web-startup: the composed connection trustedHosts must be an array of strings')
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-internal IPv4 interface addresses of this machine — the IP-literal
|
||||
* authorities an all-interfaces bind is reachable by on the LAN.
|
||||
* @returns the addresses in interface order (possibly empty).
|
||||
*/
|
||||
function lanIPv4Addresses(): string[] {
|
||||
return Object.values(networkInterfaces()).flat()
|
||||
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
|
||||
.map(iface => iface.address)
|
||||
}
|
||||
|
||||
/**
|
||||
* One LAN-trust resolution for one invocation, sampled exactly once: the
|
||||
* machine's LAN IP literals when the effective bind is all-interfaces, and the
|
||||
* `trustedHosts` value built from them plus the explicit extras. The single
|
||||
* sample is deliberate — display must advertise only addresses the fence was
|
||||
* configured with, so the `web-runtime` row receives this same snapshot.
|
||||
* Derived entries are port-less IP literals: DNS rebinding needs an
|
||||
* attacker-controlled name, so an IP-literal Host is safe on any port, and the
|
||||
* bound port may be OS-assigned, unknowable before the server binds.
|
||||
* @param bindHost - the effective webserver bind host (the flag, else the composed row value).
|
||||
* @param extra - `--trusted-host` values, in argv order.
|
||||
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
|
||||
*/
|
||||
export function resolveLanTrust(
|
||||
bindHost: string | undefined,
|
||||
extra: readonly string[],
|
||||
): { lanAddresses: string[]; trustedHosts: string[] } {
|
||||
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
|
||||
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
|
||||
}
|
||||
|
||||
/** The web flag family, as commander parsed it. */
|
||||
@@ -125,51 +62,29 @@ Examples:
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the parsed flags into the values the web rows read.
|
||||
* Turn the parsed flags into the value injected rows read.
|
||||
* @param program - the parsed web command.
|
||||
* @param rows - the waiting rows' composed options, in tree order.
|
||||
* @param ctx - the startup context used to resolve composed fallbacks before `webStartup` exists.
|
||||
* @returns the web rows' service value.
|
||||
* @returns this invocation's immutable Web options.
|
||||
*/
|
||||
function planWebStartup(program: Command, rows: readonly EntryOptions[], ctx: Context): WebStartupValues {
|
||||
function planWebStartup(program: Command): WebStartupValues {
|
||||
const options = program.opts<WebOptions>()
|
||||
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
|
||||
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
|
||||
}
|
||||
const row = (id: string): EntryOptions => {
|
||||
const found = rows.find(candidate => candidate.id === id)
|
||||
if (found === undefined) throw new Error(`web-startup: the web composition has no waiting ${JSON.stringify(id)} row to configure`)
|
||||
return found
|
||||
}
|
||||
const webserver = row('webserver')
|
||||
row('web-runtime')
|
||||
const connection = row('connection')
|
||||
// Include preserves nested row expressions until their own injections are
|
||||
// active. Resolve just the composed fields this startup plan needs against
|
||||
// the pre-service context, where their `ctx.get('webStartup')` fallback wins.
|
||||
const webserverConfig = interpolate(ctx, webserver.config) as { host?: string } | undefined
|
||||
const connectionConfig: unknown = interpolate(ctx, connection.config)
|
||||
const bindHost = options.host ?? webserverConfig?.host
|
||||
const sampled = resolveLanTrust(bindHost, options.trustedHost ?? [])
|
||||
// Preserve deployment authorities when invocation-derived LAN literals or
|
||||
// explicit extras become the runtime value read by the connection row.
|
||||
const composedTrusted = configuredTrustedHosts(connectionConfig)
|
||||
return {
|
||||
...options.host !== undefined && { host: options.host },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
// mode and lanAddresses describe this invocation, never the deployment, so
|
||||
// they are resolved on every boot.
|
||||
mode: options.dev === true ? 'development' : 'production',
|
||||
trustedHosts: [...composedTrusted, ...sampled.trustedHosts],
|
||||
lanAddresses: sampled.lanAddresses,
|
||||
trustedHosts: options.trustedHost ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the web flag family for rows waiting on `webStartup`.
|
||||
* @param ctx - plugin context carrying the command line and the Loader.
|
||||
* @returns nothing once the values are provided, or once `--help` requested exit.
|
||||
* Parse and provide the Web invocation as an ordinary Cordis service.
|
||||
* @param ctx - plugin context carrying the command line.
|
||||
* @returns nothing once values are provided, or when the command requested exit.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup)
|
||||
const values = parseCmdline(ctx, webCommand(), planWebStartup)
|
||||
if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values)
|
||||
}
|
||||
Reference in New Issue
Block a user