diff --git a/cli/src/build.ts b/cli/src/build.ts index 06c038ece7..4f68ef22db 100644 --- a/cli/src/build.ts +++ b/cli/src/build.ts @@ -46,9 +46,9 @@ import { toHex, versionTryParse, } from "jacdac-ts" -import { execSync } from "node:child_process" import { BuildOptions } from "./sideprotocol" import { readJSON5Sync } from "./jsonc" +import { execCmd } from "./exec" // TODO should we move this to jacdac-ts and call automatically for transports? export function setupWebsocket() { @@ -216,14 +216,6 @@ function toDevsDiag(d: jdspec.Diagnostic): DevsDiagnostic { } } -function execCmd(cmd: string) { - try { - return execSync(cmd, { encoding: "utf-8" }).trim() - } catch { - return "" - } -} - function isGit() { let pref = "" for (let i = 0; i < 10; ++i) { diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 0373dc1af2..e9b0e1b868 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -17,6 +17,7 @@ import { incVerbose, setConsoleColors, setDeveloperMode, + setInteractive, setQuiet, verboseLog, } from "./command" @@ -75,6 +76,10 @@ export async function mainCli() { .version(cliVersion()) .option("-v, --verbose", "more logging (can be repeated)") .option("--quiet", "less logging") + .option( + "--ci", + "disable interactions with user, default is false unless CI env var is set" + ) .option("--no-colors", "disable color output") .option("--dev", "developer mode") @@ -429,6 +434,7 @@ export async function mainCli() { .action(binPatch) program.on("option:quiet", () => setQuiet(true)) + program.on("option:ci", () => setInteractive(false)) program.on("option:verbose", incVerbose) program.on("option:no-colors", () => setConsoleColors(false)) program.on("option:dev", () => { diff --git a/cli/src/command.ts b/cli/src/command.ts index c05eab2f8c..f9629a69e0 100644 --- a/cli/src/command.ts +++ b/cli/src/command.ts @@ -70,6 +70,7 @@ export function logToConsole(priority: LoggerPriority, message: string) { export let isVerbose = 0 export let isQuiet = false +export let isInteractive = !Boolean(process.env.CI) export function incVerbose() { isVerbose++ @@ -80,6 +81,10 @@ export function setQuiet(v: boolean) { isQuiet = v } +export function setInteractive(v: boolean) { + isInteractive = v +} + export function verboseLog(msg: string) { if (isVerbose) console.debug(wrapColor(90, msg)) } diff --git a/cli/src/devtools.ts b/cli/src/devtools.ts index 37c986e620..c5535c473e 100644 --- a/cli/src/devtools.ts +++ b/cli/src/devtools.ts @@ -3,7 +3,7 @@ const WebSocket = require("faye-websocket") import http from "http" import url from "url" import net from "net" -import { error, log } from "./command" +import { error, isInteractive, log, setInteractive } from "./command" import { watch } from "fs-extra" import { resolveBuildConfig, SrcFile } from "@devicescript/compiler" import { @@ -100,6 +100,7 @@ export async function devtools( const tcpPort = 8082 const dbgPort = 8083 + if (options.vscode) setInteractive(false) // don't prompt for anything if (options.diagnostics) Flags.diagnostics = true overrideConsoleDebug() diff --git a/cli/src/exec.ts b/cli/src/exec.ts new file mode 100644 index 0000000000..3cfd427ee0 --- /dev/null +++ b/cli/src/exec.ts @@ -0,0 +1,9 @@ +import { execSync } from "node:child_process" + +export function execCmd(cmd: string) { + try { + return execSync(cmd, { encoding: "utf-8" }).trim() + } catch { + return "" + } +} diff --git a/cli/src/init.ts b/cli/src/init.ts index 6732430b21..0b525e74de 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -11,7 +11,7 @@ import { existsSync, } from "fs-extra" import { build } from "./build" -import { spawnSync, execSync } from "node:child_process" +import { spawnSync } from "node:child_process" import { assert, clone, randomUInt } from "jacdac-ts" import { addReqHandler } from "./sidedata" import type { @@ -32,6 +32,7 @@ import { addBoard } from "./addboard" import { readJSON5Sync } from "./jsonc" import { MARKETPLACE_EXTENSION_ID } from "@devicescript/interop" import { TSDOC_TAGS } from "@devicescript/compiler" +import { execCmd } from "./exec" const MAIN = "src/main.ts" const GITIGNORE = ".gitignore" @@ -570,14 +571,6 @@ export interface AddNpmOptions extends InitOptions { export interface AddSettingsOptions extends InitOptions {} -export function execCmd(cmd: string) { - try { - return execSync(cmd, { encoding: "utf-8" }).trim() - } catch { - return "" - } -} - export async function addSettings(options: AddSettingsOptions) { const files = clone(settingsFiles) const cwd = writeFiles(".", options, files) diff --git a/cli/src/logging.ts b/cli/src/logging.ts index 673ceab6da..0c1e66ac7e 100644 --- a/cli/src/logging.ts +++ b/cli/src/logging.ts @@ -2,8 +2,6 @@ import { JDBus, Packet, PACKET_REPORT, - SRV_DEVICE_SCRIPT_MANAGER, - DeviceScriptManagerCmd, LoggerPriority, SRV_LOGGER, LoggerCmd, diff --git a/cli/src/packageinstaller.ts b/cli/src/packageinstaller.ts new file mode 100644 index 0000000000..92d0fe7d25 --- /dev/null +++ b/cli/src/packageinstaller.ts @@ -0,0 +1,95 @@ +import { readFile, constants } from "node:fs/promises" +import { accessSync } from "node:fs" +import { join } from "node:path" +import { spawn } from "node:child_process" +import { isInteractive, verboseLog } from "./command" +import { PkgJson } from "@devicescript/compiler" +import { consoleBooleanAsk } from "./prompt" + +function isYarnRepo(): boolean { + try { + accessSync(join(process.cwd(), "yarn.lock"), constants.R_OK) + return true + } catch { + return false + } +} + +function getPackageInstallerCommand( + packageName?: string +): string[] { + if (isYarnRepo()) { + if (!packageName) return ["yarn", "install"] + + return ["yarn", "add", packageName] + } + + if (!packageName) return ["npm", "install"] + + return ["npm", "install", "--save", "--no-workspaces", packageName] +} + +async function isPackageInstalledLocally(pkgName: string): Promise { + const pkgJsonString = await readFile(join(process.cwd(), "package.json"), { + encoding: "utf-8", + }) + const pkgJson = JSON.parse(pkgJsonString) as PkgJson + + return Object.keys(pkgJson.dependencies).includes(pkgName) +} + +async function spawnAsyncInstaller(packageName: string) { + let resolve: () => void + let reject: () => void + + const [rootCmd, ...cmdArgs] = getPackageInstallerCommand(packageName) + + const installProcess = spawn(rootCmd, cmdArgs, { + cwd: process.cwd(), + env: process.env, + }) + + installProcess.stderr.on("data", data => { + verboseLog(`package installer process: ${data}`) + }) + + installProcess.on("close", code => { + verboseLog(`install process exit with code ${code}`) + + if (code === 0) resolve() + else reject() + }) + + return new Promise((res, rej) => { + // @ts-ignore + resolve = res + reject = rej + }) +} + +export async function askForPackageInstallation( + pkgName: string, + installByDefault = true +) { + if (!isInteractive) return + + if (await isPackageInstalledLocally(pkgName)) return + + const shouldInstallPackage = await consoleBooleanAsk( + `Install package "${pkgName}"`, + installByDefault + ) + + if (shouldInstallPackage) { + try { + console.log(`Installing package "${pkgName}" ...`) + await spawnAsyncInstaller(pkgName) + console.log(`Package "${pkgName}" installed!`) + } catch (e) { + const installCmd = getPackageInstallerCommand(pkgName).join(" ") + console.log( + `Automatic package installation failed :( You can try to install it manually by running "${installCmd}"` + ) + } + } +} diff --git a/cli/src/prompt.ts b/cli/src/prompt.ts new file mode 100644 index 0000000000..2592c9f24a --- /dev/null +++ b/cli/src/prompt.ts @@ -0,0 +1,29 @@ +export function consoleBooleanAsk( + question: string, + defValue = true +): Promise { + return new Promise(resolve => { + const ask = (q: string) => + process.stdout.write(`${q} ${defValue ? "(Y/n)" : "(y/N)"}?`) + const handler = (data: Buffer) => { + const response = data.toString().trim().toLowerCase() + if (response === "y") { + process.stdin.removeListener("data", handler) + resolve(true) + } else if (response === "n") { + process.stdin.removeListener("data", handler) + process.stdin.end() + resolve(false) + } else if (response === "") { + process.stdin.removeListener("data", handler) + resolve(defValue) + } else { + ask( + `Unknown option: ${response}. Use y/n and try again \n${question}` + ) + } + } + process.stdin.addListener("data", handler) + ask(question) + }) +} diff --git a/cli/src/transport.ts b/cli/src/transport.ts index 05336884e6..4a58925215 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -1,4 +1,4 @@ -import { log } from "./command" +import { isInteractive, log } from "./command" import { CONNECTION_STATE, createNodeSPITransport, @@ -27,6 +27,7 @@ import type { SideUploadJsonFromDevice, } from "@devicescript/interop" import { printDmesg } from "./vmworker" +import { askForPackageInstallation } from "./packageinstaller" export interface TransportsOptions { usb?: boolean @@ -40,41 +41,38 @@ export class RequireError extends Error { } } -interface RequireOptions { - interactive?: boolean -} - -async function tryRequire(name: string, options: RequireOptions) { +async function tryRequire(name: string) { try { return require(name) } catch (e) { log(`failed to require package "${name}"`) console.debug(e.stderr?.toString()) - if (options.interactive) { - // TODO + if (isInteractive) { + await askForPackageInstallation(name) + return require(name) } throw new RequireError(name) } } -async function createSPI(options?: RequireOptions) { +async function createSPI() { log(`adding SPI transport (requires "rpio" package)`) // eslint-disable-next-line @typescript-eslint/no-var-requires - const RPIO = await tryRequire("rpio", options) + const RPIO = await tryRequire("rpio") // eslint-disable-next-line @typescript-eslint/no-var-requires - const SpiDev = await tryRequire("spi-device", options) + const SpiDev = await tryRequire("spi-device") return createNodeSPITransport(RPIO, SpiDev) } -async function createUSB(options?: RequireOptions) { +async function createUSB() { log(`adding USB transport (requires "usb" package)`) - const usb = await tryRequire("usb", options) + const usb = await tryRequire("usb") const usbOptions = createNodeUSBOptions(usb.WebUSB) return createUSBTransport(usbOptions) } -async function createSerial(options?: RequireOptions) { +async function createSerial() { log(`adding serial transport (requires "serialport" package)`) // eslint-disable-next-line @typescript-eslint/no-var-requires - const sp = await tryRequire("serialport", options) + const sp = await tryRequire("serialport") return createNodeWebSerialTransport(sp.SerialPort) } @@ -178,10 +176,9 @@ export async function connectTransport( export async function createTransports(options: TransportsOptions) { const transports: Transport[] = [] - if (options.usb) transports.push(await createUSB({ interactive: true })) - if (options.serial) - transports.push(await createSerial({ interactive: true })) - if (options.spi) transports.push(await createSPI({ interactive: true })) + if (options.usb) transports.push(await createUSB()) + if (options.serial) transports.push(await createSerial()) + if (options.spi) transports.push(await createSPI()) return transports } diff --git a/interop/src/archconfig.ts b/interop/src/archconfig.ts index ee9f9f41b4..49cae32c96 100644 --- a/interop/src/archconfig.ts +++ b/interop/src/archconfig.ts @@ -188,6 +188,7 @@ export interface PkgJson { library?: boolean bundle?: boolean } + dependencies?: Record } export interface LocalBuildConfig {