+
Blank canvas — saved scenes are under Scenes (not this page).
-
+
Open saved scenes
diff --git a/apps/editor/bunfig.toml b/apps/editor/bunfig.toml
new file mode 100644
index 0000000000..eec7d338da
--- /dev/null
+++ b/apps/editor/bunfig.toml
@@ -0,0 +1,4 @@
+preload = ["../../scripts/bun-preload-three.ts"]
+
+[test]
+preload = ["../../scripts/bun-preload-three.ts"]
diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx
index 0440e1d1ea..995b453141 100644
--- a/apps/editor/components/build-tab.tsx
+++ b/apps/editor/components/build-tab.tsx
@@ -1,25 +1,34 @@
'use client'
-import { nodeRegistry } from '@pascal-app/core'
import {
+ nodeRegistry,
+ type RoofType,
+ RoofType as RoofTypeSchema,
+ useRegistryVersion,
+} from '@pascal-app/core'
+import {
+ CATALOG_ITEMS,
type FloorplanMode,
getFloorplanNodeExtension,
isFloorplanToolAvailableInMode,
MaterialPaintPanel,
TerrainSculptPanel,
+ ToolOptionsPanel,
triggerSFX,
useEditor,
useFloorplanMode,
} from '@pascal-app/editor'
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
+import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
-import { useCallback, useEffect, useMemo, useRef } from 'react'
+import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/toolbar-tooltip'
+import { getActiveRoofFeatureId, ROOF_TYPE_OPTIONS } from '@/lib/build-tab-state'
import { cn } from '@/lib/utils'
/**
@@ -38,7 +47,7 @@ type MepToolKind =
| 'pipe-trap'
type BuildType = {
- /** Selection id — equals `kind` for tool types, `'painting'` for paint mode, `'mep'` for the MEP group. */
+ /** Selection id — equals `kind` for tool types, with dedicated ids for modes and groups. */
id: string
label: string
/** Raster asset tile (legacy Build sidebar artwork). */
@@ -72,12 +81,15 @@ const BASE_BUILD_TYPES: BuildType[] = [
{ id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' },
{ id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' },
{ id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' },
+ { id: 'kitchen', label: 'Kitchen', iconSrc: '/icons/kitchen.webp' },
// Group tile — no tool of its own; opens the MEP sub-grid below (like Roof).
{ id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' },
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
{ id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' },
]
+const subscribeToClientMount = () => () => {}
+
function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] {
const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : [])))
const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({
@@ -90,6 +102,7 @@ function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] {
const extension = getFloorplanNodeExtension(definition)
if (
baseKinds.has(kind) ||
+ definition.presentation?.paletteGroup === 'roof-features' ||
!extension?.tool ||
!isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) ||
!presentation ||
@@ -126,6 +139,9 @@ const MEP_ITEMS: MepItem[] = [
{ id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' },
]
+const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet')
+const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp'
+
/**
* Activate a raw structure draw/cursor tool. Mirrors the editor's own
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
@@ -150,6 +166,17 @@ function activateBuildTool(kind: string): void {
ed.setTool(kind)
}
+function activateModularCabinetTool(): void {
+ const ed = useEditor.getState()
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
+ if (MODULAR_CABINET_CATALOG_ITEM) ed.setSelectedItem(MODULAR_CABINET_CATALOG_ITEM)
+ ed.setPhase('structure')
+ ed.setStructureLayer('elements')
+ ed.setCatalogCategory(null)
+ ed.setMode('build')
+ ed.setTool('cabinet')
+}
+
/** Enter material-paint mode — the Build tab's "Painting" category. */
function activatePaintMode(): void {
const ed = useEditor.getState()
@@ -166,26 +193,57 @@ function activateTerrainSculptMode(): void {
useEditor.getState().setMode('terrain-sculpt')
}
-type RoofFeature = { kind: string; label: string; iconSrc: string }
+type RoofFeature = {
+ id: string
+ label: string
+ iconSrc: string
+ kind?: string
+}
const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp'
+function collectRoofFeatures(): RoofFeature[] {
+ const features: RoofFeature[] = []
+ for (const [kind, def] of nodeRegistry.entries()) {
+ if (
+ def.capabilities.roofAccessory === undefined &&
+ def.presentation?.paletteGroup !== 'roof-features'
+ ) {
+ continue
+ }
+ if (def.capabilities.wallOpeningPlacement) continue
+ const icon = def.presentation?.icon
+ features.push({
+ id: kind,
+ kind,
+ label: def.presentation?.label ?? kind,
+ iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
+ })
+ }
+ return features
+}
+
/**
- * Roof accessories surfaced under the Roof tile (a "Features" group). Unlike
- * the community editor these aren't DB presets — each is a registry kind with
- * `capabilities.roofAccessory`, enumerated from the registry at render time
- * (it is populated by the app bootstrap — a module-scope const would race it)
- * and activated like any structure tool (the kind's tool attaches it to the
- * roof segment under the cursor). Label + icon come from the registry's
- * `presentation`; non-url icons fall back to the roof icon.
+ * Roof accessories and extensions surfaced under the Roof tile. Unlike the
+ * community editor these aren't DB presets — each is a registry kind, either
+ * carrying `capabilities.roofAccessory` or explicitly classified as a roof
+ * extension. They are enumerated at render time because the registry is
+ * populated during app bootstrap. Label + icon come from `presentation`;
+ * non-url icons fall back to the roof icon.
*/
-function activateRoofFeatureTool(kind: string): void {
+function activateRoofFeatureTool(feature: RoofFeature): void {
const ed = useEditor.getState()
ed.setPhase('structure')
ed.setStructureLayer('elements')
ed.setCatalogCategory(null)
ed.setMode('build')
- ed.setTool(kind)
+ if (feature.kind) ed.setTool(feature.kind)
+}
+
+function activateRoofType(roofType: RoofType): void {
+ const editor = useEditor.getState()
+ if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof')
+ editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, roofType })
}
/**
@@ -204,16 +262,21 @@ const MEP_TOOL_KINDS = new Set
([
])
export function BuildTab() {
+ const [mepOpen, setMepOpen] = useState(false)
const activeTool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
+ const roofDefaults = useEditor((s) => s.toolDefaults.roof)
const floorplanMode = useFloorplanMode((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow)
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
- const buildTypes = useMemo(() => collectBuildTypes(floorplanMode), [floorplanMode])
+ useRegistryVersion()
+ const registryReady = useSyncExternalStore(
+ subscribeToClientMount,
+ () => true,
+ () => false,
+ )
+ const buildTypes = registryReady ? collectBuildTypes(floorplanMode) : BASE_BUILD_TYPES
- // The fitting / follow tools are armed from a segment's panel, not a grid
- // tile — keep the segment tile lit so the panel (and the way back) stays
- // visible.
const ductContext =
mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting')
const pipeContext =
@@ -221,34 +284,11 @@ export function BuildTab() {
(activeTool === 'pipe-segment' || activeTool === 'pipe-fitting' || activeTool === 'pipe-trap')
const liquidLineContext = mode === 'build' && activeTool === 'liquid-line'
- const isMepItemActive = (item: MepItem) =>
- item.kind === 'duct-segment'
- ? ductContext
- : item.kind === 'pipe-segment'
- ? pipeContext
- : item.kind === 'liquid-line'
- ? liquidLineContext
- : mode === 'build' && activeTool === item.kind
+ const isMepItemActive = (item: MepItem) => mode === 'build' && activeTool === item.kind
// Read at render time (not module scope): the registry is populated by the
// app bootstrap, so enumerating earlier would race it and see no kinds.
- const roofFeatures = useMemo(() => {
- const features: RoofFeature[] = []
- for (const [kind, def] of nodeRegistry.entries()) {
- if (def.capabilities.roofAccessory === undefined) continue
- // Door / window declare `roofAccessory` for the wall-face cut but
- // already have their own Build tiles — listing them here too
- // would duplicate the entry under Roof → Features.
- if (def.capabilities.wallOpeningPlacement) continue
- const icon = def.presentation?.icon
- features.push({
- kind,
- label: def.presentation?.label ?? kind,
- iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
- })
- }
- return features
- }, [])
+ const roofFeatures = registryReady ? collectRoofFeatures() : []
// Tile highlight derives from the single source of truth (the active tool /
// mode), never a separate local selection — so keyboard shortcuts and panel
@@ -256,27 +296,39 @@ export function BuildTab() {
// The roof Features sub-grid arms roof-accessory tools (skylight, chimney,
// …); keep the Roof tile lit (and its panel open) while any of them is the
// active tool, the same way MEP stays lit for its sub-grid tools.
- const isRoofFeatureActive =
- mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool)
- const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)
+ const activeRoofFeatureId = getActiveRoofFeatureId(roofFeatures, activeTool)
+ const isRoofFeatureActive = mode === 'build' && activeRoofFeatureId !== null
+ const isMepActive =
+ (mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)) ||
+ (mode === 'select' && mepOpen)
+ const isKitchenActive = mode === 'build' && activeTool === 'cabinet'
+ const parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType)
+ const activeRoofType = parsedRoofType.success ? parsedRoofType.data : 'gable'
const isTypeActive = (type: BuildType) => {
if (type.mode) return mode === type.mode
if (type.id === 'mep') return isMepActive
+ if (type.id === 'kitchen') return isKitchenActive
if (type.id === 'roof')
return mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive)
return mode === 'build' && activeTool === type.kind
}
const handleTypeClick = useCallback((type: BuildType) => {
+ setMepOpen(type.id === 'mep')
if (type.mode === 'material-paint') {
activatePaintMode()
} else if (type.mode === 'terrain-sculpt') {
activateTerrainSculptMode()
} else if (type.id === 'mep') {
- // MEP is a group tile: arm its first tool so a usable tool is active
- // (and we leave any prior paint mode), then reveal the MEP sub-grid.
- activateBuildTool('duct-segment')
+ const ed = useEditor.getState()
+ ed.setPhase('structure')
+ ed.setStructureLayer('elements')
+ ed.setCatalogCategory(null)
+ ed.setMode('build')
+ ed.setTool(null)
+ } else if (type.id === 'kitchen') {
+ activateModularCabinetTool()
} else if (type.kind) {
activateBuildTool(type.kind)
}
@@ -284,13 +336,14 @@ export function BuildTab() {
// On open, land on the first build tool — parity with the community Build
// sidebar, so switching to Build immediately arms a usable tool. Skip when a
- // build tool is already active (e.g. the B shortcut armed one before this
- // panel mounted): the active tool is the source of truth, not this default.
+ // Build-tab tool or special mode is already active: the current editor state
+ // is the source of truth, including entry from another panel.
const didInitRef = useRef(false)
useEffect(() => {
if (didInitRef.current) return
didInitRef.current = true
const ed = useEditor.getState()
+ if (ed.mode === 'material-paint' || ed.mode === 'terrain-sculpt') return
if (ed.mode === 'build' && ed.tool) return
const firstType = buildTypes.find((t) => t.kind)
if (firstType) handleTypeClick(firstType)
@@ -348,50 +401,133 @@ export function BuildTab() {
- ) : mode === 'build' &&
- (activeTool === 'roof' || isRoofFeatureActive) &&
- roofFeatures.length > 0 ? (
+ ) : mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive) ? (
+
+
+
Roof type
+
+ {ROOF_TYPE_OPTIONS.map((roofType) => {
+ const active = activeTool === 'roof' && activeRoofType === roofType.value
+ return (
+ {
+ triggerSFX('sfx:menu-click')
+ activateRoofType(roofType.value)
+ }}
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
+ type="button"
+ >
+ {roofType.label}
+
+ )
+ })}
+
+
+
+
{
+ const editor = useEditor.getState()
+ if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof')
+ }}
+ />
+ {activeRoofType === 'conical' && (
+
+ Select a curved wall to match its radius and arc.
+
+ )}
+
+ {roofFeatures.length > 0 ? (
+
+
+ Features & extensions
+
+
+
+ {roofFeatures.map((feature) => {
+ const active = mode === 'build' && feature.id === activeRoofFeatureId
+ return (
+
+
+ {
+ triggerSFX('sfx:menu-click')
+ activateRoofFeatureTool(feature)
+ }}
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
+ type="button"
+ >
+
+
+
+
+ {feature.label}
+
+
+ )
+ })}
+
+
+
+ ) : null}
+
+ ) : isKitchenActive ? (
-
Features
+
Kitchen
- {roofFeatures.map((feature) => {
- const active = mode === 'build' && activeTool === feature.kind
- return (
-
-
- {
- triggerSFX('sfx:menu-click')
- activateRoofFeatureTool(feature.kind)
- }}
- onMouseEnter={() => triggerSFX('sfx:menu-hover')}
- type="button"
- >
-
-
-
-
- {feature.label}
-
-
- )
- })}
+
+
+ {
+ triggerSFX('sfx:menu-click')
+ activateModularCabinetTool()
+ }}
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
+ type="button"
+ >
+
+
+
+
+ Modular Cabinet
+
+
@@ -409,6 +545,7 @@ export function BuildTab() {
- {ductContext ? (
-
- Duct
- {
- triggerSFX('sfx:menu-click')
- activateBuildTool(activeTool === 'duct-fitting' ? 'duct-segment' : 'duct-fitting')
- }}
- onMouseEnter={() => triggerSFX('sfx:menu-hover')}
- type="button"
- >
-
- Add Fitting
-
-
- ) : null}
-
- {pipeContext ? (
-
- DWV Pipe
- {
- triggerSFX('sfx:menu-click')
- activateBuildTool(activeTool === 'pipe-fitting' ? 'pipe-segment' : 'pipe-fitting')
+ {(['duct-fitting', 'pipe-fitting'] as const)
+ .filter((kind) => (kind === 'duct-fitting' ? ductContext : pipeContext))
+ .map((kind) => (
+ {
+ if (option.id !== 'fittingType') return undefined
+ if (kind === 'duct-fitting' && value === 'elbow')
+ return '/icons/duct-fitting.webp'
+ return `/icons/fittings/${kind === 'duct-fitting' ? 'duct' : 'pipe'}-${value}.webp`
}}
- onMouseEnter={() => triggerSFX('sfx:menu-hover')}
- type="button"
- >
-
- Add Fitting
-
- {
- triggerSFX('sfx:menu-click')
- activateBuildTool(activeTool === 'pipe-trap' ? 'pipe-segment' : 'pipe-trap')
+ kind={kind}
+ onSelect={(option, value) => {
+ if (activeTool !== kind) {
+ const defaults = useEditor.getState().toolDefaults[kind]
+ activateBuildTool(kind)
+ if (defaults) useEditor.getState().setToolDefaults(kind, defaults)
+ }
+ option.set(value)
}}
- onMouseEnter={() => triggerSFX('sfx:menu-hover')}
- type="button"
- >
-
- Add Trap
-
-
- ) : null}
+ />
+ ))}
{liquidLineContext ? (
diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx
index a0179005bd..d7538ead71 100644
--- a/apps/editor/components/scene-loader.tsx
+++ b/apps/editor/components/scene-loader.tsx
@@ -9,11 +9,12 @@ import {
type SceneGraph,
type SidebarTab,
} from '@pascal-app/editor'
-import { Hammer, Layers } from 'lucide-react'
+import { Hammer, Layers, Settings } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
+import { countGraphNodes, isEmptyGraphOverwrite } from '@/lib/empty-graph-guard'
import { type PersistedSceneGraph, sceneGraphSignature } from '@/lib/scene-signature'
import { cn } from '@/lib/utils'
import { BuildTab } from './build-tab'
@@ -65,6 +66,22 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
/>
),
},
+ {
+ id: 'settings',
+ label: 'Settings',
+ component: () => null,
+ mobileDefaultSnap: 0.5,
+ mobileIcon:
,
+ icon: (
+
+ ),
+ },
]
interface SceneLoaderProps {
@@ -95,6 +112,10 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
const router = useRouter()
const searchParams = useSearchParams()
const versionRef = useRef(meta.version)
+ // Node count of the graph the server is known to hold. Guards against the
+ // autosave wipe class: a save fired from a not-yet-hydrated (empty) editor
+ // store must never overwrite a populated server copy.
+ const serverNodeCountRef = useRef(meta.nodeCount)
const lastRemoteGraphJsonRef = useRef
(null)
const suppressRemoteSaveUntilRef = useRef(0)
const [conflict, setConflict] = useState(false)
@@ -115,6 +136,19 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
}
if (isRecentRemoteApply) return
+ // Wipe guard: never PUT an empty graph over a populated server copy.
+ // An empty serialization here means the editor store was not hydrated
+ // (load in flight or failed), not that the user deleted everything.
+ const outgoingNodeCount = countGraphNodes(graph)
+ if (isEmptyGraphOverwrite(outgoingNodeCount, serverNodeCountRef.current)) {
+ console.error(
+ `[scene-loader] Blocked autosave: refusing to overwrite scene ${meta.id} ` +
+ `(${serverNodeCountRef.current} nodes on the server) with an empty graph.`,
+ )
+ setSaveError('Autosave blocked: the editor tried to save an empty scene')
+ return
+ }
+
try {
const response = await fetch(`/api/scenes/${meta.id}`, {
method: 'PUT',
@@ -131,6 +165,16 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
})
if (response.status === 409) {
+ const body = (await response.json().catch(() => null)) as { error?: string } | null
+ if (body?.error === 'empty_graph_rejected') {
+ // Server-side wipe guard (defense in depth behind the client-side
+ // check above) — not a concurrent-session conflict.
+ console.error(
+ `[scene-loader] Server rejected an empty-graph save for scene ${meta.id}.`,
+ )
+ setSaveError('Autosave blocked: the editor tried to save an empty scene')
+ return
+ }
setConflict(true)
return
}
@@ -142,6 +186,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
const next = (await response.json()) as SceneMeta
versionRef.current = next.version
+ serverNodeCountRef.current = next.nodeCount
setSaveError(null)
} catch (error) {
setSaveError(error instanceof Error ? error.message : 'Save failed')
@@ -164,6 +209,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
if (payload.version <= versionRef.current) return
versionRef.current = payload.version
+ serverNodeCountRef.current = countGraphNodes(payload.graph)
lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph)
suppressRemoteSaveUntilRef.current = Date.now() + 2500
applySceneGraphToEditor(payload.graph)
@@ -225,7 +271,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
{saveError}
)}
-
+
void
+
+beforeAll(async () => {
+ const saved = {
+ PASCAL_DB_PATH: process.env.PASCAL_DB_PATH,
+ PASCAL_SCENE_API_TOKEN: process.env.PASCAL_SCENE_API_TOKEN,
+ }
+ restoreEnv = () => {
+ for (const [key, value] of Object.entries(saved)) {
+ if (value === undefined) delete process.env[key]
+ else process.env[key] = value
+ }
+ }
+ process.env.PASCAL_DB_PATH = join(tempDir, 'pascal.db')
+ delete process.env.PASCAL_SCENE_API_TOKEN // loopback requests need no token
+
+ const storeServer = await import('./scene-store-server')
+ storeServer.__resetSceneStoreForTests()
+
+ // Build REAL store+operations from relative SOURCE imports and inject
+ // them: '@pascal-app/mcp/*' subpaths may be mock.module'd by other test
+ // files in the same process (the stubs stick for later dynamic imports
+ // on linux), which starved this fixture of saveScene/loadStoredScene in
+ // CI three runs straight.
+ const { SqliteSceneStore } = await import('../../../packages/mcp/src/storage/sqlite-scene-store')
+ const { createSceneOperations } = await import(
+ '../../../packages/mcp/src/operations/scene-operations'
+ )
+ const store = new SqliteSceneStore({ env: process.env })
+ const operations = createSceneOperations({ store })
+ storeServer.__setSceneStoreForTests(store, operations)
+ await store.save({
+ id: SCENE_ID,
+ name: 'Wipe guard fixture',
+ projectId: null,
+ graph: POPULATED_GRAPH as never,
+ })
+
+ const route = await import('../app/api/scenes/[id]/route')
+ PUT = route.PUT
+})
+
+afterAll(async () => {
+ const storeServer = await import('./scene-store-server')
+ const store = await storeServer.getSceneStore()
+ ;(store as unknown as { close?: () => void }).close?.()
+ storeServer.__resetSceneStoreForTests()
+ restoreEnv()
+ rmSync(tempDir, { recursive: true, force: true })
+})
+
+function putRequest(body: unknown, ifMatch?: number): NextRequest {
+ return new NextRequest(`http://127.0.0.1:3000/api/scenes/${SCENE_ID}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ host: '127.0.0.1:3000',
+ ...(ifMatch === undefined ? {} : { 'If-Match': `"${ifMatch}"` }),
+ },
+ body: JSON.stringify(body),
+ })
+}
+
+const params = { params: Promise.resolve({ id: SCENE_ID }) }
+
+test('rejects an empty graph over a populated scene with 409 empty_graph_rejected', async () => {
+ const response = await PUT(putRequest({ graph: EMPTY_GRAPH }, 1), params)
+
+ expect(response.status).toBe(409)
+ const body = (await response.json()) as {
+ error: string
+ currentVersion: number
+ currentNodeCount: number
+ }
+ expect(body.error).toBe('empty_graph_rejected')
+ expect(body.currentVersion).toBe(1)
+ expect(body.currentNodeCount).toBe(2)
+})
+
+test('the rejected PUT leaves the stored scene untouched', async () => {
+ const storeServer = await import('./scene-store-server')
+ const operations = await storeServer.getSceneOperations()
+ const scene = await operations.loadStoredScene(SCENE_ID)
+
+ expect(scene?.version).toBe(1)
+ expect(Object.keys(scene?.graph.nodes ?? {})).toHaveLength(2)
+})
+
+test('a populated save still goes through', async () => {
+ const graph = {
+ nodes: { ...POPULATED_GRAPH.nodes, n3: { id: 'n3', type: 'qa:box' } },
+ rootNodeIds: ['n1'],
+ }
+ const response = await PUT(putRequest({ graph }, 1), params)
+
+ expect(response.status).toBe(200)
+ const meta = (await response.json()) as { version: number; nodeCount: number }
+ expect(meta.version).toBe(2)
+ expect(meta.nodeCount).toBe(3)
+})
+
+test('force: true allows an intentional wipe', async () => {
+ const response = await PUT(putRequest({ graph: EMPTY_GRAPH, force: true }, 2), params)
+
+ expect(response.status).toBe(200)
+ const meta = (await response.json()) as { version: number; nodeCount: number }
+ expect(meta.version).toBe(3)
+ expect(meta.nodeCount).toBe(0)
+})
+
+test('an empty save over an already-empty scene needs no force', async () => {
+ const response = await PUT(putRequest({ graph: EMPTY_GRAPH }, 3), params)
+
+ expect(response.status).toBe(200)
+ const meta = (await response.json()) as { version: number; nodeCount: number }
+ expect(meta.version).toBe(4)
+ expect(meta.nodeCount).toBe(0)
+})
diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts
index 93edd78030..fbebec2d12 100644
--- a/apps/editor/lib/bootstrap.ts
+++ b/apps/editor/lib/bootstrap.ts
@@ -9,7 +9,16 @@ import {
} from '@pascal-app/core'
import { registerEditorHostPanel } from '@pascal-app/editor'
import { builtinPlugin } from '@pascal-app/nodes'
+import { bonesHostPanel, bonesPlugin } from '@pascal-app/plugin-bones'
+import {
+ environmentHostPanel,
+ environmentPlugin,
+ environmentPresentation,
+} from '@pascal-app/plugin-environment'
+import { poolHostPanel, poolPlugin } from '@pascal-app/plugin-pool'
+import { streetscapeHostPanel, streetscapePlugin } from '@pascal-app/plugin-streetscape'
import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees'
+import { registerViewerPresentation } from '@pascal-app/viewer'
// Idempotency guards: HMR can reload this module, but `registerNode`
// throws on duplicate kinds. Flags live in the module closure so they
@@ -86,8 +95,23 @@ export async function loadExternalPlugins(): Promise {
// so it is registered separately from the core plugin manifest.
extendPluginDiscovery(async () => [treesPlugin])
registerEditorHostPanel(treesHostPanel)
+extendPluginDiscovery(async () => [environmentPlugin])
+registerEditorHostPanel(environmentHostPanel)
+registerViewerPresentation(environmentPresentation)
+extendPluginDiscovery(async () => [bonesPlugin])
+// Opt-in: Bones ships uninstalled — users enable it per scene from the
+// Plugins panel (engineering X-ray is a specialist view, not a default).
+registerEditorHostPanel({ ...bonesHostPanel, defaultInstalled: false })
extendPluginDiscovery(async () => [mintPlugin])
registerEditorHostPanel(mintHostPanel)
+extendPluginDiscovery(async () => [poolPlugin])
+registerEditorHostPanel(poolHostPanel)
+extendPluginDiscovery(async () => [streetscapePlugin])
+// The upstream manifest still names 'Pascal' as creator; credit the author.
+registerEditorHostPanel({
+ ...streetscapeHostPanel,
+ creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' },
+})
loadBuiltinsSync()
void loadExternalPlugins()
diff --git a/apps/editor/lib/build-tab-state.test.ts b/apps/editor/lib/build-tab-state.test.ts
new file mode 100644
index 0000000000..6e40d899b4
--- /dev/null
+++ b/apps/editor/lib/build-tab-state.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from 'bun:test'
+import {
+ getActiveRoofFeatureId,
+ ROOF_TYPE_OPTIONS,
+ type RoofFeatureIdentity,
+} from './build-tab-state'
+
+const FEATURES: RoofFeatureIdentity[] = [
+ { id: 'lean-to-extension', kind: 'lean-to-extension' },
+ { id: 'skylight', kind: 'skylight' },
+]
+
+describe('roof feature selection', () => {
+ test('does not select every accessory for the plain roof tool', () => {
+ expect(getActiveRoofFeatureId(FEATURES, 'roof')).toBeNull()
+ })
+
+ test('selects exactly the matching accessory', () => {
+ expect(getActiveRoofFeatureId(FEATURES, 'lean-to-extension')).toBe('lean-to-extension')
+ })
+
+ test('ignores missing tool identities', () => {
+ const malformed = FEATURES.map(({ id }) => ({ id }))
+ expect(getActiveRoofFeatureId(malformed, undefined)).toBeNull()
+ expect(getActiveRoofFeatureId(malformed, 'skylight')).toBeNull()
+ })
+})
+
+test('roof creation exposes every supported roof type', () => {
+ expect(ROOF_TYPE_OPTIONS.map((option) => option.value)).toEqual([
+ 'hip',
+ 'gable',
+ 'shed',
+ 'flat',
+ 'gambrel',
+ 'dutch',
+ 'mansard',
+ 'conical',
+ ])
+})
diff --git a/apps/editor/lib/build-tab-state.ts b/apps/editor/lib/build-tab-state.ts
new file mode 100644
index 0000000000..478f4e559f
--- /dev/null
+++ b/apps/editor/lib/build-tab-state.ts
@@ -0,0 +1,25 @@
+import type { RoofType } from '@pascal-app/core'
+
+export type RoofFeatureIdentity = {
+ id: string
+ kind?: string
+}
+
+export const ROOF_TYPE_OPTIONS: ReadonlyArray<{ label: string; value: RoofType }> = [
+ { label: 'Hip', value: 'hip' },
+ { label: 'Gable', value: 'gable' },
+ { label: 'Shed', value: 'shed' },
+ { label: 'Flat', value: 'flat' },
+ { label: 'Gambrel', value: 'gambrel' },
+ { label: 'Dutch', value: 'dutch' },
+ { label: 'Mansard', value: 'mansard' },
+ { label: 'Conical', value: 'conical' },
+]
+
+export function getActiveRoofFeatureId(
+ features: readonly RoofFeatureIdentity[],
+ activeTool: string | null | undefined,
+): string | null {
+ if (!activeTool) return null
+ return features.find((feature) => feature.kind === activeTool)?.id ?? null
+}
diff --git a/apps/editor/lib/empty-graph-guard.test.ts b/apps/editor/lib/empty-graph-guard.test.ts
new file mode 100644
index 0000000000..15664c7bf4
--- /dev/null
+++ b/apps/editor/lib/empty-graph-guard.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, test } from 'bun:test'
+import { countGraphNodes, isEmptyGraphOverwrite } from './empty-graph-guard'
+
+describe('countGraphNodes', () => {
+ test('counts nodes on a well-formed graph', () => {
+ expect(countGraphNodes({ nodes: { a: {}, b: {} } })).toBe(2)
+ })
+
+ test('treats missing/odd shapes as empty', () => {
+ expect(countGraphNodes(null)).toBe(0)
+ expect(countGraphNodes(undefined)).toBe(0)
+ expect(countGraphNodes({})).toBe(0)
+ expect(countGraphNodes({ nodes: null })).toBe(0)
+ })
+})
+
+describe('isEmptyGraphOverwrite', () => {
+ test('blocks a 0-node write over a populated server copy (the wipe class)', () => {
+ // Scene-wipe repro 2026-08-18: a pre-hydration autosave flush serialized
+ // the empty editor store and PUT it over a 74-node scene at If-Match: 1,
+ // leaving v2 with 0 nodes. This is the exact write that must not pass.
+ expect(isEmptyGraphOverwrite(0, 74)).toBe(true)
+ expect(isEmptyGraphOverwrite(0, 1)).toBe(true)
+ })
+
+ test('allows saves that carry nodes', () => {
+ expect(isEmptyGraphOverwrite(74, 74)).toBe(false)
+ expect(isEmptyGraphOverwrite(1, 74)).toBe(false)
+ })
+
+ test('allows empty saves over an already-empty scene', () => {
+ expect(isEmptyGraphOverwrite(0, 0)).toBe(false)
+ })
+})
diff --git a/apps/editor/lib/empty-graph-guard.ts b/apps/editor/lib/empty-graph-guard.ts
new file mode 100644
index 0000000000..af955630c6
--- /dev/null
+++ b/apps/editor/lib/empty-graph-guard.ts
@@ -0,0 +1,26 @@
+/**
+ * Guard shared by the scene-save client path and the scenes API PUT route:
+ * an incoming graph with ZERO nodes must never silently replace a server copy
+ * that has nodes.
+ *
+ * Rationale (scene-wipe class, 2026-08-16..18): an editor session whose store
+ * has not hydrated yet (load in flight, failed GET, pre-hydration flush) can
+ * serialize an empty graph. Persisting it destroys the scene at the next
+ * version. Losing a save of a legitimately-emptied scene is far rarer and is
+ * recoverable (scene_revisions keeps every version), so the trade is blocking
+ * empty overwrites by default and requiring an explicit `force` to allow them.
+ */
+
+export function countGraphNodes(
+ graph: { nodes?: Record | null } | null | undefined,
+): number {
+ if (!graph?.nodes || typeof graph.nodes !== 'object') return 0
+ return Object.keys(graph.nodes).length
+}
+
+export function isEmptyGraphOverwrite(
+ incomingNodeCount: number,
+ knownServerNodeCount: number,
+): boolean {
+ return incomingNodeCount === 0 && knownServerNodeCount > 0
+}
diff --git a/apps/editor/lib/floorplan-export-surface.test.ts b/apps/editor/lib/floorplan-export-surface.test.ts
new file mode 100644
index 0000000000..e424f94d7c
--- /dev/null
+++ b/apps/editor/lib/floorplan-export-surface.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from 'bun:test'
+import { exportFloorplanPdf, type FloorplanExportScope } from '@pascal-app/editor'
+
+// Runtime smoke assertion for the package-entry re-export (plan U2 / issue
+// #619): this test imports the whole @pascal-app/editor barrel, so if the
+// entry stops re-exporting `exportFloorplanPdf` or the `FloorplanExportScope`
+// type, the import fails at test-run time (and `check-types`) instead of the
+// regression passing silently. Runtime coverage of the export pipeline
+// itself lives in @pascal-app/editor's floorplan tests; here we only pin the
+// public surface.
+describe('package entry floorplan export surface', () => {
+ test('exportFloorplanPdf accepts every scope member', () => {
+ const scopes: FloorplanExportScope[] = ['full', 'structure']
+ expect(scopes).toEqual(['full', 'structure'])
+ expect(typeof exportFloorplanPdf).toBe('function')
+ })
+})
diff --git a/apps/editor/lib/graph-schema.test.ts b/apps/editor/lib/graph-schema.test.ts
index ca63ca9c3e..fe90d58a34 100644
--- a/apps/editor/lib/graph-schema.test.ts
+++ b/apps/editor/lib/graph-schema.test.ts
@@ -1,4 +1,5 @@
import { expect, test } from 'bun:test'
+import { CabinetModuleNode, CabinetNode } from '@pascal-app/core/schema'
import { apiGraphSchema } from './graph-schema'
function buildGraph(nodes: Record, rootNodeIds: string[] = []) {
@@ -39,6 +40,28 @@ test('accepts a builtin container whose children include a plugin node id', () =
expect(apiGraphSchema.safeParse(graph).success).toBe(true)
})
+test('accepts a cabinet run containing a derived L-corner run', () => {
+ const source = CabinetNode.parse({
+ id: 'cabinet_graph-source',
+ children: ['cabinet_graph-derived'],
+ })
+ const derived = CabinetNode.parse({
+ id: 'cabinet_graph-derived',
+ parentId: source.id,
+ children: ['cabinet-module_graph-derived'],
+ })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_graph-derived',
+ parentId: derived.id,
+ })
+
+ expect(
+ apiGraphSchema.safeParse(
+ buildGraph({ [source.id]: source, [derived.id]: derived, [module.id]: module }, [source.id]),
+ ).success,
+ ).toBe(true)
+})
+
test('keeps plugin child ids in the parsed graph', () => {
const graph = buildGraph({ [LEVEL_ID]: level([TREE_ID]), [TREE_ID]: pluginTree() }, [LEVEL_ID])
diff --git a/apps/editor/lib/graph-schema.ts b/apps/editor/lib/graph-schema.ts
index f597bfa73c..7a07027bee 100644
--- a/apps/editor/lib/graph-schema.ts
+++ b/apps/editor/lib/graph-schema.ts
@@ -1,4 +1,4 @@
-import { AnyNode, AssetUrl, BaseNode, SceneMaterial } from '@pascal-app/core/schema'
+import { AnyNode, AssetUrl, BaseNode, nodeKindOf, SceneMaterial } from '@pascal-app/core/schema'
import { z } from 'zod'
/**
@@ -24,9 +24,7 @@ import { z } from 'zod'
* hostile scheme where `AssetUrl` already enumerates the safe ones.
*/
-const KNOWN_TYPES = new Set(
- AnyNode.options.map((o) => o.shape.type.parse(undefined) as string),
-)
+const KNOWN_TYPES = new Set(AnyNode.options.map(nodeKindOf))
/** The envelope every persisted node satisfies, builtin or foreign. */
const ForeignNodeEnvelope = BaseNode.extend({
diff --git a/apps/editor/lib/import-src.test.ts b/apps/editor/lib/import-src.test.ts
new file mode 100644
index 0000000000..3176ce13a8
--- /dev/null
+++ b/apps/editor/lib/import-src.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'bun:test'
+import { parseImportSrc } from './import-src'
+
+describe('parseImportSrc', () => {
+ it('accepts plain https URLs', () => {
+ const result = parseImportSrc('https://example.com/scan/pascal.json')
+ expect(result.ok).toBe(true)
+ })
+
+ it('accepts http for localhost during development', () => {
+ expect(parseImportSrc('http://localhost:8080/scene.json').ok).toBe(true)
+ expect(parseImportSrc('http://127.0.0.1/scene.json').ok).toBe(true)
+ })
+
+ it('rejects http for non-local hosts', () => {
+ expect(parseImportSrc('http://example.com/scene.json').ok).toBe(false)
+ })
+
+ it('rejects non-http schemes', () => {
+ expect(parseImportSrc('javascript:alert(1)').ok).toBe(false)
+ expect(parseImportSrc('file:///etc/passwd').ok).toBe(false)
+ expect(parseImportSrc('ftp://example.com/x.json').ok).toBe(false)
+ })
+
+ it('rejects embedded credentials', () => {
+ expect(parseImportSrc('https://user:pass@example.com/x.json').ok).toBe(false)
+ })
+
+ it('rejects relative and malformed values', () => {
+ expect(parseImportSrc('/scene.json').ok).toBe(false)
+ expect(parseImportSrc('').ok).toBe(false)
+ expect(parseImportSrc(undefined).ok).toBe(false)
+ })
+})
diff --git a/apps/editor/lib/import-src.ts b/apps/editor/lib/import-src.ts
new file mode 100644
index 0000000000..800c5700ea
--- /dev/null
+++ b/apps/editor/lib/import-src.ts
@@ -0,0 +1,43 @@
+/**
+ * Validation for the `src` parameter of the `/import` page: the URL a
+ * scanning app (or any external tool) hands us to import a build JSON
+ * from. The fetch itself happens client-side in the visitor's browser —
+ * same trust model as dropping a file on Load Build — so the checks here
+ * are about not being tricked into requesting something that is not a
+ * plain https resource, not about SSRF (no server ever fetches it).
+ */
+
+/**
+ * Hard cap on the fetched document. Matches the scene store's own limit
+ * (`DEFAULT_MAX_SCENE_BYTES` in the sqlite scene store, 10 MB): a file
+ * that passes review must not then fail `POST /api/scenes` with a 413.
+ */
+export const MAX_IMPORT_BYTES = 10 * 1024 * 1024
+
+export type ImportSrcResult = { ok: true; url: URL } | { ok: false; reason: string }
+
+/**
+ * Accepts only absolute `https:` URLs without embedded credentials.
+ * `http:` is allowed for localhost only, so a scan app on the same
+ * machine can hand over a file during development.
+ */
+export function parseImportSrc(raw: string | null | undefined): ImportSrcResult {
+ if (!raw) {
+ return { ok: false, reason: 'Missing `src` parameter.' }
+ }
+ let url: URL
+ try {
+ url = new URL(raw)
+ } catch {
+ return { ok: false, reason: 'The `src` parameter is not an absolute URL.' }
+ }
+ if (url.username || url.password) {
+ return { ok: false, reason: 'Credentials in the `src` URL are not allowed.' }
+ }
+ const isLocalhost =
+ url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'
+ if (url.protocol === 'https:' || (url.protocol === 'http:' && isLocalhost)) {
+ return { ok: true, url }
+ }
+ return { ok: false, reason: 'Only https URLs can be imported.' }
+}
diff --git a/apps/editor/lib/scene-store-server.test.ts b/apps/editor/lib/scene-store-server.test.ts
index cf0d28ef11..ab87e4e756 100644
--- a/apps/editor/lib/scene-store-server.test.ts
+++ b/apps/editor/lib/scene-store-server.test.ts
@@ -1,4 +1,15 @@
-import { beforeEach, describe, expect, mock, test } from 'bun:test'
+import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
+
+// bun's mock.module poisons the module registry for EVERY test file that
+// runs after this one in the same process — capture the real modules and
+// restore them when this file finishes, or route tests downstream get a
+// stub facade without saveScene/loadStoredScene (night-5 CI failure).
+const realOperations = await import('@pascal-app/mcp/operations')
+const realStorage = await import('@pascal-app/mcp/storage')
+afterAll(() => {
+ mock.module('@pascal-app/mcp/operations', () => realOperations)
+ mock.module('@pascal-app/mcp/storage', () => realStorage)
+})
describe('getSceneStore', () => {
beforeEach(() => {
diff --git a/apps/editor/lib/scene-store-server.ts b/apps/editor/lib/scene-store-server.ts
index 796381f097..ca0c6fda19 100644
--- a/apps/editor/lib/scene-store-server.ts
+++ b/apps/editor/lib/scene-store-server.ts
@@ -42,3 +42,14 @@ export function __resetSceneStoreForTests(): void {
cachedStore = null
cachedOperations = null
}
+
+/**
+ * Test-only injection: other test files in the same bun process may have
+ * mock.module'd the '@pascal-app/mcp/*' subpaths (the mocks stick for
+ * later dynamic imports on some platforms), so route tests inject REAL
+ * instances built from relative source imports instead.
+ */
+export function __setSceneStoreForTests(store: SceneStore, operations: SceneOperations): void {
+ cachedStore = Promise.resolve(store)
+ cachedOperations = Promise.resolve(operations)
+}
diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts
index 08961a3b3d..44cffefdbc 100644
--- a/apps/editor/next.config.ts
+++ b/apps/editor/next.config.ts
@@ -1,6 +1,14 @@
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
import type { NextConfig } from 'next'
+const appDirectory = path.dirname(fileURLToPath(import.meta.url))
+const portableBuild = process.env.PASCAL_PORTABLE_BUILD === '1'
+
const nextConfig: NextConfig = {
+ ...(portableBuild
+ ? { output: 'standalone' as const, outputFileTracingRoot: path.join(appDirectory, '../..') }
+ : {}),
logging: {
browserToTerminal: true,
},
@@ -24,8 +32,12 @@ const nextConfig: NextConfig = {
'@pascal-app/core',
'@pascal-app/editor',
'@pascal-app/mcp',
+ '@pascal-app/plugin-pool',
+ '@pascal-app/plugin-streetscape',
'@pascal-app/plugin-trees',
'@mint/pascal-plugin',
+ '@pascal-app/plugin-bones',
+ '@pascal-app/plugin-environment',
'@dgreenheck/ez-tree',
],
turbopack: {
@@ -42,7 +54,9 @@ const nextConfig: NextConfig = {
},
},
images: {
- unoptimized: process.env.NEXT_PUBLIC_ASSETS_CDN_URL?.startsWith('http://localhost') ?? false,
+ unoptimized:
+ portableBuild ||
+ (process.env.NEXT_PUBLIC_ASSETS_CDN_URL?.startsWith('http://localhost') ?? false),
remotePatterns: [
{
protocol: 'https',
diff --git a/apps/editor/package.json b/apps/editor/package.json
index ba2da86503..d60a2f7ab0 100644
--- a/apps/editor/package.json
+++ b/apps/editor/package.json
@@ -19,6 +19,10 @@
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
+ "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7",
+ "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295",
+ "@pascal-app/plugin-pool": "file:./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz",
+ "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef",
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -31,11 +35,12 @@
"next": "16.3.0",
"postcss": "^8.5.6",
"react": "^19.2.4",
+ "react-colorful": "^5.6.1",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
- "three": "^0.185.0",
- "zod": "^4.3.5"
+ "three": "^0.186.0",
+ "zod": ">=4.5.4 <4.6"
},
"devDependencies": {
"@pascal/typescript-config": "*",
diff --git a/apps/editor/public/audios/radios/classic/Ballroom in Miniature.mp3 b/apps/editor/public/audios/radios/classic/Ballroom in Miniature.mp3
deleted file mode 100644
index ebcf4dd7b7..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Ballroom in Miniature.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Blueprints in Springtime.mp3 b/apps/editor/public/audios/radios/classic/Blueprints in Springtime.mp3
deleted file mode 100644
index 973955ba31..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Blueprints in Springtime.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Clockwork Tea Party (Alternate).mp3 b/apps/editor/public/audios/radios/classic/Clockwork Tea Party (Alternate).mp3
deleted file mode 100644
index d4ec67d752..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Clockwork Tea Party (Alternate).mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Clockwork Tea Party.mp3 b/apps/editor/public/audios/radios/classic/Clockwork Tea Party.mp3
deleted file mode 100644
index 88915bad39..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Clockwork Tea Party.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Clockwork Teacups.mp3 b/apps/editor/public/audios/radios/classic/Clockwork Teacups.mp3
deleted file mode 100644
index a8967b205d..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Clockwork Teacups.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Evening in the Parlor.mp3 b/apps/editor/public/audios/radios/classic/Evening in the Parlor.mp3
deleted file mode 100644
index ed7b590fee..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Evening in the Parlor.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Glass Atrium.mp3 b/apps/editor/public/audios/radios/classic/Glass Atrium.mp3
deleted file mode 100644
index 460f72f910..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Glass Atrium.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Moonlight On The Drafting Table.mp3 b/apps/editor/public/audios/radios/classic/Moonlight On The Drafting Table.mp3
deleted file mode 100644
index 9b4e527515..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Moonlight On The Drafting Table.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Sunlit Garden Reverie.mp3 b/apps/editor/public/audios/radios/classic/Sunlit Garden Reverie.mp3
deleted file mode 100644
index 120d20ab00..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Sunlit Garden Reverie.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3 b/apps/editor/public/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3
deleted file mode 100644
index 037cd88902..0000000000
Binary files a/apps/editor/public/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3 and /dev/null differ
diff --git a/apps/editor/public/audios/sfx/success.mp3 b/apps/editor/public/audios/sfx/success.mp3
new file mode 100644
index 0000000000..fc85f51765
Binary files /dev/null and b/apps/editor/public/audios/sfx/success.mp3 differ
diff --git a/apps/editor/public/icons/box-vent.webp b/apps/editor/public/icons/box-vent.webp
new file mode 100644
index 0000000000..4f25db6b80
Binary files /dev/null and b/apps/editor/public/icons/box-vent.webp differ
diff --git a/apps/editor/public/icons/chimney.webp b/apps/editor/public/icons/chimney.webp
new file mode 100644
index 0000000000..5b6fc33f34
Binary files /dev/null and b/apps/editor/public/icons/chimney.webp differ
diff --git a/apps/editor/public/icons/cupola.webp b/apps/editor/public/icons/cupola.webp
new file mode 100644
index 0000000000..80ee1dea10
Binary files /dev/null and b/apps/editor/public/icons/cupola.webp differ
diff --git a/apps/editor/public/icons/dormer.webp b/apps/editor/public/icons/dormer.webp
new file mode 100644
index 0000000000..32a7b2049f
Binary files /dev/null and b/apps/editor/public/icons/dormer.webp differ
diff --git a/apps/editor/public/icons/downspout.webp b/apps/editor/public/icons/downspout.webp
new file mode 100644
index 0000000000..73b5e58109
Binary files /dev/null and b/apps/editor/public/icons/downspout.webp differ
diff --git a/apps/editor/public/icons/eyebrow-vent.webp b/apps/editor/public/icons/eyebrow-vent.webp
new file mode 100644
index 0000000000..b9e42ab055
Binary files /dev/null and b/apps/editor/public/icons/eyebrow-vent.webp differ
diff --git a/apps/editor/public/icons/fittings/README.md b/apps/editor/public/icons/fittings/README.md
new file mode 100644
index 0000000000..f1ca01776d
--- /dev/null
+++ b/apps/editor/public/icons/fittings/README.md
@@ -0,0 +1,32 @@
+# Fitting thumbnails
+
+Generated with the built-in image generation tool using `../duct-fitting.webp` as the style reference. The existing duct elbow keeps that original icon.
+
+The generator emits a large PNG; re-encode it before committing, because these ship in the portable CLI runtime and render at 56 px:
+
+```sh
+cwebp -q 85 -m 6 -alpha_q 100 -resize 256 256 .png -o .webp
+```
+
+## Prompt template
+
+Create one catalog thumbnail asset for SUBJECT. Reference image is STYLE REFERENCE ONLY. Match its polished lavender purple 3D isometric product icon, soft lilac highlights, darker violet interiors, fine bright edges. Actual subject must be SUBJECT, not the reference elbow. Single isolated object centered, occupying 78% of square canvas. Three-quarter view from above showing its identifying geometry clearly at 56px. Transparent background, no floor, no text, no labels, no watermark, no additional objects. Save generated asset. Asset identifier NAME.
+
+## Subjects
+
+- `duct-tee.webp`: rectangular HVAC duct T junction with exactly three rectangular flanged openings.
+- `duct-cross.webp`: rectangular HVAC duct cross junction with exactly four rectangular flanged openings.
+- `duct-reducer.webp`: round HVAC concentric reducer, wide circular opening tapering into a smaller circular opening.
+- `duct-transition.webp`: HVAC transition from a wide rectangular flanged opening to a round circular collar.
+- `duct-end-cap.webp`: short rectangular HVAC duct end cap with a sealed flat rectangular face and flanged rim.
+- `duct-damper.webp`: rectangular HVAC balancing damper, short flanged hollow rectangular sleeve with a visible internal blade and external adjustment lever.
+- `duct-access-panel.webp`: rectangular HVAC access door panel, flat framed closed door with two hinges and two latches.
+- `duct-coupling.webp`: short straight rectangular HVAC coupling sleeve with two opposite equal rectangular openings and a center seam.
+- `pipe-elbow.webp`: round plumbing pipe 90 degree curved elbow, exactly two circular socket openings.
+- `pipe-wye.webp`: round plumbing pipe Y junction with three circular socket openings, branch at 45 degrees.
+- `pipe-sanitary-tee.webp`: round plumbing sanitary tee with three circular socket openings, curved sweeping side branch.
+- `pipe-cross.webp`: round plumbing cross fitting with four circular socket openings.
+- `pipe-end-cap.webp`: round plumbing pipe end cap with a closed circular end.
+- `pipe-cleanout.webp`: round plumbing cleanout fitting with a prominent threaded hexagonal removable plug sealing its end.
+- `pipe-reducer.webp`: round plumbing concentric reducer with wide circular socket tapering to narrow circular socket.
+- `pipe-coupling.webp`: short straight round plumbing coupling with two equal circular socket openings.
diff --git a/apps/editor/public/icons/fittings/duct-access-panel.webp b/apps/editor/public/icons/fittings/duct-access-panel.webp
new file mode 100644
index 0000000000..e111436c31
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-access-panel.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-coupling.webp b/apps/editor/public/icons/fittings/duct-coupling.webp
new file mode 100644
index 0000000000..7a2850397a
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-coupling.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-cross.webp b/apps/editor/public/icons/fittings/duct-cross.webp
new file mode 100644
index 0000000000..2bf4440cd7
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-cross.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-damper.webp b/apps/editor/public/icons/fittings/duct-damper.webp
new file mode 100644
index 0000000000..d6b097fbbe
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-damper.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-end-cap.webp b/apps/editor/public/icons/fittings/duct-end-cap.webp
new file mode 100644
index 0000000000..ac03974669
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-end-cap.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-reducer.webp b/apps/editor/public/icons/fittings/duct-reducer.webp
new file mode 100644
index 0000000000..5af2254b8b
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-reducer.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-tee.webp b/apps/editor/public/icons/fittings/duct-tee.webp
new file mode 100644
index 0000000000..9afdc9f266
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-tee.webp differ
diff --git a/apps/editor/public/icons/fittings/duct-transition.webp b/apps/editor/public/icons/fittings/duct-transition.webp
new file mode 100644
index 0000000000..4af29ceb75
Binary files /dev/null and b/apps/editor/public/icons/fittings/duct-transition.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-cleanout.webp b/apps/editor/public/icons/fittings/pipe-cleanout.webp
new file mode 100644
index 0000000000..8837457471
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-cleanout.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-coupling.webp b/apps/editor/public/icons/fittings/pipe-coupling.webp
new file mode 100644
index 0000000000..ead21a241e
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-coupling.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-cross.webp b/apps/editor/public/icons/fittings/pipe-cross.webp
new file mode 100644
index 0000000000..de6c033e61
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-cross.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-elbow.webp b/apps/editor/public/icons/fittings/pipe-elbow.webp
new file mode 100644
index 0000000000..fd7ca58e34
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-elbow.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-end-cap.webp b/apps/editor/public/icons/fittings/pipe-end-cap.webp
new file mode 100644
index 0000000000..24695065a8
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-end-cap.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-reducer.webp b/apps/editor/public/icons/fittings/pipe-reducer.webp
new file mode 100644
index 0000000000..ef3a835947
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-reducer.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-sanitary-tee.webp b/apps/editor/public/icons/fittings/pipe-sanitary-tee.webp
new file mode 100644
index 0000000000..fb2bf2bcad
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-sanitary-tee.webp differ
diff --git a/apps/editor/public/icons/fittings/pipe-wye.webp b/apps/editor/public/icons/fittings/pipe-wye.webp
new file mode 100644
index 0000000000..8eb29b1e3b
Binary files /dev/null and b/apps/editor/public/icons/fittings/pipe-wye.webp differ
diff --git a/apps/editor/public/icons/gutter.webp b/apps/editor/public/icons/gutter.webp
new file mode 100644
index 0000000000..500c9c2719
Binary files /dev/null and b/apps/editor/public/icons/gutter.webp differ
diff --git a/apps/editor/public/icons/lean-to-extension.webp b/apps/editor/public/icons/lean-to-extension.webp
new file mode 100644
index 0000000000..0db20b7442
Binary files /dev/null and b/apps/editor/public/icons/lean-to-extension.webp differ
diff --git a/apps/editor/public/icons/ridge-vent.webp b/apps/editor/public/icons/ridge-vent.webp
new file mode 100644
index 0000000000..933ef56d48
Binary files /dev/null and b/apps/editor/public/icons/ridge-vent.webp differ
diff --git a/apps/editor/public/icons/skylight.webp b/apps/editor/public/icons/skylight.webp
new file mode 100644
index 0000000000..dde7483827
Binary files /dev/null and b/apps/editor/public/icons/skylight.webp differ
diff --git a/apps/editor/public/icons/solar-panel.webp b/apps/editor/public/icons/solar-panel.webp
new file mode 100644
index 0000000000..f95ae2a2f4
Binary files /dev/null and b/apps/editor/public/icons/solar-panel.webp differ
diff --git a/apps/editor/public/icons/turbine-vent.webp b/apps/editor/public/icons/turbine-vent.webp
new file mode 100644
index 0000000000..4e25e0f6c6
Binary files /dev/null and b/apps/editor/public/icons/turbine-vent.webp differ
diff --git a/apps/editor/public/items/rectangular-ceiling-light/Screenshot 2026-01-23 at 12.15.00.png b/apps/editor/public/items/rectangular-ceiling-light/Screenshot 2026-01-23 at 12.15.00.png
deleted file mode 100644
index edfdc6af86..0000000000
Binary files a/apps/editor/public/items/rectangular-ceiling-light/Screenshot 2026-01-23 at 12.15.00.png and /dev/null differ
diff --git a/apps/editor/public/items/small-kitchen-cabinet/model.glb b/apps/editor/public/items/small-kitchen-cabinet/model.glb
deleted file mode 100644
index aa28eb1ced..0000000000
Binary files a/apps/editor/public/items/small-kitchen-cabinet/model.glb and /dev/null differ
diff --git a/apps/editor/public/items/small-kitchen-cabinet/thumbnail.webp b/apps/editor/public/items/small-kitchen-cabinet/thumbnail.webp
deleted file mode 100644
index e8a1ad05f3..0000000000
Binary files a/apps/editor/public/items/small-kitchen-cabinet/thumbnail.webp and /dev/null differ
diff --git a/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz b/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz
new file mode 100644
index 0000000000..4461b548cb
Binary files /dev/null and b/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz differ
diff --git a/apps/editor/vercel.json b/apps/editor/vercel.json
index a71b39e98b..5b507d26f4 100644
--- a/apps/editor/vercel.json
+++ b/apps/editor/vercel.json
@@ -1,7 +1,7 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "cd ../.. && npx -y bun@1.3.13 run build --filter=editor",
- "installCommand": "cd ../.. && npx -y bun@1.3.13 install --frozen-lockfile",
+ "installCommand": "cd ../.. && (npx -y bun@1.3.13 install --frozen-lockfile || (sleep 20 && npx -y bun@1.3.13 install --frozen-lockfile) || (sleep 60 && npx -y bun@1.3.13 install --frozen-lockfile))",
"outputDirectory": ".next",
"cleanUrls": true,
"trailingSlash": false
diff --git a/apps/ifc-converter/components/IfcConverter.tsx b/apps/ifc-converter/components/IfcConverter.tsx
index 829a36d5d3..663e9aa024 100644
--- a/apps/ifc-converter/components/IfcConverter.tsx
+++ b/apps/ifc-converter/components/IfcConverter.tsx
@@ -13,7 +13,7 @@ const PascalViewer = dynamic(() => import('./PascalSceneViewer'), { ssr: false }
type Status = 'idle' | 'loading' | 'converting' | 'ready' | 'error'
// The converter writes a fixed shape into BaseNode.metadata, but the
-// underlying type is z.json() — a loose JSON value. This helper gives
+// underlying type is an open `Record`. This helper gives
// the UI dot-access on the fields the converter actually writes.
type ConverterMetadata = {
ifcType?: string
@@ -66,7 +66,7 @@ export default function IfcConverter() {
}, [pascalData])
const elementTypes = useMemo(() => {
- const order = ['wall', 'slab', 'door', 'window', 'stair', 'roof', 'column', 'item']
+ const order = ['wall', 'slab', 'door', 'window', 'stair', 'roof', 'column', 'block', 'item']
return order.filter((t) => typeCounts[t])
}, [typeCounts])
diff --git a/apps/ifc-converter/package.json b/apps/ifc-converter/package.json
index ed86452bfc..c5798f6e32 100644
--- a/apps/ifc-converter/package.json
+++ b/apps/ifc-converter/package.json
@@ -29,9 +29,8 @@
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
- "three": "^0.185.0",
- "web-ifc": "^0.0.77",
- "zod": "^4.3.5"
+ "three": "^0.186.0",
+ "web-ifc": "^0.0.77"
},
"devDependencies": {
"@pascal/typescript-config": "*",
diff --git a/assets/pascal-mark-plate.png b/assets/pascal-mark-plate.png
new file mode 100644
index 0000000000..d9c7b80dff
Binary files /dev/null and b/assets/pascal-mark-plate.png differ
diff --git a/assets/pascal-mark-plate.svg b/assets/pascal-mark-plate.svg
new file mode 100644
index 0000000000..0f8a41d8d1
--- /dev/null
+++ b/assets/pascal-mark-plate.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/assets/pascal-mark.svg b/assets/pascal-mark.svg
new file mode 100644
index 0000000000..176994e5a4
--- /dev/null
+++ b/assets/pascal-mark.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/biome.jsonc b/biome.jsonc
index c725787202..8e545ad325 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -89,6 +89,11 @@
"files": {
"ignoreUnknown": true,
"includes": [
+ "scripts/**/*.ts",
+ "skills/**/*.json",
+ ".agents/plugins/**/*.json",
+ ".claude-plugin/**/*.json",
+ ".codex-plugin/**/*.json",
"packages/**/*.ts",
"packages/**/*.tsx",
"packages/**/*.js",
diff --git a/bun.lock b/bun.lock
index 8a5789266c..b63a089de4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "editor",
@@ -7,6 +8,7 @@
"@biomejs/biome": "^2.4.16",
"@typescript/native-preview": "7.0.0-dev.20260624.1",
"dotenv-cli": "^11.0.0",
+ "fast-xml-parser": "^5.4.2",
"turbo": "^2.9.17",
"typescript": "6.0.3",
"ultracite": "^7.8.2",
@@ -33,6 +35,10 @@
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
+ "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7",
+ "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295",
+ "@pascal-app/plugin-pool": "file:./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz",
+ "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef",
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -45,11 +51,12 @@
"next": "16.3.0",
"postcss": "^8.5.6",
"react": "^19.2.4",
+ "react-colorful": "^5.6.1",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
- "three": "^0.185.0",
- "zod": "^4.3.5",
+ "three": "^0.186.0",
+ "zod": ">=4.5.4 <4.6",
},
"devDependencies": {
"@pascal/typescript-config": "*",
@@ -83,9 +90,8 @@
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
- "three": "^0.185.0",
+ "three": "^0.186.0",
"web-ifc": "^0.0.77",
- "zod": "^4.3.5",
},
"devDependencies": {
"@pascal/typescript-config": "*",
@@ -96,15 +102,30 @@
"typescript": "7.0.2",
},
},
+ "packages/cli": {
+ "name": "@pascal-app/cli",
+ "version": "1.0.0",
+ "bin": {
+ "pascal": "dist/bin/pascal.js",
+ },
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.30.0",
+ },
+ "devDependencies": {
+ "@pascal/typescript-config": "*",
+ "@types/node": "^22.19.20",
+ "typescript": "6.0.3",
+ },
+ },
"packages/core": {
"name": "@pascal-app/core",
- "version": "1.0.0-beta.4",
+ "version": "1.0.0",
"dependencies": {
"dedent": "^1.7.1",
"idb-keyval": "^6.2.2",
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
- "zod": "^4.3.5",
+ "zod": ">=4.5.4 <4.6",
"zundo": "^2.3.0",
"zustand": "^5",
},
@@ -113,18 +134,19 @@
"@types/bun": "^1.3.0",
"@types/react": "^19.2.2",
"@types/three": "^0.184.0",
+ "fake-indexeddb": "^6.2.5",
"typescript": "6.0.3",
},
"peerDependencies": {
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
- "three": "^0.185",
+ "three": "^0.186",
},
},
"packages/editor": {
"name": "@pascal-app/editor",
- "version": "1.0.0-beta.4",
+ "version": "1.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -149,21 +171,25 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "fflate": "^0.8.3",
"howler": "^2.2.4",
"lucide-react": "^1.7.0",
+ "manifold-3d": "3.5.1",
"mitt": "^3.0.1",
"motion": "^12.34.3",
"nanoid": "^5.1.6",
"pdfkit": "^0.19.1",
"tailwind-merge": "^3.5.0",
+ "three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "~0.9.8",
- "zod": "^4.3.6",
+ "zod": ">=4.5.4 <4.6",
"zustand": "^5.0.11",
},
"devDependencies": {
- "@pascal-app/core": "^1.0.0-beta.4",
- "@pascal-app/viewer": "^1.0.0-beta.4",
+ "@pascal-app/core": "^1.0.0",
+ "@pascal-app/viewer": "^1.0.0",
"@pascal/typescript-config": "*",
+ "@react-three/test-renderer": "^9.1.0",
"@types/blob-stream": "^0.1.33",
"@types/bun": "^1.3.0",
"@types/howler": "^2.2.12",
@@ -171,17 +197,18 @@
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/three": "^0.184.0",
+ "fast-xml-parser": "^5.4.2",
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^1.0.0-beta.4",
- "@pascal-app/viewer": "^1.0.0-beta.4",
+ "@pascal-app/core": "^1.0.0",
+ "@pascal-app/viewer": "^1.0.0",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"next": ">=15",
"react": "^18 || ^19",
"react-dom": "^18 || ^19",
- "three": "^0.185",
+ "three": "^0.186",
},
},
"packages/eslint-config": {
@@ -203,9 +230,9 @@
},
"packages/ifc-converter": {
"name": "@pascal-app/ifc-converter",
- "version": "1.0.0-beta.4",
+ "version": "1.0.0",
"dependencies": {
- "@pascal-app/core": "*",
+ "@pascal-app/core": "^1.0.0",
"nanoid": "^5.1.6",
"web-ifc": "^0.0.77",
},
@@ -217,48 +244,50 @@
},
"packages/mcp": {
"name": "@pascal-app/mcp",
- "version": "1.0.0-beta.4",
+ "version": "1.0.0",
"bin": {
"pascal-mcp": "./dist/bin/pascal-mcp.js",
},
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.29.0",
+ "@modelcontextprotocol/sdk": "^1.30.0",
"@pascal-app/lingo": "^0.2.0",
- "zod": "^4.3.5",
+ "zod": ">=4.5.4 <4.6",
},
"devDependencies": {
- "@pascal-app/core": "^1.0.0-beta.4",
+ "@pascal-app/core": "^1.0.0",
"@pascal/typescript-config": "*",
"@types/node": "^22.19.20",
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^1.0.0-beta.4",
+ "@pascal-app/core": "^1.0.0",
},
},
"packages/nodes": {
"name": "@pascal-app/nodes",
- "version": "1.0.0-beta.4",
+ "version": "1.0.0",
"devDependencies": {
- "@pascal-app/core": "^1.0.0-beta.4",
- "@pascal-app/editor": "^1.0.0-beta.4",
- "@pascal-app/viewer": "^1.0.0-beta.4",
+ "@pascal-app/core": "^1.0.0",
+ "@pascal-app/editor": "^1.0.0",
+ "@pascal-app/viewer": "^1.0.0",
"@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/node": "^22.19.12",
"@types/react": "^19.2.2",
+ "@types/react-dom": "^19.2.3",
"@types/three": "^0.184.0",
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^1.0.0-beta.4",
- "@pascal-app/editor": "^1.0.0-beta.4",
- "@pascal-app/viewer": "^1.0.0-beta.4",
+ "@pascal-app/core": "^1.0.0",
+ "@pascal-app/editor": "^1.0.0",
+ "@pascal-app/viewer": "^1.0.0",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"lucide-react": "^1",
"react": "^18 || ^19",
- "three": "^0.185",
+ "react-dom": "^18 || ^19",
+ "three": "^0.186",
"zustand": "^5",
},
},
@@ -285,26 +314,29 @@
},
"packages/viewer": {
"name": "@pascal-app/viewer",
- "version": "1.0.0-beta.4",
+ "version": "1.0.0",
"dependencies": {
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zustand": "^5",
},
"devDependencies": {
- "@pascal-app/core": "^1.0.0-beta.4",
+ "@pascal-app/core": "^1.0.0",
"@pascal/typescript-config": "*",
+ "@react-three/test-renderer": "^9.1.0",
"@types/node": "^22",
"@types/react": "^19.2.2",
+ "@types/react-dom": "19.2.2",
"@types/three": "^0.184.0",
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^1.0.0-beta.4",
+ "@pascal-app/core": "^1.0.0",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
- "three": "^0.185",
+ "react-dom": "^18 || ^19",
+ "three": "^0.186",
},
},
"tooling/typescript": {
@@ -318,7 +350,7 @@
"@types/three": "0.184.1",
"next": "16.3.0",
"react-grab": "0.1.50",
- "three": "0.185.1",
+ "three": "0.186.0",
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -425,6 +457,12 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
+ "@gltf-transform/core": ["@gltf-transform/core@4.4.2", "", { "dependencies": { "property-graph": "^4.1.0" } }, "sha512-qsWKwNSwK+2s834Mt4xbYcyHqCrgNFP7hIv5s487JxebngRfDgelpghNF+kSswGb2/NuapasfK3UViFoSJJoMg=="],
+
+ "@gltf-transform/extensions": ["@gltf-transform/extensions@4.4.2", "", { "dependencies": { "@gltf-transform/core": "^4.4.2", "ktx-parse": "^1.1.0" } }, "sha512-HJH1FM+edC5eNvl6xO0SOXJ/j/3oDoIpSu150OTdJaLBoM3TgCCGIfh4wyhgWAqZrkvgHKVGiZKxcKV5LkgPCQ=="],
+
+ "@gltf-transform/functions": ["@gltf-transform/functions@4.4.2", "", { "dependencies": { "@gltf-transform/core": "^4.4.2", "@gltf-transform/extensions": "^4.4.2", "ktx-parse": "^1.1.0", "ndarray": "^1.0.19", "ndarray-lanczos": "^0.3.0", "ndarray-pixels": "^5.0.1" } }, "sha512-dclXgv9TshMaWBqPDUYd4xTwBQ2PpuR8p0Y9pokrRzGQDUPXRP6lTDzbqT0UmEmxFSvRyPJjvOWUmzeiRafpvw=="],
+
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
@@ -507,11 +545,13 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@jscadui/3mf-export": ["@jscadui/3mf-export@0.5.0", "", {}, "sha512-y5vZktqCjyi7wA38zqNlLIdZUIRZoOO9vCjLzwmL4bR0hk7B/Zm1IeffzJPFe1vFc0C1IEv3hm8caDv3doRk9g=="],
+
"@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="],
- "@mint/pascal-plugin": ["@mint/pascal-plugin@github:mintdotgg/mint-pascal-plugin#902c546", { "peerDependencies": { "@pascal-app/core": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/editor": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/viewer": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "react": "^18 || ^19", "three": "^0.185" } }, "mintdotgg-mint-pascal-plugin-902c546"],
+ "@mint/pascal-plugin": ["@mint/pascal-plugin@github:mintdotgg/mint-pascal-plugin#902c546", { "peerDependencies": { "@pascal-app/core": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/editor": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/viewer": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "react": "^18 || ^19", "three": "^0.185" } }, "mintdotgg-mint-pascal-plugin-902c546", "sha512-/itUH9r9OIP8ZPrklW8iWe6B2SDOtlL1m8r9hlVM4Rw5josYtDdkDKQ37FbanvxxuUKcQnukHbtxWe3svRaCrQ=="],
- "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
+ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="],
"@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="],
@@ -553,6 +593,8 @@
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
+ "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="],
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -693,6 +735,8 @@
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.69.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA=="],
+ "@pascal-app/cli": ["@pascal-app/cli@workspace:packages/cli"],
+
"@pascal-app/core": ["@pascal-app/core@workspace:packages/core"],
"@pascal-app/editor": ["@pascal-app/editor@workspace:packages/editor"],
@@ -705,7 +749,15 @@
"@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"],
- "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c"],
+ "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#5679260", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-5679260", "sha512-uQkyHHOl/VuYx2+d/MmcJui/KEAQZ2UUnO4ywp1XaopmD2YfN+Yx/fR/lS/VL2joEoyyZeBLC3KaXK1Sx5qEvg=="],
+
+ "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#40baf63", { "peerDependencies": { "@dgreenheck/ez-tree": "^1.1.0", "@pascal-app/core": ">=1.0.0-beta.6 <2", "@pascal-app/editor": ">=1.0.0-beta.6 <2", "@pascal-app/viewer": ">=1.0.0-beta.6 <2", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/fiber": "^9", "lucide-react": "^1.7.0", "react": "^18 || ^19", "react-colorful": "^5.8.1", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-40baf63", "sha512-mb9i4IFq1c62BcAhtGJAKgJHBVQtFYySFW/LzkmnNfnWSOlGdUavoiR4f8cq5Y4xg6MTvGFX0ytO1Urk8wOPPQ=="],
+
+ "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@./vendor/pascal-app-plugin-pool-0.1.0-connection-status.tgz", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sha512-nu/7x5xF1k0IYnduNu1rh9YJ9hZrmjaRUiXADcDqF82Kc+GLjZFrPRRHCci37QplREY2lqePIXBYfTOXCQczgg=="],
+
+ "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:sudhir9297/streetscape-pascal-plugin#1c04ec9", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-streetscape-pascal-plugin-1c04ec9", "sha512-X7Zg7wi0ghZRcTtbH5LF6xye493uSZ2ft3AmAQBtguBCU6VCwCexA7pdKDr5AnVxX/JRj2s6JSd/OX4aIZ9Y6Q=="],
+
+ "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="],
"@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"],
@@ -799,6 +851,8 @@
"@react-three/fiber": ["@react-three/fiber@9.6.1", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg=="],
+ "@react-three/test-renderer": ["@react-three/test-renderer@9.1.1", "", { "peerDependencies": { "@react-three/fiber": ">=9.0.0", "react": "^19.0.0", "three": ">=0.156" } }, "sha512-4DmLn0tg+AP8aU0Mb5vekjHqFrwRJ3z11HfQGOJIDj6DxZ/5BjRHRsDxY0Y+g4l8+WHysQ6PeuSdzTDDYoGuXg=="],
+
"@repo/eslint-config": ["@repo/eslint-config@workspace:packages/eslint-config"],
"@repo/typescript-config": ["@repo/typescript-config@workspace:packages/typescript-config"],
@@ -881,6 +935,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+ "@types/ndarray": ["@types/ndarray@1.0.14", "", {}, "sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg=="],
+
"@types/node": ["@types/node@22.19.20", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw=="],
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
@@ -1005,6 +1061,8 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+ "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="],
+
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
@@ -1123,6 +1181,8 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+ "cwise-compiler": ["cwise-compiler@1.1.3", "", { "dependencies": { "uniq": "^1.0.0" } }, "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ=="],
+
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
@@ -1199,6 +1259,8 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
+ "esbuild-wasm": ["esbuild-wasm@0.27.7", "", { "bin": { "esbuild": "bin/esbuild" } }, "sha512-1k03e2/tGz+sLz3/xzoZmUsIqtaGIvJa8k4UqUeqCUry83nHmlxQYZUUES0WBFUYilSQUf7nDUGAciIIklljSg=="],
+
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -1245,6 +1307,8 @@
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
+ "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="],
+
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -1263,6 +1327,10 @@
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
+ "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="],
+
+ "fast-xml-parser": ["fast-xml-parser@5.11.0", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw=="],
+
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -1379,6 +1447,8 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
+ "iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="],
+
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -1391,6 +1461,8 @@
"is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
+ "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
+
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
"is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
@@ -1417,7 +1489,7 @@
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
- "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="],
+ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -1433,6 +1505,8 @@
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
+ "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="],
+
"is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
"is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
@@ -1477,6 +1551,8 @@
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
+ "ktx-parse": ["ktx-parse@1.1.0", "", {}, "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="],
+
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
@@ -1527,6 +1603,8 @@
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
+ "manifold-3d": ["manifold-3d@3.5.1", "", { "dependencies": { "@gltf-transform/core": "^4.2.0", "@gltf-transform/extensions": "^4.2.0", "@gltf-transform/functions": "^4.2.0", "@jridgewell/resolve-uri": "^3.1.2", "@jridgewell/trace-mapping": "^0.3.31", "@jscadui/3mf-export": "^0.5.0", "commander": "^13.1.0", "convert-source-map": "^2.0.0", "fast-xml-parser": "^5.4.2", "fflate": "^0.8.0", "magic-string": "^0.30.21" }, "peerDependencies": { "esbuild-wasm": "^0.27.3" }, "bin": { "manifold-cad": "bin/manifold-cad" } }, "sha512-/+m6kxYMMhnPutcQ5oSmFJiJ+gyP/0fmuUCb9Qeaunvecm/bfqogKYDDJarsnWiFioSMtKheF+lGmSlnYCik9g=="],
+
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
@@ -1575,6 +1653,14 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+ "ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="],
+
+ "ndarray-lanczos": ["ndarray-lanczos@0.3.0", "", { "dependencies": { "@types/ndarray": "^1.0.11", "ndarray": "^1.0.19" } }, "sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg=="],
+
+ "ndarray-ops": ["ndarray-ops@1.2.2", "", { "dependencies": { "cwise-compiler": "^1.0.0" } }, "sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw=="],
+
+ "ndarray-pixels": ["ndarray-pixels@5.2.0", "", { "dependencies": { "@types/ndarray": "^1.0.14", "ndarray": "^1.0.19", "ndarray-ops": "^1.2.2", "sharp": "^0.35.0" } }, "sha512-lTh4tFKziAatVTa9crIsidUyn+lqujVOQpzfdBWvdFu2wo9Uo6z261lVX7SgMyP89xGmj3TMTPbbxl9YDnV4SA=="],
+
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"next": ["next@16.3.0", "", { "dependencies": { "@next/env": "16.3.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.0", "@next/swc-darwin-x64": "16.3.0", "@next/swc-linux-arm64-gnu": "16.3.0", "@next/swc-linux-arm64-musl": "16.3.0", "@next/swc-linux-x64-gnu": "16.3.0", "@next/swc-linux-x64-musl": "16.3.0", "@next/swc-win32-arm64-msvc": "16.3.0", "@next/swc-win32-x64-msvc": "16.3.0", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A=="],
@@ -1637,6 +1723,8 @@
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+ "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="],
+
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
@@ -1673,6 +1761,8 @@
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
+ "property-graph": ["property-graph@4.1.0", "", {}, "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="],
+
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -1689,6 +1779,8 @@
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
+ "react-colorful": ["react-colorful@5.8.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-oz68bhsnFWnpDf1ZR8daiQbYpXUnM2h2J6hl9Zg2rTpM/DU6vCqe1E+CpqmqLnJucMZetHZeifSAfJ+geN9lcA=="],
+
"react-doctor": ["react-doctor@0.5.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@effect/platform-node-shared": "4.0.0-beta.70", "@sentry/node": "^10.54.0", "agent-install": "0.0.5", "conf": "^15.1.0", "confbox": "^0.2.4", "deslop-js": "^0.0.21", "effect": "4.0.0-beta.70", "eslint-plugin-react-hooks": "^7.1.1", "jiti": "^2.7.0", "magicast": "^0.5.3", "oxlint": "^1.66.0", "oxlint-plugin-react-doctor": "0.5.0", "prompts": "^2.4.2", "typescript": ">=5.0.4 <7", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.1.0" }, "bin": { "react-doctor": "bin/react-doctor.js" } }, "sha512-MEx7RgRv2kXp6HoPMzc7fFz5Mm3vCqS3Z7clkjmDbsPamjCz7TrDDWQarfaPr7woWA6FOAaWHt1bGhQ88cfEEw=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
@@ -1799,6 +1891,8 @@
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
+ "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="],
+
"stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="],
"stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="],
@@ -1819,7 +1913,7 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
- "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="],
+ "three": ["three@0.186.0", "", {}, "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ=="],
"three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="],
@@ -1885,6 +1979,8 @@
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
+ "uniq": ["uniq@1.0.1", "", {}, "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA=="],
+
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="],
@@ -1943,6 +2039,8 @@
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
+ "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
+
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -1951,7 +2049,7 @@
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
- "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
+ "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
@@ -2027,6 +2125,8 @@
"linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
+ "manifold-3d/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
+
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
@@ -2037,6 +2137,8 @@
"postcss/nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="],
+ "promise-worker-transferable/is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="],
+
"react-doctor/agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="],
"react-doctor/eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
@@ -2045,8 +2147,6 @@
"react-scan/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
- "router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
-
"sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="],
@@ -2055,6 +2155,8 @@
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
+ "ultracite/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
+
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@tailwindcss/postcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
@@ -2071,6 +2173,8 @@
"react-doctor/agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+ "react-doctor/eslint-plugin-react-hooks/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
+
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"deslop-js/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 0000000000..958800cebc
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,4 @@
+preload = ["./scripts/bun-preload-three.ts"]
+
+[test]
+preload = ["./scripts/bun-preload-three.ts"]
diff --git a/gemini-extension.json b/gemini-extension.json
new file mode 100644
index 0000000000..3d7c6ff80d
--- /dev/null
+++ b/gemini-extension.json
@@ -0,0 +1,12 @@
+{
+ "name": "pascal",
+ "version": "0.1.8",
+ "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.",
+ "contextFileName": "skills/README.md",
+ "mcpServers": {
+ "pascal": {
+ "command": "pascal",
+ "args": ["mcp", "connect"]
+ }
+ }
+}
diff --git a/mcp.json b/mcp.json
new file mode 100644
index 0000000000..8f2b4523da
--- /dev/null
+++ b/mcp.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
+ "mcpServers": {
+ "pascal": {
+ "type": "stdio",
+ "command": "pascal",
+ "args": ["mcp", "connect"]
+ }
+ }
+}
diff --git a/package.json b/package.json
index 88bb8ea16a..7d1528c169 100644
--- a/package.json
+++ b/package.json
@@ -9,9 +9,11 @@
"format": "biome format --write",
"format:check": "biome format",
"check": "biome check",
+ "checks": "bun run check && bun run check-types",
"check:fix": "biome check --write",
"check-types": "turbo run check-types",
"test": "turbo run test",
+ "skills:validate": "bun scripts/validate-skills.ts && bun test scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/path-containment.test.ts scripts/public-skill-discovery-policy.test.ts",
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
"clean:cache": "rm -rf apps/*/.next apps/*/.swc apps/*/.turbo packages/*/.turbo tooling/*/.turbo .turbo node_modules/.cache",
"restart": "bun kill && bun clean:cache && bun dev",
@@ -22,6 +24,7 @@
"release:editor": "gh workflow run release.yml -f package=editor -f bump=patch",
"release:nodes": "gh workflow run release.yml -f package=nodes -f bump=patch",
"release:mcp": "gh workflow run release.yml -f package=mcp -f bump=patch",
+ "release:cli": "gh workflow run release.yml -f package=cli -f bump=patch",
"release:minor": "gh workflow run release.yml -f package=all -f bump=minor",
"release:major": "gh workflow run release.yml -f package=all -f bump=major"
},
@@ -29,6 +32,7 @@
"@biomejs/biome": "^2.4.16",
"@typescript/native-preview": "7.0.0-dev.20260624.1",
"dotenv-cli": "^11.0.0",
+ "fast-xml-parser": "^5.4.2",
"turbo": "^2.9.17",
"typescript": "6.0.3",
"ultracite": "^7.8.2"
@@ -43,7 +47,7 @@
"@types/three": "0.184.1",
"next": "16.3.0",
"react-grab": "0.1.50",
- "three": "0.185.1"
+ "three": "0.186.0"
},
"optionalDependencies": {
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE
new file mode 100644
index 0000000000..083fd9e323
--- /dev/null
+++ b/packages/cli/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Pascal Group Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/cli/README.md b/packages/cli/README.md
new file mode 100644
index 0000000000..db1d3dc3ae
--- /dev/null
+++ b/packages/cli/README.md
@@ -0,0 +1,246 @@
+# Pascal CLI
+
+Run the open-source [Pascal 3D building editor](https://editor.pascal.app) locally
+from your terminal—without cloning or building the Pascal repository.
+
+[](https://www.npmjs.com/package/@pascal-app/cli)
+[](../../LICENSE)
+[](https://editor.pascal.app/docs/developers/local-editor)
+
+```bash
+npx @pascal-app/cli editor
+```
+
+On an interactive first run through `npx`, Pascal installs the same CLI version globally
+after the editor becomes healthy. The shorter `pascal` command is therefore available
+for `status`, `logs`, `stop`, and future sessions without another setup step. If the
+global installation is unavailable because of local npm permissions, the editor remains
+running and the CLI shows the equivalent `npx` commands plus the manual install command.
+
+The first run walks through local storage, the one-time web runtime download, automatic
+editor and MCP port selection, process startup, and both health checks with live terminal
+feedback. It then opens `http://pascal.localhost:`. Your projects are stored
+separately from the runtime, so updating the CLI does not replace your work.
+
+## Why use the CLI?
+
+- Run a complete local Pascal editor with one command.
+- Keep projects on your machine in a local SQLite database.
+- Start and stop the editor independently from your terminal session.
+- Inspect health, logs, versions, storage, and project state from scripts or agents.
+- Connect Codex, Claude Code, Cursor, or another MCP client to the same local projects.
+- Update through a health-checked activation that rolls back if the new runtime fails.
+
+## Requirements
+
+- Node.js 22.13 or newer
+- npm, including when the CLI itself is launched with pnpm or Bun
+- A browser, unless you pass `--no-open`
+- Network access the first time you start the editor, or a local copy of the web runtime
+ archive (see [The web editor runtime](#the-web-editor-runtime)); `pascal mcp connect`
+ needs neither
+
+The initial supported release is macOS. A clean claim-command installation also passed in
+a Linux arm64 container. This is not an x86_64 or Windows result.
+
+Use one active agent client per local CLI service. The standalone local HTTP runtime shares active scene state between clients; use separate `PASCAL_HOME` directories and service processes when independent concurrent work is required.
+
+## Install and run
+
+Use your preferred package runner:
+
+```bash
+# npm
+npx @pascal-app/cli editor
+
+# pnpm
+pnpm dlx @pascal-app/cli editor
+
+# Bun
+bunx @pascal-app/cli editor
+```
+
+To install the `pascal` command before starting the editor:
+
+```bash
+npm install --global @pascal-app/cli
+pascal editor
+```
+
+After the interactive `npx` first run or a global installation, `pascal status`,
+`pascal logs --follow`, and the other commands work directly in the current terminal
+and future sessions.
+
+Use `--no-open` on a headless machine. Use `--foreground` when a process supervisor
+should own the editor or when you want logs attached to the current terminal.
+Pascal asks the operating system for an available loopback port by default, so it does
+not compete with other local development servers. Pass `--port ` to request a
+specific port; if it is occupied, Pascal reports that and safely selects another one.
+
+```bash
+npx @pascal-app/cli editor --no-open
+npx @pascal-app/cli editor --foreground --no-open
+```
+
+## The web editor runtime
+
+The npm package carries the CLI and the MCP service only: about 0.5 MB compressed and
+2.5 MB installed. The web editor itself—the Next.js server, its static assets, and the
+bundled item library—is published as one archive per CLI version, about 64 MB compressed
+and 106 MB on disk.
+
+Every command that starts the editor (`editor`, `start`, `open`, `resume`, `projects`,
+`project open`, `update`) resolves that runtime in this order:
+
+1. `PASCAL_BUNDLED_RUNTIME_DIR`, an already-extracted runtime directory.
+2. `--runtime `, which every one of those commands accepts.
+3. The runtime already installed in `~/.pascal/runtime/` for this CLI version.
+4. The release asset recorded in the package, streamed into `~/.pascal/tmp` with download
+ progress in the terminal.
+
+A downloaded archive is checked against the SHA-256 digest published inside the npm
+package before anything is extracted. On a mismatch the CLI deletes the temporary file and
+installs nothing, so a corrupted or substituted archive never becomes your runtime.
+Concurrent first runs share one download through the runtime install lock.
+
+An offline or air-gapped machine can take the archive from the release page:
+
+```bash
+# On a connected machine
+curl --fail --location --remote-name \
+ "https://github.com/pascalorg/editor/releases/download/@pascal-app/cli@/pascal-web-runtime-.tar.gz"
+
+# On the target machine
+pascal editor --runtime ./pascal-web-runtime-.tar.gz
+```
+
+An archive passed with `--runtime` is digest-verified exactly like a download. A directory
+is installed as it is, which is the escape hatch for a runtime you built yourself from this
+repository.
+
+`HTTPS_PROXY` (or `ALL_PROXY`), including a proxy that requires basic authentication, and
+`NO_PROXY` are honoured; only `https://` URLs are accepted. When a download fails, the CLI
+prints the archive URL, the expected digest, and the `--runtime` command to run after
+copying the file across.
+
+Agent tools need none of this. `pascal mcp connect` starts the MCP service that ships in
+the npm package, so an agent can read and write local projects on a machine that has never
+downloaded the web runtime.
+
+## Commands
+
+| Command | Purpose |
+| --- | --- |
+| `pascal editor [--runtime ]` | Install the web runtime if needed, ensure the editor is running, and open it. |
+| `pascal start [--runtime ]` | Ensure the editor is running without opening a browser. |
+| `pascal stop [--force]` | Stop the managed editor and MCP processes; `--force` is a guarded recovery path. |
+| `pascal restart` | Restart the editor and MCP service with their current configuration. |
+| `pascal status [--json]` | Show editor and MCP health, version, PIDs, ports, URL, and runtime metadata. |
+| `pascal open [project]` | Start Pascal if needed, then open the editor or a project by ID, ID prefix, or unique name. |
+| `pascal resume [project]` | Open the latest project, or a selected project. |
+| `pascal projects [--json]` | List local projects. |
+| `pascal logs [--follow]` | Read or follow the managed editor log. |
+| `pascal update [--version ] [--runtime ]` | Health-check and activate the runtime this CLI publishes, or an npm-published target. |
+| `pascal doctor [--json]` | Diagnose Node.js, storage, runtime, process, and plugin state. |
+| `pascal info [--json]` | Print platform, paths, runtime, and plugin context. |
+| `pascal project list [--json]` | Explicit form of `pascal projects`. |
+| `pascal project open ` | Explicit form of `pascal open `. |
+| `pascal agent claim [--no-open] [--json]` | Link an autonomous hosted agent to the person accountable for it. |
+| `pascal agent status [--json]` | Verify the hosted agent credential and inspect its claim and organization scope. |
+| `pascal mcp connect` | Stable local connector for MCP clients; starts the bundled MCP service without the web runtime. |
+| `pascal mcp status [--json]` | Show managed MCP health. |
+| `pascal mcp config [--json]` | Print generic MCP client configuration. |
+| `pascal mcp setup ` | Configure an installed client without overwriting existing entries. |
+| `pascal plugin list [--json]` | Inspect the reserved managed-plugin lock. |
+
+When you do not install globally, prefix commands with a runner—for example,
+`npx @pascal-app/cli doctor`.
+
+## Local data and security
+
+Pascal binds the editor and MCP service only to `127.0.0.1` and uses the reserved
+`.localhost` hostname. MCP requires a random token stored in Pascal's private runtime
+directory; client configuration never contains that token.
+
+```text
+~/.pascal/
+ runtime// installed web editor runtimes
+ data/pascal.db projects and scenes
+ logs/editor.log detached editor and MCP output
+ run/editor.json managed editor process identity
+ run/mcp.json managed MCP service identity
+ run/mcp-token private local MCP token
+ tmp/ runtime downloads in progress
+ plugins/ reserved verified-plugin storage
+ pascal.plugins.lock reserved managed-plugin lock
+```
+
+Runtime installation, project data, process state, and logs have separate lifecycles.
+The CLI does not include a command that deletes project data. Updates retain the
+previous runtime for rollback, and `pascal doctor` warns when more than three versions
+have accumulated.
+
+## Local AI agents
+
+The MCP service ships in the npm package. It starts automatically with `pascal editor`, and
+`pascal mcp connect` starts it on its own—no web runtime download, no editor process. Add
+the stable connector to your client once:
+
+```bash
+pascal mcp setup codex
+pascal mcp setup claude
+```
+
+Or use `pascal mcp config` for JSON-based clients. Ask the agent to read
+`pascal://agent-guide`, list or load a scene, edit it, and return the `editorUrl`. Those
+`editorUrl` values point at the local editor; run `pascal editor` to open one, which is
+also when the web runtime is downloaded.
+
+## Hosted autonomous agents
+
+An autonomous agent registered with hosted Pascal receives its own API key and identity. The
+agent can create a short-lived claim code so the person working with it can establish the
+accountability link:
+
+```bash
+PASCAL_API_KEY='sk_live_...' pascal agent claim
+PASCAL_API_KEY='sk_live_...' pascal agent status
+```
+
+The CLI sends that key once to Pascal's claim endpoint, does not store or print it, and opens
+the claim page. Use `--no-open` on a headless host. `--json` returns structured output without
+opening a browser. A new claim request supersedes the agent's previous code; each code expires
+after 15 minutes.
+
+`pascal agent status` confirms that the credential remains active and reports the agent ID,
+autonomous or delegated mode, claim state, and whether the key is scoped to an organization.
+It does not expose the accountable person's identity or inspect local editor projects.
+
+Claiming lifts claim-gated capabilities for the autonomous agent. It does not transfer project
+ownership, grant the agent access to the person's private projects, or grant the person access
+to the agent's private projects. The local editor and its projects remain local unless a
+separate hosted project action explicitly moves data.
+
+## Plugins
+
+The current CLI manages the local editor runtime; it does not yet download plugin code
+from GitHub or npm. Follow the [plugin authoring guide](https://editor.pascal.app/docs/developers/plugins)
+and the standalone [Nature plugin](https://github.com/pascalorg/plugin-trees) when
+building an extension today.
+
+Pascal also exposes a hosted Model Context Protocol endpoint for projects in a Pascal
+account. See [Connect an AI agent](https://editor.pascal.app/docs/developers/mcp) for
+the local and hosted workflows and the standalone `@pascal-app/mcp` package.
+
+## Documentation and support
+
+- [Complete CLI guide](https://editor.pascal.app/docs/developers/local-editor)
+- [Plugin authoring guide](https://editor.pascal.app/docs/developers/plugins)
+- [MCP and AI-agent guide](https://editor.pascal.app/docs/developers/mcp)
+- [Open-source repository](https://github.com/pascalorg/editor)
+- [Issues and feature requests](https://github.com/pascalorg/editor/issues)
+- [Discord community](https://discord.gg/XRKsDcpqgS)
+
+## License
+
+MIT
diff --git a/packages/cli/package.json b/packages/cli/package.json
new file mode 100644
index 0000000000..962a699758
--- /dev/null
+++ b/packages/cli/package.json
@@ -0,0 +1,73 @@
+{
+ "name": "@pascal-app/cli",
+ "version": "1.0.0",
+ "description": "Run the open-source Pascal 3D editor, local projects, and MCP agent tools from your terminal",
+ "type": "module",
+ "bin": {
+ "pascal": "dist/bin/pascal.js"
+ },
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "build": "tsc --build",
+ "build-runtime": "cd ../../apps/editor && PASCAL_PORTABLE_BUILD=1 bun run build",
+ "check-types": "tsc --build --pretty false && tsc --project tsconfig.scripts.json --pretty false",
+ "stage-runtime": "bun run scripts/stage-runtime.ts",
+ "smoke-runtime": "bun run scripts/smoke-packed-runtime.ts",
+ "test": "bun test src",
+ "prepublishOnly": "bun run check-types && bun run build && bun run test && bun run build-runtime && bun run stage-runtime && bun run smoke-runtime"
+ },
+ "devDependencies": {
+ "@pascal/typescript-config": "*",
+ "@types/node": "^22.19.20",
+ "typescript": "6.0.3"
+ },
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.30.0"
+ },
+ "engines": {
+ "node": ">=22.13.0"
+ },
+ "keywords": [
+ "pascal",
+ "editor",
+ "3d-editor",
+ "3d",
+ "architecture",
+ "building-design",
+ "cad",
+ "bim",
+ "local-first",
+ "cli",
+ "mcp",
+ "ai-agents"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/pascalorg/editor.git",
+ "directory": "packages/cli"
+ },
+ "license": "MIT",
+ "author": {
+ "name": "Pascal",
+ "email": "open@pascal.app",
+ "url": "https://pascal.app"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "homepage": "https://editor.pascal.app/docs/developers/local-editor",
+ "bugs": "https://github.com/pascalorg/editor/issues"
+}
diff --git a/packages/cli/scripts/smoke-packed-runtime.ts b/packages/cli/scripts/smoke-packed-runtime.ts
new file mode 100644
index 0000000000..9db2d6db73
--- /dev/null
+++ b/packages/cli/scripts/smoke-packed-runtime.ts
@@ -0,0 +1,400 @@
+import { spawn } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { copyFile, mkdtemp, open, readFile, rm, stat } from 'node:fs/promises'
+import http from 'node:http'
+import os from 'node:os'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
+
+const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const smokeRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-smoke-'))
+let tarballPath: string | null = null
+let smokeExecutable: string | null = null
+let mcpOnlyExecutable: string | null = null
+const defaultPortBlocker = http.createServer((_request, response) => {
+ response.setHeader('content-type', 'application/json')
+ response.end(JSON.stringify({ status: 'ok', app: 'foreign' }))
+})
+/** MCP-only mode is verified in its own home so no web runtime can be installed there. */
+const mcpOnlyEnvironment = {
+ ...process.env,
+ PASCAL_HOME: path.join(smokeRoot, 'home-mcp-only'),
+ PASCAL_NO_OPEN: '1',
+}
+const smokeEnvironment = {
+ ...process.env,
+ PASCAL_HOME: path.join(smokeRoot, 'home'),
+ PASCAL_NO_OPEN: '1',
+}
+
+try {
+ await listen(defaultPortBlocker)
+ const pack = await run('npm', ['pack', '--json', '--ignore-scripts'], packageDirectory)
+ const packResult = JSON.parse(pack.stdout) as
+ | Array
+ | Record
+ const artifact = Array.isArray(packResult) ? packResult[0] : Object.values(packResult)[0]
+ if (!artifact) throw new Error('npm pack did not return an artifact')
+ tarballPath = path.join(packageDirectory, artifact.filename)
+ enforceArtifactBudget(artifact)
+ const runtimeArchive = await verifyStagedWebRuntime()
+
+ const installDirectory = path.join(smokeRoot, 'install')
+ await run('npm', ['install', '--ignore-scripts', '--prefix', installDirectory, tarballPath])
+ const executable = path.join(installDirectory, 'node_modules/@pascal-app/cli/dist/bin/pascal.js')
+
+ mcpOnlyExecutable = executable
+ await checkMcpWithoutWebRuntime(executable)
+ mcpOnlyExecutable = null
+
+ smokeExecutable = executable
+ await checkTamperedArchiveIsRejected(executable, runtimeArchive.file)
+ await checkEditorFromLocalArchive(executable, runtimeArchive.file)
+ smokeExecutable = null
+
+ console.log(
+ `Packed CLI smoke passed (${formatMb(artifact.size)} MB compressed, ${formatMb(artifact.unpackedSize)} MB unpacked, ${artifact.entryCount} files).`,
+ )
+ console.log(
+ `Web runtime archive ${path.basename(runtimeArchive.file)} (${formatMb(runtimeArchive.size)} MB) verified against ${runtimeArchive.url}`,
+ )
+} finally {
+ await close(defaultPortBlocker)
+ for (const [command, environment] of [
+ [smokeExecutable, smokeEnvironment],
+ [mcpOnlyExecutable, mcpOnlyEnvironment],
+ ] as Array<[string | null, NodeJS.ProcessEnv]>) {
+ if (!command) continue
+ await run(
+ process.execPath,
+ [command, 'stop', '--force', '--json'],
+ undefined,
+ environment,
+ ).catch(() => undefined)
+ }
+ if (tarballPath) await rm(tarballPath, { force: true })
+ await rm(smokeRoot, { recursive: true, force: true })
+}
+
+/**
+ * Phase 1: agent tools must work on a machine that has never downloaded the web runtime.
+ */
+async function checkMcpWithoutWebRuntime(executable: string): Promise {
+ const client = new Client({ name: 'pascal-cli-smoke-mcp-only', version: '0.0.0' })
+ const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [executable, 'mcp', 'connect'],
+ env: mcpOnlyEnvironment as Record,
+ stderr: 'pipe',
+ })
+ try {
+ await client.connect(transport)
+ const tools = await client.listTools()
+ if (!tools.tools.some((tool) => tool.name === 'save_scene')) {
+ throw new Error('MCP-only mode did not expose save_scene')
+ }
+ const saved = await client.callTool({
+ name: 'save_scene',
+ arguments: { id: 'mcp-only-project', name: 'MCP only project' },
+ })
+ if (saved.isError) throw new Error(`MCP-only save_scene failed: ${JSON.stringify(saved)}`)
+ const listed = await client.callTool({ name: 'list_scenes', arguments: {} })
+ if (listed.isError || !JSON.stringify(listed).includes('mcp-only-project')) {
+ throw new Error(`MCP-only list_scenes failed: ${JSON.stringify(listed)}`)
+ }
+ console.log(
+ `MCP-only mode exposed ${tools.tools.length} tools and stored a scene with no web runtime installed.`,
+ )
+ } finally {
+ await client.close()
+ }
+ const status = JSON.parse(
+ (await run(process.execPath, [executable, 'status', '--json'], undefined, mcpOnlyEnvironment))
+ .stdout,
+ ) as { installed: boolean; running: boolean; runtime: unknown; mcp: { healthy: boolean } }
+ if (status.installed || status.runtime !== null || status.running) {
+ throw new Error('MCP-only mode installed or started the web runtime')
+ }
+ if (!status.mcp.healthy) throw new Error('the managed MCP service is not healthy on its own')
+ const stopped = JSON.parse(
+ (await run(process.execPath, [executable, 'stop', '--json'], undefined, mcpOnlyEnvironment))
+ .stdout,
+ ) as { stopped: boolean }
+ if (!stopped.stopped) throw new Error('stop did not report the MCP-only service as stopped')
+}
+
+/** Phase 2: a modified archive must never reach the runtime directory. */
+async function checkTamperedArchiveIsRejected(
+ executable: string,
+ archiveFile: string,
+): Promise {
+ const tampered = path.join(smokeRoot, 'tampered-web-runtime.tar.gz')
+ await copyFile(archiveFile, tampered)
+ const handle = await open(tampered, 'r+')
+ try {
+ const offset = Math.floor((await handle.stat()).size / 2)
+ const byte = Buffer.alloc(1)
+ await handle.read(byte, 0, 1, offset)
+ byte[0] = ((byte[0] ?? 0) ^ 0xff) & 0xff
+ await handle.write(byte, 0, 1, offset)
+ } finally {
+ await handle.close()
+ }
+ const failure = await runExpectingFailure(
+ process.execPath,
+ [executable, 'editor', '--no-open', '--json', '--runtime', tampered],
+ smokeEnvironment,
+ )
+ const reported = JSON.parse(failure.stderr) as { error: string; message: string }
+ if (reported.error !== 'runtime_digest_mismatch') {
+ throw new Error(`a tampered archive was not rejected: ${failure.stderr}`)
+ }
+ await stat(tampered)
+ const status = JSON.parse(
+ (await run(process.execPath, [executable, 'status', '--json'], undefined, smokeEnvironment))
+ .stdout,
+ ) as { installed: boolean }
+ if (status.installed) throw new Error('a tampered archive was installed')
+ console.log(`Tampered archive rejected: ${reported.message.split('\n')[0]}`)
+}
+
+/** Phase 3: the offline install path, then the full editor and MCP flow over that runtime. */
+async function checkEditorFromLocalArchive(executable: string, archiveFile: string): Promise {
+ const started = JSON.parse(
+ (
+ await run(
+ process.execPath,
+ [executable, 'editor', '--no-open', '--json', '--runtime', archiveFile],
+ undefined,
+ smokeEnvironment,
+ )
+ ).stdout,
+ ) as { pid: number; port: number; url: string; mcp: { port: number } }
+ if (started.port === 3000) throw new Error('editor reused the occupied default port')
+ if (!started.mcp?.port) throw new Error('the editor did not report a managed MCP port')
+ const rootResponse = await fetch(`http://127.0.0.1:${started.port}/`)
+ if (!rootResponse.ok) throw new Error(`editor root returned ${rootResponse.status}`)
+ const scenesResponse = await fetch(`${started.url}/scenes`)
+ if (!scenesResponse.ok) throw new Error(`editor scenes returned ${scenesResponse.status}`)
+ const repeatedStart = JSON.parse(
+ (
+ await run(
+ process.execPath,
+ [executable, 'editor', '--no-open', '--port', '0', '--json'],
+ undefined,
+ smokeEnvironment,
+ )
+ ).stdout,
+ ) as { alreadyRunning: boolean; pid: number; port: number }
+ if (
+ !repeatedStart.alreadyRunning ||
+ repeatedStart.pid !== started.pid ||
+ repeatedStart.port !== started.port
+ ) {
+ throw new Error('a repeated editor command did not reuse the managed process')
+ }
+ const humanStart = await run(
+ process.execPath,
+ [executable, 'editor', '--no-open'],
+ undefined,
+ smokeEnvironment,
+ )
+ if (
+ !humanStart.stdout.includes('pascal status') ||
+ humanStart.stdout.includes('npm install --global @pascal-app/cli')
+ ) {
+ throw new Error('direct CLI start output did not use the persistent pascal command')
+ }
+ await run(
+ process.execPath,
+ [executable, 'project', 'list', '--json'],
+ undefined,
+ smokeEnvironment,
+ )
+ const mcpTransport = new StdioClientTransport({
+ command: process.execPath,
+ args: [executable, 'mcp', 'connect'],
+ env: smokeEnvironment as Record,
+ stderr: 'pipe',
+ })
+ const mcpClient = new Client({ name: 'pascal-cli-smoke', version: '0.0.0' })
+ try {
+ await mcpClient.connect(mcpTransport)
+ const tools = await mcpClient.listTools()
+ if (!tools.tools.some((tool) => tool.name === 'save_scene')) {
+ throw new Error('managed MCP did not expose save_scene')
+ }
+ const saved = await mcpClient.callTool({
+ name: 'save_scene',
+ arguments: { id: 'smoke-project', name: 'Smoke project' },
+ })
+ if (saved.isError) throw new Error(`managed MCP save_scene failed: ${JSON.stringify(saved)}`)
+ } finally {
+ await mcpClient.close()
+ }
+ const resumed = JSON.parse(
+ (
+ await run(
+ process.execPath,
+ [executable, 'resume', 'Smoke project', '--json'],
+ undefined,
+ smokeEnvironment,
+ )
+ ).stdout,
+ ) as { project: { id: string }; url: string }
+ if (resumed.project.id !== 'smoke-project' || !resumed.url.endsWith('/scene/smoke-project')) {
+ throw new Error('CLI project resume did not resolve the MCP-saved project')
+ }
+ console.log(
+ `Editor installed from ${path.basename(archiveFile)} on port ${started.port}, MCP on port ${started.mcp.port}, and a scene round-tripped between MCP and the CLI.`,
+ )
+ await run(process.execPath, [executable, 'doctor', '--json'], undefined, smokeEnvironment)
+ await run(process.execPath, [executable, 'stop', '--json'], undefined, smokeEnvironment)
+}
+
+async function verifyStagedWebRuntime(): Promise<{ file: string; size: number; url: string }> {
+ const source = JSON.parse(
+ await readFile(path.join(packageDirectory, 'dist/runtime-source.json'), 'utf8'),
+ ) as { version: string; url: string; sha256: string; size: number }
+ const packageVersion = (
+ JSON.parse(await readFile(path.join(packageDirectory, 'package.json'), 'utf8')) as {
+ version: string
+ }
+ ).version
+ if (source.version !== packageVersion) {
+ throw new Error(`dist/runtime-source.json targets ${source.version}, not ${packageVersion}`)
+ }
+ const archiveName = `pascal-web-runtime-${packageVersion}.tar.gz`
+ const expectedUrl = `https://github.com/pascalorg/editor/releases/download/@pascal-app/cli@${packageVersion}/${archiveName}`
+ if (source.url !== expectedUrl) {
+ throw new Error(`dist/runtime-source.json points at ${source.url}, not ${expectedUrl}`)
+ }
+ const file = path.join(packageDirectory, 'build', archiveName)
+ const { size } = await stat(file)
+ if (size !== source.size) {
+ throw new Error(`${archiveName} is ${size} bytes; runtime-source.json records ${source.size}`)
+ }
+ const maximumArchiveSize = 70 * 1024 * 1024
+ if (size > maximumArchiveSize) {
+ throw new Error(
+ `the web runtime archive exceeds its release budget: ${formatMb(size)} MB > ${formatMb(maximumArchiveSize)} MB`,
+ )
+ }
+ const digestFile = `${file}.sha256`
+ const recordedDigest = (await readFile(digestFile, 'utf8')).trim().split(/\s+/)[0]
+ if (recordedDigest !== source.sha256) {
+ throw new Error(`${digestFile} does not match dist/runtime-source.json`)
+ }
+ const hashed = await sha256(file)
+ if (hashed !== source.sha256) {
+ throw new Error(`${archiveName} hashes to ${hashed}, not the published ${source.sha256}`)
+ }
+ return { file, size, url: source.url }
+}
+
+async function sha256(filePath: string): Promise {
+ const hash = createHash('sha256')
+ for await (const chunk of createReadStream(filePath)) hash.update(chunk as Buffer)
+ return hash.digest('hex')
+}
+
+async function listen(server: http.Server): Promise {
+ await new Promise((resolve, reject) => {
+ server.once('error', (error: NodeJS.ErrnoException) =>
+ error.code === 'EADDRINUSE' ? resolve() : reject(error),
+ )
+ server.listen({ host: '::', port: 3000, ipv6Only: false }, resolve)
+ })
+}
+
+async function close(server: http.Server): Promise {
+ if (!server.listening) return
+ await new Promise((resolve, reject) =>
+ server.close((error) => (error ? reject(error) : resolve())),
+ )
+}
+
+interface PackedArtifact {
+ filename: string
+ size: number
+ unpackedSize: number
+ entryCount: number
+}
+
+/**
+ * The npm package carries the CLI and the MCP service only. The web runtime rides a GitHub
+ * release asset, so both budgets are enforced separately.
+ */
+function enforceArtifactBudget(artifact: {
+ size: number
+ unpackedSize: number
+ entryCount: number
+}): void {
+ const maximumSize = 3 * 1024 * 1024
+ const maximumUnpackedSize = 10 * 1024 * 1024
+ const maximumEntryCount = 250
+ if (
+ artifact.size > maximumSize ||
+ artifact.unpackedSize > maximumUnpackedSize ||
+ artifact.entryCount > maximumEntryCount
+ ) {
+ throw new Error(
+ `packed CLI exceeds its release budget: ${formatMb(artifact.size)} MB compressed, ${formatMb(artifact.unpackedSize)} MB unpacked, ${artifact.entryCount} files`,
+ )
+ }
+}
+
+async function run(
+ command: string,
+ args: string[],
+ cwd?: string,
+ env: NodeJS.ProcessEnv = process.env,
+): Promise<{ stdout: string; stderr: string }> {
+ const result = await capture(command, args, cwd, env)
+ if (result.exitCode !== 0) {
+ throw new Error(`${command} ${args.join(' ')} failed (${result.exitCode}): ${result.stderr}`)
+ }
+ return result
+}
+
+async function runExpectingFailure(
+ command: string,
+ args: string[],
+ env: NodeJS.ProcessEnv,
+): Promise<{ stdout: string; stderr: string }> {
+ const result = await capture(command, args, undefined, env)
+ if (result.exitCode === 0) {
+ throw new Error(`${command} ${args.join(' ')} succeeded but should have failed`)
+ }
+ return result
+}
+
+async function capture(
+ command: string,
+ args: string[],
+ cwd?: string,
+ env: NodeJS.ProcessEnv = process.env,
+): Promise<{ exitCode: number; stdout: string; stderr: string }> {
+ const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command
+ const child = spawn(executable, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] })
+ const stdout: Buffer[] = []
+ const stderr: Buffer[] = []
+ child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk))
+ child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk))
+ const exitCode = await new Promise((resolve, reject) => {
+ child.once('error', reject)
+ child.once('exit', (code) => resolve(code ?? 1))
+ })
+ return {
+ exitCode,
+ stdout: Buffer.concat(stdout).toString('utf8'),
+ stderr: Buffer.concat(stderr).toString('utf8'),
+ }
+}
+
+function formatMb(bytes: number): string {
+ return (bytes / 1024 / 1024).toFixed(1)
+}
diff --git a/packages/cli/scripts/stage-runtime.ts b/packages/cli/scripts/stage-runtime.ts
new file mode 100644
index 0000000000..c973558fdd
--- /dev/null
+++ b/packages/cli/scripts/stage-runtime.ts
@@ -0,0 +1,341 @@
+import { spawn } from 'node:child_process'
+import {
+ chmod,
+ cp,
+ mkdir,
+ readdir,
+ readFile,
+ realpath,
+ rm,
+ stat,
+ writeFile,
+} from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { fileSha256 } from '../src/runtime-download.js'
+import { createRuntimeArchive } from '../src/tar.js'
+
+const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const repositoryRoot = path.resolve(packageDirectory, '../..')
+const appDirectory = path.join(repositoryRoot, 'apps/editor')
+const standaloneDirectory = path.join(appDirectory, '.next/standalone')
+const standaloneAppDirectory = path.join(standaloneDirectory, 'apps/editor')
+/**
+ * The web runtime is a release asset, not part of the npm package: it is staged and archived
+ * under `build/`, while `dist/` only gains the MCP service and the digest of that archive.
+ */
+const buildDirectory = path.join(packageDirectory, 'build')
+const outputDirectory = path.join(buildDirectory, 'runtime')
+const releaseAssetBaseUrl = 'https://github.com/pascalorg/editor/releases/download'
+
+/**
+ * `next build` copies its tracing root into `.next/standalone`, so the portable runtime
+ * inherits app sources, repository documentation and build-time-only assets that
+ * `server.js` never reads. Every entry below was checked against the staged tree: nothing
+ * in `.next`, `node_modules` or the bundled MCP server resolves it.
+ */
+const buildOnlyRuntimePaths = [
+ 'apps/editor/app',
+ 'apps/editor/components',
+ 'apps/editor/lib',
+ 'apps/editor/AGENTS.md',
+ 'apps/editor/CLAUDE.md',
+ 'apps/editor/README.md',
+ 'apps/editor/bunfig.toml',
+ 'apps/editor/next.config.ts',
+ 'apps/editor/postcss.config.mjs',
+ 'apps/editor/tsconfig.json',
+ 'apps/editor/vercel.json',
+ // The radio catalogue is played by the hosted community app, which serves its own copy.
+ 'apps/editor/public/audios/radios',
+ // `next/dist/server/font-utils.js` is the sole reader of these font metrics and is
+ // itself unreachable from the standalone server.
+ 'node_modules/next/dist/server/capsize-font-metrics.json',
+ 'node_modules/next/dist/server/font-utils.js',
+]
+
+const packageJson = JSON.parse(
+ await readFile(path.join(packageDirectory, 'package.json'), 'utf8'),
+) as {
+ version: string
+}
+
+const archiveName = `pascal-web-runtime-${packageJson.version}.tar.gz`
+const archiveFile = path.join(buildDirectory, archiveName)
+const assetUrl = `${releaseAssetBaseUrl}/@pascal-app/cli@${packageJson.version}/${archiveName}`
+
+await chmod(path.join(packageDirectory, 'dist/bin/pascal.js'), 0o755)
+await bundleMcpServer(
+ path.join(packageDirectory, 'dist/services/pascal-mcp.mjs'),
+ packageJson.version,
+)
+await assertFile(path.join(standaloneAppDirectory, 'server.js'))
+await rm(outputDirectory, { recursive: true, force: true })
+await mkdir(path.dirname(outputDirectory), { recursive: true })
+await cp(standaloneDirectory, outputDirectory, { recursive: true, dereference: false })
+
+await cp(path.join(appDirectory, 'public'), path.join(outputDirectory, 'apps/editor/public'), {
+ recursive: true,
+ force: true,
+})
+await cp(
+ path.join(appDirectory, '.next/static'),
+ path.join(outputDirectory, 'apps/editor/.next/static'),
+ { recursive: true, force: true },
+)
+await rm(path.join(outputDirectory, 'apps/editor/vendor'), { recursive: true, force: true })
+await removeUnusedSharp(outputDirectory)
+await flattenBunNodeModules(outputDirectory)
+await materializeSymlinks(outputDirectory)
+await rm(path.join(outputDirectory, 'node_modules/.bun'), { recursive: true, force: true })
+await pruneBuildOnlyFiles(outputDirectory)
+const nativeFiles = await findNativeModules(outputDirectory)
+if (nativeFiles.length > 0) {
+ throw new Error(`portable runtime contains native modules:\n${nativeFiles.join('\n')}`)
+}
+
+await writeFile(
+ path.join(outputDirectory, 'runtime-manifest.json'),
+ `${JSON.stringify(
+ { schemaVersion: 2, version: packageJson.version, entrypoint: 'apps/editor/server.js' },
+ null,
+ 2,
+ )}\n`,
+)
+
+const archive = await createRuntimeArchive(outputDirectory, archiveFile)
+const sha256 = await fileSha256(archiveFile)
+await writeFile(`${archiveFile}.sha256`, `${sha256} ${archiveName}\n`)
+await writeFile(
+ path.join(packageDirectory, 'dist/runtime-source.json'),
+ `${JSON.stringify(
+ { version: packageJson.version, url: assetUrl, sha256, size: archive.size },
+ null,
+ 2,
+ )}\n`,
+)
+
+console.log(`Staged Pascal web runtime ${packageJson.version} at ${outputDirectory}`)
+console.log(
+ `Archived ${archive.entryCount} entries to ${archiveFile} (${formatMegabytes(archive.size)} MB)`,
+)
+console.log(`Digest ${sha256}`)
+console.log(`Release asset ${assetUrl}`)
+
+async function bundleMcpServer(output: string, version: string): Promise {
+ await mkdir(path.dirname(output), { recursive: true })
+ const child = spawn(
+ process.execPath,
+ [
+ 'build',
+ path.join(repositoryRoot, 'packages/mcp/src/bin/pascal-mcp.ts'),
+ '--outfile',
+ output,
+ '--target',
+ 'node',
+ '--format',
+ 'esm',
+ '--define',
+ `process.env.PASCAL_MCP_VERSION=${JSON.stringify(version)}`,
+ ],
+ { stdio: ['ignore', 'ignore', 'pipe'] },
+ )
+ const stderr: Buffer[] = []
+ child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk))
+ const exitCode = await new Promise((resolve, reject) => {
+ child.once('error', reject)
+ child.once('exit', (code) => resolve(code ?? 1))
+ })
+ if (exitCode !== 0) {
+ throw new Error(`Unable to bundle the Pascal MCP server: ${Buffer.concat(stderr).toString()}`)
+ }
+}
+
+async function assertFile(filePath: string): Promise {
+ try {
+ await readFile(filePath)
+ } catch {
+ throw new Error(
+ `standalone editor build not found at ${filePath}; run PASCAL_PORTABLE_BUILD=1 bun run build from apps/editor first`,
+ )
+ }
+}
+
+async function pruneBuildOnlyFiles(root: string): Promise {
+ await Promise.all(
+ buildOnlyRuntimePaths.map((relative) =>
+ rm(path.join(root, relative), { recursive: true, force: true }),
+ ),
+ )
+ await removeStrayItemAssets(path.join(root, 'apps/editor/public/items'))
+ await removeTraceArtifacts(path.join(root, 'apps/editor/.next'))
+}
+
+/**
+ * Item directories are addressed by convention (`model.glb`, `thumbnail.*`, `floor-plan.*`).
+ * Anything else is an authoring leftover, so it is dropped and named on stdout: a future
+ * asset that does not follow the convention has to be reported rather than silently lost.
+ */
+async function removeStrayItemAssets(itemsDirectory: string): Promise {
+ let entries
+ try {
+ entries = await readdir(itemsDirectory, { withFileTypes: true })
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ return
+ }
+ const isConventional = (name: string): boolean =>
+ name === 'model.glb' || name.startsWith('thumbnail.') || name.startsWith('floor-plan.')
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue
+ const itemDirectory = path.join(itemsDirectory, entry.name)
+ for (const asset of await readdir(itemDirectory, { withFileTypes: true })) {
+ if (!asset.isFile() || isConventional(asset.name)) continue
+ const assetPath = path.join(itemDirectory, asset.name)
+ const { size } = await stat(assetPath)
+ await rm(assetPath, { force: true })
+ console.log(
+ `Dropped unreferenced item asset ${entry.name}/${asset.name} (${formatMegabytes(size)} MB)`,
+ )
+ }
+ }
+}
+
+function formatMegabytes(bytes: number): string {
+ return (bytes / 1024 / 1024).toFixed(2)
+}
+
+async function removeTraceArtifacts(nextDirectory: string): Promise {
+ const walk = async (directory: string): Promise => {
+ let entries
+ try {
+ entries = await readdir(directory, { withFileTypes: true })
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ return
+ }
+ for (const entry of entries) {
+ const absolute = path.join(directory, entry.name)
+ if (entry.isDirectory()) await walk(absolute)
+ else if (entry.name.endsWith('.nft.json') || entry.name.endsWith('.map')) {
+ await rm(absolute, { force: true })
+ }
+ }
+ }
+ await walk(nextDirectory)
+}
+
+async function removeUnusedSharp(root: string): Promise {
+ const nodeModules = path.join(root, 'node_modules')
+ await rm(path.join(nodeModules, 'sharp'), { recursive: true, force: true })
+ await rm(path.join(nodeModules, '@img'), { recursive: true, force: true })
+ const bunModules = path.join(nodeModules, '.bun')
+ let entries: string[] = []
+ try {
+ entries = await readdir(bunModules)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ return
+ }
+ await Promise.all(
+ entries
+ .filter(
+ (entry) => entry === 'sharp' || entry.startsWith('sharp@') || entry.startsWith('@img+'),
+ )
+ .map((entry) => rm(path.join(bunModules, entry), { recursive: true, force: true })),
+ )
+}
+
+async function flattenBunNodeModules(root: string): Promise {
+ const nodeModules = path.join(root, 'node_modules')
+ const bunNodeModules = path.join(nodeModules, '.bun/node_modules')
+ let entries
+ try {
+ entries = await readdir(bunNodeModules, { withFileTypes: true })
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ return
+ }
+ for (const entry of entries) {
+ if (entry.name.startsWith('@') && entry.isDirectory()) {
+ const scope = path.join(bunNodeModules, entry.name)
+ for (const packageEntry of await readdir(scope, { withFileTypes: true })) {
+ await copyLinkedPackage(
+ path.join(scope, packageEntry.name),
+ path.join(nodeModules, entry.name, packageEntry.name),
+ )
+ }
+ } else {
+ await copyLinkedPackage(
+ path.join(bunNodeModules, entry.name),
+ path.join(nodeModules, entry.name),
+ )
+ }
+ }
+}
+
+async function copyLinkedPackage(source: string, destination: string): Promise {
+ let target: string
+ try {
+ target = await realpath(source)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
+ throw error
+ }
+ await rm(destination, { recursive: true, force: true })
+ await mkdir(path.dirname(destination), { recursive: true })
+ await cp(target, destination, { recursive: true, dereference: false })
+}
+
+async function materializeSymlinks(root: string): Promise {
+ const resolvedRoot = path.resolve(root)
+ for (let pass = 0; pass < 100; pass += 1) {
+ const links = await findSymlinks(root)
+ if (links.length === 0) return
+
+ for (const link of links) {
+ let target: string
+ try {
+ target = await realpath(link)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ await rm(link, { force: true })
+ continue
+ }
+ if (!target.startsWith(`${resolvedRoot}${path.sep}`)) {
+ throw new Error(`portable runtime symlink escapes its root: ${link}`)
+ }
+ await rm(link, { force: true })
+ await cp(target, link, { recursive: true, dereference: false })
+ }
+ }
+ throw new Error('portable runtime contains a cyclic symlink')
+}
+
+async function findSymlinks(root: string): Promise {
+ const result: string[] = []
+ const walk = async (directory: string): Promise => {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const absolute = path.join(directory, entry.name)
+ if (absolute === path.join(root, 'node_modules/.bun')) continue
+ if (entry.isSymbolicLink()) result.push(absolute)
+ else if (entry.isDirectory()) await walk(absolute)
+ }
+ }
+ await walk(root)
+ return result
+}
+
+async function findNativeModules(root: string): Promise {
+ const result: string[] = []
+ const walk = async (directory: string): Promise => {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const absolute = path.join(directory, entry.name)
+ if (entry.isDirectory()) await walk(absolute)
+ else if (entry.isFile() && entry.name.endsWith('.node'))
+ result.push(path.relative(root, absolute))
+ }
+ }
+ await walk(root)
+ return result.sort()
+}
diff --git a/packages/cli/src/agent-account.test.ts b/packages/cli/src/agent-account.test.ts
new file mode 100644
index 0000000000..54d6a20d0d
--- /dev/null
+++ b/packages/cli/src/agent-account.test.ts
@@ -0,0 +1,215 @@
+import { describe, expect, test } from 'bun:test'
+import { agentClaimHandoffUrl, getAgentStatus, startAgentClaim } from './agent-account.js'
+import { CliError } from './errors.js'
+
+const API_KEY = 'sk_live_private-agent-key'
+const VALID_CLAIM = {
+ claimCode: 'BCDF-GHJK-LMNP',
+ claimUrl: 'https://editor.pascal.app/settings/agents/claim',
+ expiresAt: '2026-09-10T18:30:00.000Z',
+}
+const VALID_STATUS = {
+ schemaVersion: 1 as const,
+ agentId: 'agent_test',
+ mode: 'autonomous' as const,
+ claimed: false,
+ organizationScoped: true,
+}
+
+describe('agent account claims', () => {
+ test('builds a prefilled handoff URL without changing the API result', () => {
+ expect(agentClaimHandoffUrl(VALID_CLAIM)).toBe(
+ 'https://editor.pascal.app/settings/agents/claim?code=BCDF-GHJK-LMNP',
+ )
+ expect(VALID_CLAIM.claimUrl).toBe('https://editor.pascal.app/settings/agents/claim')
+ })
+
+ test('starts a claim with the agent credential and returns the bounded public result', async () => {
+ let authorization: string | null = null
+ let redirect: RequestRedirect | undefined
+ const fetchMock: typeof fetch = async (_input, init) => {
+ authorization = new Headers(init?.headers).get('authorization')
+ redirect = init?.redirect
+ return Response.json({
+ ...VALID_CLAIM,
+ agent: { name: '\u001b[2Jmalicious', client: 'openclaw' },
+ message: 'server copy is not part of the CLI result',
+ })
+ }
+
+ const result = await startAgentClaim(API_KEY, { fetch: fetchMock })
+
+ expect(authorization).toBe(`Bearer ${API_KEY}`)
+ expect(redirect).toBe('error')
+ expect(result).toEqual(VALID_CLAIM)
+ })
+
+ test.each([
+ [400, 'agent_claim_not_available'],
+ [401, 'agent_claim_unauthorized'],
+ [403, 'agent_claim_forbidden'],
+ [409, 'agent_already_claimed'],
+ [429, 'agent_claim_rate_limited'],
+ [503, 'agent_claim_failed'],
+ ])('maps HTTP %i without exposing the API key or response body', async (status, code) => {
+ const fetchMock: typeof fetch = async () =>
+ new Response(`credential ${API_KEY} rejected`, {
+ headers: { 'content-type': 'text/html' },
+ status,
+ })
+
+ const error = await captureError(() => startAgentClaim(API_KEY, { fetch: fetchMock }))
+
+ expect(error.code).toBe(code)
+ expect(JSON.stringify(error)).not.toContain(API_KEY)
+ expect(error.message).not.toContain(API_KEY)
+ })
+
+ test('rejects malformed or chunked oversized responses', async () => {
+ const malformed: typeof fetch = async () => Response.json({ ...VALID_CLAIM, claimCode: '123' })
+ const unsafeDate: typeof fetch = async () =>
+ Response.json({ ...VALID_CLAIM, expiresAt: 'Wed, 10 Sep 2026 18:30:00 GMT (\u001b[2J)' })
+ const oversized: typeof fetch = async () => {
+ const encoder = new TextEncoder()
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('{"padding":"'))
+ controller.enqueue(encoder.encode('x'.repeat(33 * 1024)))
+ controller.close()
+ },
+ }),
+ )
+ }
+
+ expect((await captureError(() => startAgentClaim(API_KEY, { fetch: malformed }))).code).toBe(
+ 'agent_claim_invalid_response',
+ )
+ expect((await captureError(() => startAgentClaim(API_KEY, { fetch: unsafeDate }))).code).toBe(
+ 'agent_claim_invalid_response',
+ )
+ expect((await captureError(() => startAgentClaim(API_KEY, { fetch: oversized }))).code).toBe(
+ 'agent_claim_invalid_response',
+ )
+ })
+
+ test('reports network failures and bounded timeouts without reflecting secrets', async () => {
+ const unavailable: typeof fetch = async () => {
+ throw new Error(`failed with ${API_KEY}`)
+ }
+ const pending: typeof fetch = async (_input, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
+ })
+
+ const networkError = await captureError(() => startAgentClaim(API_KEY, { fetch: unavailable }))
+ const timeoutError = await captureError(() =>
+ startAgentClaim(API_KEY, { fetch: pending, timeoutMs: 1 }),
+ )
+
+ expect(networkError.code).toBe('agent_claim_unavailable')
+ expect(timeoutError.code).toBe('agent_claim_timeout')
+ expect(`${networkError.message}${timeoutError.message}`).not.toContain(API_KEY)
+ })
+
+ test('keeps the timeout active while reading the response body', async () => {
+ const stalled: typeof fetch = async (_input, init) =>
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ init?.signal?.addEventListener('abort', () => controller.error(new Error('aborted')), {
+ once: true,
+ })
+ },
+ }),
+ )
+
+ const error = await captureError(() =>
+ startAgentClaim(API_KEY, { fetch: stalled, timeoutMs: 1 }),
+ )
+
+ expect(error.code).toBe('agent_claim_timeout')
+ })
+
+ test('checks status with the agent credential and returns the bounded public result', async () => {
+ let endpoint = ''
+ let method = ''
+ let authorization: string | null = null
+ let redirect: RequestRedirect | undefined
+ const fetchMock: typeof fetch = async (input, init) => {
+ endpoint = String(input)
+ method = init?.method ?? ''
+ authorization = new Headers(init?.headers).get('authorization')
+ redirect = init?.redirect
+ return Response.json({
+ ...VALID_STATUS,
+ agentName: '\u001b[2Jmalicious',
+ credentialName: API_KEY,
+ })
+ }
+
+ const result = await getAgentStatus(API_KEY, { fetch: fetchMock })
+
+ expect(endpoint).toBe('https://editor.pascal.app/api/auth/agent/status')
+ expect(method).toBe('GET')
+ expect(authorization).toBe(`Bearer ${API_KEY}`)
+ expect(redirect).toBe('error')
+ expect(result).toEqual(VALID_STATUS)
+ expect(JSON.stringify(result)).not.toContain(API_KEY)
+ })
+
+ test.each([
+ [401, 'agent_status_unauthorized'],
+ [403, 'agent_status_forbidden'],
+ [503, 'agent_status_failed'],
+ ])('maps status HTTP %i without exposing the API key or response body', async (status, code) => {
+ const fetchMock: typeof fetch = async () =>
+ new Response(`credential ${API_KEY} rejected`, { status })
+
+ const error = await captureError(() => getAgentStatus(API_KEY, { fetch: fetchMock }))
+
+ expect(error.code).toBe(code)
+ expect(JSON.stringify(error)).not.toContain(API_KEY)
+ expect(error.message).not.toContain(API_KEY)
+ })
+
+ test('rejects malformed and oversized status responses', async () => {
+ const malformed: typeof fetch = async () => Response.json({ ...VALID_STATUS, claimed: 'false' })
+ const oversized: typeof fetch = async () =>
+ new Response(JSON.stringify({ ...VALID_STATUS, padding: 'x'.repeat(33 * 1024) }))
+
+ expect((await captureError(() => getAgentStatus(API_KEY, { fetch: malformed }))).code).toBe(
+ 'agent_status_invalid_response',
+ )
+ expect((await captureError(() => getAgentStatus(API_KEY, { fetch: oversized }))).code).toBe(
+ 'agent_status_invalid_response',
+ )
+ })
+
+ test('reports status network failures and bounded timeouts', async () => {
+ const unavailable: typeof fetch = async () => {
+ throw new Error(`failed with ${API_KEY}`)
+ }
+ const pending: typeof fetch = async (_input, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
+ })
+
+ expect((await captureError(() => getAgentStatus(API_KEY, { fetch: unavailable }))).code).toBe(
+ 'agent_status_unavailable',
+ )
+ expect(
+ (await captureError(() => getAgentStatus(API_KEY, { fetch: pending, timeoutMs: 1 }))).code,
+ ).toBe('agent_status_timeout')
+ })
+})
+
+async function captureError(run: () => Promise): Promise {
+ try {
+ await run()
+ throw new Error('Expected the operation to fail')
+ } catch (error) {
+ expect(error).toBeInstanceOf(CliError)
+ return error as CliError
+ }
+}
diff --git a/packages/cli/src/agent-account.ts b/packages/cli/src/agent-account.ts
new file mode 100644
index 0000000000..fbf44f603a
--- /dev/null
+++ b/packages/cli/src/agent-account.ts
@@ -0,0 +1,295 @@
+import { CliError } from './errors.js'
+
+const CLAIM_ENDPOINT = 'https://editor.pascal.app/api/auth/agent/claim/start'
+const STATUS_ENDPOINT = 'https://editor.pascal.app/api/auth/agent/status'
+const CLAIM_PAGE = 'https://editor.pascal.app/settings/agents/claim'
+const MAX_RESPONSE_BYTES = 32 * 1024
+const DEFAULT_TIMEOUT_MS = 15_000
+const CLAIM_CODE_PATTERN =
+ /^[23456789BCDFGHJKLMNPQRSTVWXZ]{4}(?:-[23456789BCDFGHJKLMNPQRSTVWXZ]{4}){2}$/
+
+export interface AgentClaim {
+ claimCode: string
+ claimUrl: string
+ expiresAt: string
+}
+
+export interface AgentStatus {
+ schemaVersion: 1
+ agentId: string
+ mode: 'autonomous' | 'delegated'
+ claimed: boolean
+ organizationScoped: boolean
+}
+
+export function agentClaimHandoffUrl(claim: AgentClaim): string {
+ const url = new URL(claim.claimUrl)
+ url.searchParams.set('code', claim.claimCode)
+ return url.toString()
+}
+
+interface AgentAccountRequestOptions {
+ fetch?: typeof fetch
+ timeoutMs?: number
+}
+
+export async function startAgentClaim(
+ apiKey: string,
+ options: AgentAccountRequestOptions = {},
+): Promise {
+ const credential = apiKey.trim()
+ if (!credential) {
+ throw new CliError(
+ 'agent_api_key_missing',
+ "Set PASCAL_API_KEY to this autonomous agent's API key and try again.",
+ )
+ }
+
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
+ try {
+ let response: Response
+ try {
+ response = await (options.fetch ?? fetch)(CLAIM_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${credential}`,
+ },
+ redirect: 'error',
+ signal: controller.signal,
+ })
+ } catch {
+ if (controller.signal.aborted) {
+ throw claimTimeout()
+ }
+ throw new CliError(
+ 'agent_claim_unavailable',
+ 'Pascal could not be reached while starting the agent claim. Try again.',
+ )
+ }
+
+ if (!response.ok) {
+ if (response.body) void response.body.cancel().catch(() => {})
+ throw claimResponseError(response.status)
+ }
+ const body = await readJsonResponse(response, controller.signal, invalidResponse, claimTimeout)
+ if (!isAgentClaim(body)) throw invalidResponse()
+ return {
+ claimCode: body.claimCode,
+ claimUrl: body.claimUrl,
+ expiresAt: body.expiresAt,
+ }
+ } finally {
+ clearTimeout(timeout)
+ }
+}
+
+export async function getAgentStatus(
+ apiKey: string,
+ options: AgentAccountRequestOptions = {},
+): Promise {
+ const credential = apiKey.trim()
+ if (!credential) {
+ throw new CliError(
+ 'agent_api_key_missing',
+ "Set PASCAL_API_KEY to this agent's API key and try again.",
+ )
+ }
+
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
+ try {
+ let response: Response
+ try {
+ response = await (options.fetch ?? fetch)(STATUS_ENDPOINT, {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${credential}`,
+ },
+ redirect: 'error',
+ signal: controller.signal,
+ })
+ } catch {
+ if (controller.signal.aborted) throw statusTimeout()
+ throw new CliError(
+ 'agent_status_unavailable',
+ 'Pascal could not be reached while checking the agent status. Try again.',
+ )
+ }
+
+ if (!response.ok) {
+ if (response.body) void response.body.cancel().catch(() => {})
+ throw statusResponseError(response.status)
+ }
+ const body = await readJsonResponse(
+ response,
+ controller.signal,
+ invalidStatusResponse,
+ statusTimeout,
+ )
+ if (!isAgentStatus(body)) throw invalidStatusResponse()
+ return {
+ schemaVersion: 1,
+ agentId: body.agentId,
+ mode: body.mode,
+ claimed: body.claimed,
+ organizationScoped: body.organizationScoped,
+ }
+ } finally {
+ clearTimeout(timeout)
+ }
+}
+
+async function readJsonResponse(
+ response: Response,
+ signal: AbortSignal,
+ invalid: () => CliError,
+ timeout: () => CliError,
+): Promise {
+ const declaredLength = Number(response.headers.get('content-length'))
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
+ throw invalid()
+ }
+
+ if (!response.body) throw invalid()
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let bytes = 0
+ let text = ''
+ while (true) {
+ let chunk
+ try {
+ chunk = await reader.read()
+ } catch {
+ if (signal.aborted) throw timeout()
+ throw invalid()
+ }
+ if (chunk.done) break
+ bytes += chunk.value.byteLength
+ if (bytes > MAX_RESPONSE_BYTES) {
+ void reader.cancel().catch(() => {})
+ throw invalid()
+ }
+ text += decoder.decode(chunk.value, { stream: true })
+ }
+ text += decoder.decode()
+
+ try {
+ return JSON.parse(text) as unknown
+ } catch {
+ throw invalid()
+ }
+}
+
+function isAgentClaim(value: unknown): value is AgentClaim {
+ if (!isRecord(value)) return false
+ if (typeof value.claimCode !== 'string' || !CLAIM_CODE_PATTERN.test(value.claimCode)) return false
+ if (typeof value.expiresAt !== 'string') return false
+ const expiresAt = Date.parse(value.expiresAt)
+ if (Number.isNaN(expiresAt) || new Date(expiresAt).toISOString() !== value.expiresAt) return false
+ return value.claimUrl === CLAIM_PAGE
+}
+
+function isAgentStatus(value: unknown): value is AgentStatus {
+ return (
+ isRecord(value) &&
+ value.schemaVersion === 1 &&
+ typeof value.agentId === 'string' &&
+ value.agentId.length > 0 &&
+ (value.mode === 'autonomous' || value.mode === 'delegated') &&
+ typeof value.claimed === 'boolean' &&
+ typeof value.organizationScoped === 'boolean'
+ )
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function claimResponseError(status: number): CliError {
+ switch (status) {
+ case 400:
+ return new CliError(
+ 'agent_claim_not_available',
+ 'This agent credential cannot start a claim.',
+ { status },
+ )
+ case 401:
+ return new CliError('agent_claim_unauthorized', 'PASCAL_API_KEY is invalid or revoked.', {
+ status,
+ })
+ case 403:
+ return new CliError(
+ 'agent_claim_forbidden',
+ 'PASCAL_API_KEY must belong to an autonomous Pascal agent.',
+ { status },
+ )
+ case 409:
+ return new CliError('agent_already_claimed', 'This agent has already been claimed.', {
+ status,
+ })
+ case 429:
+ return new CliError(
+ 'agent_claim_rate_limited',
+ 'Too many agent claim attempts. Wait and try again.',
+ { status },
+ )
+ default:
+ return new CliError(
+ 'agent_claim_failed',
+ `Pascal could not start the agent claim (HTTP ${status}).`,
+ { status },
+ )
+ }
+}
+
+function statusResponseError(status: number): CliError {
+ switch (status) {
+ case 401:
+ return new CliError('agent_status_unauthorized', 'PASCAL_API_KEY is invalid or revoked.', {
+ status,
+ })
+ case 403:
+ return new CliError(
+ 'agent_status_forbidden',
+ 'PASCAL_API_KEY must belong to a Pascal agent.',
+ { status },
+ )
+ default:
+ return new CliError(
+ 'agent_status_failed',
+ `Pascal could not check the agent status (HTTP ${status}).`,
+ { status },
+ )
+ }
+}
+
+function invalidResponse(): CliError {
+ return new CliError(
+ 'agent_claim_invalid_response',
+ 'Pascal returned an invalid agent claim response. Try again.',
+ )
+}
+
+function invalidStatusResponse(): CliError {
+ return new CliError(
+ 'agent_status_invalid_response',
+ 'Pascal returned an invalid agent status response. Try again.',
+ )
+}
+
+function claimTimeout(): CliError {
+ return new CliError(
+ 'agent_claim_timeout',
+ 'Pascal did not respond while starting the agent claim. Try again.',
+ )
+}
+
+function statusTimeout(): CliError {
+ return new CliError(
+ 'agent_status_timeout',
+ 'Pascal did not respond while checking the agent status. Try again.',
+ )
+}
diff --git a/packages/cli/src/bin/pascal.ts b/packages/cli/src/bin/pascal.ts
new file mode 100755
index 0000000000..9cf8e4dbfe
--- /dev/null
+++ b/packages/cli/src/bin/pascal.ts
@@ -0,0 +1,892 @@
+#!/usr/bin/env node
+import { spawn } from 'node:child_process'
+import { parseArgs } from 'node:util'
+import { agentClaimHandoffUrl, getAgentStatus, startAgentClaim } from '../agent-account.js'
+import { openBrowser } from '../browser.js'
+import { installGlobalPascalCommand, isNpxInvocation } from '../command-install.js'
+import { collectInfo, runDoctor } from '../diagnostics.js'
+import {
+ activateEditorRuntime,
+ type EditorStartProgress,
+ followLog,
+ getEditorStatus,
+ readLogTail,
+ restartEditor,
+ startEditor,
+ stopEditor,
+} from '../editor-process.js'
+import { CliError, toCliError } from '../errors.js'
+import { readJsonFile } from '../json-files.js'
+import { connectManagedMcp } from '../mcp-connector.js'
+import { getMcpServiceStatus } from '../mcp-service.js'
+import { resolvePascalPaths } from '../paths.js'
+import { listLocalProjects, projectUrl, resolveLocalProject } from '../projects.js'
+import { ensureWebRuntime } from '../runtime-download.js'
+import { TerminalProgress } from '../terminal-progress.js'
+import { version } from '../version.js'
+
+const HELP = `Pascal — local 3D editor
+
+FIRST RUN:
+ npx @pascal-app/cli editor
+ Starts the editor and installs the shorter "pascal" command interactively.
+
+RUN A COMMAND THROUGH NPX:
+ npx @pascal-app/cli
+
+ENABLE THE SHORT GLOBAL COMMAND:
+ npm install --global @pascal-app/cli
+ pascal
+
+USAGE:
+ pascal editor [--foreground] [--no-open] [--port ] [--runtime ]
+ pascal start [--foreground] [--port ] [--runtime ]
+ pascal stop | restart | status
+ pascal open [project]
+ pascal resume [project]
+ pascal projects [--json]
+ pascal logs [--follow] [--lines ]
+ pascal update [--version ]
+ pascal doctor [--json]
+ pascal info [--json]
+ pascal project list [--json]
+ pascal project open
+ pascal project resume [id-or-name]
+ pascal agent claim [--no-open] [--json]
+ pascal agent status [--json]
+ pascal mcp connect | status | config | setup
+ pascal plugin list [--json]
+
+THE WEB EDITOR RUNTIME:
+ The npm package holds the CLI and the MCP service. The web editor runtime is
+ downloaded once per version into ~/.pascal/runtime the first time a command
+ starts the editor, and verified against a digest published with this CLI.
+ Offline: pass --runtime . "pascal mcp connect" needs no
+ download at all.
+
+Documentation: https://editor.pascal.app/docs/developers/local-editor
+`
+
+const MCP_HELP = `Pascal MCP — connect AI agents to local projects
+
+The authenticated MCP service ships inside this package. It starts on demand and
+needs neither the web editor nor its downloaded runtime, so agents can read and
+write local projects on a machine that never runs the editor.
+
+USAGE:
+ pascal mcp status [--json] Check the managed MCP service
+ pascal mcp setup codex Configure Codex CLI
+ pascal mcp setup claude Configure Claude Code
+ pascal mcp config [--json] Print generic MCP client JSON
+ pascal mcp connect Start the stdio client connector
+
+MCP clients should run "pascal mcp connect"; the connector discovers the
+dynamic loopback port without exposing Pascal's private local token.
+
+Documentation: https://editor.pascal.app/docs/developers/mcp
+`
+
+const AGENT_HELP = `Pascal agent — connect an autonomous agent to a person
+
+USAGE:
+ pascal agent claim [--no-open] [--json]
+ pascal agent status [--json]
+
+Set PASCAL_API_KEY to the autonomous agent's hosted Pascal API key. The CLI
+uses it once to request a 15-minute claim code and never stores it. It opens
+the claim page unless --no-open or --json is set.
+
+Use "pascal agent status" to verify whether that credential is active and
+whether its autonomous agent has been claimed.
+
+Claiming records who is accountable for the agent and lifts claim-gated
+capabilities. It does not transfer project ownership or grant access to either
+account's private projects.
+
+Documentation: https://editor.pascal.app/docs/developers/mcp
+`
+
+const paths = resolvePascalPaths()
+const agentApiKey = process.env.PASCAL_API_KEY
+Reflect.deleteProperty(process.env, 'PASCAL_API_KEY')
+
+async function main(): Promise {
+ const [command = 'help', ...args] = process.argv.slice(2)
+ if (command === '--version' || command === '-v') return print(version)
+ if (command === '--help' || command === '-h' || command === 'help') return print(HELP)
+ if (args.includes('--help') || args.includes('-h')) {
+ return print(command === 'mcp' ? MCP_HELP : command === 'agent' ? AGENT_HELP : HELP)
+ }
+
+ switch (command) {
+ case 'editor':
+ return runStart(args, true)
+ case 'start':
+ return runStart(args, false)
+ case 'stop':
+ return runStop(args)
+ case 'restart':
+ return runRestart(args)
+ case 'status':
+ return runStatus(args)
+ case 'open':
+ return runOpen(args)
+ case 'resume':
+ return runProjectOpen(args, true)
+ case 'projects':
+ return runProject(['list', ...args])
+ case 'logs':
+ return runLogs(args)
+ case 'doctor':
+ return runDoctorCommand(args)
+ case 'info':
+ return runInfo(args)
+ case 'update':
+ return runUpdate(args)
+ case 'project':
+ return runProject(args)
+ case 'agent':
+ return runAgent(args, agentApiKey)
+ case 'plugin':
+ return runPlugin(args)
+ case 'mcp':
+ return runMcp(args)
+ case '_install-runtime':
+ return output(true, (await ensureWebRuntime({ paths, activate: false })).runtime, '')
+ default:
+ throw new CliError('unknown_command', `Unknown command: ${command}`, { command }, 2)
+ }
+}
+
+async function runStart(args: string[], shouldOpen: boolean): Promise {
+ const { values } = parseArgs({
+ args,
+ strict: true,
+ options: {
+ foreground: { type: 'boolean', default: false },
+ open: { type: 'boolean', default: shouldOpen },
+ 'no-open': { type: 'boolean', default: false },
+ port: { type: 'string' },
+ runtime: { type: 'string' },
+ json: { type: 'boolean', default: false },
+ help: { type: 'boolean', short: 'h', default: false },
+ },
+ })
+ if (values.help) return print(HELP)
+ const port = parseIntegerOption(values.port, 'port')
+ const progress = values.json ? undefined : new TerminalProgress()
+ progress?.start('Preparing your local Pascal editor')
+ let result: Awaited>
+ try {
+ result = await startEditor({
+ paths,
+ port,
+ foreground: values.foreground,
+ runtimeSource: values.runtime,
+ onProgress: progress ? createStartProgressReporter(progress) : undefined,
+ })
+ } catch (error) {
+ progress?.stop()
+ throw error
+ }
+ progress?.stop()
+ if (values.open && !values['no-open']) openBrowser(result.state.url)
+ const npxInvocation = isNpxInvocation()
+ let commandInstalled = false
+ if (npxInvocation && !values.json && process.stdin.isTTY && process.stderr.isTTY) {
+ progress?.start('Installing the pascal command')
+ commandInstalled = await installGlobalPascalCommand(version)
+ if (commandInstalled) {
+ progress?.succeed('pascal command installed')
+ } else {
+ progress?.stop()
+ process.stderr.write(
+ '! The editor is ready, but npm could not install the pascal command globally.\n',
+ )
+ }
+ }
+ const useShortCommand = !npxInvocation || commandInstalled
+ const commandPrefix = useShortCommand ? 'pascal' : 'npx @pascal-app/cli'
+ output(
+ values.json,
+ { ...result.state, mcp: result.mcp, alreadyRunning: result.alreadyRunning },
+ [
+ result.alreadyRunning
+ ? `Pascal is already running at ${result.state.url}`
+ : `Pascal is ready at ${result.state.url}`,
+ `MCP is ready on port ${result.mcp.port}`,
+ `Projects stay in ${paths.data}`,
+ '',
+ `Manage it with ${useShortCommand ? 'pascal' : 'npx'}:`,
+ ` ${commandPrefix} status Check the local editor`,
+ ` ${commandPrefix} projects List local projects`,
+ ` ${commandPrefix} resume Resume your latest project`,
+ ` ${commandPrefix} logs --follow Follow editor logs`,
+ ` ${commandPrefix} stop Stop the background process`,
+ ...(useShortCommand
+ ? ['', 'Connect an AI agent:', ` ${commandPrefix} mcp setup codex`]
+ : []),
+ ...(useShortCommand
+ ? []
+ : [
+ '',
+ 'To install the shorter "pascal" command:',
+ ' npm install --global @pascal-app/cli',
+ ]),
+ ].join('\n'),
+ )
+ if (result.child) {
+ const exitCode = await new Promise((resolve) =>
+ result.child?.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))),
+ )
+ await stopEditor(paths, { force: true }).catch(() => undefined)
+ process.exitCode = exitCode
+ }
+}
+
+/**
+ * Download progress arrives far more often than a non-TTY log should print, so percentages
+ * are reported per whole percent on a terminal and per tenth otherwise.
+ */
+function createStartProgressReporter(
+ progress: TerminalProgress,
+): (event: EditorStartProgress) => void {
+ const perPercent = Boolean(process.stderr.isTTY)
+ let lastReportedStep = -1
+ return (event) => {
+ if (event.step !== 'runtime-downloading') return reportStartProgress(progress, event)
+ if (event.received === 0) {
+ lastReportedStep = -1
+ progress.start(`Downloading the editor runtime from ${event.url}`)
+ return
+ }
+ const percent = event.total
+ ? Math.min(100, Math.floor((event.received / event.total) * 100))
+ : 0
+ const step = perPercent ? percent : Math.floor(percent / 10)
+ if (step === lastReportedStep) return
+ lastReportedStep = step
+ progress.update(
+ event.total
+ ? `Downloading the editor runtime ${percent}% (${formatMegabytes(event.received)} of ${formatMegabytes(event.total)})`
+ : `Downloading the editor runtime (${formatMegabytes(event.received)})`,
+ )
+ }
+}
+
+function formatMegabytes(bytes: number): string {
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+}
+
+function reportStartProgress(progress: TerminalProgress, event: EditorStartProgress): void {
+ switch (event.step) {
+ case 'storage-ready':
+ progress.succeed(`Local data directory ready at ${event.dataDirectory}`)
+ return
+ case 'runtime-downloading':
+ progress.update('Downloading the editor runtime')
+ return
+ case 'runtime-verifying':
+ progress.update('Verifying the editor runtime digest')
+ return
+ case 'runtime-extracting':
+ progress.update('Extracting the editor runtime')
+ return
+ case 'runtime-installing':
+ progress.start('Installing the editor runtime')
+ return
+ case 'runtime-ready':
+ progress.succeed(
+ event.installed
+ ? `Editor runtime ${event.version} installed`
+ : `Editor runtime ${event.version} ready`,
+ )
+ return
+ case 'port-ready':
+ progress.succeed(
+ event.preferredPort === 0
+ ? `Local port ${event.port} selected automatically`
+ : event.port === event.preferredPort
+ ? `Local port ${event.port} is available`
+ : `Port ${event.preferredPort} is busy; using ${event.port} instead`,
+ )
+ return
+ case 'process-starting':
+ progress.start(`Starting Pascal on port ${event.port}`)
+ return
+ case 'health-checking':
+ progress.update('Checking that the editor is ready')
+ return
+ case 'mcp-port-ready':
+ progress.succeed(`MCP port ${event.port} selected automatically`)
+ return
+ case 'mcp-starting':
+ progress.start('Starting Pascal MCP')
+ return
+ case 'mcp-health-checking':
+ progress.update('Checking that MCP is ready')
+ return
+ case 'mcp-ready':
+ progress.succeed(`MCP is ready on port ${event.port}`)
+ return
+ case 'mcp-already-running':
+ progress.succeed(`MCP is already running on port ${event.port}`)
+ return
+ case 'ready':
+ progress.succeed('Pascal Editor and MCP are ready')
+ return
+ case 'already-running':
+ progress.succeed(`Pascal is already running on port ${event.port}`)
+ }
+}
+
+async function runStop(args: string[]): Promise {
+ const { values } = parseArgs({
+ args,
+ strict: true,
+ options: {
+ force: { type: 'boolean', default: false },
+ json: { type: 'boolean', default: false },
+ },
+ })
+ const stopped = await stopEditor(paths, { force: values.force })
+ output(values.json, { stopped }, stopped ? 'Pascal stopped.' : 'Pascal is not running.')
+}
+
+async function runRestart(args: string[]): Promise {
+ const json = booleanOption(args, 'json')
+ const result = await restartEditor(paths)
+ output(json, result.state, `Pascal restarted at ${result.state.url}`)
+}
+
+async function runStatus(args: string[]): Promise {
+ const json = booleanOption(args, 'json')
+ const [status, mcp] = await Promise.all([getEditorStatus(paths), getMcpServiceStatus(paths)])
+ output(
+ json,
+ { ...status, mcp },
+ status.healthy
+ ? [
+ `Pascal ${status.state?.version} is running at ${status.state?.url}`,
+ mcp.healthy ? `MCP is ready on port ${mcp.state?.port}` : 'MCP is stopped.',
+ ].join('\n')
+ : status.running
+ ? 'Pascal has a running but unhealthy process.'
+ : status.installed
+ ? `Pascal ${status.runtime?.version} is installed and stopped.`
+ : 'The Pascal web runtime is not installed yet.',
+ )
+ if (status.running && !status.healthy) process.exitCode = 1
+}
+
+async function runOpen(args: string[]): Promise {
+ const { values, positionals } = parseArgs({
+ args,
+ strict: true,
+ allowPositionals: true,
+ options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } },
+ })
+ if (positionals.length > 1) {
+ throw new CliError('invalid_option', 'Use "pascal open [project]".', undefined, 2)
+ }
+ if (positionals[0]) return runProjectOpen(args, false)
+ const status = await ensureRunningEditor(values.runtime)
+ openBrowser(status.state.url)
+ output(values.json, { url: status.state.url }, status.state.url)
+}
+
+async function runLogs(args: string[]): Promise {
+ const { values } = parseArgs({
+ args,
+ strict: true,
+ options: {
+ follow: { type: 'boolean', short: 'f', default: false },
+ lines: { type: 'string', default: '100' },
+ },
+ })
+ const lines = parseIntegerOption(values.lines ?? '100', 'lines')
+ if (lines === undefined || lines < 1) {
+ throw new CliError('invalid_option', '--lines must be a positive integer.', undefined, 2)
+ }
+ print(await readLogTail(paths.editorLog, lines))
+ if (values.follow) await followLog(paths.editorLog)
+}
+
+async function runDoctorCommand(args: string[]): Promise {
+ const json = booleanOption(args, 'json')
+ const checks = await runDoctor(paths)
+ output(
+ json,
+ { checks },
+ checks
+ .map(
+ (check) =>
+ `${check.status === 'pass' ? '✓' : check.status === 'warn' ? '!' : '✗'} ${check.message}`,
+ )
+ .join('\n'),
+ )
+ if (checks.some((check) => check.status === 'fail')) process.exitCode = 1
+}
+
+async function runInfo(args: string[]): Promise {
+ const json = booleanOption(args, 'json')
+ const info = await collectInfo(paths)
+ output(
+ json,
+ info,
+ [
+ `CLI: ${version}`,
+ `Node: ${info.cli.node}`,
+ `Home: ${paths.root}`,
+ `Web runtime: ${info.editor.runtime?.version ?? 'not installed'}`,
+ `Editor: ${info.editor.healthy ? info.editor.state?.url : 'stopped'}`,
+ `MCP: ${info.mcp.healthy ? `ready on port ${info.mcp.state?.port}` : 'stopped'}`,
+ `Plugins: ${info.plugins.length}`,
+ ].join('\n'),
+ )
+}
+
+async function runUpdate(args: string[]): Promise {
+ const { values } = parseArgs({
+ args,
+ strict: true,
+ options: {
+ version: { type: 'string' },
+ runtime: { type: 'string' },
+ json: { type: 'boolean', default: false },
+ },
+ })
+ const target = values.version ?? 'latest'
+ if (!isAllowedUpdateVersion(target)) {
+ throw new CliError(
+ 'invalid_version',
+ '--version must be an exact semantic version or the "latest" tag.',
+ undefined,
+ 2,
+ )
+ }
+ let candidate
+ if (target === version) {
+ candidate = (await ensureWebRuntime({ paths, runtimeSource: values.runtime, activate: false }))
+ .runtime
+ } else {
+ const spec = `@pascal-app/cli@${target}`
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
+ if (!values.json) print(`Installing ${spec}...`)
+ let result: Awaited>
+ try {
+ result = await spawnAndCapture(
+ npm,
+ [
+ 'exec',
+ '--yes',
+ '--ignore-scripts',
+ `--package=${spec}`,
+ '--',
+ 'pascal',
+ '_install-runtime',
+ ],
+ !values.json,
+ )
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ throw new CliError(
+ 'npm_unavailable',
+ 'npm is required to install another Pascal runtime. Install Node.js with npm and try again.',
+ )
+ }
+ throw error
+ }
+ if (result.exitCode !== 0) {
+ throw new CliError('update_failed', `Unable to install ${spec}.`, {
+ stderr: result.stderr.trim() || undefined,
+ })
+ }
+ try {
+ candidate = JSON.parse(result.stdout) as {
+ schemaVersion: 1
+ version: string
+ directory: string
+ }
+ } catch {
+ throw new CliError('update_failed', `The installer for ${spec} returned invalid output.`)
+ }
+ }
+ const activation = await activateEditorRuntime(paths, candidate)
+ output(
+ values.json,
+ activation,
+ `Pascal runtime ${activation.runtime.version} is active${activation.restarted ? ' and the editor was restarted' : ''}.`,
+ )
+}
+
+async function runProject(args: string[]): Promise {
+ const [subcommand, ...rest] = args
+ if (subcommand === 'list') {
+ const { values } = parseArgs({
+ args: rest,
+ strict: true,
+ options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } },
+ })
+ const status = await ensureRunningEditor(values.runtime)
+ const projects = await listLocalProjects(status.state)
+ output(
+ values.json,
+ { projects },
+ projects.length
+ ? projects
+ .map(
+ (project) =>
+ `${project.id}\t${project.name}\t${new Date(project.updatedAt).toLocaleString()}`,
+ )
+ .join('\n')
+ : 'No projects yet.',
+ )
+ return
+ }
+ if (subcommand === 'open') {
+ return runProjectOpen(rest, false)
+ }
+ if (subcommand === 'resume') {
+ return runProjectOpen(rest, true)
+ }
+ throw new CliError(
+ 'unknown_command',
+ 'Use "pascal project list", "pascal project open ", or "pascal project resume".',
+ undefined,
+ 2,
+ )
+}
+
+async function runProjectOpen(args: string[], latestWhenMissing: boolean): Promise {
+ const { values, positionals } = parseArgs({
+ args,
+ strict: true,
+ allowPositionals: true,
+ options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } },
+ })
+ if (positionals.length > 1 || (!latestWhenMissing && positionals.length !== 1)) {
+ throw new CliError(
+ 'invalid_option',
+ latestWhenMissing ? 'Use "pascal resume [project]".' : 'Use "pascal open ".',
+ undefined,
+ 2,
+ )
+ }
+ const status = await ensureRunningEditor(values.runtime)
+ const projects = await listLocalProjects(status.state)
+ const project = resolveLocalProject(projects, positionals[0])
+ const url = projectUrl(status.state, project)
+ openBrowser(url)
+ output(values.json, { project, url }, `${project.name}\n${url}`)
+}
+
+async function runMcp(args: string[]): Promise {
+ const [subcommand, ...rest] = args
+ if (subcommand === 'connect') {
+ if (rest.length > 0) {
+ throw new CliError('invalid_option', 'Use "pascal mcp connect".', undefined, 2)
+ }
+ await connectManagedMcp(paths)
+ return
+ }
+ if (subcommand === 'status') {
+ const json = booleanOption(rest, 'json')
+ const status = await getMcpServiceStatus(paths)
+ const result = {
+ running: status.running,
+ healthy: status.healthy,
+ port: status.state?.port ?? null,
+ }
+ output(
+ json,
+ result,
+ result.healthy
+ ? `Pascal MCP is ready on port ${result.port}.`
+ : result.running
+ ? 'Pascal MCP is running but unhealthy.'
+ : 'Pascal MCP is stopped. It starts when an MCP client runs "pascal mcp connect".',
+ )
+ if (result.running && !result.healthy) process.exitCode = 1
+ return
+ }
+ if (subcommand === 'config') {
+ const json = booleanOption(rest, 'json')
+ const config = { command: 'pascal', args: ['mcp', 'connect'] }
+ const document = { mcpServers: { pascal: config } }
+ output(json, document, JSON.stringify(document, null, 2))
+ return
+ }
+ if (subcommand === 'setup') {
+ const { values, positionals } = parseArgs({
+ args: rest,
+ strict: true,
+ allowPositionals: true,
+ options: { json: { type: 'boolean', default: false } },
+ })
+ const client = positionals[0]
+ if (positionals.length !== 1 || (client !== 'codex' && client !== 'claude')) {
+ throw new CliError(
+ 'invalid_option',
+ 'Use "pascal mcp setup codex" or "pascal mcp setup claude".',
+ undefined,
+ 2,
+ )
+ }
+ await ensureShortCommandAvailable()
+ const command = client === 'codex' ? 'codex' : 'claude'
+ const commandArgs =
+ client === 'codex'
+ ? ['mcp', 'add', 'pascal', '--', 'pascal', 'mcp', 'connect']
+ : ['mcp', 'add', '--scope', 'user', 'pascal', '--', 'pascal', 'mcp', 'connect']
+ let result: Awaited>
+ try {
+ result = await spawnAndCapture(command, commandArgs)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ throw new CliError(
+ 'mcp_client_unavailable',
+ `${client === 'codex' ? 'Codex' : 'Claude Code'} is not installed or is not on PATH.`,
+ )
+ }
+ throw error
+ }
+ if (result.exitCode !== 0) {
+ throw new CliError(
+ 'mcp_setup_failed',
+ `Unable to configure ${client}. It may already have a Pascal MCP entry.`,
+ { stderr: result.stderr.trim() || undefined, stdout: result.stdout.trim() || undefined },
+ )
+ }
+ output(
+ values.json,
+ { client, configured: true, command: 'pascal', args: ['mcp', 'connect'] },
+ `${client === 'codex' ? 'Codex' : 'Claude Code'} now uses the managed Pascal MCP service. Start a new agent session to connect.`,
+ )
+ return
+ }
+ throw new CliError(
+ 'unknown_command',
+ 'Use "pascal mcp connect", "pascal mcp status", "pascal mcp config", or "pascal mcp setup ".',
+ undefined,
+ 2,
+ )
+}
+
+async function runAgent(args: string[], apiKey: string | undefined): Promise {
+ const [subcommand, ...rest] = args
+ if (subcommand === 'status') {
+ const json = booleanOption(rest, 'json')
+ const status = await getAgentStatus(apiKey ?? '')
+ output(
+ json,
+ status,
+ [
+ `Agent ID: ${JSON.stringify(status.agentId)}`,
+ `Mode: ${status.mode}`,
+ `Claimed: ${status.claimed ? 'yes' : 'no'}`,
+ `Organization scoped: ${status.organizationScoped ? 'yes' : 'no'}`,
+ ...(!status.claimed && status.mode === 'autonomous'
+ ? ['', 'Next: run "pascal agent claim" to link a person accountable for this agent.']
+ : []),
+ ].join('\n'),
+ )
+ return
+ }
+ if (subcommand !== 'claim') {
+ throw new CliError(
+ 'unknown_command',
+ 'Use "pascal agent claim" or "pascal agent status".',
+ undefined,
+ 2,
+ )
+ }
+ const { values } = parseArgs({
+ args: rest,
+ strict: true,
+ options: {
+ json: { type: 'boolean', default: false },
+ 'no-open': { type: 'boolean', default: false },
+ },
+ })
+ const claim = await startAgentClaim(apiKey ?? '')
+ const claimHandoffUrl = agentClaimHandoffUrl(claim)
+ if (!values['no-open'] && !values.json) openBrowser(claimHandoffUrl)
+ output(
+ values.json,
+ claim,
+ [
+ `Claim code: ${claim.claimCode}`,
+ `Claim page: ${claimHandoffUrl}`,
+ `Expires: ${claim.expiresAt}`,
+ '',
+ 'Claiming links accountability. It does not transfer project ownership or grant access to private projects.',
+ ].join('\n'),
+ )
+}
+
+async function runPlugin(args: string[]): Promise {
+ const [subcommand, ...rest] = args
+ if (subcommand === 'list') {
+ const json = booleanOption(rest, 'json')
+ const storedLock = await readJsonFile<{ schemaVersion?: unknown; plugins?: unknown }>(
+ paths.pluginLock,
+ )
+ if (storedLock && (storedLock.schemaVersion !== 1 || !Array.isArray(storedLock.plugins))) {
+ throw new CliError('invalid_plugin_state', 'The managed plugin lock is invalid.')
+ }
+ const lock = {
+ schemaVersion: 1 as const,
+ plugins: storedLock ? (storedLock.plugins as unknown[]) : [],
+ }
+ output(
+ json,
+ lock,
+ lock.plugins.length ? JSON.stringify(lock.plugins, null, 2) : 'No plugins installed.',
+ )
+ return
+ }
+ throw new CliError(
+ 'plugin_command_unavailable',
+ 'Plugin installation is not enabled in this CLI release yet. Use "pascal plugin list".',
+ undefined,
+ 2,
+ )
+}
+
+async function ensureRunningEditor(runtimeSource?: string) {
+ const status = await getEditorStatus(paths)
+ if (status.healthy && status.state) return { ...status, state: status.state }
+ const progress = process.stderr.isTTY ? new TerminalProgress() : undefined
+ let started: Awaited>
+ try {
+ started = await startEditor({
+ paths,
+ runtimeSource,
+ onProgress: progress ? createStartProgressReporter(progress) : undefined,
+ })
+ } finally {
+ progress?.stop()
+ }
+ return {
+ ...(await getEditorStatus(paths)),
+ state: started.state,
+ }
+}
+
+function booleanOption(args: string[], name: string): boolean {
+ const { values } = parseArgs({
+ args,
+ strict: true,
+ options: { [name]: { type: 'boolean', default: false } },
+ })
+ return Boolean(values[name])
+}
+
+function parseIntegerOption(value: string | undefined, name: string): number | undefined {
+ if (value === undefined) return undefined
+ if (!/^\d+$/.test(value)) {
+ throw new CliError('invalid_option', `--${name} must be an integer.`, undefined, 2)
+ }
+ const parsed = Number(value)
+ if (!Number.isSafeInteger(parsed)) {
+ throw new CliError('invalid_option', `--${name} is outside the supported range.`, undefined, 2)
+ }
+ return parsed
+}
+
+function output(json: boolean | undefined, value: unknown, human: string): void {
+ print(json ? JSON.stringify(value, null, 2) : human)
+}
+
+function print(value: string): void {
+ process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
+}
+
+async function ensureShortCommandAvailable(): Promise {
+ try {
+ const result = await spawnAndCapture('pascal', ['--version'])
+ if (result.exitCode === 0 && result.stdout === version) return
+ } catch {}
+ throw new CliError(
+ 'pascal_command_unavailable',
+ `The matching Pascal CLI ${version} is required in MCP client configuration. Run "npm install --global @pascal-app/cli@${version}" and try again.`,
+ )
+}
+
+async function spawnAndCapture(
+ command: string,
+ args: string[],
+ streamStderr = false,
+): Promise<{ exitCode: number; stdout: string; stderr: string }> {
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
+ const stdout: Buffer[] = []
+ const stderr: Buffer[] = []
+ let capturedBytes = 0
+ let captureError: CliError | undefined
+ let forceKill: ReturnType | undefined
+ const terminateInstaller = (error: CliError) => {
+ captureError ??= error
+ child.kill('SIGTERM')
+ forceKill ??= setTimeout(() => child.kill('SIGKILL'), 5_000)
+ }
+ const capture = (target: Buffer[]) => (chunk: Buffer) => {
+ capturedBytes += chunk.byteLength
+ if (capturedBytes > 4 * 1024 * 1024) {
+ terminateInstaller(
+ new CliError('update_failed', 'The package installer produced more than 4 MiB of output.'),
+ )
+ return
+ }
+ target.push(chunk)
+ }
+ const captureStdout = capture(stdout)
+ const captureStderr = capture(stderr)
+ child.stdout?.on('data', captureStdout)
+ child.stderr?.on('data', (chunk: Buffer) => {
+ if (streamStderr) process.stderr.write(chunk)
+ captureStderr(chunk)
+ })
+ const exitCode = await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ terminateInstaller(
+ new CliError('update_timeout', 'The package installer did not finish within 10 minutes.'),
+ )
+ }, 10 * 60_000)
+ child.once('error', (error) => {
+ clearTimeout(timeout)
+ if (forceKill) clearTimeout(forceKill)
+ reject(error)
+ })
+ child.once('exit', (code) => {
+ clearTimeout(timeout)
+ if (forceKill) clearTimeout(forceKill)
+ captureError ? reject(captureError) : resolve(code ?? 1)
+ })
+ })
+ return {
+ exitCode,
+ stdout: Buffer.concat(stdout).toString('utf8').trim(),
+ stderr: Buffer.concat(stderr).toString('utf8'),
+ }
+}
+
+function isAllowedUpdateVersion(value: string): boolean {
+ return (
+ value === 'latest' ||
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(value)
+ )
+}
+
+main().catch((error) => {
+ const cliError = toCliError(error)
+ const wantsJson = process.argv.includes('--json')
+ if (wantsJson) {
+ process.stderr.write(
+ `${JSON.stringify({ error: cliError.code, message: cliError.message, details: cliError.details })}\n`,
+ )
+ } else {
+ process.stderr.write(`Error: ${cliError.message}\n`)
+ }
+ process.exitCode = cliError.exitCode
+})
diff --git a/packages/cli/src/browser.test.ts b/packages/cli/src/browser.test.ts
new file mode 100644
index 0000000000..fee703b616
--- /dev/null
+++ b/packages/cli/src/browser.test.ts
@@ -0,0 +1,44 @@
+import { afterEach, describe, expect, mock, test } from 'bun:test'
+import type { spawn } from 'node:child_process'
+import { EventEmitter } from 'node:events'
+import { openBrowser } from './browser.js'
+
+const spawnMock = mock(() => {
+ const child = new EventEmitter() as EventEmitter & { unref: () => void }
+ child.unref = mock(() => {})
+ return child
+}) as unknown as typeof spawn
+
+afterEach(() => spawnMock.mockClear())
+
+describe('browser launch', () => {
+ test('removes the Pascal API key from the spawned process environment', () => {
+ openBrowser(
+ 'https://editor.pascal.app/settings/agents/claim',
+ {
+ HOME: '/tmp/pascal-home',
+ PASCAL_API_KEY: 'sk_live_private-agent-key',
+ PATH: '/usr/bin',
+ },
+ spawnMock,
+ )
+
+ expect(spawnMock).toHaveBeenCalledTimes(1)
+ const options = spawnMock.mock.calls[0]?.[2]
+ expect(options?.env).toEqual({ HOME: '/tmp/pascal-home', PATH: '/usr/bin' })
+ expect(JSON.stringify(options)).not.toContain('sk_live_private-agent-key')
+ })
+
+ test('does not spawn when browser opening is disabled', () => {
+ openBrowser(
+ 'https://editor.pascal.app/settings/agents/claim',
+ {
+ PASCAL_API_KEY: 'sk_live_private-agent-key',
+ PASCAL_NO_OPEN: '1',
+ },
+ spawnMock,
+ )
+
+ expect(spawnMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/cli/src/browser.ts b/packages/cli/src/browser.ts
new file mode 100644
index 0000000000..26ed824b80
--- /dev/null
+++ b/packages/cli/src/browser.ts
@@ -0,0 +1,20 @@
+import { spawn } from 'node:child_process'
+
+export function openBrowser(
+ url: string,
+ environment: NodeJS.ProcessEnv = process.env,
+ spawnProcess: typeof spawn = spawn,
+): void {
+ if (environment.PASCAL_NO_OPEN === '1') return
+ const { PASCAL_API_KEY: _pascalApiKey, ...browserEnvironment } = environment
+ const command =
+ process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
+ const child = spawnProcess(command, args, {
+ detached: true,
+ env: browserEnvironment,
+ stdio: 'ignore',
+ })
+ child.once('error', () => {})
+ child.unref()
+}
diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts
new file mode 100644
index 0000000000..706a993321
--- /dev/null
+++ b/packages/cli/src/cli.test.ts
@@ -0,0 +1,313 @@
+import { afterAll, describe, expect, test } from 'bun:test'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+
+const executable = path.join(import.meta.dir, 'bin/pascal.ts')
+const testRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-command-test-'))
+const testHome = path.join(testRoot, 'home')
+const claimFetchPreload = path.join(testRoot, 'claim-fetch-preload.mjs')
+
+await writeFile(
+ claimFetchPreload,
+ `globalThis.fetch = async (input, init) => {
+ if (String(input) !== process.env.PASCAL_AGENT_TEST_ENDPOINT) {
+ throw new Error('Unexpected agent endpoint')
+ }
+ if (init?.method !== process.env.PASCAL_AGENT_TEST_METHOD) throw new Error('Unexpected method')
+ if (init?.redirect !== 'error') throw new Error('Redirects must be disabled')
+ if (process.env.PASCAL_API_KEY !== undefined) {
+ throw new Error('PASCAL_API_KEY remained in the process environment')
+ }
+ const authorization = new Headers(init?.headers).get('authorization')
+ if (authorization !== process.env.PASCAL_AGENT_TEST_AUTHORIZATION) {
+ throw new Error('Unexpected agent authorization')
+ }
+ return new Response(process.env.PASCAL_AGENT_TEST_BODY, {
+ headers: { 'content-type': 'application/json' },
+ status: Number(process.env.PASCAL_AGENT_TEST_STATUS),
+ })
+ }
+`,
+)
+
+afterAll(() => rm(testRoot, { recursive: true, force: true }))
+
+describe('command parsing', () => {
+ test('shows the command reference for subcommand help', async () => {
+ const result = await runCli('status', '--help')
+
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout).toContain('pascal editor')
+ expect(result.stdout).toContain('npx @pascal-app/cli ')
+ expect(result.stdout).toContain('npm install --global @pascal-app/cli')
+ })
+
+ test('shows focused help for MCP commands', async () => {
+ const result = await runCli('mcp', '--help')
+
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout).toContain('pascal mcp setup codex')
+ expect(result.stdout).toContain('dynamic loopback port')
+ expect(result.stdout).not.toContain('pascal plugin list')
+ })
+
+ test('shows focused help for hosted agent claims', async () => {
+ const result = await runCli('agent', '--help')
+
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout).toContain('pascal agent claim')
+ expect(result.stdout).toContain('PASCAL_API_KEY')
+ expect(result.stdout).toContain('does not transfer project ownership')
+ expect(result.stdout).not.toContain('pascal plugin list')
+ })
+
+ test('requires an environment credential before starting an agent claim', async () => {
+ const result = await runCli('agent', 'claim', '--no-open', '--json')
+
+ expect(result.exitCode).toBe(1)
+ expect(JSON.parse(result.stderr)).toEqual({
+ error: 'agent_api_key_missing',
+ message: "Set PASCAL_API_KEY to this autonomous agent's API key and try again.",
+ })
+ expect(result.stdout).toBe('')
+ })
+
+ test('prints the exact successful JSON claim contract without opening a browser', async () => {
+ const claim = {
+ claimCode: 'BCDF-GHJK-LMNP',
+ claimUrl: 'https://editor.pascal.app/settings/agents/claim',
+ expiresAt: '2026-09-10T18:30:00.000Z',
+ }
+
+ const result = await runClaimCli(
+ 200,
+ { ...claim, agent: { name: '\u001b[2J', client: 'test' } },
+ '--json',
+ )
+
+ expect(result.exitCode).toBe(0)
+ expect(JSON.parse(result.stdout)).toEqual(claim)
+ expect(result.stderr).toBe('')
+ })
+
+ test('prints a terminal-safe human claim without server-controlled identity text', async () => {
+ const result = await runClaimCli(
+ 200,
+ {
+ claimCode: 'BCDF-GHJK-LMNP',
+ claimUrl: 'https://editor.pascal.app/settings/agents/claim',
+ expiresAt: '2026-09-10T18:30:00.000Z',
+ agent: { name: '\u001b[2Jmalicious', client: 'test' },
+ },
+ '--no-open',
+ )
+
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout).toContain('Claim code: BCDF-GHJK-LMNP')
+ expect(result.stdout).toContain(
+ 'Claim page: https://editor.pascal.app/settings/agents/claim?code=BCDF-GHJK-LMNP',
+ )
+ expect(result.stdout).toContain('Claiming links accountability.')
+ expect(result.stdout).not.toContain('malicious')
+ expect(result.stdout).not.toContain('\u001b')
+ expect(result.stderr).toBe('')
+ })
+
+ test.each([
+ [401, 'agent_claim_unauthorized'],
+ [409, 'agent_already_claimed'],
+ ])('preserves the hosted HTTP %i error contract', async (status, errorCode) => {
+ const result = await runClaimCli(status, 'untrusted error', '--json')
+
+ expect(result.exitCode).toBe(1)
+ expect(JSON.parse(result.stderr)).toMatchObject({
+ details: { status },
+ error: errorCode,
+ })
+ expect(result.stdout).toBe('')
+ })
+
+ test('prints the exact successful JSON agent status contract', async () => {
+ const status = {
+ schemaVersion: 1,
+ agentId: 'agent_cli_test',
+ mode: 'autonomous',
+ claimed: false,
+ organizationScoped: true,
+ }
+
+ const result = await runStatusCli(200, { ...status, credentialName: '\u001b[2J' }, '--json')
+
+ expect(result.exitCode).toBe(0)
+ expect(JSON.parse(result.stdout)).toEqual(status)
+ expect(result.stderr).toBe('')
+ })
+
+ test('prints terminal-safe human status and an unclaimed next action', async () => {
+ const result = await runStatusCli(200, {
+ schemaVersion: 1,
+ agentId: '\u001b[2Jmalicious',
+ mode: 'autonomous',
+ claimed: false,
+ organizationScoped: false,
+ })
+
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout).toContain('Mode: autonomous')
+ expect(result.stdout).toContain('Claimed: no')
+ expect(result.stdout).toContain('pascal agent claim')
+ expect(result.stdout).not.toContain('\u001b')
+ expect(result.stderr).toBe('')
+ })
+
+ test.each([
+ [401, 'agent_status_unauthorized'],
+ [403, 'agent_status_forbidden'],
+ ])('preserves the hosted status HTTP %i error contract', async (status, errorCode) => {
+ const result = await runStatusCli(status, 'untrusted error', '--json')
+
+ expect(result.exitCode).toBe(1)
+ expect(JSON.parse(result.stderr)).toMatchObject({
+ details: { status },
+ error: errorCode,
+ })
+ expect(result.stdout).toBe('')
+ })
+
+ test('rejects unknown agent account commands', async () => {
+ const result = await runCli('agent', 'login', '--json')
+
+ expect(result.exitCode).toBe(2)
+ expect(JSON.parse(result.stderr)).toMatchObject({ error: 'unknown_command' })
+ })
+
+ test('rejects a partially numeric port', async () => {
+ const result = await runCli('editor', '--port', '3000junk', '--no-open', '--json')
+
+ expect(result.exitCode).toBe(2)
+ expect(JSON.parse(result.stderr)).toMatchObject({ error: 'invalid_option' })
+ })
+
+ test('rejects a non-numeric log line count', async () => {
+ const result = await runCli('logs', '--lines', 'many')
+
+ expect(result.exitCode).toBe(2)
+ expect(result.stderr).toContain('--lines must be an integer')
+ })
+
+ test('rejects non-registry update sources before invoking npm', async () => {
+ const result = await runCli('update', '--version', 'file:/tmp/untrusted', '--json')
+
+ expect(result.exitCode).toBe(2)
+ expect(JSON.parse(result.stderr)).toMatchObject({ error: 'invalid_version' })
+ })
+
+ test('reports unknown options as command errors', async () => {
+ const result = await runCli('project', 'list', '--unknown', '--json')
+
+ expect(result.exitCode).toBe(2)
+ expect(JSON.parse(result.stderr)).toMatchObject({ error: 'invalid_option' })
+ })
+
+ test('prints stable local MCP client configuration', async () => {
+ const result = await runCli('mcp', 'config', '--json')
+
+ expect(result.exitCode).toBe(0)
+ expect(JSON.parse(result.stdout)).toEqual({
+ mcpServers: { pascal: { command: 'pascal', args: ['mcp', 'connect'] } },
+ })
+ })
+
+ test('rejects unsupported automatic MCP client setup', async () => {
+ const result = await runCli('mcp', 'setup', 'cursor', '--json')
+
+ expect(result.exitCode).toBe(2)
+ expect(JSON.parse(result.stderr)).toMatchObject({ error: 'invalid_option' })
+ })
+
+ test('reports a malformed plugin lock as managed-state corruption', async () => {
+ await mkdir(testHome, { recursive: true })
+ await writeFile(path.join(testHome, 'pascal.plugins.lock'), '{"schemaVersion":1}')
+
+ const result = await runCli('plugin', 'list', '--json')
+
+ expect(result.exitCode).toBe(1)
+ expect(JSON.parse(result.stderr)).toMatchObject({ error: 'invalid_plugin_state' })
+ })
+})
+
+async function runCli(...args: string[]) {
+ const child = Bun.spawn([process.execPath, executable, ...args], {
+ env: {
+ ...process.env,
+ PASCAL_API_KEY: '',
+ PASCAL_HOME: testHome,
+ PASCAL_NO_OPEN: '1',
+ },
+ stdout: 'pipe',
+ stderr: 'pipe',
+ })
+ const [exitCode, stdout, stderr] = await Promise.all([
+ child.exited,
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ ])
+ return { exitCode, stdout, stderr }
+}
+
+async function runClaimCli(status: number, body: unknown, ...args: string[]) {
+ const apiKey = 'sk_live_cli-test-key'
+ const child = Bun.spawn(
+ [process.execPath, '--preload', claimFetchPreload, executable, 'agent', 'claim', ...args],
+ {
+ env: {
+ ...process.env,
+ PASCAL_API_KEY: apiKey,
+ PASCAL_AGENT_TEST_AUTHORIZATION: `Bearer ${apiKey}`,
+ PASCAL_AGENT_TEST_BODY: typeof body === 'string' ? body : JSON.stringify(body),
+ PASCAL_AGENT_TEST_ENDPOINT: 'https://editor.pascal.app/api/auth/agent/claim/start',
+ PASCAL_AGENT_TEST_METHOD: 'POST',
+ PASCAL_AGENT_TEST_STATUS: String(status),
+ PASCAL_HOME: testHome,
+ PASCAL_NO_OPEN: '1',
+ },
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+ )
+ const [exitCode, stdout, stderr] = await Promise.all([
+ child.exited,
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ ])
+ return { exitCode, stdout, stderr }
+}
+
+async function runStatusCli(status: number, body: unknown, ...args: string[]) {
+ const apiKey = 'sk_live_cli-status-test-key'
+ const child = Bun.spawn(
+ [process.execPath, '--preload', claimFetchPreload, executable, 'agent', 'status', ...args],
+ {
+ env: {
+ ...process.env,
+ PASCAL_API_KEY: apiKey,
+ PASCAL_AGENT_TEST_AUTHORIZATION: `Bearer ${apiKey}`,
+ PASCAL_AGENT_TEST_BODY: typeof body === 'string' ? body : JSON.stringify(body),
+ PASCAL_AGENT_TEST_ENDPOINT: 'https://editor.pascal.app/api/auth/agent/status',
+ PASCAL_AGENT_TEST_METHOD: 'GET',
+ PASCAL_AGENT_TEST_STATUS: String(status),
+ PASCAL_HOME: testHome,
+ PASCAL_NO_OPEN: '1',
+ },
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+ )
+ const [exitCode, stdout, stderr] = await Promise.all([
+ child.exited,
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ ])
+ return { exitCode, stdout, stderr }
+}
diff --git a/packages/cli/src/command-install.test.ts b/packages/cli/src/command-install.test.ts
new file mode 100644
index 0000000000..ede6ae3df8
--- /dev/null
+++ b/packages/cli/src/command-install.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test } from 'bun:test'
+import { installGlobalPascalCommand, isNpxInvocation } from './command-install.js'
+
+describe('short command installation', () => {
+ test('recognizes npm exec package-runner invocations', () => {
+ expect(isNpxInvocation({ npm_lifecycle_event: 'npx' })).toBe(true)
+ expect(
+ isNpxInvocation({ npm_command: 'exec', PATH: '/tmp/_npx/example/node_modules/.bin' }),
+ ).toBe(true)
+ expect(isNpxInvocation({ PATH: '/usr/local/bin:/usr/bin' })).toBe(false)
+ })
+
+ test('installs the exact running version without lifecycle scripts', async () => {
+ let invocation: { command: string; args: string[] } | undefined
+ const installed = await installGlobalPascalCommand('1.2.3', async (command, args) => {
+ invocation = { command, args }
+ return 0
+ })
+
+ expect(installed).toBe(true)
+ expect(invocation).toEqual({
+ command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
+ args: ['install', '--global', '--ignore-scripts', '@pascal-app/cli@1.2.3'],
+ })
+ })
+
+ test('reports an installer failure without throwing', async () => {
+ expect(await installGlobalPascalCommand('1.2.3', async () => 1)).toBe(false)
+ })
+})
diff --git a/packages/cli/src/command-install.ts b/packages/cli/src/command-install.ts
new file mode 100644
index 0000000000..24d90e256a
--- /dev/null
+++ b/packages/cli/src/command-install.ts
@@ -0,0 +1,46 @@
+import { spawn } from 'node:child_process'
+
+const INSTALL_TIMEOUT_MS = 2 * 60_000
+
+export function isNpxInvocation(environment: NodeJS.ProcessEnv = process.env): boolean {
+ return (
+ environment.npm_lifecycle_event === 'npx' ||
+ (environment.npm_command === 'exec' &&
+ (environment.PATH ?? '').split(':').some((entry) => entry.includes('/_npx/')))
+ )
+}
+
+export async function installGlobalPascalCommand(
+ packageVersion: string,
+ runInstaller: Installer = runNpmInstaller,
+): Promise {
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
+ return (
+ (await runInstaller(npm, [
+ 'install',
+ '--global',
+ '--ignore-scripts',
+ `@pascal-app/cli@${packageVersion}`,
+ ])) === 0
+ )
+}
+
+export type Installer = (command: string, args: string[]) => Promise
+
+async function runNpmInstaller(command: string, args: string[]): Promise {
+ return new Promise((resolve) => {
+ const child = spawn(command, args, { stdio: 'ignore' })
+ const timeout = setTimeout(() => {
+ child.kill('SIGTERM')
+ resolve(1)
+ }, INSTALL_TIMEOUT_MS)
+ child.once('error', () => {
+ clearTimeout(timeout)
+ resolve(1)
+ })
+ child.once('exit', (code) => {
+ clearTimeout(timeout)
+ resolve(code ?? 1)
+ })
+ })
+}
diff --git a/packages/cli/src/diagnostics.test.ts b/packages/cli/src/diagnostics.test.ts
new file mode 100644
index 0000000000..3da6f89376
--- /dev/null
+++ b/packages/cli/src/diagnostics.test.ts
@@ -0,0 +1,34 @@
+import { expect, test } from 'bun:test'
+import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { collectInfo, runDoctor } from './diagnostics.js'
+import { resolvePascalPaths } from './paths.js'
+
+test('doctor reports corrupt managed state instead of crashing', async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-doctor-'))
+ try {
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ await mkdir(paths.run, { recursive: true })
+ await writeFile(paths.currentRuntime, '{not-json')
+
+ const checks = await runDoctor(paths)
+
+ expect(checks).toContainEqual(expect.objectContaining({ id: 'runtime', status: 'fail' }))
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+test('info creates private local storage on a fresh home', async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-info-'))
+ try {
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+
+ await collectInfo(paths)
+
+ expect((await stat(paths.root)).mode & 0o077).toBe(0)
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+})
diff --git a/packages/cli/src/diagnostics.ts b/packages/cli/src/diagnostics.ts
new file mode 100644
index 0000000000..7b91ffdb7d
--- /dev/null
+++ b/packages/cli/src/diagnostics.ts
@@ -0,0 +1,133 @@
+import { constants } from 'node:fs'
+import { access, readdir, stat } from 'node:fs/promises'
+import { ensurePascalDirectories, getEditorStatus } from './editor-process.js'
+import { readJsonFile } from './json-files.js'
+import { getMcpServiceStatus } from './mcp-service.js'
+import type { PascalPaths } from './paths.js'
+
+export interface DiagnosticCheck {
+ id: string
+ status: 'pass' | 'warn' | 'fail'
+ message: string
+}
+
+export async function runDoctor(paths: PascalPaths): Promise {
+ const checks: DiagnosticCheck[] = []
+ const [major = 0, minor = 0] = process.versions.node
+ .split('.')
+ .slice(0, 2)
+ .map((part) => Number.parseInt(part, 10))
+ const nodeSupported = major > 22 || (major === 22 && minor >= 13)
+ checks.push({
+ id: 'node',
+ status: nodeSupported ? 'pass' : 'fail',
+ message: nodeSupported ? `Node ${process.versions.node}` : 'Node 22.13 or newer is required.',
+ })
+ try {
+ await ensurePascalDirectories(paths)
+ await access(paths.root, constants.R_OK | constants.W_OK)
+ checks.push({ id: 'storage', status: 'pass', message: `Writable: ${paths.root}` })
+ const exposed = []
+ for (const directory of [paths.root, paths.data, paths.run, paths.logs]) {
+ if (((await stat(directory)).mode & 0o077) !== 0) exposed.push(directory)
+ }
+ checks.push({
+ id: 'permissions',
+ status: exposed.length === 0 ? 'pass' : 'warn',
+ message:
+ exposed.length === 0
+ ? 'Local storage is private to the current user.'
+ : `Group or other users can access: ${exposed.join(', ')}`,
+ })
+ } catch (error) {
+ checks.push({
+ id: 'storage',
+ status: 'fail',
+ message: error instanceof Error ? error.message : 'Pascal storage is not writable.',
+ })
+ }
+ try {
+ const [status, mcp] = await Promise.all([getEditorStatus(paths), getMcpServiceStatus(paths)])
+ checks.push({
+ id: 'runtime',
+ status: status.installed ? 'pass' : 'warn',
+ message: status.runtime
+ ? `Installed web runtime ${status.runtime.version}`
+ : 'No web runtime installed yet. It downloads when the editor first starts.',
+ })
+ checks.push({
+ id: 'editor',
+ status: status.healthy ? 'pass' : status.running ? 'fail' : 'warn',
+ message: status.healthy
+ ? `Healthy at ${status.state?.url}`
+ : status.running
+ ? 'A recorded editor process is running but unhealthy.'
+ : 'The editor is stopped.',
+ })
+ checks.push({
+ id: 'mcp',
+ status: mcp.healthy ? 'pass' : mcp.running ? 'fail' : 'warn',
+ message: mcp.healthy
+ ? `MCP is healthy on loopback port ${mcp.state?.port}.`
+ : mcp.running
+ ? 'The managed MCP process is running but unhealthy.'
+ : 'MCP is stopped. "pascal mcp connect" starts it on demand.',
+ })
+ const runtimeVersions = (await readdir(paths.runtime, { withFileTypes: true }))
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
+ .map((entry) => entry.name)
+ checks.push({
+ id: 'runtime-retention',
+ status: runtimeVersions.length > 3 ? 'warn' : 'pass',
+ message:
+ runtimeVersions.length > 3
+ ? `${runtimeVersions.length} runtime versions are retained. Review inactive versions if disk space is constrained.`
+ : `${runtimeVersions.length} runtime version(s) retained for updates and rollback.`,
+ })
+ } catch (error) {
+ checks.push({
+ id: 'runtime',
+ status: 'fail',
+ message: `Runtime or process state is invalid: ${errorMessage(error)}`,
+ })
+ }
+ try {
+ const pluginLock = await readJsonFile<{ plugins?: unknown[] }>(paths.pluginLock)
+ checks.push({
+ id: 'plugins',
+ status: pluginLock && !Array.isArray(pluginLock.plugins) ? 'fail' : 'pass',
+ message: pluginLock
+ ? `${Array.isArray(pluginLock.plugins) ? pluginLock.plugins.length : 0} plugin(s) in lock.`
+ : 'No local plugins installed.',
+ })
+ } catch (error) {
+ checks.push({
+ id: 'plugins',
+ status: 'fail',
+ message: `Plugin state is invalid: ${errorMessage(error)}`,
+ })
+ }
+ return checks
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+export async function collectInfo(paths: PascalPaths) {
+ await ensurePascalDirectories(paths)
+ const [status, mcp, runtimeVersions, pluginLock] = await Promise.all([
+ getEditorStatus(paths),
+ getMcpServiceStatus(paths),
+ readdir(paths.runtime).catch(() => [] as string[]),
+ readJsonFile<{ schemaVersion?: number; plugins?: unknown[] }>(paths.pluginLock),
+ ])
+ return {
+ cli: { node: process.versions.node, platform: process.platform, arch: process.arch },
+ editor: status,
+ mcp,
+ paths,
+ runtimes: runtimeVersions.filter((entry) => !entry.startsWith('.')).sort(),
+ plugins: pluginLock?.plugins ?? [],
+ }
+}
diff --git a/packages/cli/src/editor-process.ts b/packages/cli/src/editor-process.ts
new file mode 100644
index 0000000000..0c4d260bc0
--- /dev/null
+++ b/packages/cli/src/editor-process.ts
@@ -0,0 +1,508 @@
+import { type ChildProcess, spawn } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
+import { closeSync, openSync } from 'node:fs'
+import { mkdir, open, rename, rm, stat } from 'node:fs/promises'
+import path from 'node:path'
+import { CliError } from './errors.js'
+import { withFileLock } from './file-lock.js'
+import { readJsonFile, writeJsonFile } from './json-files.js'
+import {
+ ensureMcpService,
+ type McpServiceState,
+ type McpStartProgress,
+ stopMcpService,
+} from './mcp-service.js'
+import type { PascalPaths } from './paths.js'
+import {
+ errorMessage,
+ findAvailablePort,
+ isProcessRunning,
+ processCommand,
+ terminateProcess,
+ waitForSpawn,
+} from './process-control.js'
+import {
+ type ActiveRuntime,
+ activateRuntime,
+ readActiveRuntime,
+ readRuntimeManifest,
+} from './runtime.js'
+import { ensureWebRuntime, type RuntimeProvisionProgress } from './runtime-download.js'
+
+export interface EditorState {
+ schemaVersion: 1
+ pid: number
+ version: string
+ port: number
+ host: '127.0.0.1'
+ url: string
+ instanceId: string
+ runtimeDirectory: string
+ startedAt: string
+}
+
+export interface EditorStatus {
+ installed: boolean
+ running: boolean
+ healthy: boolean
+ state: EditorState | null
+ runtime: ActiveRuntime | null
+}
+
+export interface StartEditorOptions {
+ paths: PascalPaths
+ port?: number
+ foreground?: boolean
+ /** A web-runtime directory or `.tar.gz` archive to install instead of downloading one. */
+ runtimeSource?: string
+ onProgress?: (event: EditorStartProgress) => void
+}
+
+export type EditorStartProgress =
+ | { step: 'storage-ready'; dataDirectory: string }
+ | { step: 'runtime-ready'; version: string; installed: boolean }
+ | { step: 'port-ready'; port: number; preferredPort: number }
+ | { step: 'process-starting'; port: number }
+ | { step: 'health-checking'; port: number }
+ | { step: 'ready'; port: number }
+ | { step: 'already-running'; port: number }
+ | RuntimeProvisionProgress
+ | McpStartProgress
+
+export interface StartEditorResult {
+ state: EditorState
+ mcp: McpServiceState
+ alreadyRunning: boolean
+ child?: ChildProcess
+}
+
+export interface StopEditorOptions {
+ force?: boolean
+}
+
+export interface RuntimeActivationResult {
+ runtime: ActiveRuntime
+ restarted: boolean
+}
+
+export async function ensurePascalDirectories(paths: PascalPaths): Promise {
+ await Promise.all(
+ [paths.root, paths.runtime, paths.data, paths.plugins, paths.run, paths.logs, paths.tmp].map(
+ (directory) => mkdir(directory, { recursive: true, mode: 0o700 }),
+ ),
+ )
+}
+
+export async function getEditorStatus(paths: PascalPaths): Promise {
+ const [runtime, state] = await Promise.all([
+ readActiveRuntime(paths),
+ readJsonFile(paths.state),
+ ])
+ if (state?.schemaVersion !== 1 || typeof state.pid !== 'number') {
+ return { installed: Boolean(runtime), running: false, healthy: false, state: null, runtime }
+ }
+ const running = isProcessRunning(state.pid)
+ return {
+ installed: Boolean(runtime),
+ running,
+ healthy: running ? await checkHealth(state) : false,
+ state,
+ runtime,
+ }
+}
+
+export async function startEditor(options: StartEditorOptions): Promise {
+ return withEditorLifecycleLock(options.paths, () => startEditorUnlocked(options))
+}
+
+async function startEditorUnlocked(options: StartEditorOptions): Promise {
+ await ensurePascalDirectories(options.paths)
+ options.onProgress?.({ step: 'storage-ready', dataDirectory: options.paths.data })
+ let currentStatus: EditorStatus
+ try {
+ currentStatus = await getEditorStatus(options.paths)
+ } catch (error) {
+ if (!(error instanceof CliError) || error.code !== 'invalid_runtime') throw error
+ await stopEditorUnlocked(options.paths, { force: true })
+ await rm(options.paths.currentRuntime, { force: true })
+ currentStatus = await getEditorStatus(options.paths)
+ }
+ if (currentStatus.healthy && currentStatus.state) {
+ const mcp = await ensureMcpService({
+ paths: options.paths,
+ editorOrigin: currentStatus.state.url,
+ onProgress: options.onProgress,
+ })
+ options.onProgress?.({ step: 'already-running', port: currentStatus.state.port })
+ return { state: currentStatus.state, mcp: mcp.state, alreadyRunning: true }
+ }
+ if (currentStatus.running) {
+ throw new CliError(
+ 'state_conflict',
+ 'A recorded Pascal editor process is running but its identity could not be verified. Inspect "pascal status --json", then use "pascal stop --force" only if the recorded command is trusted.',
+ )
+ }
+ await rm(options.paths.state, { force: true })
+
+ let runtime = await readActiveRuntime(options.paths)
+ let installedRuntime = false
+ if (!runtime || options.runtimeSource) {
+ const provisioned = await ensureWebRuntime({
+ paths: options.paths,
+ runtimeSource: options.runtimeSource,
+ onProgress: options.onProgress,
+ })
+ runtime = provisioned.runtime
+ installedRuntime = provisioned.installed
+ }
+ options.onProgress?.({
+ step: 'runtime-ready',
+ version: runtime.version,
+ installed: installedRuntime,
+ })
+ const manifest = await readRuntimeManifest(runtime.directory)
+ const serverPath = path.resolve(runtime.directory, manifest.entrypoint)
+ const preferredPort = options.port ?? 0
+ const port = await findAvailablePort(preferredPort)
+ options.onProgress?.({ step: 'port-ready', port, preferredPort })
+ const instanceId = randomUUID()
+ const state: EditorState = {
+ schemaVersion: 1,
+ pid: 0,
+ version: runtime.version,
+ port,
+ host: '127.0.0.1',
+ url: `http://pascal.localhost:${port}`,
+ instanceId,
+ runtimeDirectory: runtime.directory,
+ startedAt: new Date().toISOString(),
+ }
+
+ const environment: NodeJS.ProcessEnv = {
+ ...process.env,
+ NODE_ENV: 'production',
+ HOSTNAME: state.host,
+ PORT: String(port),
+ PASCAL_DATA_DIR: options.paths.data,
+ PASCAL_INSTANCE_ID: instanceId,
+ PASCAL_RUNTIME_VERSION: runtime.version,
+ MINT_PASCAL_HOST_ORIGIN: process.env.MINT_PASCAL_HOST_ORIGIN || state.url,
+ }
+ const nodeBinary = process.env.PASCAL_NODE_BINARY || 'node'
+ if (!options.foreground) await rotateEditorLog(options.paths.editorLog)
+ const logDescriptor = options.foreground
+ ? undefined
+ : openSync(options.paths.editorLog, 'a', 0o600)
+ options.onProgress?.({ step: 'process-starting', port })
+ const child = spawn(nodeBinary, [serverPath], {
+ cwd: path.dirname(serverPath),
+ env: environment,
+ detached: !options.foreground,
+ stdio: options.foreground ? 'inherit' : ['ignore', logDescriptor!, logDescriptor!],
+ })
+ if (logDescriptor !== undefined) closeSync(logDescriptor)
+
+ let mcp: McpServiceState
+ try {
+ await waitForSpawn(child, nodeBinary)
+ if (!child.pid) throw new CliError('start_failed', 'The Pascal editor process did not start.')
+ state.pid = child.pid
+ await writeJsonFile(options.paths.state, state)
+ if (!options.foreground) child.unref()
+ options.onProgress?.({ step: 'health-checking', port })
+ await waitForHealth(state, 30_000)
+ mcp = (
+ await ensureMcpService({
+ paths: options.paths,
+ editorOrigin: state.url,
+ foreground: options.foreground,
+ onProgress: options.onProgress,
+ })
+ ).state
+ options.onProgress?.({ step: 'ready', port })
+ } catch (error) {
+ if (child.pid) await terminateProcess(child.pid)
+ await rm(options.paths.state, { force: true })
+ throw error
+ }
+ return { state, mcp, alreadyRunning: false, child: options.foreground ? child : undefined }
+}
+
+export async function stopEditor(
+ paths: PascalPaths,
+ options: StopEditorOptions = {},
+): Promise {
+ const editorStopped = await withEditorLifecycleLock(paths, () =>
+ stopEditorUnlocked(paths, options),
+ )
+ const mcpStopped = await stopMcpService(paths, options)
+ return editorStopped || mcpStopped
+}
+
+async function stopEditorUnlocked(
+ paths: PascalPaths,
+ options: StopEditorOptions = {},
+): Promise {
+ const state = await readJsonFile(paths.state)
+ if (!state || !isProcessRunning(state.pid)) {
+ await rm(paths.state, { force: true })
+ return false
+ }
+ const identified =
+ (await checkHealth(state)) ||
+ (options.force && (await matchesRecordedEditorProcess(paths, state)))
+ if (!identified) {
+ throw new CliError(
+ 'state_conflict',
+ options.force
+ ? 'Refusing to stop a process whose health identity and operating-system command do not match the recorded Pascal runtime.'
+ : 'The Pascal editor identity is unavailable. Inspect "pascal status --json", then use "pascal stop --force" only if the recorded command is trusted.',
+ )
+ }
+ await terminateProcess(state.pid)
+ await rm(paths.state, { force: true })
+ return true
+}
+
+export async function restartEditor(paths: PascalPaths): Promise {
+ return withEditorLifecycleLock(paths, async () => {
+ const previousPort = (await readJsonFile(paths.state))?.port
+ await stopEditorUnlocked(paths)
+ return startEditorUnlocked({ paths, port: previousPort })
+ })
+}
+
+export async function activateEditorRuntime(
+ paths: PascalPaths,
+ candidate: ActiveRuntime,
+): Promise {
+ return withEditorLifecycleLock(paths, async () => {
+ let previousRuntime: ActiveRuntime | null = null
+ let previousRuntimeWasInvalid = false
+ try {
+ previousRuntime = await readActiveRuntime(paths)
+ } catch (error) {
+ if (!(error instanceof CliError) || error.code !== 'invalid_runtime') throw error
+ previousRuntimeWasInvalid = true
+ }
+ let previousStatus: EditorStatus
+ if (previousRuntimeWasInvalid) {
+ const state = await readJsonFile(paths.state)
+ const running = Boolean(state && isProcessRunning(state.pid))
+ previousStatus = {
+ installed: false,
+ running,
+ healthy: Boolean(state && running && (await checkHealth(state))),
+ state: state ?? null,
+ runtime: null,
+ }
+ } else {
+ previousStatus = await getEditorStatus(paths)
+ }
+ if (previousStatus.running && !previousStatus.healthy) {
+ throw new CliError(
+ 'state_conflict',
+ 'A recorded Pascal editor process is running but its identity could not be verified. Recover or stop it before updating.',
+ )
+ }
+ if (
+ previousRuntime?.version === candidate.version &&
+ previousRuntime.directory === candidate.directory
+ ) {
+ return { runtime: previousRuntime, restarted: false }
+ }
+
+ const wasRunning = previousStatus.running
+ const previousPort = previousStatus.state?.port
+ if (wasRunning) await stopEditorUnlocked(paths)
+
+ try {
+ await activateRuntime(paths, candidate.version, candidate.directory)
+ await startEditorUnlocked({ paths, port: previousPort })
+ if (!wasRunning) {
+ await stopEditorUnlocked(paths)
+ await stopMcpService(paths)
+ }
+ return { runtime: candidate, restarted: wasRunning }
+ } catch (error) {
+ try {
+ await stopEditorUnlocked(paths, { force: true })
+ await stopMcpService(paths, { force: true })
+ } catch {}
+ let rollbackError: unknown
+ if (previousRuntime) {
+ try {
+ await activateRuntime(paths, previousRuntime.version, previousRuntime.directory)
+ } catch (activationError) {
+ rollbackError = activationError
+ await rm(paths.currentRuntime, { force: true })
+ }
+ } else {
+ await rm(paths.currentRuntime, { force: true })
+ }
+ if (!rollbackError && wasRunning && previousRuntime) {
+ try {
+ await startEditorUnlocked({ paths, port: previousPort })
+ } catch (restartError) {
+ rollbackError = restartError
+ }
+ }
+ if (rollbackError) {
+ throw new CliError('update_failed', 'The candidate and rollback runtimes both failed.', {
+ candidateError: errorMessage(error),
+ rollbackError: errorMessage(rollbackError),
+ })
+ }
+ throw new CliError(
+ 'update_failed',
+ previousRuntime
+ ? 'The candidate runtime failed; the previous runtime was restored.'
+ : 'The candidate runtime failed and no valid previous runtime was available.',
+ {
+ candidateError: errorMessage(error),
+ },
+ )
+ }
+ })
+}
+
+export async function readLogTail(filePath: string, lines = 100): Promise {
+ try {
+ const fileSize = (await stat(filePath)).size
+ const length = Math.min(fileSize, 8 * 1024 * 1024)
+ const handle = await open(filePath, 'r')
+ const buffer = Buffer.alloc(length)
+ try {
+ await handle.read(buffer, 0, length, fileSize - length)
+ } finally {
+ await handle.close()
+ }
+ return buffer
+ .toString('utf8')
+ .split(/\r?\n/)
+ .slice(-Math.max(1, lines) - 1)
+ .join('\n')
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
+ throw error
+ }
+}
+
+export async function followLog(filePath: string): Promise {
+ let offset = 0
+ try {
+ offset = (await stat(filePath)).size
+ } catch {}
+ for (;;) {
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ try {
+ const size = (await stat(filePath)).size
+ if (size < offset) offset = 0
+ if (size === offset) continue
+ const handle = await open(filePath, 'r')
+ const buffer = Buffer.alloc(Math.min(size - offset, 1024 * 1024))
+ try {
+ await handle.read(buffer, 0, buffer.length, offset)
+ } finally {
+ await handle.close()
+ }
+ process.stdout.write(buffer)
+ offset += buffer.length
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ offset = 0
+ }
+ }
+}
+
+async function checkHealth(state: EditorState): Promise {
+ return (await probeHealth(state)) === 'healthy'
+}
+
+async function probeHealth(state: EditorState): Promise<'healthy' | 'foreign' | 'unreachable'> {
+ try {
+ const response = await fetch(`http://127.0.0.1:${state.port}/api/health`, {
+ signal: AbortSignal.timeout(1_000),
+ })
+ if (!response.ok) return 'foreign'
+ let body: {
+ status?: string
+ app?: string
+ version?: string
+ instanceId?: string
+ }
+ try {
+ body = (await response.json()) as typeof body
+ } catch {
+ return 'foreign'
+ }
+ return body.status === 'ok' &&
+ body.app === 'editor' &&
+ body.version === state.version &&
+ body.instanceId === state.instanceId
+ ? 'healthy'
+ : 'foreign'
+ } catch {
+ return 'unreachable'
+ }
+}
+
+export async function waitForHealth(state: EditorState, timeoutMs: number): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ const health = await probeHealth(state)
+ if (health === 'healthy') return
+ if (health === 'foreign') {
+ throw new CliError(
+ 'port_conflict',
+ `Port ${state.port} is responding as another application. Run Pascal again to choose another port, or pass --port .`,
+ )
+ }
+ if (!isProcessRunning(state.pid)) {
+ throw new CliError('start_failed', 'The Pascal editor exited before becoming healthy.')
+ }
+ await new Promise((resolve) => setTimeout(resolve, 200))
+ }
+ throw new CliError('health_timeout', `Pascal did not become healthy within ${timeoutMs}ms.`)
+}
+
+async function withEditorLifecycleLock(
+ paths: PascalPaths,
+ action: () => Promise,
+): Promise {
+ return withFileLock(
+ path.join(paths.run, 'editor-lifecycle.lock'),
+ 'editor_locked',
+ 'Another Pascal editor lifecycle operation is active.',
+ action,
+ )
+}
+
+async function matchesRecordedEditorProcess(
+ paths: PascalPaths,
+ state: EditorState,
+): Promise {
+ if (process.platform === 'win32') return false
+ const runtimeDirectory = path.resolve(state.runtimeDirectory)
+ if (!runtimeDirectory.startsWith(`${path.resolve(paths.runtime)}${path.sep}`)) return false
+ let expectedEntrypoint: string
+ try {
+ const manifest = await readRuntimeManifest(runtimeDirectory)
+ expectedEntrypoint = path.resolve(runtimeDirectory, manifest.entrypoint)
+ } catch {
+ expectedEntrypoint = path.join(runtimeDirectory, 'apps/editor/server.js')
+ }
+ const command = await processCommand(state.pid)
+ return command.includes(expectedEntrypoint)
+}
+
+async function rotateEditorLog(filePath: string): Promise {
+ try {
+ if ((await stat(filePath)).size <= 10 * 1024 * 1024) return
+ const previousPath = `${filePath}.1`
+ await rm(previousPath, { force: true })
+ await rename(filePath, previousPath)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ }
+}
diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts
new file mode 100644
index 0000000000..31e21da11a
--- /dev/null
+++ b/packages/cli/src/errors.ts
@@ -0,0 +1,30 @@
+export class CliError extends Error {
+ readonly code: string
+ readonly details?: unknown
+ readonly exitCode: number
+
+ constructor(code: string, message: string, details?: unknown, exitCode = 1) {
+ super(message)
+ this.name = 'CliError'
+ this.code = code
+ this.details = details
+ this.exitCode = exitCode
+ }
+}
+
+export function toCliError(error: unknown): CliError {
+ if (error instanceof CliError) return error
+ const nodeCode = (error as { code?: unknown })?.code
+ if (typeof nodeCode === 'string' && nodeCode.startsWith('ERR_PARSE_ARGS_')) {
+ return new CliError(
+ 'invalid_option',
+ error instanceof Error ? error.message : 'Invalid command options.',
+ undefined,
+ 2,
+ )
+ }
+ return new CliError(
+ 'unexpected_error',
+ error instanceof Error ? error.message : 'An unexpected error occurred.',
+ )
+}
diff --git a/packages/cli/src/file-lock.ts b/packages/cli/src/file-lock.ts
new file mode 100644
index 0000000000..75f4f1df5a
--- /dev/null
+++ b/packages/cli/src/file-lock.ts
@@ -0,0 +1,123 @@
+import { randomUUID } from 'node:crypto'
+import { mkdir, open, readFile, rm, stat } from 'node:fs/promises'
+import path from 'node:path'
+import { CliError } from './errors.js'
+
+interface LockRecord {
+ schemaVersion: 1
+ pid: number
+ token: string
+ createdAt: string
+}
+
+const DEFAULT_TIMEOUT_MS = 10_000
+const INVALID_LOCK_GRACE_MS = 5_000
+const MAX_LOCK_AGE_MS = 30 * 60_000
+
+export async function withFileLock(
+ lockPath: string,
+ code: string,
+ message: string,
+ action: () => Promise,
+ options: { timeoutMs?: number } = {},
+): Promise {
+ await mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 })
+ const token = randomUUID()
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
+
+ while (!(await tryAcquire(lockPath, token))) {
+ if (await reclaimStaleLock(lockPath)) continue
+ if (Date.now() >= deadline) throw new CliError(code, message)
+ await delay(100)
+ }
+
+ try {
+ return await action()
+ } finally {
+ await removeOwnedLock(lockPath, token)
+ }
+}
+
+async function tryAcquire(lockPath: string, token: string): Promise {
+ try {
+ const handle = await open(lockPath, 'wx', 0o600)
+ try {
+ const record: LockRecord = {
+ schemaVersion: 1,
+ pid: process.pid,
+ token,
+ createdAt: new Date().toISOString(),
+ }
+ await handle.writeFile(`${JSON.stringify(record)}\n`)
+ } finally {
+ await handle.close()
+ }
+ return true
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false
+ throw error
+ }
+}
+
+async function reclaimStaleLock(lockPath: string): Promise {
+ let ageMs: number
+ try {
+ ageMs = Date.now() - (await stat(lockPath)).mtimeMs
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true
+ throw error
+ }
+
+ let record: LockRecord | null = null
+ try {
+ record = JSON.parse(await readFile(lockPath, 'utf8')) as LockRecord
+ } catch {
+ if (ageMs < INVALID_LOCK_GRACE_MS) return false
+ }
+
+ if (isValidRecord(record) && isProcessRunning(record.pid) && ageMs < MAX_LOCK_AGE_MS) {
+ return false
+ }
+
+ try {
+ await rm(lockPath)
+ return true
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true
+ throw error
+ }
+}
+
+function isValidRecord(record: LockRecord | null): record is LockRecord {
+ return Boolean(
+ record?.schemaVersion === 1 &&
+ Number.isSafeInteger(record.pid) &&
+ record.pid > 0 &&
+ typeof record.token === 'string' &&
+ typeof record.createdAt === 'string',
+ )
+}
+
+async function removeOwnedLock(lockPath: string, token: string): Promise {
+ try {
+ const record = JSON.parse(await readFile(lockPath, 'utf8')) as Partial
+ if (record.token === token) await rm(lockPath)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && !(error instanceof SyntaxError)) {
+ throw error
+ }
+ }
+}
+
+function isProcessRunning(pid: number): boolean {
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch (error) {
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
+ }
+}
+
+function delay(milliseconds: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds))
+}
diff --git a/packages/cli/src/http-download.ts b/packages/cli/src/http-download.ts
new file mode 100644
index 0000000000..9dd7d5a340
--- /dev/null
+++ b/packages/cli/src/http-download.ts
@@ -0,0 +1,235 @@
+import { createWriteStream } from 'node:fs'
+import http from 'node:http'
+import https from 'node:https'
+import type { Socket } from 'node:net'
+import tls from 'node:tls'
+import { CliError } from './errors.js'
+import { version } from './version.js'
+
+const DEFAULT_TIMEOUT_MS = 60_000
+const MAX_REDIRECTS = 5
+const PROGRESS_INTERVAL_MS = 200
+
+export interface DownloadProgress {
+ received: number
+ total: number | null
+}
+
+export interface DownloadOptions {
+ environment?: NodeJS.ProcessEnv
+ onProgress?: (progress: DownloadProgress) => void
+ timeoutMs?: number
+}
+
+/**
+ * Streams an HTTPS URL to disk without adding a dependency. Node's built-in `fetch` only
+ * honours `HTTPS_PROXY` when the process was started with `--use-env-proxy`, which a
+ * published CLI cannot retrofit onto its own entrypoint, so the proxy tunnel is explicit.
+ */
+export async function downloadToFile(
+ url: string,
+ destination: string,
+ options: DownloadOptions = {},
+): Promise {
+ const environment = options.environment ?? process.env
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
+ let target = parseHttpsUrl(url)
+ for (let redirect = 0; ; redirect += 1) {
+ const response = await requestOnce(target, environment, timeoutMs)
+ const status = response.statusCode ?? 0
+ if (status >= 300 && status < 400 && response.headers.location) {
+ response.resume()
+ if (redirect >= MAX_REDIRECTS) {
+ throw new CliError('download_failed', `${url} redirected more than ${MAX_REDIRECTS} times.`)
+ }
+ target = parseHttpsUrl(new URL(response.headers.location, target).toString())
+ continue
+ }
+ if (status !== 200) {
+ response.resume()
+ throw new CliError('download_failed', `${target.href} returned HTTP ${status}.`)
+ }
+ return writeResponse(response, destination, options.onProgress)
+ }
+}
+
+export function resolveProxyUrl(target: URL, environment: NodeJS.ProcessEnv): string | null {
+ if (isProxyBypassed(target.hostname, environment.NO_PROXY ?? environment.no_proxy)) return null
+ const configured =
+ environment.HTTPS_PROXY ??
+ environment.https_proxy ??
+ environment.ALL_PROXY ??
+ environment.all_proxy
+ return configured?.trim() ? configured.trim() : null
+}
+
+export function isProxyBypassed(hostname: string, noProxy: string | undefined): boolean {
+ if (!noProxy?.trim()) return false
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
+ for (const raw of noProxy.split(/[,\s]+/)) {
+ const entry = raw.trim().toLowerCase()
+ if (!entry) continue
+ if (entry === '*') return true
+ const pattern = entry.replace(/^\*/, '').replace(/^\./, '').replace(/:\d+$/, '')
+ if (!pattern) continue
+ if (host === pattern || host.endsWith(`.${pattern}`)) return true
+ }
+ return false
+}
+
+function parseHttpsUrl(value: string): URL {
+ let url: URL
+ try {
+ url = new URL(value)
+ } catch {
+ throw new CliError('download_failed', `Invalid download URL: ${value}`)
+ }
+ if (url.protocol !== 'https:') {
+ throw new CliError('download_failed', `Only https downloads are supported: ${value}`)
+ }
+ return url
+}
+
+async function requestOnce(
+ target: URL,
+ environment: NodeJS.ProcessEnv,
+ timeoutMs: number,
+): Promise {
+ const proxy = resolveProxyUrl(target, environment)
+ const agent = proxy
+ ? new TunnelAgent(
+ await openProxyTunnel(parseProxyUrl(proxy), target, timeoutMs),
+ target.hostname,
+ )
+ : undefined
+ const request = https.request({
+ hostname: target.hostname,
+ port: target.port || 443,
+ path: `${target.pathname}${target.search}`,
+ method: 'GET',
+ headers: {
+ accept: 'application/octet-stream, */*',
+ 'accept-encoding': 'identity',
+ 'user-agent': `pascal-cli/${version}`,
+ },
+ ...(agent ? { agent } : {}),
+ })
+ request.setTimeout(timeoutMs, () =>
+ request.destroy(new Error(`no response from ${target.host} within ${timeoutMs}ms`)),
+ )
+ request.end()
+ return new Promise((resolve, reject) => {
+ request.once('response', resolve)
+ request.once('error', (error) =>
+ reject(new CliError('download_failed', `Unable to reach ${target.href}: ${error.message}`)),
+ )
+ })
+}
+
+async function writeResponse(
+ response: http.IncomingMessage,
+ destination: string,
+ onProgress: ((progress: DownloadProgress) => void) | undefined,
+): Promise {
+ const declared = Number(response.headers['content-length'])
+ const total = Number.isFinite(declared) && declared > 0 ? declared : null
+ const file = createWriteStream(destination, { mode: 0o600 })
+ let received = 0
+ let lastReport = 0
+ await new Promise((resolve, reject) => {
+ const fail = (error: Error) => {
+ response.destroy()
+ file.destroy()
+ reject(error)
+ }
+ response.on('data', (chunk: Buffer) => {
+ received += chunk.byteLength
+ if (!file.write(chunk)) response.pause()
+ const now = Date.now()
+ if (onProgress && now - lastReport >= PROGRESS_INTERVAL_MS) {
+ lastReport = now
+ onProgress({ received, total })
+ }
+ })
+ file.on('drain', () => response.resume())
+ response.once('error', fail)
+ file.once('error', fail)
+ response.once('end', () => file.end(resolve))
+ })
+ onProgress?.({ received, total })
+ return received
+}
+
+function parseProxyUrl(value: string): URL {
+ const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`
+ let proxy: URL
+ try {
+ proxy = new URL(candidate)
+ } catch {
+ throw new CliError('download_failed', `Invalid proxy URL: ${value}`)
+ }
+ if (proxy.protocol !== 'http:' && proxy.protocol !== 'https:') {
+ throw new CliError('download_failed', `Unsupported proxy protocol: ${proxy.protocol}`)
+ }
+ return proxy
+}
+
+async function openProxyTunnel(proxy: URL, target: URL, timeoutMs: number): Promise {
+ const authority = `${target.hostname}:${target.port || 443}`
+ const headers: Record = { host: authority }
+ if (proxy.username) {
+ const credentials = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`
+ headers['proxy-authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`
+ }
+ const requestFn = proxy.protocol === 'https:' ? https.request : http.request
+ const request = requestFn({
+ host: proxy.hostname,
+ port: proxy.port || (proxy.protocol === 'https:' ? 443 : 80),
+ method: 'CONNECT',
+ path: authority,
+ headers,
+ })
+ request.setTimeout(timeoutMs, () =>
+ request.destroy(new Error(`proxy ${proxy.host} did not answer CONNECT within ${timeoutMs}ms`)),
+ )
+ request.end()
+ return new Promise((resolve, reject) => {
+ request.once('connect', (response, socket) => {
+ if (response.statusCode !== 200) {
+ socket.destroy()
+ reject(
+ new CliError(
+ 'download_failed',
+ `Proxy ${proxy.host} refused CONNECT ${authority} with HTTP ${response.statusCode}.`,
+ ),
+ )
+ return
+ }
+ resolve(socket)
+ })
+ request.once('error', (error) =>
+ reject(
+ new CliError('download_failed', `Unable to reach proxy ${proxy.host}: ${error.message}`),
+ ),
+ )
+ })
+}
+
+class TunnelAgent extends https.Agent {
+ private readonly tunnel: Socket
+ private readonly servername: string
+
+ constructor(tunnel: Socket, servername: string) {
+ super({ keepAlive: false, maxSockets: 1 })
+ this.tunnel = tunnel
+ this.servername = servername
+ }
+
+ override createConnection(): tls.TLSSocket {
+ return tls.connect({
+ socket: this.tunnel,
+ servername: this.servername,
+ ALPNProtocols: ['http/1.1'],
+ })
+ }
+}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
new file mode 100644
index 0000000000..ba86bb163b
--- /dev/null
+++ b/packages/cli/src/index.ts
@@ -0,0 +1,36 @@
+export { collectInfo, type DiagnosticCheck, runDoctor } from './diagnostics.js'
+export {
+ activateEditorRuntime,
+ type EditorState,
+ type EditorStatus,
+ ensurePascalDirectories,
+ getEditorStatus,
+ type RuntimeActivationResult,
+ restartEditor,
+ type StopEditorOptions,
+ startEditor,
+ stopEditor,
+} from './editor-process.js'
+export { CliError } from './errors.js'
+export {
+ ensureMcpService,
+ getMcpServiceStatus,
+ type McpServiceState,
+ type McpServiceStatus,
+ stopMcpService,
+} from './mcp-service.js'
+export { type PascalPaths, resolvePascalPaths } from './paths.js'
+export {
+ type ActiveRuntime,
+ installBundledRuntime,
+ type RuntimeManifest,
+ readActiveRuntime,
+ readRuntimeManifest,
+} from './runtime.js'
+export {
+ ensureWebRuntime,
+ type RuntimeSource,
+ readRuntimeSource,
+ verifyArchiveDigest,
+} from './runtime-download.js'
+export { version } from './version.js'
diff --git a/packages/cli/src/json-files.ts b/packages/cli/src/json-files.ts
new file mode 100644
index 0000000000..038de2e5de
--- /dev/null
+++ b/packages/cli/src/json-files.ts
@@ -0,0 +1,18 @@
+import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+
+export async function readJsonFile(filePath: string): Promise {
+ try {
+ return JSON.parse(await readFile(filePath, 'utf8')) as T
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
+ throw error
+ }
+}
+
+export async function writeJsonFile(filePath: string, value: unknown): Promise {
+ await mkdir(path.dirname(filePath), { recursive: true })
+ const temporaryPath = `${filePath}.${process.pid}.tmp`
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
+ await rename(temporaryPath, filePath)
+}
diff --git a/packages/cli/src/mcp-connector.ts b/packages/cli/src/mcp-connector.ts
new file mode 100644
index 0000000000..1ff5d3fed4
--- /dev/null
+++ b/packages/cli/src/mcp-connector.ts
@@ -0,0 +1,65 @@
+import { readFile } from 'node:fs/promises'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
+import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
+import { CliError } from './errors.js'
+import { ensureMcpService } from './mcp-service.js'
+import type { PascalPaths } from './paths.js'
+
+/**
+ * Bridges stdio to the managed MCP service. The service ships with the CLI, so this never
+ * starts the web editor and never needs the downloaded web runtime.
+ */
+export async function connectManagedMcp(paths: PascalPaths): Promise {
+ const { state } = await ensureMcpService({ paths })
+ const token = await readMcpToken(paths)
+
+ const remote = new StreamableHTTPClientTransport(new URL(state.url), {
+ requestInit: { headers: { authorization: `Bearer ${token}` } },
+ })
+ const stdio = new StdioServerTransport()
+ let initializeRequestId: string | number | null = null
+
+ stdio.onmessage = (message) => {
+ if ('method' in message && message.method === 'initialize' && 'id' in message) {
+ initializeRequestId = message.id
+ }
+ remote.send(message).catch(reportConnectorError)
+ }
+ stdio.onerror = reportConnectorError
+ remote.onmessage = (message) => {
+ applyProtocolVersion(remote, message, initializeRequestId)
+ stdio.send(message).catch(reportConnectorError)
+ }
+ remote.onerror = reportConnectorError
+
+ await remote.start()
+ await stdio.start()
+}
+
+async function readMcpToken(paths: PascalPaths): Promise {
+ let token = ''
+ try {
+ token = (await readFile(paths.mcpToken, 'utf8')).trim()
+ } catch {}
+ if (!token) throw new CliError('mcp_unavailable', 'Pascal MCP credentials are missing.')
+ return token
+}
+
+function applyProtocolVersion(
+ transport: StreamableHTTPClientTransport,
+ message: JSONRPCMessage,
+ initializeRequestId: string | number | null,
+): void {
+ if (!(initializeRequestId !== null && 'id' in message && message.id === initializeRequestId)) {
+ return
+ }
+ if (!('result' in message) || typeof message.result !== 'object' || message.result === null)
+ return
+ const protocolVersion = (message.result as { protocolVersion?: unknown }).protocolVersion
+ if (typeof protocolVersion === 'string') transport.setProtocolVersion(protocolVersion)
+}
+
+function reportConnectorError(error: Error): void {
+ process.stderr.write(`[pascal-mcp] ${error.message}\n`)
+}
diff --git a/packages/cli/src/mcp-service.test.ts b/packages/cli/src/mcp-service.test.ts
new file mode 100644
index 0000000000..2f2c56c351
--- /dev/null
+++ b/packages/cli/src/mcp-service.test.ts
@@ -0,0 +1,165 @@
+import { afterAll, afterEach, describe, expect, test } from 'bun:test'
+import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import {
+ ensureMcpService,
+ getMcpServiceStatus,
+ type McpServiceState,
+ stopMcpService,
+} from './mcp-service.js'
+import { type PascalPaths, resolvePascalPaths } from './paths.js'
+import { readActiveRuntime } from './runtime.js'
+import { writeFakeMcpService } from './test-support/fake-mcp-service.js'
+
+const roots: string[] = []
+const started: PascalPaths[] = []
+/** The MCP service ships with the CLI; the tests inject a stand-in for the bundled bundle. */
+const serviceRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-mcp-service-'))
+process.env.PASCAL_MCP_SERVICE_PATH = await writeFakeMcpService(serviceRoot)
+
+afterEach(async () => {
+ for (const paths of started.splice(0)) {
+ await stopMcpService(paths, { force: true }).catch(() => undefined)
+ }
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+afterAll(() => rm(serviceRoot, { recursive: true, force: true }))
+
+describe('managed MCP service', () => {
+ test('starts on demand without a web runtime installed', async () => {
+ const paths = await temporaryPaths()
+
+ const result = await ensureMcpService({ paths })
+
+ expect(result.alreadyRunning).toBe(false)
+ expect(result.state.editorOrigin).toBeNull()
+ expect(result.state.host).toBe('127.0.0.1')
+ expect(result.state.url).toBe(`http://127.0.0.1:${result.state.port}/mcp`)
+ expect(await readActiveRuntime(paths)).toBeNull()
+ expect(paths.mcpState.endsWith(path.join('run', 'mcp.json'))).toBe(true)
+ const status = await getMcpServiceStatus(paths)
+ expect(status).toMatchObject({ running: true, healthy: true })
+ expect(status.state?.pid).toBe(result.state.pid)
+ expect((await stat(paths.mcpToken)).mode & 0o077).toBe(0)
+ })
+
+ test('reuses a healthy service instead of starting a second one', async () => {
+ const paths = await temporaryPaths()
+ const first = await ensureMcpService({ paths })
+
+ const second = await ensureMcpService({ paths })
+
+ expect(second.alreadyRunning).toBe(true)
+ expect(second.state.pid).toBe(first.state.pid)
+ expect(second.state.instanceId).toBe(first.state.instanceId)
+ })
+
+ test('serializes concurrent starts into one service', async () => {
+ const paths = await temporaryPaths()
+
+ const [first, second] = await Promise.all([
+ ensureMcpService({ paths }),
+ ensureMcpService({ paths }),
+ ])
+
+ expect(first.state.pid).toBe(second.state.pid)
+ expect([first.alreadyRunning, second.alreadyRunning].sort()).toEqual([false, true])
+ })
+
+ test('keeps the recorded editor origin when the caller does not run the editor', async () => {
+ const paths = await temporaryPaths()
+ const editorOrigin = 'http://pascal.localhost:41234'
+ const first = await ensureMcpService({ paths, editorOrigin })
+
+ const connected = await ensureMcpService({ paths })
+
+ expect(connected.alreadyRunning).toBe(true)
+ expect(connected.state.pid).toBe(first.state.pid)
+ expect(connected.state.editorOrigin).toBe(editorOrigin)
+ expect(await reportedEditorOrigin(paths, connected.state)).toBe(editorOrigin)
+ })
+
+ test('restarts with the new origin when the editor moves to another port', async () => {
+ const paths = await temporaryPaths()
+ const first = await ensureMcpService({ paths, editorOrigin: 'http://pascal.localhost:41234' })
+
+ const moved = await ensureMcpService({ paths, editorOrigin: 'http://pascal.localhost:41235' })
+
+ expect(moved.alreadyRunning).toBe(false)
+ expect(moved.state.pid).not.toBe(first.state.pid)
+ expect(moved.state.editorOrigin).toBe('http://pascal.localhost:41235')
+ expect(await reportedEditorOrigin(paths, moved.state)).toBe('http://pascal.localhost:41235')
+ })
+
+ test('stops the service once and clears its state and token', async () => {
+ const paths = await temporaryPaths()
+ await ensureMcpService({ paths })
+
+ expect(await stopMcpService(paths)).toBe(true)
+ expect(await stopMcpService(paths)).toBe(false)
+ expect(await getMcpServiceStatus(paths)).toEqual({
+ running: false,
+ healthy: false,
+ state: null,
+ })
+ expect(await exists(paths.mcpState)).toBe(false)
+ expect(await exists(paths.mcpToken)).toBe(false)
+ })
+
+ test('refuses to stop a recorded process that is not the MCP service', async () => {
+ const paths = await temporaryPaths(false)
+ await writeFile(
+ paths.mcpState,
+ JSON.stringify({
+ schemaVersion: 1,
+ pid: process.pid,
+ port: 1,
+ host: '127.0.0.1',
+ url: 'http://127.0.0.1:1/mcp',
+ version: '0.0.0',
+ instanceId: 'not-the-service',
+ servicePath: path.join(serviceRoot, 'pascal-mcp.mjs'),
+ editorOrigin: null,
+ startedAt: new Date().toISOString(),
+ }),
+ )
+
+ await expect(stopMcpService(paths)).rejects.toMatchObject({ code: 'state_conflict' })
+ await expect(stopMcpService(paths, { force: true })).rejects.toMatchObject({
+ code: 'state_conflict',
+ })
+ await rm(paths.mcpState, { force: true })
+ })
+})
+
+async function temporaryPaths(tracked = true): Promise {
+ const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-mcp-test-'))
+ roots.push(root)
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ await mkdir(paths.run, { recursive: true, mode: 0o700 })
+ if (tracked) started.push(paths)
+ return paths
+}
+
+/** The stand-in service echoes the origin it was started with, proving the restart repointed it. */
+async function reportedEditorOrigin(
+ paths: PascalPaths,
+ state: McpServiceState,
+): Promise {
+ const token = (await readFile(paths.mcpToken, 'utf8')).trim()
+ const response = await fetch(`http://127.0.0.1:${state.port}/health`, {
+ headers: { authorization: `Bearer ${token}` },
+ })
+ return ((await response.json()) as { editorOrigin: string | null }).editorOrigin
+}
+
+async function exists(file: string): Promise {
+ try {
+ await stat(file)
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/packages/cli/src/mcp-service.ts b/packages/cli/src/mcp-service.ts
new file mode 100644
index 0000000000..af633b8bf8
--- /dev/null
+++ b/packages/cli/src/mcp-service.ts
@@ -0,0 +1,292 @@
+import type { ChildProcess } from 'node:child_process'
+import { spawn } from 'node:child_process'
+import { randomBytes, randomUUID } from 'node:crypto'
+import { closeSync, openSync } from 'node:fs'
+import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { CliError } from './errors.js'
+import { withFileLock } from './file-lock.js'
+import { readJsonFile, writeJsonFile } from './json-files.js'
+import type { PascalPaths } from './paths.js'
+import {
+ findAvailablePort,
+ isProcessRunning,
+ processCommand,
+ terminateProcess,
+ waitForSpawn,
+} from './process-control.js'
+import { version } from './version.js'
+
+export interface McpServiceState {
+ schemaVersion: 1
+ pid: number
+ port: number
+ host: '127.0.0.1'
+ url: string
+ version: string
+ instanceId: string
+ servicePath: string
+ editorOrigin: string | null
+ startedAt: string
+}
+
+export interface McpServiceStatus {
+ running: boolean
+ healthy: boolean
+ state: McpServiceState | null
+}
+
+export type McpStartProgress =
+ | { step: 'mcp-port-ready'; port: number }
+ | { step: 'mcp-starting'; port: number }
+ | { step: 'mcp-health-checking'; port: number }
+ | { step: 'mcp-ready'; port: number }
+ | { step: 'mcp-already-running'; port: number }
+
+export interface EnsureMcpServiceOptions {
+ paths: PascalPaths
+ /**
+ * The editor origin the MCP service should format `editorUrl` values against. Omit it when
+ * the caller does not run the web editor: a recorded origin is then kept as it is.
+ */
+ editorOrigin?: string
+ foreground?: boolean
+ onProgress?: (event: McpStartProgress) => void
+}
+
+export interface McpServiceResult {
+ state: McpServiceState
+ alreadyRunning: boolean
+ child?: ChildProcess
+}
+
+/**
+ * The MCP service is bundled with the CLI itself, not with the downloaded web runtime, so
+ * agent tools work before (and without) any editor runtime being installed.
+ */
+export function resolveMcpServicePath(environment: NodeJS.ProcessEnv = process.env): string {
+ if (environment.PASCAL_MCP_SERVICE_PATH) {
+ return path.resolve(environment.PASCAL_MCP_SERVICE_PATH)
+ }
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url))
+ return path.basename(moduleDirectory) === 'dist'
+ ? path.join(moduleDirectory, 'services/pascal-mcp.mjs')
+ : path.resolve(moduleDirectory, '../dist/services/pascal-mcp.mjs')
+}
+
+export async function getMcpServiceStatus(paths: PascalPaths): Promise {
+ const state = await readJsonFile(paths.mcpState)
+ if (state?.schemaVersion !== 1 || typeof state.pid !== 'number') {
+ return { running: false, healthy: false, state: null }
+ }
+ const running = isProcessRunning(state.pid)
+ return { running, healthy: running ? await checkMcpHealth(paths, state) : false, state }
+}
+
+export async function ensureMcpService(
+ options: EnsureMcpServiceOptions,
+): Promise {
+ return withMcpLifecycleLock(options.paths, () => ensureMcpServiceUnlocked(options))
+}
+
+export async function stopMcpService(
+ paths: PascalPaths,
+ options: { force?: boolean } = {},
+): Promise {
+ return withMcpLifecycleLock(paths, () => stopMcpServiceUnlocked(paths, options))
+}
+
+async function ensureMcpServiceUnlocked(
+ options: EnsureMcpServiceOptions,
+): Promise {
+ const { paths } = options
+ const status = await getMcpServiceStatus(paths)
+ if (status.healthy && status.state) {
+ const originMatches =
+ options.editorOrigin === undefined || options.editorOrigin === status.state.editorOrigin
+ if (originMatches) {
+ options.onProgress?.({ step: 'mcp-already-running', port: status.state.port })
+ return { state: status.state, alreadyRunning: true }
+ }
+ }
+ if (status.running && status.state) {
+ if (!(status.healthy || (await matchesRecordedMcpProcess(status.state)))) {
+ throw new CliError(
+ 'state_conflict',
+ 'A recorded Pascal MCP process is running but its identity could not be verified. Inspect "pascal mcp status --json", then use "pascal stop --force" only if the recorded command is trusted.',
+ )
+ }
+ await terminateProcess(status.state.pid)
+ }
+ await rm(paths.mcpState, { force: true })
+ await rm(paths.mcpToken, { force: true })
+
+ const servicePath = resolveMcpServicePath()
+ const port = await findAvailablePort(0)
+ const instanceId = randomUUID()
+ const token = randomBytes(32).toString('base64url')
+ const state: McpServiceState = {
+ schemaVersion: 1,
+ pid: 0,
+ port,
+ host: '127.0.0.1',
+ url: `http://127.0.0.1:${port}/mcp`,
+ version,
+ instanceId,
+ servicePath,
+ editorOrigin: options.editorOrigin ?? null,
+ startedAt: new Date().toISOString(),
+ }
+ options.onProgress?.({ step: 'mcp-port-ready', port })
+ await Promise.all(
+ [paths.run, paths.logs, paths.data].map((directory) =>
+ mkdir(directory, { recursive: true, mode: 0o700 }),
+ ),
+ )
+ await writeFile(paths.mcpToken, `${token}\n`, { mode: 0o600 })
+
+ const environment: NodeJS.ProcessEnv = {
+ ...process.env,
+ NODE_ENV: 'production',
+ PASCAL_DATA_DIR: paths.data,
+ PASCAL_INSTANCE_ID: instanceId,
+ PASCAL_RUNTIME_VERSION: version,
+ PASCAL_MCP_HTTP_TOKEN: token,
+ ...(state.editorOrigin ? { PASCAL_EDITOR_ORIGIN: state.editorOrigin } : {}),
+ }
+ const nodeBinary = process.env.PASCAL_NODE_BINARY || 'node'
+ const logDescriptor = options.foreground ? undefined : openSync(paths.editorLog, 'a', 0o600)
+ options.onProgress?.({ step: 'mcp-starting', port })
+ const child = spawn(
+ nodeBinary,
+ [servicePath, '--http', '--host', state.host, '--port', String(port)],
+ {
+ cwd: path.dirname(servicePath),
+ env: environment,
+ detached: !options.foreground,
+ stdio: options.foreground
+ ? ['ignore', 'inherit', 'inherit']
+ : ['ignore', logDescriptor!, logDescriptor!],
+ },
+ )
+ if (logDescriptor !== undefined) closeSync(logDescriptor)
+ try {
+ await waitForSpawn(child, nodeBinary)
+ if (!child.pid) throw new CliError('start_failed', 'The Pascal MCP process did not start.')
+ state.pid = child.pid
+ await writeJsonFile(paths.mcpState, state)
+ if (!options.foreground) child.unref()
+ options.onProgress?.({ step: 'mcp-health-checking', port })
+ await waitForMcpHealth(paths, state, 20_000)
+ options.onProgress?.({ step: 'mcp-ready', port })
+ } catch (error) {
+ if (child.pid) await terminateProcess(child.pid)
+ await rm(paths.mcpState, { force: true })
+ await rm(paths.mcpToken, { force: true })
+ throw error
+ }
+ return { state, alreadyRunning: false, child: options.foreground ? child : undefined }
+}
+
+async function stopMcpServiceUnlocked(
+ paths: PascalPaths,
+ options: { force?: boolean },
+): Promise {
+ const status = await getMcpServiceStatus(paths)
+ if (!status.state || !status.running) {
+ await rm(paths.mcpState, { force: true })
+ await rm(paths.mcpToken, { force: true })
+ return false
+ }
+ if (!(status.healthy || (options.force && (await matchesRecordedMcpProcess(status.state))))) {
+ throw new CliError(
+ 'state_conflict',
+ options.force
+ ? 'Refusing to stop a process whose health identity and operating-system command do not match the recorded Pascal MCP service.'
+ : 'The Pascal MCP identity is unavailable. Inspect "pascal mcp status --json", then use "pascal stop --force" only if the recorded command is trusted.',
+ )
+ }
+ await terminateProcess(status.state.pid)
+ await rm(paths.mcpState, { force: true })
+ await rm(paths.mcpToken, { force: true })
+ return true
+}
+
+export async function checkMcpHealth(paths: PascalPaths, state: McpServiceState): Promise {
+ return (await probeMcpHealth(paths, state)) === 'healthy'
+}
+
+async function probeMcpHealth(
+ paths: PascalPaths,
+ state: McpServiceState,
+): Promise<'healthy' | 'foreign' | 'unreachable'> {
+ let token: string
+ try {
+ token = (await readFile(paths.mcpToken, 'utf8')).trim()
+ } catch {
+ return 'unreachable'
+ }
+ if (!token) return 'unreachable'
+ try {
+ const response = await fetch(`http://127.0.0.1:${state.port}/health`, {
+ headers: { authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(1_000),
+ })
+ if (!response.ok) return 'foreign'
+ const body = (await response.json()) as {
+ status?: string
+ app?: string
+ version?: string
+ instanceId?: string
+ }
+ return body.status === 'ok' &&
+ body.app === 'mcp' &&
+ body.version === state.version &&
+ body.instanceId === state.instanceId
+ ? 'healthy'
+ : 'foreign'
+ } catch {
+ return 'unreachable'
+ }
+}
+
+async function waitForMcpHealth(
+ paths: PascalPaths,
+ state: McpServiceState,
+ timeoutMs: number,
+): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ const health = await probeMcpHealth(paths, state)
+ if (health === 'healthy') return
+ if (health === 'foreign') {
+ throw new CliError(
+ 'port_conflict',
+ `Port ${state.port} is responding as another application. Run the command again to choose another port.`,
+ )
+ }
+ if (!isProcessRunning(state.pid)) {
+ throw new CliError('start_failed', 'Pascal MCP exited before becoming healthy.')
+ }
+ await new Promise((resolve) => setTimeout(resolve, 200))
+ }
+ throw new CliError('health_timeout', `Pascal MCP did not become healthy within ${timeoutMs}ms.`)
+}
+
+async function matchesRecordedMcpProcess(state: McpServiceState): Promise {
+ if (process.platform === 'win32') return false
+ const servicePath = path.resolve(state.servicePath)
+ if (path.basename(servicePath) !== 'pascal-mcp.mjs') return false
+ return (await processCommand(state.pid)).includes(servicePath)
+}
+
+async function withMcpLifecycleLock(paths: PascalPaths, action: () => Promise): Promise {
+ return withFileLock(
+ path.join(paths.run, 'mcp-lifecycle.lock'),
+ 'mcp_locked',
+ 'Another Pascal MCP lifecycle operation is active.',
+ action,
+ { timeoutMs: 30_000 },
+ )
+}
diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts
new file mode 100644
index 0000000000..f352dd83af
--- /dev/null
+++ b/packages/cli/src/paths.ts
@@ -0,0 +1,39 @@
+import os from 'node:os'
+import path from 'node:path'
+
+export interface PascalPaths {
+ root: string
+ runtime: string
+ data: string
+ plugins: string
+ run: string
+ logs: string
+ tmp: string
+ state: string
+ mcpState: string
+ currentRuntime: string
+ pluginLock: string
+ database: string
+ editorLog: string
+ mcpToken: string
+}
+
+export function resolvePascalPaths(environment: NodeJS.ProcessEnv = process.env): PascalPaths {
+ const root = path.resolve(environment.PASCAL_HOME || path.join(os.homedir(), '.pascal'))
+ return {
+ root,
+ runtime: path.join(root, 'runtime'),
+ data: path.join(root, 'data'),
+ plugins: path.join(root, 'plugins'),
+ run: path.join(root, 'run'),
+ logs: path.join(root, 'logs'),
+ tmp: path.join(root, 'tmp'),
+ state: path.join(root, 'run/editor.json'),
+ mcpState: path.join(root, 'run/mcp.json'),
+ currentRuntime: path.join(root, 'run/current-runtime.json'),
+ pluginLock: path.join(root, 'pascal.plugins.lock'),
+ database: path.join(root, 'data/pascal.db'),
+ editorLog: path.join(root, 'logs/editor.log'),
+ mcpToken: path.join(root, 'run/mcp-token'),
+ }
+}
diff --git a/packages/cli/src/process-control.ts b/packages/cli/src/process-control.ts
new file mode 100644
index 0000000000..f19027f48d
--- /dev/null
+++ b/packages/cli/src/process-control.ts
@@ -0,0 +1,96 @@
+import { type ChildProcess, execFile } from 'node:child_process'
+import net from 'node:net'
+import { CliError } from './errors.js'
+
+export function isProcessRunning(pid: number): boolean {
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch (error) {
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
+ }
+}
+
+export async function terminateProcess(pid: number): Promise {
+ try {
+ process.kill(pid, 'SIGTERM')
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ESRCH') return
+ throw error
+ }
+ const deadline = Date.now() + 10_000
+ while (Date.now() < deadline) {
+ if (!isProcessRunning(pid)) return
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ }
+ try {
+ process.kill(pid, 'SIGKILL')
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
+ }
+}
+
+export async function findAvailablePort(preferredPort: number): Promise {
+ if (!Number.isInteger(preferredPort) || preferredPort < 0 || preferredPort > 65_535) {
+ throw new CliError('invalid_port', `Invalid port: ${preferredPort}`)
+ }
+ if (preferredPort === 0) return probePort(0)
+ if (!(await isPortAcceptingConnections(preferredPort))) {
+ try {
+ return await probePort(preferredPort)
+ } catch {}
+ }
+ return probePort(0)
+}
+
+async function isPortAcceptingConnections(port: number): Promise {
+ return new Promise((resolve) => {
+ const socket = net.connect({ host: '127.0.0.1', port })
+ let settled = false
+ const finish = (result: boolean) => {
+ if (settled) return
+ settled = true
+ socket.destroy()
+ resolve(result)
+ }
+ socket.setTimeout(250)
+ socket.once('connect', () => finish(true))
+ socket.once('timeout', () => finish(false))
+ socket.once('error', () => finish(false))
+ })
+}
+
+async function probePort(port: number): Promise {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer()
+ server.unref()
+ server.once('error', reject)
+ server.listen({ host: '127.0.0.1', port }, () => {
+ const address = server.address()
+ const resolvedPort = typeof address === 'object' && address ? address.port : port
+ server.close((error) => (error ? reject(error) : resolve(resolvedPort)))
+ })
+ })
+}
+
+export async function processCommand(pid: number): Promise {
+ return new Promise((resolve) => {
+ execFile('ps', ['-ww', '-p', String(pid), '-o', 'command='], (error, stdout) => {
+ resolve(error ? '' : stdout.trim())
+ })
+ })
+}
+
+export async function waitForSpawn(child: ChildProcess, binary: string): Promise {
+ await new Promise((resolve, reject) => {
+ child.once('spawn', resolve)
+ child.once('error', reject)
+ }).catch((error) => {
+ throw new CliError('start_failed', `Unable to launch ${binary}: ${errorMessage(error)}`)
+ })
+}
+
+export function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
diff --git a/packages/cli/src/projects.test.ts b/packages/cli/src/projects.test.ts
new file mode 100644
index 0000000000..b786e5f0de
--- /dev/null
+++ b/packages/cli/src/projects.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test } from 'bun:test'
+import { type LocalProject, resolveLocalProject } from './projects.js'
+
+const projects: LocalProject[] = [
+ {
+ id: 'kitchen-2026',
+ name: 'Kitchen renovation',
+ updatedAt: '2026-08-07T16:00:00.000Z',
+ version: 3,
+ nodeCount: 20,
+ },
+ {
+ id: 'garden-room',
+ name: 'Garden room',
+ updatedAt: '2026-08-06T16:00:00.000Z',
+ version: 1,
+ nodeCount: 8,
+ },
+]
+
+describe('local project selection', () => {
+ test('resumes the newest project when no selector is given', () => {
+ expect(resolveLocalProject(projects)).toBe(projects[0])
+ })
+
+ test('matches an exact id, a unique prefix, or a case-insensitive name', () => {
+ expect(resolveLocalProject(projects, 'garden-room')).toBe(projects[1])
+ expect(resolveLocalProject(projects, 'kitchen')).toBe(projects[0])
+ expect(resolveLocalProject(projects, 'GARDEN ROOM')).toBe(projects[1])
+ })
+
+ test('never guesses when a selector is ambiguous', () => {
+ const ambiguous = [...projects, { ...projects[1]!, id: 'garden-suite', name: 'Garden room' }]
+ expect(() => resolveLocalProject(ambiguous, 'garden')).toThrow(/More than one/)
+ expect(() => resolveLocalProject(ambiguous, 'Garden room')).toThrow(/More than one/)
+ })
+
+ test('returns an actionable error when no project matches', () => {
+ expect(() => resolveLocalProject(projects, 'missing')).toThrow(/No local project matches/)
+ expect(() => resolveLocalProject([], undefined)).toThrow(/No local projects exist/)
+ })
+})
diff --git a/packages/cli/src/projects.ts b/packages/cli/src/projects.ts
new file mode 100644
index 0000000000..605e26a115
--- /dev/null
+++ b/packages/cli/src/projects.ts
@@ -0,0 +1,87 @@
+import type { EditorState } from './editor-process.js'
+import { CliError } from './errors.js'
+
+export interface LocalProject {
+ id: string
+ name: string
+ updatedAt: string
+ version: number
+ nodeCount: number
+}
+
+export async function listLocalProjects(state: EditorState): Promise {
+ const response = await fetch(`http://127.0.0.1:${state.port}/api/scenes?limit=500`, {
+ signal: AbortSignal.timeout(5_000),
+ })
+ if (!response.ok) {
+ throw new CliError('project_list_failed', `Scene API returned ${response.status}.`)
+ }
+ const body = (await response.json()) as { scenes?: unknown }
+ if (!Array.isArray(body.scenes)) {
+ throw new CliError('project_list_failed', 'Scene API returned an invalid project list.')
+ }
+ return body.scenes.map(parseProject)
+}
+
+export function resolveLocalProject(projects: LocalProject[], selector?: string): LocalProject {
+ if (!selector) {
+ const latest = projects[0]
+ if (!latest) throw new CliError('project_not_found', 'No local projects exist yet.')
+ return latest
+ }
+
+ const query = selector.trim()
+ const exactId = projects.find((project) => project.id === query)
+ if (exactId) return exactId
+
+ const normalized = query.toLowerCase()
+ const exactNames = projects.filter((project) => project.name.toLowerCase() === normalized)
+ if (exactNames.length === 1) return exactNames[0]!
+ if (exactNames.length > 1) throw ambiguousProject(selector, exactNames)
+
+ const idPrefixes = projects.filter((project) => project.id.startsWith(query))
+ if (idPrefixes.length === 1) return idPrefixes[0]!
+ if (idPrefixes.length > 1) throw ambiguousProject(selector, idPrefixes)
+
+ throw new CliError('project_not_found', `No local project matches "${selector}".`, {
+ selector,
+ })
+}
+
+export function projectUrl(state: EditorState, project: LocalProject): string {
+ return `${state.url}/scene/${encodeURIComponent(project.id)}`
+}
+
+function parseProject(value: unknown): LocalProject {
+ if (!(typeof value === 'object' && value !== null)) {
+ throw new CliError('project_list_failed', 'Scene API returned invalid project metadata.')
+ }
+ const project = value as Record
+ if (
+ typeof project.id !== 'string' ||
+ typeof project.name !== 'string' ||
+ typeof project.updatedAt !== 'string' ||
+ typeof project.version !== 'number' ||
+ typeof project.nodeCount !== 'number'
+ ) {
+ throw new CliError('project_list_failed', 'Scene API returned invalid project metadata.')
+ }
+ return {
+ id: project.id,
+ name: project.name,
+ updatedAt: project.updatedAt,
+ version: project.version,
+ nodeCount: project.nodeCount,
+ }
+}
+
+function ambiguousProject(selector: string, matches: LocalProject[]): CliError {
+ return new CliError(
+ 'project_ambiguous',
+ `More than one local project matches "${selector}". Use one of these IDs: ${matches.map(({ id }) => id).join(', ')}.`,
+ {
+ selector,
+ matches: matches.map(({ id, name }) => ({ id, name })),
+ },
+ )
+}
diff --git a/packages/cli/src/runtime-download.test.ts b/packages/cli/src/runtime-download.test.ts
new file mode 100644
index 0000000000..decae30cd9
--- /dev/null
+++ b/packages/cli/src/runtime-download.test.ts
@@ -0,0 +1,339 @@
+import { afterEach, describe, expect, test } from 'bun:test'
+import { copyFile, mkdir, mkdtemp, open, rm, stat, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { isProxyBypassed, resolveProxyUrl } from './http-download.js'
+import { resolvePascalPaths } from './paths.js'
+import { findInstalledRuntime, installBundledRuntime, readActiveRuntime } from './runtime.js'
+import {
+ ensureWebRuntime,
+ fileSha256,
+ type RuntimeSource,
+ readRuntimeSource,
+ verifyArchiveDigest,
+} from './runtime-download.js'
+import { createRuntimeArchive } from './tar.js'
+
+const roots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('archive digest verification', () => {
+ test('accepts a matching digest whatever case it is written in', async () => {
+ const fixture = await createFixture()
+
+ await verifyArchiveDigest(fixture.archiveFile, fixture.source.sha256)
+ await verifyArchiveDigest(fixture.archiveFile, fixture.source.sha256.toUpperCase())
+ })
+
+ test('rejects a changed archive and deletes it when asked to', async () => {
+ const fixture = await createFixture()
+ const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz'))
+
+ await expect(
+ verifyArchiveDigest(tampered, fixture.source.sha256, { deleteOnMismatch: true }),
+ ).rejects.toMatchObject({
+ code: 'runtime_digest_mismatch',
+ message: expect.stringContaining(fixture.source.sha256),
+ })
+ expect(await exists(tampered)).toBe(false)
+ })
+
+ test('keeps a caller-supplied archive that fails verification', async () => {
+ const fixture = await createFixture()
+ const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz'))
+
+ await expect(verifyArchiveDigest(tampered, fixture.source.sha256)).rejects.toMatchObject({
+ code: 'runtime_digest_mismatch',
+ })
+ expect(await exists(tampered)).toBe(true)
+ })
+})
+
+describe('published runtime source', () => {
+ test('reads the archive URL and digest committed with the CLI', async () => {
+ const fixture = await createFixture()
+
+ expect(await readRuntimeSource(fixture.sourceFile)).toEqual(fixture.source)
+ })
+
+ test.each([
+ ['is missing', null],
+ ['is not JSON', '{not-json'],
+ ['omits the digest', { version: '1.2.3', url: 'https://example.com/a.tar.gz', size: 10 }],
+ [
+ 'carries a truncated digest',
+ { version: '1.2.3', url: 'https://example.com/a.tar.gz', sha256: 'abc123', size: 10 },
+ ],
+ [
+ 'points at a plain-http URL',
+ { version: '1.2.3', url: 'http://example.com/a.tar.gz', sha256: 'a'.repeat(64), size: 10 },
+ ],
+ [
+ 'declares an empty archive',
+ { version: '1.2.3', url: 'https://example.com/a.tar.gz', sha256: 'a'.repeat(64), size: 0 },
+ ],
+ [
+ 'carries a path-like version',
+ {
+ version: '../escape',
+ url: 'https://example.com/a.tar.gz',
+ sha256: 'a'.repeat(64),
+ size: 10,
+ },
+ ],
+ ])('refuses a runtime source that %s', async (_label, content) => {
+ const root = await temporaryRoot()
+ const sourceFile = path.join(root, 'runtime-source.json')
+ if (content !== null) {
+ await writeFile(sourceFile, typeof content === 'string' ? content : JSON.stringify(content))
+ }
+
+ await expect(readRuntimeSource(sourceFile)).rejects.toMatchObject({
+ code: 'invalid_runtime_source',
+ message: expect.stringContaining('--runtime'),
+ })
+ })
+})
+
+describe('web runtime resolution order', () => {
+ test('prefers an explicit runtime directory over the environment override', async () => {
+ const fixture = await createFixture()
+ const other = await fakeRuntimeDirectory(fixture.root, '9.9.9')
+
+ const result = await ensureWebRuntime({
+ paths: fixture.paths,
+ runtimeSource: fixture.sourceDirectory,
+ sourceFile: fixture.sourceFile,
+ environment: { PASCAL_BUNDLED_RUNTIME_DIR: other },
+ })
+
+ expect(result.runtime.version).toBe('1.2.3')
+ expect((await readActiveRuntime(fixture.paths))?.version).toBe('1.2.3')
+ expect(await findInstalledRuntime(fixture.paths, '9.9.9')).toBeNull()
+ })
+
+ test('falls back to PASCAL_BUNDLED_RUNTIME_DIR when no flag is passed', async () => {
+ const fixture = await createFixture()
+
+ const result = await ensureWebRuntime({
+ paths: fixture.paths,
+ sourceFile: fixture.sourceFile,
+ environment: { PASCAL_BUNDLED_RUNTIME_DIR: fixture.sourceDirectory },
+ })
+
+ expect(result).toMatchObject({ installed: true, runtime: { version: '1.2.3' } })
+ })
+
+ test('installs a local archive that matches the published digest', async () => {
+ const fixture = await createFixture()
+
+ const result = await ensureWebRuntime({
+ paths: fixture.paths,
+ runtimeSource: fixture.archiveFile,
+ sourceFile: fixture.sourceFile,
+ environment: {},
+ })
+
+ expect(result.runtime.directory).toBe(path.join(fixture.paths.runtime, '1.2.3'))
+ expect(await exists(path.join(result.runtime.directory, 'apps/editor/server.js'))).toBe(true)
+ })
+
+ test('installs nothing when a local archive fails verification', async () => {
+ const fixture = await createFixture()
+ const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz'))
+
+ await expect(
+ ensureWebRuntime({
+ paths: fixture.paths,
+ runtimeSource: tampered,
+ sourceFile: fixture.sourceFile,
+ environment: {},
+ }),
+ ).rejects.toMatchObject({ code: 'runtime_digest_mismatch' })
+ expect(await findInstalledRuntime(fixture.paths, '1.2.3')).toBeNull()
+ expect(await exists(tampered)).toBe(true)
+ })
+
+ test('reports a missing runtime path instead of reaching for the network', async () => {
+ const fixture = await createFixture()
+
+ await expect(
+ ensureWebRuntime({
+ paths: fixture.paths,
+ runtimeSource: path.join(fixture.root, 'absent.tar.gz'),
+ sourceFile: fixture.sourceFile,
+ environment: {},
+ }),
+ ).rejects.toMatchObject({ code: 'runtime_source_missing' })
+ })
+
+ test('reuses the installed runtime for this version without downloading', async () => {
+ const fixture = await createFixture()
+ await installBundledRuntime(fixture.paths, fixture.sourceDirectory, { activate: false })
+
+ const result = await ensureWebRuntime({
+ paths: fixture.paths,
+ sourceFile: fixture.sourceFile,
+ environment: {},
+ })
+
+ expect(result).toEqual({
+ installed: false,
+ runtime: {
+ schemaVersion: 1,
+ version: '1.2.3',
+ directory: path.join(fixture.paths.runtime, '1.2.3'),
+ },
+ })
+ expect((await readActiveRuntime(fixture.paths))?.version).toBe('1.2.3')
+ })
+
+ test('installs without activating so an update can health-check first', async () => {
+ const fixture = await createFixture()
+
+ const result = await ensureWebRuntime({
+ paths: fixture.paths,
+ runtimeSource: fixture.sourceDirectory,
+ sourceFile: fixture.sourceFile,
+ activate: false,
+ environment: {},
+ })
+
+ expect(result.runtime.version).toBe('1.2.3')
+ expect(await readActiveRuntime(fixture.paths)).toBeNull()
+ expect(await findInstalledRuntime(fixture.paths, '1.2.3')).not.toBeNull()
+ })
+
+ test('names the archive, the digest and the offline escape hatch when the download fails', async () => {
+ const fixture = await createFixture()
+
+ const failure = await ensureWebRuntime({
+ paths: fixture.paths,
+ sourceFile: fixture.sourceFile,
+ environment: {},
+ }).catch((error: unknown) => error)
+
+ expect(failure).toMatchObject({ code: 'runtime_download_failed' })
+ const message = (failure as Error).message
+ expect(message).toContain(fixture.source.url)
+ expect(message).toContain(fixture.source.sha256)
+ expect(message).toContain('pascal editor --runtime')
+ expect(message).toContain('HTTPS_PROXY')
+ expect(await findInstalledRuntime(fixture.paths, '1.2.3')).toBeNull()
+ })
+})
+
+describe('proxy configuration', () => {
+ test('prefers HTTPS_PROXY and trims the configured value', () => {
+ const target = new URL('https://github.com/pascalorg/editor')
+
+ expect(resolveProxyUrl(target, { HTTPS_PROXY: ' http://proxy:3128 ' })).toBe(
+ 'http://proxy:3128',
+ )
+ expect(resolveProxyUrl(target, { ALL_PROXY: 'http://all:3128' })).toBe('http://all:3128')
+ expect(resolveProxyUrl(target, { HTTPS_PROXY: ' ' })).toBeNull()
+ expect(resolveProxyUrl(target, {})).toBeNull()
+ })
+
+ test('honours NO_PROXY for the download host', () => {
+ const target = new URL('https://github.com/pascalorg/editor')
+
+ expect(resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', NO_PROXY: '*' })).toBeNull()
+ expect(
+ resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', no_proxy: 'github.com' }),
+ ).toBeNull()
+ expect(
+ resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', NO_PROXY: 'example.com' }),
+ ).toBe('http://proxy:3128')
+ })
+
+ test('matches NO_PROXY entries by suffix and ignores ports', () => {
+ expect(isProxyBypassed('release-assets.githubusercontent.com', '.githubusercontent.com')).toBe(
+ true,
+ )
+ expect(isProxyBypassed('github.com', 'github.com:443')).toBe(true)
+ expect(isProxyBypassed('github.com', '*.github.com')).toBe(true)
+ expect(isProxyBypassed('notgithub.com', 'github.com')).toBe(false)
+ expect(isProxyBypassed('github.com', '')).toBe(false)
+ expect(isProxyBypassed('github.com', undefined)).toBe(false)
+ })
+})
+
+interface Fixture {
+ root: string
+ paths: ReturnType
+ sourceDirectory: string
+ archiveFile: string
+ sourceFile: string
+ source: RuntimeSource
+}
+
+/** An unroutable port keeps every download test offline; the connection is refused at once. */
+const UNREACHABLE_HOST = 'https://127.0.0.1:1'
+
+async function createFixture(version = '1.2.3'): Promise {
+ const root = await temporaryRoot()
+ const sourceDirectory = await fakeRuntimeDirectory(root, version)
+ const archiveFile = path.join(root, `pascal-web-runtime-${version}.tar.gz`)
+ await createRuntimeArchive(sourceDirectory, archiveFile)
+ const source: RuntimeSource = {
+ version,
+ url: `${UNREACHABLE_HOST}/pascal-web-runtime-${version}.tar.gz`,
+ sha256: await fileSha256(archiveFile),
+ size: (await stat(archiveFile)).size,
+ }
+ const sourceFile = path.join(root, 'runtime-source.json')
+ await writeFile(sourceFile, JSON.stringify(source))
+ return {
+ root,
+ paths: resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }),
+ sourceDirectory,
+ archiveFile,
+ sourceFile,
+ source,
+ }
+}
+
+async function temporaryRoot(): Promise {
+ const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-download-test-'))
+ roots.push(root)
+ return root
+}
+
+async function fakeRuntimeDirectory(root: string, version: string): Promise {
+ const runtime = path.join(root, `source-${version}`)
+ await mkdir(path.join(runtime, 'apps/editor'), { recursive: true })
+ await writeFile(
+ path.join(runtime, 'runtime-manifest.json'),
+ JSON.stringify({ schemaVersion: 2, version, entrypoint: 'apps/editor/server.js' }),
+ )
+ await writeFile(path.join(runtime, 'apps/editor/server.js'), `// pascal ${version}\n`)
+ return runtime
+}
+
+async function tamper(archiveFile: string, destination: string): Promise {
+ await copyFile(archiveFile, destination)
+ const handle = await open(destination, 'r+')
+ try {
+ const offset = Math.floor((await handle.stat()).size / 2)
+ const byte = Buffer.alloc(1)
+ await handle.read(byte, 0, 1, offset)
+ byte[0] = ((byte[0] ?? 0) ^ 0xff) & 0xff
+ await handle.write(byte, 0, 1, offset)
+ } finally {
+ await handle.close()
+ }
+ return destination
+}
+
+async function exists(file: string): Promise {
+ try {
+ await stat(file)
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/packages/cli/src/runtime-download.ts b/packages/cli/src/runtime-download.ts
new file mode 100644
index 0000000000..8b54b50b0d
--- /dev/null
+++ b/packages/cli/src/runtime-download.ts
@@ -0,0 +1,256 @@
+import { createHash } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { CliError } from './errors.js'
+import { downloadToFile } from './http-download.js'
+import { readJsonFile } from './json-files.js'
+import type { PascalPaths } from './paths.js'
+import {
+ type ActiveRuntime,
+ activateRuntime,
+ findInstalledRuntime,
+ installBundledRuntime,
+ installRuntimeDirectory,
+ withRuntimeInstallLock,
+} from './runtime.js'
+import { extractTarGzip } from './tar.js'
+
+/** A peer process may be downloading the same archive; wait for it instead of duplicating it. */
+const DOWNLOAD_LOCK_TIMEOUT_MS = 20 * 60_000
+
+export interface RuntimeSource {
+ version: string
+ url: string
+ sha256: string
+ size: number
+}
+
+export interface WebRuntimeResult {
+ runtime: ActiveRuntime
+ installed: boolean
+}
+
+export type RuntimeProvisionProgress =
+ | { step: 'runtime-downloading'; url: string; received: number; total: number | null }
+ | { step: 'runtime-verifying' }
+ | { step: 'runtime-extracting' }
+ | { step: 'runtime-installing' }
+
+export interface EnsureWebRuntimeOptions {
+ paths: PascalPaths
+ /** A directory or `.tar.gz` archive from `--runtime`; archives are digest-verified. */
+ runtimeSource?: string
+ /** `false` installs the runtime without pointing the active runtime at it (used by updates). */
+ activate?: boolean
+ environment?: NodeJS.ProcessEnv
+ sourceFile?: string
+ onProgress?: (event: RuntimeProvisionProgress) => void
+}
+
+/**
+ * Resolves the web runtime for the commands that start the Next server. The npm package
+ * ships the CLI and the MCP service only; the runtime is downloaded once per version and
+ * verified against the digest committed in `dist/runtime-source.json`.
+ */
+export async function ensureWebRuntime(
+ options: EnsureWebRuntimeOptions,
+): Promise {
+ const { paths } = options
+ const environment = options.environment ?? process.env
+ const override = options.runtimeSource ?? environment.PASCAL_BUNDLED_RUNTIME_DIR
+ if (override) return installOverride(paths, override, options)
+
+ const source = await readRuntimeSource(options.sourceFile)
+ const existing = await findInstalledRuntime(paths, source.version)
+ if (existing) return { runtime: await useInstalled(paths, existing, options), installed: false }
+ return withRuntimeInstallLock(
+ paths,
+ async () => {
+ const peerInstalled = await findInstalledRuntime(paths, source.version)
+ if (peerInstalled) {
+ return { runtime: await useInstalled(paths, peerInstalled, options), installed: false }
+ }
+ return withWorkDirectory(paths, async (workDirectory) => {
+ const archiveFile = path.join(workDirectory, `pascal-web-runtime-${source.version}.tar.gz`)
+ await download(source, archiveFile, options)
+ options.onProgress?.({ step: 'runtime-verifying' })
+ await verifyArchiveDigest(archiveFile, source.sha256, { deleteOnMismatch: true })
+ return {
+ runtime: await extractAndInstall(archiveFile, workDirectory, options, (directory) =>
+ installRuntimeDirectory(paths, directory, { activate: options.activate }),
+ ),
+ installed: true,
+ }
+ })
+ },
+ { timeoutMs: DOWNLOAD_LOCK_TIMEOUT_MS },
+ )
+}
+
+export function resolveRuntimeSourceFile(): string {
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url))
+ return path.basename(moduleDirectory) === 'dist'
+ ? path.join(moduleDirectory, 'runtime-source.json')
+ : path.resolve(moduleDirectory, '../dist/runtime-source.json')
+}
+
+export async function readRuntimeSource(sourceFile?: string): Promise {
+ const file = sourceFile ?? resolveRuntimeSourceFile()
+ let source: RuntimeSource | null
+ try {
+ source = await readJsonFile(file)
+ } catch {
+ source = null
+ }
+ if (
+ !source ||
+ typeof source.version !== 'string' ||
+ !/^[0-9A-Za-z][0-9A-Za-z._-]*$/.test(source.version) ||
+ typeof source.url !== 'string' ||
+ !source.url.startsWith('https://') ||
+ typeof source.sha256 !== 'string' ||
+ !/^[0-9a-f]{64}$/.test(source.sha256) ||
+ !Number.isSafeInteger(source.size) ||
+ source.size <= 0
+ ) {
+ throw new CliError(
+ 'invalid_runtime_source',
+ `This CLI cannot resolve the Pascal web runtime it was published with (${file}). Reinstall @pascal-app/cli, or pass "--runtime ".`,
+ )
+ }
+ return { version: source.version, url: source.url, sha256: source.sha256, size: source.size }
+}
+
+export async function fileSha256(filePath: string): Promise {
+ const hash = createHash('sha256')
+ for await (const chunk of createReadStream(filePath)) hash.update(chunk as Buffer)
+ return hash.digest('hex')
+}
+
+export async function verifyArchiveDigest(
+ archiveFile: string,
+ expectedSha256: string,
+ options: { deleteOnMismatch?: boolean } = {},
+): Promise {
+ const actual = await fileSha256(archiveFile)
+ if (actual === expectedSha256.toLowerCase()) return
+ if (options.deleteOnMismatch) await rm(archiveFile, { force: true })
+ throw new CliError(
+ 'runtime_digest_mismatch',
+ [
+ 'The Pascal web runtime archive does not match the digest published with this CLI.',
+ ` archive: ${archiveFile}`,
+ ` expected: ${expectedSha256}`,
+ ` actual: ${actual}`,
+ 'The archive was not installed. Download it again from the Pascal release page.',
+ ].join('\n'),
+ )
+}
+
+async function installOverride(
+ paths: PascalPaths,
+ override: string,
+ options: EnsureWebRuntimeOptions,
+): Promise {
+ const resolved = path.resolve(override)
+ let info: Awaited>
+ try {
+ info = await stat(resolved)
+ } catch {
+ throw new CliError('runtime_source_missing', `No Pascal web runtime exists at ${resolved}.`)
+ }
+ if (info.isDirectory()) {
+ options.onProgress?.({ step: 'runtime-installing' })
+ return {
+ runtime: await installBundledRuntime(paths, resolved, { activate: options.activate }),
+ installed: true,
+ }
+ }
+ const source = await readRuntimeSource(options.sourceFile)
+ options.onProgress?.({ step: 'runtime-verifying' })
+ await verifyArchiveDigest(resolved, source.sha256)
+ return {
+ runtime: await withWorkDirectory(paths, (workDirectory) =>
+ extractAndInstall(resolved, workDirectory, options, (directory) =>
+ installBundledRuntime(paths, directory, { activate: options.activate }),
+ ),
+ ),
+ installed: true,
+ }
+}
+
+async function useInstalled(
+ paths: PascalPaths,
+ runtime: ActiveRuntime,
+ options: EnsureWebRuntimeOptions,
+): Promise {
+ return options.activate === false
+ ? runtime
+ : activateRuntime(paths, runtime.version, runtime.directory)
+}
+
+async function extractAndInstall(
+ archiveFile: string,
+ workDirectory: string,
+ options: EnsureWebRuntimeOptions,
+ install: (directory: string) => Promise,
+): Promise {
+ const extracted = path.join(workDirectory, 'runtime')
+ options.onProgress?.({ step: 'runtime-extracting' })
+ await extractTarGzip(archiveFile, extracted)
+ options.onProgress?.({ step: 'runtime-installing' })
+ return install(extracted)
+}
+
+async function download(
+ source: RuntimeSource,
+ archiveFile: string,
+ options: EnsureWebRuntimeOptions,
+): Promise {
+ options.onProgress?.({ step: 'runtime-downloading', url: source.url, received: 0, total: null })
+ try {
+ await downloadToFile(source.url, archiveFile, {
+ environment: options.environment ?? process.env,
+ onProgress: ({ received, total }) =>
+ options.onProgress?.({
+ step: 'runtime-downloading',
+ url: source.url,
+ received,
+ total: total ?? source.size,
+ }),
+ })
+ } catch (error) {
+ await rm(archiveFile, { force: true })
+ throw new CliError(
+ 'runtime_download_failed',
+ [
+ `Unable to download the Pascal web runtime ${source.version}.`,
+ ` archive: ${source.url}`,
+ ` sha256: ${source.sha256}`,
+ ` reason: ${error instanceof Error ? error.message : String(error)}`,
+ 'Download that archive on a connected machine, copy it over, then run:',
+ ` pascal editor --runtime /path/to/pascal-web-runtime-${source.version}.tar.gz`,
+ 'HTTPS_PROXY and NO_PROXY are honoured. "pascal mcp connect" needs no web runtime.',
+ ].join('\n'),
+ )
+ }
+}
+
+/**
+ * Downloads and extraction stay out of `runtime/`: `installRuntimeDirectory` deletes every
+ * `.install-*` directory there before it copies, which would race a partial extraction.
+ */
+async function withWorkDirectory(
+ paths: PascalPaths,
+ action: (directory: string) => Promise,
+): Promise {
+ await mkdir(paths.tmp, { recursive: true, mode: 0o700 })
+ const workDirectory = await mkdtemp(path.join(paths.tmp, 'runtime-'))
+ try {
+ return await action(workDirectory)
+ } finally {
+ await rm(workDirectory, { recursive: true, force: true })
+ }
+}
diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts
new file mode 100644
index 0000000000..1e48a58347
--- /dev/null
+++ b/packages/cli/src/runtime.test.ts
@@ -0,0 +1,357 @@
+import { afterAll, afterEach, describe, expect, test } from 'bun:test'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import http from 'node:http'
+import os from 'node:os'
+import path from 'node:path'
+import {
+ activateEditorRuntime,
+ getEditorStatus,
+ startEditor,
+ stopEditor,
+ waitForHealth,
+} from './editor-process.js'
+import { getMcpServiceStatus } from './mcp-service.js'
+import { resolvePascalPaths } from './paths.js'
+import { installBundledRuntime, readActiveRuntime } from './runtime.js'
+import { writeFakeMcpService } from './test-support/fake-mcp-service.js'
+
+const roots: string[] = []
+/** The MCP service ships with the CLI, so it is injected instead of staged in the runtime. */
+const serviceRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-test-service-'))
+process.env.PASCAL_MCP_SERVICE_PATH = await writeFakeMcpService(serviceRoot)
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+afterAll(() => rm(serviceRoot, { recursive: true, force: true }))
+
+describe('managed runtime', () => {
+ test('installs a bundled runtime outside the package-runner cache', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+
+ const active = await installBundledRuntime(paths, source)
+
+ expect(active.version).toBe('1.2.3')
+ expect(active.directory).toBe(path.join(paths.runtime, '1.2.3'))
+ expect(await Bun.file(path.join(active.directory, 'apps/editor/server.js')).exists()).toBe(true)
+ })
+
+ test('starts, identifies, and stops a detached editor while preserving data', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ await mkdir(paths.data, { recursive: true })
+ await writeFile(paths.database, 'persistent')
+
+ const started = await startEditor({ paths, runtimeSource: source })
+ expect(started.alreadyRunning).toBe(false)
+ expect((await getEditorStatus(paths)).healthy).toBe(true)
+ expect((await startEditor({ paths, runtimeSource: source })).alreadyRunning).toBe(true)
+
+ expect(await stopEditor(paths)).toBe(true)
+ expect((await getEditorStatus(paths)).running).toBe(false)
+ expect(await Bun.file(paths.database).text()).toBe('persistent')
+ })
+
+ test('preserves a configured Mint host origin in the editor process', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ const previousMintOrigin = process.env.MINT_PASCAL_HOST_ORIGIN
+ process.env.MINT_PASCAL_HOST_ORIGIN = 'https://pascal.example.com'
+
+ try {
+ const started = await startEditor({ paths, runtimeSource: source })
+ const response = await fetch(`http://127.0.0.1:${started.state.port}/mint-origin`)
+
+ expect(await response.text()).toBe('https://pascal.example.com')
+ } finally {
+ await stopEditor(paths)
+ if (previousMintOrigin === undefined) delete process.env.MINT_PASCAL_HOST_ORIGIN
+ else process.env.MINT_PASCAL_HOST_ORIGIN = previousMintOrigin
+ }
+ })
+
+ test('serializes concurrent starts into one managed editor', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+
+ const [first, second] = await Promise.all([
+ startEditor({ paths, port: 0, runtimeSource: source }),
+ startEditor({ paths, port: 0, runtimeSource: source }),
+ ])
+
+ expect(first.state.pid).toBe(second.state.pid)
+ expect([first.alreadyRunning, second.alreadyRunning].sort()).toEqual([false, true])
+ await stopEditor(paths)
+ })
+
+ test('falls back to an automatic port when the requested port is occupied', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ const foreignServer = http.createServer((_request, response) => response.end('foreign'))
+ await new Promise((resolve, reject) => {
+ foreignServer.once('error', reject)
+ foreignServer.listen({ host: '127.0.0.1', port: 0 }, resolve)
+ })
+ const address = foreignServer.address()
+ if (!address || typeof address === 'string') throw new Error('foreign server has no TCP port')
+
+ try {
+ const started = await startEditor({
+ paths,
+ port: address.port,
+ runtimeSource: source,
+ })
+
+ expect(started.state.port).not.toBe(address.port)
+ expect((await getEditorStatus(paths)).healthy).toBe(true)
+ await stopEditor(paths)
+ } finally {
+ await new Promise((resolve, reject) =>
+ foreignServer.close((error) => (error ? reject(error) : resolve())),
+ )
+ }
+ })
+
+ test('reports a foreign health responder without waiting for the timeout', async () => {
+ const foreignServer = http.createServer((_request, response) => response.end('not Pascal'))
+ await new Promise((resolve, reject) => {
+ foreignServer.once('error', reject)
+ foreignServer.listen({ host: '127.0.0.1', port: 0 }, resolve)
+ })
+ const address = foreignServer.address()
+ if (!address || typeof address === 'string') throw new Error('foreign server has no TCP port')
+
+ try {
+ const startedAt = Date.now()
+ await expect(
+ waitForHealth(
+ {
+ schemaVersion: 1,
+ pid: process.pid,
+ version: '1.2.3',
+ port: address.port,
+ host: '127.0.0.1',
+ url: `http://pascal.localhost:${address.port}`,
+ instanceId: 'expected-instance',
+ runtimeDirectory: '/tmp/pascal-test-runtime',
+ startedAt: new Date().toISOString(),
+ },
+ 5_000,
+ ),
+ ).rejects.toMatchObject({ code: 'port_conflict' })
+ expect(Date.now() - startedAt).toBeLessThan(1_000)
+ } finally {
+ await new Promise((resolve, reject) =>
+ foreignServer.close((error) => (error ? reject(error) : resolve())),
+ )
+ }
+ })
+
+ test('reclaims an install lock whose owner is gone', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ await mkdir(paths.run, { recursive: true })
+ await writeFile(
+ path.join(paths.run, 'runtime-install.lock'),
+ JSON.stringify({
+ schemaVersion: 1,
+ pid: 999_999,
+ token: 'abandoned',
+ createdAt: new Date().toISOString(),
+ }),
+ )
+
+ expect((await installBundledRuntime(paths, source)).version).toBe('1.2.3')
+ })
+
+ test('replaces a damaged installed runtime on the next start', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ const active = await installBundledRuntime(paths, source)
+ await rm(path.join(active.directory, 'apps/editor/server.js'))
+
+ const started = await startEditor({ paths, port: 0, runtimeSource: source })
+
+ expect(started.state.version).toBe('1.2.3')
+ expect((await getEditorStatus(paths)).healthy).toBe(true)
+ expect(await Bun.file(path.join(active.directory, 'apps/editor/server.js')).exists()).toBe(true)
+ await stopEditor(paths)
+ })
+
+ test('replaces a runtime whose manifest contains invalid JSON', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ const active = await installBundledRuntime(paths, source)
+ await writeFile(path.join(active.directory, 'runtime-manifest.json'), '{not-json')
+
+ const started = await startEditor({ paths, port: 0, runtimeSource: source })
+
+ expect(started.state.version).toBe('1.2.3')
+ expect((await getEditorStatus(paths)).healthy).toBe(true)
+ await stopEditor(paths)
+ })
+
+ test('recovers an active-runtime pointer containing invalid JSON', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ await mkdir(paths.run, { recursive: true })
+ await writeFile(paths.currentRuntime, '{not-json')
+
+ const started = await startEditor({ paths, port: 0, runtimeSource: source })
+
+ expect(started.state.version).toBe('1.2.3')
+ expect((await getEditorStatus(paths)).healthy).toBe(true)
+ await stopEditor(paths)
+ })
+
+ test('removes abandoned temporary runtime copies before installing', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ const abandoned = path.join(paths.runtime, '.install-abandoned')
+ await mkdir(abandoned, { recursive: true })
+ await writeFile(path.join(abandoned, 'partial'), 'incomplete')
+
+ await installBundledRuntime(paths, source)
+
+ expect(await Bun.file(path.join(abandoned, 'partial')).exists()).toBe(false)
+ })
+
+ test('allows an explicit force stop only for the recorded editor command', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ const started = await startEditor({ paths, port: 0, runtimeSource: source })
+ await writeFile(
+ paths.state,
+ `${JSON.stringify({ ...started.state, instanceId: 'no-longer-healthy' }, null, 2)}\n`,
+ )
+
+ await expect(stopEditor(paths)).rejects.toMatchObject({ code: 'state_conflict' })
+ expect(await stopEditor(paths, { force: true })).toBe(true)
+ })
+
+ test('force-stops the recorded editor when its runtime manifest is damaged', async () => {
+ const root = await temporaryRoot()
+ const source = await fakeRuntime(root, '1.2.3')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ const started = await startEditor({ paths, port: 0, runtimeSource: source })
+ await writeFile(path.join(started.state.runtimeDirectory, 'runtime-manifest.json'), '{not-json')
+ await writeFile(
+ paths.state,
+ `${JSON.stringify({ ...started.state, instanceId: 'no-longer-healthy' }, null, 2)}\n`,
+ )
+
+ expect(await stopEditor(paths, { force: true })).toBe(true)
+ })
+
+ test('restores the previous running runtime when a candidate fails health', async () => {
+ const root = await temporaryRoot()
+ const firstSource = await fakeRuntime(root, '1.2.3')
+ const brokenSource = await fakeRuntime(root, '2.0.0', false)
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ await startEditor({ paths, port: 0, runtimeSource: firstSource })
+ const candidate = await installBundledRuntime(paths, brokenSource, { activate: false })
+
+ await expect(activateEditorRuntime(paths, candidate)).rejects.toMatchObject({
+ code: 'update_failed',
+ })
+ expect((await readActiveRuntime(paths))?.version).toBe('1.2.3')
+ expect((await getEditorStatus(paths)).healthy).toBe(true)
+ await stopEditor(paths)
+ })
+
+ test('restarts the editor and repoints MCP when a new runtime is activated', async () => {
+ const root = await temporaryRoot()
+ const firstSource = await fakeRuntime(root, '1.2.3')
+ const secondSource = await fakeRuntime(root, '2.0.0')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ const started = await startEditor({ paths, runtimeSource: firstSource })
+ const candidate = await installBundledRuntime(paths, secondSource, { activate: false })
+
+ const result = await activateEditorRuntime(paths, candidate)
+
+ expect(result.restarted).toBe(true)
+ const status = await getEditorStatus(paths)
+ expect(status.healthy).toBe(true)
+ expect(status.state?.version).toBe('2.0.0')
+ expect(status.state?.pid).not.toBe(started.state.pid)
+ const mcp = await getMcpServiceStatus(paths)
+ expect(mcp.healthy).toBe(true)
+ expect(mcp.state?.editorOrigin).toBe(status.state?.url ?? '')
+ await stopEditor(paths)
+ })
+
+ test('health-checks an update without leaving a stopped editor running', async () => {
+ const root = await temporaryRoot()
+ const firstSource = await fakeRuntime(root, '1.2.3')
+ const secondSource = await fakeRuntime(root, '2.0.0')
+ const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') })
+ await installBundledRuntime(paths, firstSource)
+ const seeded = await startEditor({ paths, port: 0, runtimeSource: firstSource })
+ await stopEditor(paths)
+ await writeFile(paths.state, `${JSON.stringify(seeded.state, null, 2)}\n`)
+ const candidate = await installBundledRuntime(paths, secondSource, { activate: false })
+
+ const result = await activateEditorRuntime(paths, candidate)
+
+ expect(result).toEqual({ runtime: candidate, restarted: false })
+ expect((await readActiveRuntime(paths))?.version).toBe('2.0.0')
+ expect((await getEditorStatus(paths)).running).toBe(false)
+ })
+})
+
+async function temporaryRoot(): Promise {
+ const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-test-'))
+ roots.push(root)
+ return root
+}
+
+async function fakeRuntime(root: string, version: string, healthy = true): Promise {
+ const runtime = path.join(root, `source-${version}`)
+ const app = path.join(runtime, 'apps/editor')
+ await mkdir(app, { recursive: true })
+ await writeFile(
+ path.join(runtime, 'runtime-manifest.json'),
+ JSON.stringify({ schemaVersion: 2, version, entrypoint: 'apps/editor/server.js' }),
+ )
+ await writeFile(
+ path.join(app, 'server.js'),
+ healthy
+ ? `import http from 'node:http'
+const instanceId = process.env.PASCAL_INSTANCE_ID
+const server = http.createServer((request, response) => {
+ response.setHeader('content-type', 'application/json')
+ if (request.url === '/api/health') {
+ response.end(JSON.stringify({
+ status: 'ok',
+ app: 'editor',
+ version: process.env.PASCAL_RUNTIME_VERSION,
+ instanceId,
+ }))
+ return
+ }
+ if (request.url === '/mint-origin') {
+ response.end(process.env.MINT_PASCAL_HOST_ORIGIN ?? '')
+ return
+ }
+ response.end('{}')
+})
+server.listen(Number(process.env.PORT), process.env.HOSTNAME)
+process.on('SIGTERM', () => server.close(() => process.exit(0)))
+`
+ : 'process.exit(1)\n',
+ )
+ return runtime
+}
diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts
new file mode 100644
index 0000000000..495a726713
--- /dev/null
+++ b/packages/cli/src/runtime.ts
@@ -0,0 +1,194 @@
+import { cp, mkdir, readdir, rename, rm, stat } from 'node:fs/promises'
+import path from 'node:path'
+import { CliError } from './errors.js'
+import { withFileLock } from './file-lock.js'
+import { readJsonFile, writeJsonFile } from './json-files.js'
+import type { PascalPaths } from './paths.js'
+
+export interface RuntimeManifest {
+ schemaVersion: 2
+ version: string
+ entrypoint: string
+}
+
+export interface ActiveRuntime {
+ schemaVersion: 1
+ version: string
+ directory: string
+}
+
+export async function readRuntimeManifest(directory: string): Promise {
+ let manifest: RuntimeManifest | null
+ try {
+ manifest = await readJsonFile(path.join(directory, 'runtime-manifest.json'))
+ } catch {
+ throw new CliError('invalid_runtime', `Invalid Pascal runtime at ${directory}.`)
+ }
+ if (
+ manifest?.schemaVersion !== 2 ||
+ typeof manifest.version !== 'string' ||
+ typeof manifest.entrypoint !== 'string'
+ ) {
+ throw new CliError('invalid_runtime', `Invalid Pascal runtime at ${directory}.`)
+ }
+ if (!/^[0-9A-Za-z][0-9A-Za-z._-]*$/.test(manifest.version)) {
+ throw new CliError('invalid_runtime', `Invalid runtime version: ${manifest.version}`)
+ }
+ const entrypoint = path.resolve(directory, manifest.entrypoint)
+ if (!entrypoint.startsWith(`${path.resolve(directory)}${path.sep}`)) {
+ throw new CliError(
+ 'invalid_runtime',
+ 'The runtime entrypoint escapes the installation directory.',
+ )
+ }
+ try {
+ if (!(await stat(entrypoint)).isFile()) throw new Error('not a file')
+ } catch {
+ throw new CliError('invalid_runtime', `Runtime entrypoint is missing: ${entrypoint}`)
+ }
+ return manifest
+}
+
+/**
+ * Serializes runtime installation across processes. `ensureWebRuntime` holds this lock for
+ * the whole download so a concurrent first run waits for its peer instead of downloading
+ * the same archive twice, which is why the timeout is caller-controlled.
+ */
+export async function withRuntimeInstallLock(
+ paths: PascalPaths,
+ action: () => Promise,
+ options: { timeoutMs?: number } = {},
+): Promise {
+ return withFileLock(
+ path.join(paths.run, 'runtime-install.lock'),
+ 'install_locked',
+ 'Another Pascal runtime installation is active.',
+ action,
+ options,
+ )
+}
+
+export async function installBundledRuntime(
+ paths: PascalPaths,
+ sourceDirectory: string,
+ options: { activate?: boolean } = {},
+): Promise {
+ return withRuntimeInstallLock(paths, () =>
+ installRuntimeDirectory(paths, sourceDirectory, options),
+ )
+}
+
+/** Requires `withRuntimeInstallLock`; call `installBundledRuntime` when no lock is held. */
+export async function installRuntimeDirectory(
+ paths: PascalPaths,
+ sourceDirectory: string,
+ options: { activate?: boolean } = {},
+): Promise {
+ const sourceManifest = await readRuntimeManifest(sourceDirectory)
+ const targetDirectory = path.join(paths.runtime, sourceManifest.version)
+ await mkdir(paths.runtime, { recursive: true, mode: 0o700 })
+ await removeAbandonedInstallDirectories(paths.runtime)
+ const installed = await readInstalledManifest(targetDirectory)
+ if (
+ installed?.version === sourceManifest.version &&
+ (await isRuntimeValid(targetDirectory, sourceManifest.version))
+ ) {
+ return options.activate === false
+ ? runtimeRecord(sourceManifest.version, targetDirectory)
+ : activateRuntime(paths, sourceManifest.version, targetDirectory)
+ }
+ const temporaryDirectory = path.join(
+ paths.runtime,
+ `.install-${sourceManifest.version}-${process.pid}`,
+ )
+ await rm(temporaryDirectory, { recursive: true, force: true })
+ await cp(sourceDirectory, temporaryDirectory, { recursive: true, dereference: false })
+ await readRuntimeManifest(temporaryDirectory)
+ await rm(targetDirectory, { recursive: true, force: true })
+ await rename(temporaryDirectory, targetDirectory)
+ return options.activate === false
+ ? runtimeRecord(sourceManifest.version, targetDirectory)
+ : activateRuntime(paths, sourceManifest.version, targetDirectory)
+}
+
+export async function readActiveRuntime(paths: PascalPaths): Promise {
+ let active: ActiveRuntime | null
+ try {
+ active = await readJsonFile(paths.currentRuntime)
+ } catch {
+ throw new CliError('invalid_runtime', 'The active runtime pointer is not valid JSON.')
+ }
+ if (
+ active?.schemaVersion !== 1 ||
+ typeof active.version !== 'string' ||
+ typeof active.directory !== 'string'
+ ) {
+ return null
+ }
+ const resolvedDirectory = path.resolve(active.directory)
+ if (!resolvedDirectory.startsWith(`${path.resolve(paths.runtime)}${path.sep}`)) {
+ throw new CliError('invalid_runtime', 'The active runtime is outside Pascal runtime storage.')
+ }
+ const manifest = await readRuntimeManifest(resolvedDirectory)
+ if (manifest.version !== active.version) {
+ throw new CliError('invalid_runtime', 'The active runtime version does not match its manifest.')
+ }
+ return active
+}
+
+export async function activateRuntime(
+ paths: PascalPaths,
+ version: string,
+ directory: string,
+): Promise {
+ const resolvedDirectory = path.resolve(directory)
+ if (!resolvedDirectory.startsWith(`${path.resolve(paths.runtime)}${path.sep}`)) {
+ throw new CliError('invalid_runtime', 'Cannot activate a runtime outside Pascal storage.')
+ }
+ const manifest = await readRuntimeManifest(resolvedDirectory)
+ if (manifest.version !== version) {
+ throw new CliError('invalid_runtime', 'Cannot activate a runtime with a mismatched version.')
+ }
+ const active: ActiveRuntime = { schemaVersion: 1, version, directory: resolvedDirectory }
+ await writeJsonFile(paths.currentRuntime, active)
+ return active
+}
+
+export async function findInstalledRuntime(
+ paths: PascalPaths,
+ version: string,
+): Promise {
+ const directory = path.join(paths.runtime, version)
+ return (await isRuntimeValid(directory, version)) ? runtimeRecord(version, directory) : null
+}
+
+function runtimeRecord(version: string, directory: string): ActiveRuntime {
+ return { schemaVersion: 1, version, directory }
+}
+
+async function isRuntimeValid(directory: string, version: string): Promise {
+ try {
+ return (await readRuntimeManifest(directory)).version === version
+ } catch {
+ return false
+ }
+}
+
+async function readInstalledManifest(directory: string): Promise {
+ try {
+ return await readJsonFile(path.join(directory, 'runtime-manifest.json'))
+ } catch {
+ return null
+ }
+}
+
+async function removeAbandonedInstallDirectories(runtimeDirectory: string): Promise {
+ const entries = await readdir(runtimeDirectory, { withFileTypes: true })
+ await Promise.all(
+ entries
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('.install-'))
+ .map((entry) =>
+ rm(path.join(runtimeDirectory, entry.name), { recursive: true, force: true }),
+ ),
+ )
+}
diff --git a/packages/cli/src/tar.test.ts b/packages/cli/src/tar.test.ts
new file mode 100644
index 0000000000..566c2ec12c
--- /dev/null
+++ b/packages/cli/src/tar.test.ts
@@ -0,0 +1,171 @@
+import { afterEach, describe, expect, test } from 'bun:test'
+import { createHash } from 'node:crypto'
+import { createWriteStream } from 'node:fs'
+import {
+ chmod,
+ mkdir,
+ mkdtemp,
+ readFile,
+ rm,
+ stat,
+ symlink,
+ utimes,
+ writeFile,
+} from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { Readable } from 'node:stream'
+import { pipeline } from 'node:stream/promises'
+import { createGzip } from 'node:zlib'
+import { createRuntimeArchive, extractTarGzip, tarHeaderBlock } from './tar.js'
+
+const roots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('runtime archive', () => {
+ test('writes the same bytes for the same tree regardless of timestamps', async () => {
+ const root = await temporaryRoot()
+ const source = path.join(root, 'runtime')
+ await mkdir(path.join(source, 'apps/editor/.next'), { recursive: true })
+ await writeFile(path.join(source, 'runtime-manifest.json'), '{"schemaVersion":2}')
+ await writeFile(path.join(source, 'apps/editor/server.js'), 'console.log(1)\n')
+ await writeFile(path.join(source, 'apps/editor/.next/build.txt'), 'build\n')
+ /** Longer than the 100-byte ustar name field, so the archive needs a long-name entry. */
+ const deep = path.join(source, 'apps/editor', 'a'.repeat(60), 'b'.repeat(60))
+ await mkdir(deep, { recursive: true })
+ await writeFile(path.join(deep, 'long-path.txt'), 'long\n')
+ const executable = path.join(source, 'apps/editor/run.sh')
+ await writeFile(executable, '#!/bin/sh\n')
+ await chmod(executable, 0o755)
+
+ const first = path.join(root, 'first.tar.gz')
+ const firstResult = await createRuntimeArchive(source, first)
+ await utimes(path.join(source, 'apps/editor/server.js'), new Date(0), new Date(0))
+ const second = path.join(root, 'second.tar.gz')
+ const secondResult = await createRuntimeArchive(source, second)
+
+ expect(firstResult.entryCount).toBe(secondResult.entryCount)
+ expect(await sha256(first)).toBe(await sha256(second))
+
+ const target = path.join(root, 'extracted')
+ await extractTarGzip(first, target)
+ expect(await readFile(path.join(target, 'apps/editor/server.js'), 'utf8')).toBe(
+ 'console.log(1)\n',
+ )
+ expect(
+ await readFile(path.join(target, deep.slice(source.length + 1), 'long-path.txt'), 'utf8'),
+ ).toBe('long\n')
+ expect((await stat(path.join(target, 'apps/editor/run.sh'))).mode & 0o111).not.toBe(0)
+ })
+
+ test('refuses to archive a symbolic link', async () => {
+ const root = await temporaryRoot()
+ const source = path.join(root, 'runtime')
+ await mkdir(source, { recursive: true })
+ await writeFile(path.join(source, 'real.txt'), 'real\n')
+ await symlink('real.txt', path.join(source, 'link.txt'))
+
+ await expect(createRuntimeArchive(source, path.join(root, 'out.tar.gz'))).rejects.toMatchObject(
+ {
+ code: 'archive_failed',
+ },
+ )
+ })
+})
+
+describe('runtime archive extraction safety', () => {
+ test.each([
+ ['a parent traversal', '../escaped.txt'],
+ ['a nested parent traversal', 'apps/../../escaped.txt'],
+ ['an absolute path', '/tmp/pascal-escaped.txt'],
+ ['a Windows drive path', 'C:/pascal-escaped.txt'],
+ ])('rejects %s', async (_label, name) => {
+ const root = await temporaryRoot()
+ const archive = path.join(root, 'malicious.tar.gz')
+ await writeArchive(archive, fileEntry(name, 'escaped\n'))
+
+ await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({
+ code: 'invalid_runtime_archive',
+ })
+ expect(await exists(path.join(root, 'escaped.txt'))).toBe(false)
+ expect(await exists('/tmp/pascal-escaped.txt')).toBe(false)
+ })
+
+ test('rejects a symbolic-link entry that would point out of the target', async () => {
+ const root = await temporaryRoot()
+ const archive = path.join(root, 'symlink.tar.gz')
+ await writeArchive(archive, [
+ tarHeaderBlock({ name: 'apps/editor/escape', size: 0, mode: 0o777, typeflag: '2' }),
+ ])
+
+ await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({
+ code: 'invalid_runtime_archive',
+ })
+ expect(await exists(path.join(root, 'target/apps/editor/escape'))).toBe(false)
+ })
+
+ test('rejects a hard-link entry', async () => {
+ const root = await temporaryRoot()
+ const archive = path.join(root, 'hardlink.tar.gz')
+ await writeArchive(archive, [
+ tarHeaderBlock({ name: 'apps/editor/linked', size: 0, mode: 0o644, typeflag: '1' }),
+ ])
+
+ await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({
+ code: 'invalid_runtime_archive',
+ })
+ })
+
+ test('rejects a header whose checksum was rewritten', async () => {
+ const root = await temporaryRoot()
+ const archive = path.join(root, 'tampered.tar.gz')
+ const [header, ...rest] = fileEntry('apps/editor/server.js', 'console.log(1)\n')
+ if (!header) throw new Error('the test archive has no header block')
+ const rewritten = Buffer.from(header)
+ rewritten.write('X', 0, 1, 'ascii')
+ await writeArchive(archive, [rewritten, ...rest])
+
+ await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({
+ code: 'invalid_runtime_archive',
+ })
+ })
+})
+
+async function temporaryRoot(): Promise {
+ const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-tar-test-'))
+ roots.push(root)
+ return root
+}
+
+function fileEntry(name: string, body: string): Buffer[] {
+ const data = Buffer.from(body, 'utf8')
+ const padded = Buffer.alloc(Math.ceil(data.byteLength / 512) * 512)
+ data.copy(padded)
+ return [tarHeaderBlock({ name, size: data.byteLength, mode: 0o644, typeflag: '0' }), padded]
+}
+
+async function writeArchive(file: string, blocks: Buffer[]): Promise {
+ await pipeline(
+ Readable.from([Buffer.concat([...blocks, Buffer.alloc(1024)])]),
+ createGzip(),
+ createWriteStream(file),
+ )
+}
+
+async function sha256(file: string): Promise {
+ return createHash('sha256')
+ .update(await readFile(file))
+ .digest('hex')
+}
+
+async function exists(file: string): Promise {
+ try {
+ await stat(file)
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/packages/cli/src/tar.ts b/packages/cli/src/tar.ts
new file mode 100644
index 0000000000..470a4213ea
--- /dev/null
+++ b/packages/cli/src/tar.ts
@@ -0,0 +1,304 @@
+import { createReadStream, createWriteStream } from 'node:fs'
+import { mkdir, readdir, rm, stat } from 'node:fs/promises'
+import path from 'node:path'
+import { Readable, type Writable } from 'node:stream'
+import { pipeline } from 'node:stream/promises'
+import { createGunzip, createGzip } from 'node:zlib'
+import { CliError } from './errors.js'
+
+const BLOCK_SIZE = 512
+const LONG_NAME_ENTRY = '././@LongLink'
+
+export interface TarHeaderFields {
+ name: string
+ size: number
+ mode: number
+ typeflag: string
+}
+
+export interface RuntimeArchiveResult {
+ size: number
+ entryCount: number
+}
+
+interface ArchiveEntry {
+ relative: string
+ absolute: string
+ directory: boolean
+ size: number
+ mode: number
+}
+
+/**
+ * Writes a byte-for-byte reproducible tar.gz: entries sorted by path, zero mtime, zero
+ * uid/gid, empty owner names and normalized modes. Two runs over the same tree therefore
+ * produce the same SHA-256, which is what `dist/runtime-source.json` pins.
+ */
+export async function createRuntimeArchive(
+ sourceDirectory: string,
+ destinationFile: string,
+): Promise {
+ const root = path.resolve(sourceDirectory)
+ const destination = path.resolve(destinationFile)
+ const entries = await collectEntries(root)
+ await mkdir(path.dirname(destination), { recursive: true })
+ await rm(destination, { force: true })
+ await pipeline(
+ Readable.from(archiveBlocks(entries), { objectMode: false }),
+ createGzip({ level: 9 }),
+ createWriteStream(destination),
+ )
+ return { size: (await stat(destination)).size, entryCount: entries.length }
+}
+
+export async function extractTarGzip(archiveFile: string, targetDirectory: string): Promise {
+ const root = path.resolve(targetDirectory)
+ await mkdir(root, { recursive: true, mode: 0o700 })
+ const source = createReadStream(archiveFile)
+ const gunzip = createGunzip()
+ source.on('error', (error) => gunzip.destroy(error))
+ const reader = new BlockReader(source.pipe(gunzip))
+ let pendingLongName: string | null = null
+ try {
+ for (;;) {
+ const header = await reader.read(BLOCK_SIZE)
+ if (!header || isZeroBlock(header)) break
+ verifyChecksum(header)
+ const typeflag = String.fromCharCode(header[156] ?? 0)
+ const size = readOctal(header, 124, 12)
+ const mode = readOctal(header, 100, 8)
+ if (typeflag === 'L') {
+ const data = await reader.read(paddedSize(size))
+ if (!data) throw invalidArchive('a long-name entry is truncated')
+ pendingLongName = data.subarray(0, size).toString('utf8').replace(/\0+$/, '')
+ continue
+ }
+ const name = pendingLongName ?? readHeaderString(header, 0, 100)
+ pendingLongName = null
+ if (typeflag !== '0' && typeflag !== '\0' && typeflag !== '5') {
+ throw invalidArchive(`entry ${JSON.stringify(name)} uses unsupported type "${typeflag}"`)
+ }
+ const destination = resolveEntryPath(root, name)
+ if (typeflag === '5') {
+ await mkdir(destination, { recursive: true, mode: 0o755 })
+ continue
+ }
+ await mkdir(path.dirname(destination), { recursive: true, mode: 0o755 })
+ await reader.writeTo(
+ createWriteStream(destination, { mode: (mode & 0o111) !== 0 ? 0o755 : 0o644 }),
+ size,
+ )
+ const padding = paddedSize(size) - size
+ if (padding > 0 && !(await reader.read(padding))) {
+ throw invalidArchive(`entry ${JSON.stringify(name)} is truncated`)
+ }
+ }
+ } finally {
+ gunzip.destroy()
+ source.destroy()
+ }
+}
+
+export function tarHeaderBlock(fields: TarHeaderFields): Buffer {
+ const block = Buffer.alloc(BLOCK_SIZE)
+ Buffer.from(fields.name, 'utf8').subarray(0, 100).copy(block, 0)
+ writeOctal(block, fields.mode & 0o7777, 100, 8)
+ writeOctal(block, 0, 108, 8)
+ writeOctal(block, 0, 116, 8)
+ writeOctal(block, fields.size, 124, 12)
+ writeOctal(block, 0, 136, 12)
+ block.write(fields.typeflag, 156, 1, 'ascii')
+ block.write('ustar\0', 257, 6, 'ascii')
+ block.write('00', 263, 2, 'ascii')
+ block.fill(0x20, 148, 156)
+ let checksum = 0
+ for (const byte of block) checksum += byte
+ block.write(`${checksum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii')
+ return block
+}
+
+async function* archiveBlocks(entries: ArchiveEntry[]): AsyncGenerator {
+ for (const entry of entries) {
+ const name = entry.directory ? `${entry.relative}/` : entry.relative
+ const nameBytes = Buffer.from(name, 'utf8')
+ if (nameBytes.byteLength > 100) {
+ yield tarHeaderBlock({
+ name: LONG_NAME_ENTRY,
+ size: nameBytes.byteLength + 1,
+ mode: 0o644,
+ typeflag: 'L',
+ })
+ const data = Buffer.concat([nameBytes, Buffer.of(0)])
+ yield data
+ yield* paddingBlocks(data.byteLength)
+ }
+ yield tarHeaderBlock({
+ name,
+ size: entry.directory ? 0 : entry.size,
+ mode: entry.mode,
+ typeflag: entry.directory ? '5' : '0',
+ })
+ if (entry.directory) continue
+ let written = 0
+ for await (const chunk of createReadStream(entry.absolute)) {
+ const buffer = chunk as Buffer
+ written += buffer.byteLength
+ yield buffer
+ }
+ if (written !== entry.size) {
+ throw new CliError(
+ 'archive_failed',
+ `${entry.relative} changed size while the archive was being written.`,
+ )
+ }
+ yield* paddingBlocks(written)
+ }
+ yield Buffer.alloc(2 * BLOCK_SIZE)
+}
+
+function* paddingBlocks(size: number): Generator {
+ const padding = (BLOCK_SIZE - (size % BLOCK_SIZE)) % BLOCK_SIZE
+ if (padding > 0) yield Buffer.alloc(padding)
+}
+
+async function collectEntries(root: string): Promise {
+ const entries: ArchiveEntry[] = []
+ const walk = async (directory: string, prefix: string): Promise => {
+ for (const child of await readdir(directory, { withFileTypes: true })) {
+ const absolute = path.join(directory, child.name)
+ const relative = prefix ? `${prefix}/${child.name}` : child.name
+ if (child.isSymbolicLink()) {
+ throw new CliError('archive_failed', `Cannot archive the symbolic link ${relative}.`)
+ }
+ if (child.isDirectory()) {
+ entries.push({ relative, absolute, directory: true, size: 0, mode: 0o755 })
+ await walk(absolute, relative)
+ continue
+ }
+ if (!child.isFile()) {
+ throw new CliError('archive_failed', `Cannot archive the special file ${relative}.`)
+ }
+ const info = await stat(absolute)
+ entries.push({
+ relative,
+ absolute,
+ directory: false,
+ size: info.size,
+ mode: (info.mode & 0o111) !== 0 ? 0o755 : 0o644,
+ })
+ }
+ }
+ await walk(root, '')
+ return entries.sort((left, right) =>
+ Buffer.compare(Buffer.from(left.relative, 'utf8'), Buffer.from(right.relative, 'utf8')),
+ )
+}
+
+class BlockReader {
+ private readonly iterator: AsyncIterator
+ private pending: Buffer = Buffer.alloc(0)
+
+ constructor(stream: Readable) {
+ this.iterator = stream[Symbol.asyncIterator]() as AsyncIterator
+ }
+
+ async read(size: number): Promise {
+ if (size === 0) return Buffer.alloc(0)
+ while (this.pending.byteLength < size) {
+ const next = await this.iterator.next()
+ if (next.done) break
+ this.pending =
+ this.pending.byteLength === 0
+ ? Buffer.from(next.value)
+ : Buffer.concat([this.pending, next.value])
+ }
+ if (this.pending.byteLength < size) return null
+ const result = this.pending.subarray(0, size)
+ this.pending = this.pending.subarray(size)
+ return result
+ }
+
+ async writeTo(target: Writable, size: number): Promise {
+ let remaining = size
+ try {
+ while (remaining > 0) {
+ const chunk = await this.read(Math.min(remaining, 1024 * 1024))
+ if (!chunk) throw invalidArchive('an entry ends before its recorded size')
+ remaining -= chunk.byteLength
+ if (!target.write(chunk)) {
+ await new Promise((resolve, reject) => {
+ target.once('drain', resolve)
+ target.once('error', reject)
+ })
+ }
+ }
+ } catch (error) {
+ target.destroy()
+ throw error
+ }
+ await new Promise((resolve, reject) => {
+ target.once('error', reject)
+ target.end(resolve)
+ })
+ }
+}
+
+function resolveEntryPath(root: string, name: string): string {
+ const normalized = name.replace(/\/+$/, '')
+ const segments = normalized.split('/')
+ if (
+ !normalized ||
+ normalized.includes('\0') ||
+ normalized.startsWith('/') ||
+ path.isAbsolute(normalized) ||
+ /^[A-Za-z]:/.test(normalized) ||
+ segments.some((segment) => segment === '..' || segment === '')
+ ) {
+ throw invalidArchive(`entry ${JSON.stringify(name)} is not a safe relative path`)
+ }
+ const destination = path.resolve(root, ...segments)
+ if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) {
+ throw invalidArchive(`entry ${JSON.stringify(name)} escapes the extraction directory`)
+ }
+ return destination
+}
+
+function verifyChecksum(header: Buffer): void {
+ const expected = readOctal(header, 148, 8)
+ let checksum = 0
+ for (let index = 0; index < BLOCK_SIZE; index += 1) {
+ checksum += index >= 148 && index < 156 ? 0x20 : (header[index] ?? 0)
+ }
+ if (checksum !== expected) throw invalidArchive('an entry header checksum does not match')
+}
+
+function isZeroBlock(block: Buffer): boolean {
+ return block.every((byte) => byte === 0)
+}
+
+function paddedSize(size: number): number {
+ return size + ((BLOCK_SIZE - (size % BLOCK_SIZE)) % BLOCK_SIZE)
+}
+
+function readHeaderString(block: Buffer, offset: number, length: number): string {
+ const field = block.subarray(offset, offset + length)
+ const end = field.indexOf(0)
+ return field.subarray(0, end === -1 ? field.byteLength : end).toString('utf8')
+}
+
+function readOctal(block: Buffer, offset: number, length: number): number {
+ const text = readHeaderString(block, offset, length).trim()
+ if (!/^[0-7]*$/.test(text)) throw invalidArchive('an entry header field is not octal')
+ return text ? Number.parseInt(text, 8) : 0
+}
+
+function writeOctal(block: Buffer, value: number, offset: number, length: number): void {
+ block.write(`${value.toString(8).padStart(length - 1, '0')}\0`, offset, length, 'ascii')
+}
+
+function invalidArchive(reason: string): CliError {
+ return new CliError(
+ 'invalid_runtime_archive',
+ `The Pascal web runtime archive is invalid: ${reason}.`,
+ )
+}
diff --git a/packages/cli/src/terminal-progress.test.ts b/packages/cli/src/terminal-progress.test.ts
new file mode 100644
index 0000000000..63269538f6
--- /dev/null
+++ b/packages/cli/src/terminal-progress.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from 'bun:test'
+import { TerminalProgress } from './terminal-progress.js'
+
+describe('terminal progress', () => {
+ test('prints durable stage feedback outside a TTY', () => {
+ const chunks: string[] = []
+ const progress = new TerminalProgress({
+ isTTY: false,
+ write(chunk) {
+ chunks.push(chunk)
+ },
+ })
+
+ progress.start('Installing the editor runtime')
+ progress.update('Checking that the editor is ready')
+ progress.succeed('Pascal Editor is ready')
+
+ expect(chunks.join('')).toBe(
+ '• Installing the editor runtime\n' +
+ '• Checking that the editor is ready\n' +
+ '✓ Pascal Editor is ready\n',
+ )
+ })
+})
diff --git a/packages/cli/src/terminal-progress.ts b/packages/cli/src/terminal-progress.ts
new file mode 100644
index 0000000000..2a791ef1bb
--- /dev/null
+++ b/packages/cli/src/terminal-progress.ts
@@ -0,0 +1,67 @@
+export interface ProgressStream {
+ isTTY?: boolean
+ write(chunk: string): unknown
+}
+
+const FRAMES = [
+ '[= ]',
+ '[== ]',
+ '[ === ]',
+ '[ ===]',
+ '[ ==]',
+ '[ =]',
+ '[ ==]',
+ '[ ===]',
+]
+
+export class TerminalProgress {
+ private frame = 0
+ private message = ''
+ private timer: ReturnType | undefined
+
+ constructor(private readonly stream: ProgressStream = process.stderr) {}
+
+ start(message: string): void {
+ this.stopActive(false)
+ this.message = message
+ if (!this.stream.isTTY) {
+ this.stream.write(`• ${message}\n`)
+ return
+ }
+ this.render()
+ this.timer = setInterval(() => {
+ this.frame = (this.frame + 1) % FRAMES.length
+ this.render()
+ }, 90)
+ this.timer.unref()
+ }
+
+ update(message: string): void {
+ if (!this.timer && !this.stream.isTTY) {
+ this.start(message)
+ return
+ }
+ this.message = message
+ if (this.stream.isTTY) this.render()
+ }
+
+ succeed(message: string): void {
+ this.stopActive(true)
+ this.stream.write(`✓ ${message}\n`)
+ }
+
+ stop(): void {
+ this.stopActive(true)
+ }
+
+ private render(): void {
+ this.stream.write(`\r\u001b[2K${FRAMES[this.frame]} ${this.message}`)
+ }
+
+ private stopActive(clearLine: boolean): void {
+ if (this.timer) clearInterval(this.timer)
+ this.timer = undefined
+ if (clearLine && this.stream.isTTY && this.message) this.stream.write('\r\u001b[2K')
+ this.message = ''
+ }
+}
diff --git a/packages/cli/src/test-support/fake-mcp-service.ts b/packages/cli/src/test-support/fake-mcp-service.ts
new file mode 100644
index 0000000000..7dd70fed55
--- /dev/null
+++ b/packages/cli/src/test-support/fake-mcp-service.ts
@@ -0,0 +1,47 @@
+import { mkdir, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+
+/**
+ * A stand-in for the bundled `services/pascal-mcp.mjs`: it answers the authenticated health
+ * probe the CLI uses to identify its own MCP process, and records the environment it was
+ * started with so tests can assert the editor origin handed to it.
+ */
+const FAKE_MCP_SERVICE = `import { writeFileSync } from 'node:fs'
+import http from 'node:http'
+const token = process.env.PASCAL_MCP_HTTP_TOKEN
+const server = http.createServer((request, response) => {
+ if (request.headers.authorization !== \`Bearer \${token}\`) {
+ response.writeHead(401).end()
+ return
+ }
+ response.setHeader('content-type', 'application/json')
+ if (request.url === '/health') {
+ response.end(JSON.stringify({
+ status: 'ok',
+ app: 'mcp',
+ version: process.env.PASCAL_RUNTIME_VERSION,
+ instanceId: process.env.PASCAL_INSTANCE_ID,
+ editorOrigin: process.env.PASCAL_EDITOR_ORIGIN ?? null,
+ }))
+ return
+ }
+ response.writeHead(404).end('{}')
+})
+const portIndex = process.argv.indexOf('--port')
+server.listen(Number(process.argv[portIndex + 1]), '127.0.0.1')
+if (process.env.PASCAL_MCP_TEST_RECORD) {
+ writeFileSync(process.env.PASCAL_MCP_TEST_RECORD, JSON.stringify({
+ pid: process.pid,
+ editorOrigin: process.env.PASCAL_EDITOR_ORIGIN ?? null,
+ dataDirectory: process.env.PASCAL_DATA_DIR,
+ }))
+}
+process.on('SIGTERM', () => server.close(() => process.exit(0)))
+`
+
+export async function writeFakeMcpService(directory: string): Promise {
+ await mkdir(directory, { recursive: true })
+ const servicePath = path.join(directory, 'pascal-mcp.mjs')
+ await writeFile(servicePath, FAKE_MCP_SERVICE)
+ return servicePath
+}
diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts
new file mode 100644
index 0000000000..0ac47f8360
--- /dev/null
+++ b/packages/cli/src/version.ts
@@ -0,0 +1,7 @@
+import { readFileSync } from 'node:fs'
+
+const packageJson = JSON.parse(
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
+) as { version: string }
+
+export const version = packageJson.version
diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
new file mode 100644
index 0000000000..2cb2be14e6
--- /dev/null
+++ b/packages/cli/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "@pascal/typescript-config/base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "noEmit": false,
+ "composite": true,
+ "incremental": true,
+ "types": ["node"]
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "src/test-support", "scripts"]
+}
diff --git a/packages/cli/tsconfig.scripts.json b/packages/cli/tsconfig.scripts.json
new file mode 100644
index 0000000000..d36508e2a8
--- /dev/null
+++ b/packages/cli/tsconfig.scripts.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@pascal/typescript-config/base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": ["scripts"]
+}
diff --git a/packages/core/LICENSE b/packages/core/LICENSE
new file mode 100644
index 0000000000..083fd9e323
--- /dev/null
+++ b/packages/core/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Pascal Group Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/core/README.md b/packages/core/README.md
index 2f39c6ff83..e9edb80cf8 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -23,6 +23,9 @@ npm install react three @react-three/fiber @react-three/drei
- **Spatial Grid** - Collision detection and placement validation
- **Event Bus** - Typed event emitter for inter-component communication
- **Asset Storage** - IndexedDB-based file storage for user-uploaded assets
+- **Capture Contracts** (`@pascal-app/core/capture`) - Versioned capture-session manifests,
+ normalized stream descriptors, packet headers, and transport-neutral static/live `CaptureSource`
+ implementations
## Usage
@@ -78,6 +81,26 @@ Load the plugin before mounting `@pascal-app/viewer`. See the
[`@pascal-app/viewer` quick start](https://github.com/pascalorg/editor/tree/main/packages/viewer#usage)
for a React example.
+## Capture Sessions
+
+Capture contracts are a self-contained subpath — no React, no Three.js, no prescribed transport:
+
+```typescript
+import { createHttpCaptureSource, type CaptureSessionLocator } from '@pascal-app/core/capture'
+
+const locator: CaptureSessionLocator = {
+ sessionId: 'capture_123',
+ manifestUrl: '/api/captures/capture_123/manifest',
+}
+
+const source = createHttpCaptureSource(locator, { credentials: 'include' })
+const descriptor = await source.describe()
+```
+
+For live producers, use `PushCaptureSource` directly or implement `CaptureSource.subscribe()` with
+the same descriptor and packet event contract. The reference renderers that consume these sources
+ship in [`@pascal-app/viewer/capture`](https://github.com/pascalorg/editor/tree/main/packages/viewer#capture-sessions).
+
## License
MIT
diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml
new file mode 100644
index 0000000000..eec7d338da
--- /dev/null
+++ b/packages/core/bunfig.toml
@@ -0,0 +1,4 @@
+preload = ["../../scripts/bun-preload-three.ts"]
+
+[test]
+preload = ["../../scripts/bun-preload-three.ts"]
diff --git a/packages/core/package.json b/packages/core/package.json
index 8612ea2e7c..c293ea0206 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@pascal-app/core",
- "version": "1.0.0-beta.4",
+ "version": "1.0.0",
"description": "Core library for Pascal 3D building editor",
"type": "module",
"main": "./dist/index.js",
@@ -21,6 +21,11 @@
"import": "./dist/utils/scene-migrations.js",
"default": "./dist/utils/scene-migrations.js"
},
+ "./capture": {
+ "types": "./dist/capture/index.d.ts",
+ "import": "./dist/capture/index.js",
+ "default": "./dist/capture/index.js"
+ },
"./registry": {
"types": "./dist/registry/index.d.ts",
"import": "./dist/registry/index.js",
@@ -46,6 +51,11 @@
"import": "./dist/hooks/spatial-grid/spatial-grid-manager.js",
"default": "./dist/hooks/spatial-grid/spatial-grid-manager.js"
},
+ "./plan-footprint": {
+ "types": "./dist/lib/plan-footprint.d.ts",
+ "import": "./dist/lib/plan-footprint.js",
+ "default": "./dist/lib/plan-footprint.js"
+ },
"./wall": {
"types": "./dist/systems/wall/wall-footprint.d.ts",
"import": "./dist/systems/wall/wall-footprint.js",
@@ -66,20 +76,21 @@
"dev": "tsgo --build --watch",
"test": "bun test src",
"bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts",
+ "bench:schema": "bun run src/schema/__bench__/node-parsers.bench.ts",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
- "three": "^0.185"
+ "three": "^0.186"
},
"dependencies": {
"dedent": "^1.7.1",
"idb-keyval": "^6.2.2",
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
- "zod": "^4.3.5",
+ "zod": ">=4.5.4 <4.6",
"zundo": "^2.3.0",
"zustand": "^5"
},
@@ -88,6 +99,7 @@
"@types/bun": "^1.3.0",
"@types/react": "^19.2.2",
"@types/three": "^0.184.0",
+ "fake-indexeddb": "^6.2.5",
"typescript": "6.0.3"
},
"keywords": [
diff --git a/packages/core/src/architecture.test.ts b/packages/core/src/architecture.test.ts
new file mode 100644
index 0000000000..7a1b5e0199
--- /dev/null
+++ b/packages/core/src/architecture.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, test } from 'bun:test'
+import { readdirSync, readFileSync } from 'node:fs'
+import { join, relative, resolve } from 'node:path'
+
+/**
+ * Layer rule (AGENTS.md): core is pure logic — no Three.js, no rendering.
+ * A runtime `three`/`@react-three/*` import in core evaluates R3F (and thus
+ * React client context) in every consumer of the barrel, which crashes
+ * Next.js route handlers under the RSC server condition (capture uploads
+ * 500'd this way once). Type-only imports are erased at build and allowed.
+ */
+const SRC = resolve(import.meta.dir)
+
+function sourceFiles(dir: string): string[] {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const full = join(dir, entry.name)
+ if (entry.isDirectory()) return sourceFiles(full)
+ if (/\.test\.tsx?$/.test(entry.name)) return []
+ return /\.tsx?$/.test(entry.name) ? [full] : []
+ })
+}
+
+const BANNED_SPEC = String.raw`(?:three(?:\/[^'"]*)?|@react-three\/[^'"]*)`
+// `import`/`export … from 'three…'` — group 1 captures a whole-clause `type`
+// qualifier, the only form guaranteed to be erased by the compiler.
+const FROM_RE = new RegExp(
+ String.raw`(?:import|export)\s+(type\s)?[\w*{}\s,$]*?from\s*['"]${BANNED_SPEC}['"]`,
+ 'g',
+)
+// Bare side-effect form: `import 'three…'` — always a runtime import.
+const SIDE_EFFECT_RE = new RegExp(String.raw`import\s*['"]${BANNED_SPEC}['"]`, 'g')
+
+describe('architecture', () => {
+ test('core has no runtime three/@react-three imports', () => {
+ const files = sourceFiles(SRC)
+ const offenders: string[] = []
+
+ for (const file of files) {
+ const src = readFileSync(file, 'utf8')
+ for (const match of src.matchAll(FROM_RE)) {
+ if (!match[1]) offenders.push(`${relative(SRC, file)}: ${match[0].replaceAll('\n', ' ')}`)
+ }
+ for (const match of src.matchAll(SIDE_EFFECT_RE)) {
+ offenders.push(`${relative(SRC, file)}: ${match[0]}`)
+ }
+ }
+
+ expect(offenders).toEqual([])
+ // Guard against the walk passing vacuously.
+ expect(files.length).toBeGreaterThan(100)
+ })
+})
diff --git a/packages/core/src/capture/index.ts b/packages/core/src/capture/index.ts
new file mode 100644
index 0000000000..097ae99055
--- /dev/null
+++ b/packages/core/src/capture/index.ts
@@ -0,0 +1,47 @@
+export {
+ ArkitDeviceMotionTrajectorySchema,
+ ArkitPointCloudPayloadSchema,
+ ArkitSurfaceMeshPayloadSchema,
+ type CaptureArtifactReference,
+ CaptureArtifactReferenceSchema,
+ type CaptureClock,
+ CaptureClockSchema,
+ type CaptureCoordinateFrame,
+ CaptureCoordinateFrameSchema,
+ type CaptureSessionDescriptor,
+ CaptureSessionDescriptorSchema,
+ type CaptureSessionLocator,
+ CaptureSessionLocatorSchema,
+ type CaptureSessionManifest,
+ CaptureSessionManifestSchema,
+ type CaptureSessionManifestV1,
+ CaptureSessionManifestV1Schema,
+ type CaptureSessionManifestV2,
+ CaptureSessionManifestV2Schema,
+ type CaptureStreamDescriptor,
+ CaptureStreamDescriptorSchema,
+ CaptureTimeRangeSchema,
+ captureLayerKey,
+ captureStreamLabel,
+ DeviceMotionSampleSchema,
+ type DeviceMotionTrajectoryPayload,
+ DeviceMotionTrajectorySchema,
+ normalizeCaptureSessionManifest,
+ type PointCloudPayload,
+ PointCloudPayloadSchema,
+ type SurfaceMeshPayload,
+ SurfaceMeshPayloadSchema,
+} from './schema'
+export {
+ type CaptureArtifactResolution,
+ type CaptureSource,
+ type CaptureSourceEvent,
+ type CaptureSourceResolver,
+ type CaptureStreamPacket,
+ CaptureStreamPacketSchema,
+ type CaptureSubscriptionOptions,
+ createHttpCaptureSource,
+ type HttpCaptureSourceOptions,
+ PushCaptureSource,
+ type PushCaptureSourceOptions,
+} from './source'
diff --git a/packages/core/src/capture/schema.test.ts b/packages/core/src/capture/schema.test.ts
new file mode 100644
index 0000000000..3dbb6e8c7a
--- /dev/null
+++ b/packages/core/src/capture/schema.test.ts
@@ -0,0 +1,240 @@
+import { describe, expect, test } from 'bun:test'
+import {
+ CaptureSessionManifestV2Schema,
+ captureLayerKey,
+ normalizeCaptureSessionManifest,
+} from './schema'
+
+const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
+
+describe('capture manifests', () => {
+ test('normalizes the Community v1 manifest into extensible streams', () => {
+ const descriptor = normalizeCaptureSessionManifest({
+ schemaVersion: 1,
+ sessionId: 'capture_123',
+ projectId: 'project_123',
+ streams: {
+ roomModel: {
+ kind: 'room-model',
+ mediaType: 'model/vnd.usdz+zip',
+ url: 'https://cdn.pascal.app/room.usdz',
+ },
+ deviceMotion: {
+ kind: 'device-motion',
+ trajectory: {
+ coordinateSystem: 'arkit-world',
+ samples: [
+ { segment: 0, timestamp: 0, transform: identity },
+ { segment: 0, timestamp: 1, transform: identity },
+ ],
+ },
+ },
+ pointCloud: {
+ kind: 'point-cloud',
+ points: {
+ coordinateSystem: 'arkit-world',
+ positions: [0, 0, 0, 1, 1, 1],
+ },
+ },
+ surfaceMesh: {
+ kind: 'surface-mesh',
+ mesh: {
+ version: 1,
+ coordinateSystem: 'arkit-world',
+ representation: 'quantized-indexed-triangle-mesh',
+ appearance: 'camera-vertex-color',
+ vertexCount: 3,
+ faceCount: 1,
+ boundsMin: [0, 0, 0],
+ boundsMax: [1, 1, 0],
+ positionEncoding: 'uint16x3-base64-little-endian',
+ colorEncoding: 'uint8x3-base64-srgb',
+ indexEncoding: 'uint16x3-base64-little-endian',
+ positions: 'AAAAAAAAAAAAAAAAAAAAAAAA',
+ colors: '////////////',
+ indices: 'AAABAAIA',
+ },
+ },
+ },
+ })
+
+ expect(descriptor.streams.map(captureLayerKey)).toEqual([
+ 'model',
+ 'deviceMotion',
+ 'pointCloud',
+ 'surfaceMesh',
+ ])
+ expect(descriptor.streams[0]?.artifact?.uri).toBe('https://cdn.pascal.app/room.usdz')
+ expect(descriptor.streams[2]?.inline).toMatchObject({
+ coordinateSystem: 'arkit-world',
+ positions: [0, 0, 0, 1, 1, 1],
+ })
+ expect(descriptor.streams[3]?.inline).toMatchObject({
+ appearance: 'camera-vertex-color',
+ faceCount: 1,
+ })
+ })
+
+ test('keeps unknown v2 stream kinds without a protocol release', () => {
+ const manifest = CaptureSessionManifestV2Schema.parse({
+ schemaVersion: 2,
+ sessionId: 'capture_123',
+ state: 'live',
+ streams: [
+ {
+ id: 'wifi-rtt',
+ kind: 'wifi-ranging',
+ availability: 'live',
+ },
+ ],
+ })
+
+ expect(normalizeCaptureSessionManifest(manifest).streams[0]?.kind).toBe('wifi-ranging')
+ })
+
+ test('preserves the exact ARKit coordinate system required by v1', () => {
+ expect(() =>
+ normalizeCaptureSessionManifest({
+ schemaVersion: 1,
+ sessionId: 'capture_123',
+ projectId: 'project_123',
+ streams: {
+ deviceMotion: {
+ kind: 'device-motion',
+ trajectory: {
+ coordinateSystem: 'unknown',
+ samples: [
+ { segment: 0, timestamp: 0, transform: identity },
+ { segment: 0, timestamp: 1, transform: identity },
+ ],
+ },
+ },
+ },
+ }),
+ ).toThrow()
+ })
+
+ test('accepts the native 20,000-face preview budget and rejects malformed or oversized meshes', () => {
+ const surfaceMesh = {
+ version: 1,
+ coordinateSystem: 'arkit-world',
+ representation: 'quantized-indexed-triangle-mesh',
+ appearance: 'camera-vertex-color',
+ vertexCount: 3,
+ faceCount: 1,
+ boundsMin: [0, 0, 0],
+ boundsMax: [1, 1, 0],
+ positionEncoding: 'uint16x3-base64-little-endian',
+ colorEncoding: 'uint8x3-base64-srgb',
+ indexEncoding: 'uint16x3-base64-little-endian',
+ positions: 'AAAAAAAAAAAAAAAAAAAAAAAA',
+ colors: '////////////',
+ indices: 'AAABAAIA',
+ }
+ const manifest = (mesh: unknown) => ({
+ schemaVersion: 1,
+ sessionId: 'capture_123',
+ projectId: 'project_123',
+ streams: { surfaceMesh: { kind: 'surface-mesh', mesh } },
+ })
+
+ const atBudget = normalizeCaptureSessionManifest(
+ manifest({ ...surfaceMesh, faceCount: 20_000, indices: surfaceMesh.indices.repeat(20_000) }),
+ )
+ expect(atBudget.streams[0]?.inline).toMatchObject({ faceCount: 20_000 })
+ expect(() =>
+ normalizeCaptureSessionManifest(
+ manifest({
+ ...surfaceMesh,
+ faceCount: 20_001,
+ indices: surfaceMesh.indices.repeat(20_001),
+ }),
+ ),
+ ).toThrow('<=20000')
+ expect(() =>
+ normalizeCaptureSessionManifest(manifest({ ...surfaceMesh, positions: 'AAAA' })),
+ ).toThrow('decoded bytes')
+ expect(() =>
+ normalizeCaptureSessionManifest(manifest({ ...surfaceMesh, indices: 'AAABAP//' })),
+ ).toThrow('existing vertex')
+ })
+
+ test('rejects non-finite capture geometry', () => {
+ expect(() =>
+ normalizeCaptureSessionManifest({
+ schemaVersion: 1,
+ sessionId: 'capture_123',
+ projectId: 'project_123',
+ streams: {
+ pointCloud: {
+ kind: 'point-cloud',
+ points: {
+ coordinateSystem: 'arkit-world',
+ positions: [0, 0, Number.POSITIVE_INFINITY],
+ },
+ },
+ },
+ }),
+ ).toThrow()
+
+ expect(() =>
+ normalizeCaptureSessionManifest({
+ schemaVersion: 1,
+ sessionId: 'capture_123',
+ projectId: 'project_123',
+ streams: {
+ surfaceMesh: {
+ kind: 'surface-mesh',
+ mesh: {
+ version: 1,
+ coordinateSystem: 'arkit-world',
+ representation: 'quantized-indexed-triangle-mesh',
+ appearance: 'camera-vertex-color',
+ vertexCount: 3,
+ faceCount: 1,
+ boundsMin: [0, 0, Number.NaN],
+ boundsMax: [1, 1, 0],
+ positionEncoding: 'uint16x3-base64-little-endian',
+ colorEncoding: 'uint8x3-base64-srgb',
+ indexEncoding: 'uint16x3-base64-little-endian',
+ positions: 'AAAAAAAAAAAAAAAAAAAAAAAA',
+ colors: '////////////',
+ indices: 'AAABAAIA',
+ },
+ },
+ },
+ }),
+ ).toThrow()
+ })
+
+ test('rejects duplicate stream IDs and backwards time ranges', () => {
+ expect(() =>
+ normalizeCaptureSessionManifest({
+ schemaVersion: 2,
+ sessionId: 'capture_123',
+ streams: [
+ { id: 'points', kind: 'point-cloud' },
+ { id: 'points', kind: 'point-cloud' },
+ ],
+ }),
+ ).toThrow('Duplicate capture streams id')
+
+ expect(() =>
+ normalizeCaptureSessionManifest({
+ schemaVersion: 2,
+ sessionId: 'capture_123',
+ streams: [
+ {
+ id: 'video',
+ kind: 'video',
+ artifact: {
+ id: 'video',
+ mediaType: 'video/mp4',
+ timeRange: { start: 2, end: 1 },
+ },
+ },
+ ],
+ }),
+ ).toThrow('must end at or after')
+ })
+})
diff --git a/packages/core/src/capture/schema.ts b/packages/core/src/capture/schema.ts
new file mode 100644
index 0000000000..012e7b5fa2
--- /dev/null
+++ b/packages/core/src/capture/schema.ts
@@ -0,0 +1,397 @@
+import { z } from 'zod'
+
+const MetadataSchema = z.record(z.string(), z.unknown())
+
+export const CaptureSessionLocatorSchema = z.object({
+ sessionId: z.string().min(1),
+ manifestUrl: z.string().min(1).optional(),
+ schemaVersion: z.number().int().positive().optional(),
+ revisionId: z.string().min(1).optional(),
+})
+
+export const DeviceMotionSampleSchema = z.object({
+ segment: z.number().int().nonnegative(),
+ timestamp: z.number().nonnegative(),
+ transform: z.array(z.number()).length(16),
+})
+
+export const DeviceMotionTrajectorySchema = z.object({
+ coordinateSystem: z.string().min(1),
+ samples: z.array(DeviceMotionSampleSchema).min(2),
+})
+
+export const ArkitDeviceMotionTrajectorySchema = DeviceMotionTrajectorySchema.extend({
+ coordinateSystem: z.literal('arkit-world'),
+})
+
+export const PointCloudPayloadSchema = z
+ .object({
+ coordinateSystem: z.string().min(1),
+ positions: z.array(z.number().finite()).min(3),
+ colors: z.array(z.number().finite()).optional(),
+ })
+ .superRefine((payload, context) => {
+ if (payload.positions.length % 3 !== 0) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Point-cloud positions must contain XYZ triples.',
+ path: ['positions'],
+ })
+ }
+ if (payload.colors && payload.colors.length !== payload.positions.length) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Point-cloud colors must match the positions array length.',
+ path: ['colors'],
+ })
+ }
+ })
+
+export const ArkitPointCloudPayloadSchema = PointCloudPayloadSchema.safeExtend({
+ coordinateSystem: z.literal('arkit-world'),
+})
+
+const MAX_SURFACE_MESH_VERTICES = 65_535
+const MAX_SURFACE_MESH_FACES = 20_000
+
+export const SurfaceMeshPayloadSchema = z
+ .object({
+ version: z.literal(1),
+ coordinateSystem: z.string().min(1),
+ representation: z.literal('quantized-indexed-triangle-mesh'),
+ appearance: z.literal('camera-vertex-color'),
+ vertexCount: z.number().int().positive().max(MAX_SURFACE_MESH_VERTICES),
+ faceCount: z.number().int().positive().max(MAX_SURFACE_MESH_FACES),
+ boundsMin: z.array(z.number().finite()).length(3),
+ boundsMax: z.array(z.number().finite()).length(3),
+ positionEncoding: z.literal('uint16x3-base64-little-endian'),
+ colorEncoding: z.literal('uint8x3-base64-srgb'),
+ indexEncoding: z.literal('uint16x3-base64-little-endian'),
+ positions: z.string().min(1).max(524_280),
+ colors: z.string().min(1).max(262_140),
+ indices: z
+ .string()
+ .min(1)
+ .max(MAX_SURFACE_MESH_FACES * 8),
+ })
+ .superRefine((payload, context) => {
+ if (payload.vertexCount > payload.faceCount * 3) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Surface meshes cannot contain more than three vertices per face.',
+ path: ['vertexCount'],
+ })
+ }
+ for (let axis = 0; axis < 3; axis += 1) {
+ if ((payload.boundsMax[axis] ?? 0) < (payload.boundsMin[axis] ?? 0)) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Surface-mesh maximum bounds must not be below minimum bounds.',
+ path: ['boundsMax', axis],
+ })
+ }
+ }
+
+ const positionBytes = decodeBase64(payload.positions)
+ const colorBytes = decodeBase64(payload.colors)
+ const indexBytes = decodeBase64(payload.indices)
+ validateSurfaceMeshByteLength(positionBytes, payload.vertexCount * 3 * 2, 'positions', context)
+ validateSurfaceMeshByteLength(colorBytes, payload.vertexCount * 3, 'colors', context)
+ validateSurfaceMeshByteLength(indexBytes, payload.faceCount * 3 * 2, 'indices', context)
+
+ if (indexBytes?.byteLength === payload.faceCount * 3 * 2) {
+ const indices = new DataView(indexBytes.buffer, indexBytes.byteOffset, indexBytes.byteLength)
+ for (let offset = 0; offset < indexBytes.byteLength; offset += 2) {
+ if (indices.getUint16(offset, true) >= payload.vertexCount) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Surface-mesh indices must reference an existing vertex.',
+ path: ['indices'],
+ })
+ break
+ }
+ }
+ }
+ })
+
+export const ArkitSurfaceMeshPayloadSchema = SurfaceMeshPayloadSchema.safeExtend({
+ coordinateSystem: z.literal('arkit-world'),
+})
+
+export const CaptureTimeRangeSchema = z
+ .object({
+ start: z.number().nonnegative(),
+ end: z.number().nonnegative(),
+ })
+ .refine((range) => range.end >= range.start, {
+ message: 'Capture time ranges must end at or after they start.',
+ path: ['end'],
+ })
+
+export const CaptureArtifactReferenceSchema = z.object({
+ id: z.string().min(1),
+ uri: z.string().min(1).optional(),
+ mediaType: z.string().min(1),
+ byteLength: z.number().int().nonnegative().optional(),
+ sha256: z.string().min(1).optional(),
+ frameId: z.string().min(1).optional(),
+ timeRange: CaptureTimeRangeSchema.optional(),
+ metadata: MetadataSchema.optional(),
+})
+
+export const CaptureStreamDescriptorSchema = z.object({
+ id: z.string().min(1),
+ kind: z.string().min(1),
+ role: z.string().min(1).optional(),
+ availability: z.enum(['pending', 'live', 'ready', 'failed']).default('ready'),
+ frameId: z.string().min(1).optional(),
+ clockId: z.string().min(1).optional(),
+ artifact: CaptureArtifactReferenceSchema.optional(),
+ inline: z.unknown().optional(),
+ metadata: MetadataSchema.optional(),
+})
+
+export const CaptureClockSchema = z.object({
+ id: z.string().min(1),
+ timebase: z.enum(['seconds', 'milliseconds', 'microseconds', 'nanoseconds']),
+ epoch: z.string().min(1).optional(),
+})
+
+export const CaptureCoordinateFrameSchema = z.object({
+ id: z.string().min(1),
+ parentId: z.string().min(1).optional(),
+ convention: z.string().min(1),
+ transform: z.array(z.number()).length(16).optional(),
+})
+
+export const CaptureSessionManifestV1Schema = z.object({
+ schemaVersion: z.literal(1),
+ sessionId: z.string().min(1),
+ projectId: z.string().min(1),
+ streams: z.object({
+ roomModel: z
+ .object({
+ kind: z.literal('room-model'),
+ mediaType: z.literal('model/vnd.usdz+zip'),
+ url: z.string().min(1),
+ })
+ .optional(),
+ deviceMotion: z
+ .object({
+ kind: z.literal('device-motion'),
+ trajectory: ArkitDeviceMotionTrajectorySchema,
+ })
+ .optional(),
+ pointCloud: z
+ .object({
+ kind: z.literal('point-cloud'),
+ points: ArkitPointCloudPayloadSchema,
+ })
+ .optional(),
+ surfaceMesh: z
+ .object({
+ kind: z.literal('surface-mesh'),
+ mesh: ArkitSurfaceMeshPayloadSchema,
+ })
+ .optional(),
+ }),
+})
+
+export const CaptureSessionManifestV2Schema = z
+ .object({
+ schemaVersion: z.literal(2),
+ sessionId: z.string().min(1),
+ projectId: z.string().min(1).optional(),
+ revisionId: z.string().min(1).optional(),
+ state: z.enum(['live', 'finalizing', 'ready', 'failed']).default('ready'),
+ clocks: z.array(CaptureClockSchema).default([]),
+ coordinateFrames: z.array(CaptureCoordinateFrameSchema).default([]),
+ streams: z.array(CaptureStreamDescriptorSchema),
+ metadata: MetadataSchema.optional(),
+ })
+ .superRefine(validateUniqueSessionIds)
+
+export const CaptureSessionManifestSchema = z.union([
+ CaptureSessionManifestV1Schema,
+ CaptureSessionManifestV2Schema,
+])
+
+export const CaptureSessionDescriptorSchema = z
+ .object({
+ schemaVersion: z.number().int().positive(),
+ sessionId: z.string().min(1),
+ projectId: z.string().min(1).optional(),
+ revisionId: z.string().min(1).optional(),
+ state: z.enum(['live', 'finalizing', 'ready', 'failed']),
+ clocks: z.array(CaptureClockSchema),
+ coordinateFrames: z.array(CaptureCoordinateFrameSchema),
+ streams: z.array(CaptureStreamDescriptorSchema),
+ metadata: MetadataSchema.optional(),
+ })
+ .superRefine(validateUniqueSessionIds)
+
+export type CaptureArtifactReference = z.infer
+export type CaptureClock = z.infer
+export type CaptureCoordinateFrame = z.infer