Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# The CLI is developed on macOS; this is the only place its Windows path runs.
# The first Windows user hit seven "Failed to install" lines because nothing
# here ever executed on Windows.
name: windows
on:
push:
pull_request:
workflow_dispatch:
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install pyyaml
- run: bun install
- name: Platform unit tests
run: bun test tests/windows.test.ts
- name: Real Windows checks (cmd.exe, where, npm shims)
shell: pwsh
run: |
Remove-Item Env:HOME -ErrorAction SilentlyContinue
bun scripts/windows-smoke.ts
- name: Built CLI detects installed tools (HOME unset, as on a user's machine)
shell: pwsh
run: |
bun run build
npm pack
npm install -g (Get-ChildItem datacore-one-cli-*.tgz).FullName
Remove-Item Env:HOME -ErrorAction SilentlyContinue
$r = datacore doctor --format json | ConvertFrom-Json
$r.dependencies | Format-Table name, installed, version
"home: $($r.home)"
foreach ($n in 'git','node','python') {
$d = $r.dependencies | Where-Object name -eq $n
if (-not $d.installed) { throw "$n reported missing on a runner that has it" }
}
if ($r.home -notmatch '^[A-Za-z]:\\') { throw "home is not an absolute Windows path: $($r.home)" }
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@datacore-one/cli",
"version": "2.5.0",
"version": "2.5.1",
"description": "CLI for setting up and managing Datacore installations",
"type": "module",
"main": "dist/index.js",
Expand Down
49 changes: 49 additions & 0 deletions scripts/windows-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Runs on a real Windows machine (CI: .github/workflows/windows.yml).
*
* tests/windows.test.ts proves the platform decisions; this proves they work
* against real cmd.exe, real `where`, and real npm shims — the three things
* that made every install fail on the first Windows machine.
*/
import { execFileSync, homeDir, runShell, which } from '../src/lib/exec'
import { resolveBinary } from '../src/lib/upgrade'
import { findPython } from '../src/lib/python'

let failed = 0
function check(name: string, ok: boolean, detail: unknown) {
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}: ${String(detail)}`)
if (!ok) failed++
}

const node = which('node')
check('which(node) finds node.exe', !!node && /node\.exe$/i.test(node), node)
const npm = which('npm')
check('which(npm) picks the .cmd shim, not the bash script', !!npm && /npm\.cmd$/i.test(npm), npm)
check('which(git) finds git', which('git') !== null, which('git'))
check('which(<missing>) is null', which('definitely-not-a-command-xyz') === null, which('definitely-not-a-command-xyz'))

let npmVersion = ''
try { npmVersion = String(execFileSync('npm', ['--version'], { encoding: 'utf-8', stdio: 'pipe' })).trim() } catch (e) { npmVersion = (e as Error).message }
check('execFileSync(npm) runs the .cmd shim', /^\d+\.\d+/.test(npmVersion), npmVersion)

let shellOut = ''
try { shellOut = String(runShell('echo first && echo second', { encoding: 'utf-8', stdio: 'pipe' })) } catch (e) { shellOut = (e as Error).message }
check('runShell handles && through cmd.exe', shellOut.includes('first') && shellOut.includes('second'), JSON.stringify(shellOut))

check('resolveBinary(npm) resolves', resolveBinary('npm') !== null, resolveBinary('npm'))
check('homeDir() is absolute', /^[A-Za-z]:\\/.test(homeDir()), homeDir())
check('findPython() finds an interpreter', findPython() !== null, findPython())

// The exact step that failed for the first Windows user: an install command
// run through the shell, then the installed binary found. CI only — it
// installs a global package.
if (process.env.CI) {
let installErr = ''
try { runShell('npm install -g @plur-ai/mcp', { stdio: 'inherit', timeout: 300000 }) } catch (e) { installErr = (e as Error).message }
check('runShell(npm install -g ...) succeeds', installErr === '', installErr || 'installed')
const plur = resolveBinary('plur-mcp')
check('the installed binary is found afterwards', plur !== null, plur)
}

if (failed) { console.log(`\n${failed} check(s) failed`); process.exit(1) }
console.log('\nall Windows checks passed')
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { listModules, installModule, updateModules, removeModule } from './lib/m
import { createSnapshot, saveSnapshot, loadSnapshot, diffSnapshot, restoreFromSnapshot, lockFileExists } from './lib/snapshot'
import * as app from './lib/app'
import { VERSION } from './version'
import { homeDir } from './lib/exec'



Expand Down Expand Up @@ -717,7 +718,7 @@ async function handleResource(
// Add task to inbox.org with :AI: tag
const { existsSync, appendFileSync } = await import('fs')
const { join } = await import('path')
const inboxPath = join(process.env.HOME || '~', 'Data', '0-personal', 'org', 'inbox.org')
const inboxPath = join(homeDir(), 'Data', '0-personal', 'org', 'inbox.org')

if (!existsSync(inboxPath)) {
throw new CLIError('ERR_NOT_FOUND', 'inbox.org not found. Run datacore init first.')
Expand Down
10 changes: 2 additions & 8 deletions src/lib/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* which spawns subagents with specific system prompts.
*/

import { spawn } from 'child_process'
import { spawn, which } from './exec'
import { existsSync } from 'fs'
import { join } from 'path'
import type { AgentInvocation, AgentResult } from '../types'
Expand All @@ -14,13 +14,7 @@ import { dataDir } from './paths'
const DATA_DIR = () => dataDir()

function commandExists(cmd: string): boolean {
try {
const { execSync } = require('child_process')
execSync(`which ${cmd}`, { stdio: 'pipe' })
return true
} catch {
return false
}
return which(cmd) !== null
}

export interface InvokeOptions {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
* Used by init (ingest) and potentially nightshift.
*/

import { spawn } from 'child_process'
import { spawn, homeDir } from './exec'
import { openSync, closeSync, mkdirSync } from 'fs'
import { join } from 'path'

const DATA_DIR = join(process.env.HOME || '', 'Data')
const DATA_DIR = join(homeDir(), 'Data')
const STATE_DIR = join(DATA_DIR, '.datacore', 'state')

export interface BackgroundJob {
Expand Down
19 changes: 9 additions & 10 deletions src/lib/dependency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
* Dependency checking utilities.
*/

import { execSync, execFileSync } from 'child_process'
import { execSync } from 'child_process'
import { execFileSync, homeDir } from './exec'
import { resolveBinary } from './upgrade'
import { existsSync, readFileSync } from 'fs'
import { join, basename } from 'path'
import type { DependencyCheck, DoctorResult, LedgerCheck } from '../types'
Expand All @@ -12,12 +14,9 @@ import { classifyVerifyFailure, findPython, PYTHON_MIN_VERSION } from './python'
import { listSpaces } from './space'

function commandExists(cmd: string): boolean {
try {
execSync(`which ${cmd}`, { stdio: 'pipe' })
return true
} catch {
return false
}
// Same lookup init uses, so doctor and the installer cannot disagree about
// whether a tool is there.
return resolveBinary(cmd) !== null
}

function getVersion(cmd: string, versionFlag = '--version'): string | undefined {
Expand Down Expand Up @@ -223,7 +222,7 @@ function checkPlurMcp(platform: Platform): DependencyCheck {
}

export function checkCodePermissions(): { enableAll: boolean; mcpAllowed: boolean } {
const home = process.env.HOME || ''
const home = homeDir()
const settingsPaths = [
join(home, 'Data', '.claude', 'settings.local.json'),
join(home, 'Data', '.claude', 'settings.json'),
Expand Down Expand Up @@ -252,7 +251,7 @@ export function checkCodePermissions(): { enableAll: boolean; mcpAllowed: boolea

export function checkMcpConfig(): { claudeDesktop: boolean; claudeCode: boolean } {
// Check Claude Desktop config
const home = process.env.HOME || ''
const home = homeDir()
const desktopPaths = [
join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), // macOS
join(home, '.config', 'claude', 'claude_desktop_config.json'), // Linux
Expand Down Expand Up @@ -454,7 +453,7 @@ export function runDoctor(): DoctorResult {
return {
platform: `${platform} (${release})`,
arch,
home: process.env.HOME || '~',
home: homeDir(),
dataDir: resolveDataDir(),
python: { path: py },
datacoreExists: datacore.exists,
Expand Down
135 changes: 135 additions & 0 deletions src/lib/exec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Process spawning and command lookup that work on native Windows.
*
* Every other module spawns through here. The first Windows install failed
* on all seven tools because each install ran via `/bin/bash`, which Windows
* does not have; lookups used `which`, also absent, so every tool looked
* missing; and npm's `.cmd` shims cannot be spawned without a shell (Node
* refuses them outright since CVE-2024-27980). On macOS and Linux everything
* here is a passthrough, so behaviour there is unchanged.
*
* The decisions are pure functions of the platform so they can be tested on
* the Mac this is developed on: tests/windows.test.ts.
*/

import * as cp from 'child_process'
import type { ExecFileSyncOptions, SpawnOptions, ChildProcess } from 'child_process'
import { homedir } from 'os'
import { join, extname } from 'path'

const IS_WINDOWS = process.platform === 'win32'

/**
* The user's home directory. HOME is unset on native Windows, and
* `join(process.env.HOME || '', 'Data')` is then the RELATIVE path `Data` —
* the second brain would be created in whatever directory init ran from.
*/
export function homeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir()
}

/** How to run a command string that may contain pipes and `&&`. */
export function shellInvocation(command: string, platform: NodeJS.Platform = process.platform): { file: string; args: string[] } {
if (platform === 'win32') {
// /d: skip AutoRun, /s: take the rest literally, /c: run and exit.
return { file: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', command] }
}
return { file: '/bin/bash', args: ['-c', command] }
}

export function whichInvocation(cmd: string, platform: NodeJS.Platform = process.platform): { file: string; args: string[] } {
return platform === 'win32' ? { file: 'where', args: [cmd] } : { file: 'which', args: [cmd] }
}

/**
* `where npm` prints the extensionless bash script before npm.cmd; only the
* latter is runnable from cmd or Node. Prefer anything with an executable
* extension, and fall back to the first line.
*/
export function pickWhereResult(output: string): string | null {
const lines = output.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
if (lines.length === 0) return null
const runnable = ['.exe', '.cmd', '.bat', '.com']
return lines.find((l) => runnable.includes(extname(l).toLowerCase())) ?? lines[0]!
}

/** Where `npm install -g` puts a binary under a given prefix. */
export function npmBinCandidates(prefix: string, cmd: string, platform: NodeJS.Platform = process.platform): string[] {
return platform === 'win32' ? [join(prefix, `${cmd}.cmd`)] : [join(prefix, 'bin', cmd)]
}

export function needsShell(file: string): boolean {
return /\.(cmd|bat)$/i.test(file)
}

/** Quote one argument for a cmd.exe command line. */
export function quoteWindowsArg(arg: string): string {
if (arg === '') return '""'
if (!/[\s"&|<>^%(),;!]/.test(arg)) return arg
return `"${arg.replace(/"/g, '""')}"`
}

