Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
b6dddf934This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* `harness dsh install`: clone (or link) a DSH repo under `~/.harness/dsh/<owner>/<name>`, run its
* setup once, run its doctor, and record it in the index. The same function backs the desktop's
* "Harness will install Circuit on this machine before starting" — the phases it reports are the
* `dsh_install_status` frames the app shows.
*
* The clone lands in a temporary directory first, because the install path is derived from the
* manifest's own id — which we cannot know until the clone exists. A manifest that fails to parse
* leaves nothing behind.
*/
import { execFile, spawn } from 'node:child_process'
import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
import { promisify } from 'node:util'
import {
dshInstallDir, dshRootDir, installedDsh, readInstalledIndex, removeInstalledRecord, upsertInstalledRecord,
type InstalledDsh, type InstalledDshRecord,
} from './installed.js'
import { readDshManifest, viewerUse, type DshManifest } from './manifest.js'
import { PACKAGE_PATH_RE, type DshRegistryEntry } from './registry.js'
import { catalogEntry, refreshDshRegistry } from './catalog.js'
import { resolveDshCommand } from './materialize.js'
import { runDshCommand } from './shell.js'
import { lockDsh, dshBusy } from './lock.js'
const execFileAsync = promisify(execFile)
export type DshInstallPhase = 'clone' | 'setup' | 'doctor' | 'done' | 'failed'
export interface DshInstallProgress {
/** Known once the manifest has been read; null while cloning by URL. */
id: string | null
phase: DshInstallPhase
detail?: string
/**
* The latest line the phase's command printed — what the install is ON right now, for a dialog
* that would otherwise say "Setting up…" for three minutes. Sent by the daemon's narrator, not by
* installDsh itself (which reports lines through `onLine`), throttled to a few a second.
*/
line?: string
/** On `failed`: the code the reply carries (`CLONE_FAILED`, `SETUP_FAILED`…), so a client can say what kind of failure it was. */
error?: string
}
export interface DshInstallOptions {
/** A git URL, or a local path (cloned unless `link`). */
source: string
/** A catalog install must resolve to the package the user selected. */
expectedId?: string
ref?: string
/**
* The folder inside `source` that is the package (`store/agents/typst` of the Harness monorepo).
* Only that folder is fetched — a sparse, blob-less clone — and only that folder is installed.
*/
path?: string
/** Symlink a local checkout instead of cloning it — the development loop. */
link?: boolean
onProgress?: (progress: DshInstallProgress) => void
/** Setup/doctor output, line by line. */
onLine?: (line: string) => void
setupTimeoutMs?: number
/** Where a `viewer.use` id resolves to a repo; the bundled registry by default. Test seam. */
registry?: (id: string) => DshRegistryEntry | undefined
/** Waits between fetch attempts; [[CLONE_RETRY_DELAYS_MS]] by default. Test seam. */
cloneRetryDelaysMs?: readonly number[]
}
export interface DshDoctorResult {
ok: boolean
lines: string[]
}
export type DshInstallResult =
| { ok: true; installed: InstalledDsh; doctor: DshDoctorResult; setupLines: string[] }
| { ok: false; error: string; detail: string }
/** A registry id (`autonomous/typst`) resolves to its repo, ref and folder; anything else is a source. */
export function resolveInstallSource(
idOrSource: string,
registry: (id: string) => DshRegistryEntry | undefined = catalogEntry,
): { source: string; ref?: string; path?: string; id?: string } | null {
const entry = registry(idOrSource)
if (entry) return { source: entry.repo, ref: entry.ref, ...(entry.path ? { path: entry.path } : {}), id: entry.id }
if (!idOrSource || /[\x00-\x1f\x7f]/.test(idOrSource)) return null
return { source: idOrSource }
}
async function gitHead(dir: string, revision = 'HEAD'): Promise<string | null> {
try {
const { stdout } = await execFileAsync('git', ['-C', dir, 'rev-parse', revision], { timeout: 10_000 })
return stdout.trim() || null
} catch {
return null
}
}
function linkInstall(source: string): { ok: true; realDir: string; manifest: DshManifest } | { ok: false; error: string; detail: string } {
if (!isAbsolute(source)) return { ok: false, error: 'INVALID_SOURCE', detail: '--link needs an absolute path to a checkout' }
let realDir: string
try {
realDir = realpathSync(source)
} catch {
return { ok: false, error: 'SOURCE_NOT_FOUND', detail: `${source} does not exist` }
}
const manifest = readDshManifest(realDir)
if (!manifest.ok) return { ok: false, error: 'INVALID_MANIFEST', detail: manifest.error }
// Placing the link first clears the install directory. When the checkout IS that directory (a clone
// installed earlier), or lives inside it, that would delete the only copy before linking to it.
const installDir = dshInstallDir(manifest.manifest.id)
let placed: ReturnType<typeof lstatSync> | null = null
try { placed = lstatSync(installDir) } catch { placed = null }
if (placed && !placed.isSymbolicLink()) {
const installed = realpathSync(installDir)
if (realDir === installed || realDir.startsWith(installed + sep)) {
return { ok: false, error: 'INVALID_SOURCE', detail: `${source} is inside the installed copy of ${manifest.manifest.id}; link a checkout that lives elsewhere` }
}
}
return { ok: true, realDir, manifest: manifest.manifest }
}
/**
* Between fetch attempts, in order; the length is how many retries a transient failure gets. A stalled
* transfer already cost the low-speed window, so these are short — the point is a second connection,
* not a long wait.
*/
export const CLONE_RETRY_DELAYS_MS: readonly number[] = [2_000, 5_000]
/**
* Whether what git said is the network, not the repository: the wording curl and git's transport use
* for a stall, a reset, a resolver miss or a 5xx. A repo that is not there, a ref that does not exist,
* a refused credential and our own ten-minute stop are not — a second try answers the same.
*/
export function isTransientGitFailure(detail: string): boolean {
if (/was still running after \d+ min/.test(detail)) return false
// A 4xx rides the same `RPC failed; curl 22` prefix as a stall; a repo that is not there or a
// credential that was refused answers the same on every try.
if (/returned error: 4\d\d|HTTP 4\d\d|Authentication failed|could not read Username|Repository not found|Permission denied/i.test(detail)) return false
return /\bcurl \d+\b|RPC failed|early EOF|unexpected disconnect|remote end hung up|Could not resolve host|Connection (?:reset|refused|timed out)|Operation too slow|Timeout was reached|Failed to connect to|The requested URL returned error: 5\d\d|GnuTLS recv error|SSL_read|Connection closed|TLS connect error|Empty reply from server/i.test(detail)
}
export type CloneInstallResult = { ok: true; tmpDir: string; manifest: DshManifest; commit: string | null; revision: string | null } | { ok: false; error: string; detail: string }
/**
* Fetch the package into a fresh temporary directory. A fetch that failed the way a bad connection
* fails is tried again, a bounded number of times, each from nothing (the failed attempt's directories
* are gone): a clone is idempotent, and a Store install that stalled once on a WSL link and worked the
* next time should not have needed a person to click.
*/
export async function cloneInstall(
source: string,
ref: string | undefined,
path: string | undefined,
onLine: ((line: string) => void) | undefined,
retryDelaysMs: readonly number[] = CLONE_RETRY_DELAYS_MS,
): Promise<CloneInstallResult> {
const attempts = retryDelaysMs.length + 1
for (let attempt = 1; ; attempt++) {
const result = await cloneOnce(source, ref, path, onLine)
if (result.ok || result.error !== 'CLONE_FAILED' || !isTransientGitFailure(result.detail)) return result
if (attempt >= attempts) {
return { ...result, detail: `${result.detail} · gave up after ${attempts} attempts`.slice(0, 2000) }
}
const delay = retryDelaysMs[attempt - 1] ?? 0
onLine?.(`fetch failed · ${result.detail} · retrying (${attempt + 1}/${attempts})`)
await new Promise<void>((resolve) => setTimeout(resolve, delay))
}
}
async function cloneOnce(
source: string,
ref: string | undefined,
path: string | undefined,
onLine: ((line: string) => void) | undefined,
): Promise<CloneInstallResult> {
const root = dshRootDir()
mkdirSync(root, { recursive: true, mode: 0o700 })
const tmpDir = join(root, `.tmp-${randomUUID()}`)
const fail = (error: string, detail: string, ...dirs: string[]): { ok: false; error: string; detail: string } => {
for (const dir of [tmpDir, ...dirs]) rmSync(dir, { recursive: true, force: true })
return { ok: false, error, detail: detail.slice(0, 2000) }
}
if (path !== undefined && !PACKAGE_PATH_RE.test(path)) return fail('INVALID_SOURCE', `${path} is not a folder inside the repo`)
let commit: string | null
let revision: string | null
if (path === undefined) {
// `--progress` because stderr is not a tty here and git would otherwise stay silent until the end;
// streamed, not collected, so "Receiving objects: 39%" reaches the dialog while it is true.
const clone = await cloneRepo(source, ref, tmpDir, false, onLine)
if (!clone.ok) return fail('CLONE_FAILED', clone.detail)
commit = await gitHead(tmpDir)
revision = await gitHead(tmpDir, 'HEAD^{tree}')
} else {
// A package that is ONE FOLDER of a bigger repo — the built-in shelf is `store/*/*` of the Harness
// monorepo, whose other folders are the app, the CLI and the backend. A blob-less, sparse clone
// fetches the tree of one commit and the file contents of that folder alone; the folder is then
// moved out and the rest of the clone thrown away, so the install is laid out exactly like a
// whole-repo one (the manifest at its root) and nothing else of the monorepo lands on the machine.
const repoDir = join(root, `.tmp-${randomUUID()}`)
const clone = await cloneRepo(source, ref, repoDir, true, onLine)
if (!clone.ok) return fail('CLONE_FAILED', clone.detail, repoDir)
const sparse = await streamGit(['-C', repoDir, 'sparse-checkout', 'set', '--', path], onLine)
if (!sparse.ok) return fail('CLONE_FAILED', sparse.detail, repoDir)
const folder = join(repoDir, ...path.split('/'))
let isFolder = false
try { isFolder = lstatSync(folder).isDirectory() } catch { isFolder = false }
if (!isFolder) return fail('CLONE_FAILED', `${source}${ref ? ` at ${ref}` : ''} has no folder ${path}`, repoDir)
commit = await gitHead(repoDir)
revision = await gitHead(repoDir, `HEAD:${path}`)
renameSync(folder, tmpDir)
rmSync(repoDir, { recursive: true, force: true })
}
const manifest = readDshManifest(tmpDir)
if (!manifest.ok) return fail('INVALID_MANIFEST', manifest.error)
return { ok: true, tmpDir, manifest: manifest.manifest, commit, revision }
}
/** How long one git command may take: a clone of a large repository over a slow link, not a hang. */
const GIT_TIMEOUT_MS = 10 * 60_000
/**
* What git is run with, on top of the daemon's environment. A transfer under 1 KiB/s for a minute is
* a stalled connection, and curl gives it up then (`RPC failed; curl 28`) rather than sitting silent
* until the ten-minute stop — which is what "Fetching…" for a quarter of an hour on a WSL link was.
* No stdin here, so a credential prompt could only hang: git is told not to ask.
*/
const GIT_ENV: NodeJS.ProcessEnv = {
GIT_HTTP_LOW_SPEED_LIMIT: '1024',
GIT_HTTP_LOW_SPEED_TIME: '60',
GIT_TERMINAL_PROMPT: '0',
}
/** Catalogs pin built-ins to the commit they describe; `git clone --branch` cannot take a SHA. */
async function cloneRepo(source: string, ref: string | undefined, dir: string, sparse: boolean, onLine: DshInstallOptions['onLine']): Promise<{ ok: true } | { ok: false; detail: string }> {
if (!ref || !/^[a-f0-9]{40}$/i.test(ref)) {
return streamGit(['clone', '--depth', '1', ...(sparse ? ['--filter=blob:none', '--sparse'] : []), '--progress', ...(ref ? ['--branch', ref] : []), '--', source, dir], onLine)
}
const steps = [
['init', '--quiet', dir],
['-C', dir, 'remote', 'add', 'origin', source],
['-C', dir, 'fetch', '--depth', '1', ...(sparse ? ['--filter=blob:none'] : []), '--progress', 'origin', ref],
...(sparse ? [['-C', dir, 'sparse-checkout', 'init', '--cone']] : []),
['-C', dir, 'checkout', '--detach', 'FETCH_HEAD'],
]
for (const args of steps) {
const result = await streamGit(args, onLine)
if (!result.ok) return result
}
return { ok: true }
}
/** Run git, handing each stderr line (and each carriage-return progress segment) to `onLine` as it lands. */
function streamGit(args: string[], onLine: ((line: string) => void) | undefined): Promise<{ ok: true } | { ok: false; detail: string }> {
return new Promise((resolve) => {
const child = spawn('git', args, { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, ...GIT_ENV } })
const tail: string[] = []
let rest = ''
let timedOut = false
const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM') }, GIT_TIMEOUT_MS)
child.stderr?.on('data', (chunk: Buffer) => {
rest += chunk.toString('utf8')
let at: number
while ((at = rest.search(/[\r\n]/)) >= 0) {
const line = rest.slice(0, at).trim()
rest = rest.slice(at + 1)
if (!line) continue
onLine?.(line)
tail.push(line)
if (tail.length > 20) tail.shift()
}
})
child.on('error', (error) => { clearTimeout(timer); resolve({ ok: false, detail: error.message }) })
child.on('exit', (code, signal) => {
clearTimeout(timer)
if (rest.trim()) { onLine?.(rest.trim()); tail.push(rest.trim()) }
if (code === 0) { resolve({ ok: true }); return }
const what = `git ${args[0] === '-C' ? args[2] : args[0]}`
const said = tail.filter((l) => !/^(Receiving|Resolving|Updating|remote:)/.test(l)).slice(-3).join(' · ') || tail.slice(-1).join('')
const how = timedOut ? `${what} was still running after ${GIT_TIMEOUT_MS / 60_000} min` : `${what} exited ${code ?? signal}`
resolve({ ok: false, detail: said ? `${how}: ${said}` : how })
})
})
}
/** Put the clone (or the link) at its final path, replacing whatever an earlier install left there. */
function placeAt(id: string, from: { tmpDir: string } | { linkTo: string }): string {
const dir = dshInstallDir(id)
mkdirSync(dirname(dir), { recursive: true, mode: 0o700 })
let existing: ReturnType<typeof lstatSync> | null = null
try { existing = lstatSync(dir) } catch { existing = null }
if (existing) rmSync(dir, { recursive: true, force: true })
if ('tmpDir' in from) renameSync(from.tmpDir, dir)
else symlinkSync(from.linkTo, dir)
return dir
}
/**
* How long a doctor may take. Five minutes, not one: the FIRST run after setup is the slow one — a
* check like Solid's `import cadgen, build123d` loads OCP and vtk from packages downloaded seconds
* ago, and macOS verifies every one of those dylibs on first load. Measured 2026-09-16: over two
* minutes cold, five seconds warm. At 60s the install reported "doctor failed" after two `ok` lines
* on a machine that was fine.
*/
export const DOCTOR_TIMEOUT_MS = 5 * 60_000
export async function runDshDoctor(installed: InstalledDsh, onLine?: (line: string) => void): Promise<DshDoctorResult> {
const doctor = installed.manifest.toolchain?.doctor
if (!doctor) return { ok: true, lines: [] }
// Resolved inside the harness, as check.ts reads it: a bare `doctor.sh` is that file, not a PATH lookup.
const result = await runDshCommand(resolveDshCommand(installed, doctor), {
cwd: installed.realDir,
env: { HARNESS_DSH: installed.id, HARNESS_DSH_DIR: installed.realDir },
onLine,
timeoutMs: DOCTOR_TIMEOUT_MS,
})
if (result.timedOut) {
// Said as what it is. A timeout reported as "failed" with the last three (passing) lines under
// it reads as a machine with a fault nobody can find.
const line = `miss doctor still running after ${DOCTOR_TIMEOUT_MS / 60_000} min — stopped; run \`harness dsh doctor ${installed.id}\` again`
result.lines.push(line)
onLine?.(line)
}
return { ok: result.code === 0 && !result.timedOut, lines: result.lines }
}
export async function installDsh(opts: DshInstallOptions): Promise<DshInstallResult> {
const progress = (p: DshInstallProgress): void => opts.onProgress?.(p)
const wrongId = (actual: string): DshInstallResult => {
const detail = `Catalog requested ${opts.expectedId}, but the package declares ${actual}`
progress({ id: opts.expectedId ?? null, phase: 'failed', detail, error: 'PACKAGE_ID_MISMATCH' })
return { ok: false, error: 'PACKAGE_ID_MISMATCH', detail }
}
let manifest: DshManifest
let dir: string
let commit: string | null = null
let realDir: string
let revision: string | null = null
let unlock: (() => void) | null = null
let staged: string | null = null
try {
progress({ id: null, phase: 'clone', detail: opts.link ? `linking ${opts.source}` : `cloning ${opts.source}${opts.path ? ` · ${opts.path}` : ''}` })
if (opts.link) {
const linked = linkInstall(opts.source)
if (!linked.ok) { progress({ id: null, phase: 'failed', detail: linked.detail, error: linked.error }); return linked }
manifest = linked.manifest
if (opts.expectedId && manifest.id !== opts.expectedId) return wrongId(manifest.id)
unlock = lockDsh(manifest.id)
if (!unlock) return dshBusy(manifest.id)
realDir = linked.realDir
dir = placeAt(manifest.id, { linkTo: realDir })
commit = await gitHead(realDir)
} else {
const cloned = await cloneInstall(opts.source, opts.ref, opts.path, opts.onLine, opts.cloneRetryDelaysMs)
if (!cloned.ok) { progress({ id: null, phase: 'failed', detail: cloned.detail, error: cloned.error }); return cloned }
staged = cloned.tmpDir
manifest = cloned.manifest
if (opts.expectedId && manifest.id !== opts.expectedId) {
rmSync(cloned.tmpDir, { recursive: true, force: true })
return wrongId(manifest.id)
}
unlock = lockDsh(manifest.id)
if (!unlock) return dshBusy(manifest.id)
commit = cloned.commit
revision = cloned.revision
dir = placeAt(manifest.id, { tmpDir: cloned.tmpDir })
realDir = realpathSync(dir)
}
const record: InstalledDshRecord = {
id: manifest.id,
dir,
source: opts.link || existsSync(opts.source) ? resolve(opts.source) : opts.source,
ref: opts.ref ?? null,
...(opts.link || !opts.path ? {} : { path: opts.path }),
commit,
revision,
linked: opts.link === true,
installedAt: Date.now(),
}
return await finishInstall({ ...record, manifest, realDir }, opts, true)
} finally {
if (staged) rmSync(staged, { recursive: true, force: true })
unlock?.()
}
}
/** Run at the permanent path: venvs and generated launchers often embed it. */
export async function finishInstall(resolved: InstalledDsh, opts: DshInstallOptions, recordDoctorFailure: boolean): Promise<DshInstallResult> {
const { manifest, realDir } = resolved
const progress = (p: DshInstallProgress): void => opts.onProgress?.(p)
const setupLines: string[] = []
if (manifest.toolchain?.setup) {
progress({ id: manifest.id, phase: 'setup', detail: manifest.toolchain.setup })
const setup = await runDshCommand(resolveDshCommand({ realDir }, manifest.toolchain.setup), {
cwd: realDir,
env: { HARNESS_DSH: manifest.id, HARNESS_DSH_DIR: realDir },
onLine: (line) => { setupLines.push(line); opts.onLine?.(line) },
timeoutMs: opts.setupTimeoutMs ?? 30 * 60_000,
})
if (setup.code !== 0 || setup.timedOut) {
const detail = setup.timedOut
? 'setup timed out'
: `setup exited ${setup.code ?? setup.signal} · ${setup.lines.slice(-5).join(' · ')}`.slice(0, 2000)
progress({ id: manifest.id, phase: 'failed', detail, error: 'SETUP_FAILED' })
return { ok: false, error: 'SETUP_FAILED', detail }
}
}
// The viewer it points at is part of the install: without it the tile opens with no pane. The
// registry names the package's repo; a package not in the registry is the author's to install
// first (`harness dsh install <url>`), and the doctor says so rather than the pane going blank.
const uses = viewerUse(manifest)
if (uses && !installedDsh(uses)) {
if (!opts.registry && !catalogEntry(uses)) await refreshDshRegistry()
const entry = (opts.registry ?? catalogEntry)(uses)
if (entry) {
opts.onLine?.(`viewer ${uses} · installing`)
// The viewer's own phases are narrated UNDER THE HARNESS: the dialog watches the id it asked
// for, and a frame carrying the viewer's id would land in a run nobody is looking at — the
// install would read as hung for the minutes OpenCascade takes to arrive. The viewer's `done`
// is not the harness's done, so it reports as the harness's setup still going.
const dep = await installDsh({
source: entry.repo, expectedId: entry.id, ref: entry.ref, path: entry.path, registry: opts.registry, setupTimeoutMs: opts.setupTimeoutMs, cloneRetryDelaysMs: opts.cloneRetryDelaysMs, onLine: opts.onLine,
onProgress: (p) => {
if (p.phase === 'failed') return // reported below, once, with the viewer named
const phase: DshInstallPhase = p.phase === 'done' ? 'setup' : p.phase
progress({ id: manifest.id, phase, detail: `viewer ${uses} · ${p.phase}${p.detail ? ` · ${p.detail}` : ''}`.slice(0, 300) })
},
})
if (!dep.ok) {
const detail = `viewer ${uses} · ${dep.detail}`.slice(0, 2000)
progress({ id: manifest.id, phase: 'failed', detail, error: dep.error })
return { ok: false, error: dep.error, detail }
}
progress({ id: manifest.id, phase: 'setup', detail: `viewer ${uses} · installed` })
} else {
opts.onLine?.(`miss viewer ${uses} is not installed and not in the registry · install it first`)
}
}
if (!recordDoctorFailure && uses && installedDsh(uses)?.manifest.kind !== 'viewer') {
const detail = `viewer ${uses} is not available; the previous package will be kept`
progress({ id: manifest.id, phase: 'failed', detail, error: 'VIEWER_UNAVAILABLE' })
return { ok: false, error: 'VIEWER_UNAVAILABLE', detail }
}
progress({ id: manifest.id, phase: 'doctor' })
const doctor = await runDshDoctor(resolved, opts.onLine)
// Recorded even when the doctor complains: the user can fix the machine and run the doctor again
// without re-cloning. The desktop reads the doctor's answer, not the index, before a create.
if (doctor.ok || recordDoctorFailure) {
const { manifest: _manifest, realDir: _realDir, ...record } = resolved
upsertInstalledRecord(record)
}
if (!doctor.ok) {
const detail = `doctor failed · ${doctor.lines.filter((line) => line.startsWith('miss')).join(' · ') || doctor.lines.slice(-3).join(' · ')}`.slice(0, 2000)
progress({ id: manifest.id, phase: 'failed', detail, error: 'DOCTOR_FAILED' })
return { ok: false, error: 'DOCTOR_FAILED', detail }
}
progress({ id: manifest.id, phase: 'done' })
return { ok: true, installed: resolved, doctor, setupLines }
}
export function removeDsh(id: string): { ok: true } | { ok: false; error: string; detail: string } {
const record = readInstalledIndex().find((row) => row.id === id)
if (!record) return { ok: false, error: 'NOT_INSTALLED', detail: `${id} is not installed` }
const unlock = lockDsh(id)
if (!unlock) return dshBusy(id)
try {
try {
// A linked install is a symlink: remove the link, never the checkout it points at.
let isLink = false
try { isLink = lstatSync(record.dir).isSymbolicLink() } catch { isLink = false }
// unlink, not rmSync: Node 25 refuses a non-recursive rm of a link to a directory (EISDIR).
if (isLink) unlinkSync(record.dir)
else if (existsSync(record.dir)) rmSync(record.dir, { recursive: true, force: true })
} catch (error) {
// Only system errors reach here (EACCES, EBUSY).
return { ok: false, error: 'REMOVE_FAILED', detail: (error as Error).message }
}
removeInstalledRecord(id)
return { ok: true }
} finally { unlock() }
}
export { installedDsh }