/** Absolute path of `cmd` on PATH, or null. */
export function which(cmd: string): string | null {
const inv = whichInvocation(cmd)
try {
const out = cp.execFileSync(inv.file, inv.args, { stdio: 'pipe', encoding: 'utf-8' })
return IS_WINDOWS ? pickWhereResult(out) : (out.trim() || null)
} catch {
return null
}
}

/**
* On Windows, turn a bare command name into something spawnable: `.exe` runs
* directly with args untouched; a `.cmd`/`.bat` shim runs through cmd.exe
* with its args quoted. Anything unresolvable is passed through unchanged, so
* the caller sees the same ENOENT it would have.
*/
function windowsTarget(file: string, args: readonly string[]): { file: string; args: string[]; shell: boolean } {
const resolved = /[\\/]/.test(file) ? file : which(file)
if (resolved && needsShell(resolved)) {
return { file: quoteWindowsArg(resolved), args: args.map(quoteWindowsArg), shell: true }
}
return { file: resolved ?? file, args: [...args], shell: false }
}

/** Drop-in for child_process.execFileSync that can run npm shims on Windows. */
export function execFileSync(file: string, args: readonly string[] = [], options?: ExecFileSyncOptions): any {
if (!IS_WINDOWS) return cp.execFileSync(file, args, options)
const t = windowsTarget(file, args)
return cp.execFileSync(t.file, t.args, t.shell ? { ...options, shell: true } : options)
}

/** Drop-in for child_process.spawn with the same Windows handling. */
export function spawn(file: string, args: readonly string[] = [], options: SpawnOptions = {}): ChildProcess {
if (!IS_WINDOWS) return cp.spawn(file, args, options)
const t = windowsTarget(file, args)
return cp.spawn(t.file, t.args, t.shell ? { ...options, shell: true } : options)
}

/** Run a command string (pipes, &&) through the platform's shell. */
export function runShell(command: string, options?: ExecFileSyncOptions): any {
const inv = shellInvocation(command)
return cp.execFileSync(inv.file, inv.args, IS_WINDOWS ? ({ ...options, windowsVerbatimArguments: true } as ExecFileSyncOptions) : options)
}

/**
* winget updates the registry PATH, not this process's. Without re-reading
* it, git installed a second ago is "not found", and npm — needed for the
* next four installs — does not exist yet. No-op outside Windows.
*/
export function refreshWindowsPath(): void {
if (!IS_WINDOWS) return
try {
const out = cp.execFileSync('powershell.exe', [
'-NoProfile', '-NonInteractive', '-Command',
"[Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User')",
], { stdio: 'pipe', encoding: 'utf-8', timeout: 15000 }).trim()
if (!out) return
const current = (process.env.Path ?? process.env.PATH ?? '').split(';')
const merged = [...new Set([...out.split(';'), ...current].filter(Boolean))].join(';')
process.env.Path = merged
process.env.PATH = merged
} catch { /* keep the PATH we have */ }
}
Loading
Loading