tapeo-cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,50 @@
1
- # Tapeo local clients (developer preview)
1
+ <p align="center">
2
+ <img src="https://tapeo.net/icons/icon-512.png" alt="Tapeo" width="96" height="96">
3
+ </p>
2
4
 
3
- A terminal client and a VS Code extension for the same Tapeo projects as the web app. They call the same server, file storage, workflow queue, and plan allowances as the browser, through the token-authenticated API under `/api/client/v1`. They are a developer preview: installed from this repository. Publishing on npm (`tapeo-cli`) and the VS Code Marketplace (`tapeo.tapeo-vscode`) is prepared but not done; see [`docs/RELEASING.md`](../docs/RELEASING.md).
5
+ <h1 align="center">Tapeo for your terminal and VS Code</h1>
6
+
7
+ <p align="center">Design, run, and fix chips from your own shell or editor, on the same projects as <a href="https://tapeo.net">tapeo.net</a>.</p>
8
+
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/tapeo-cli"><img src="https://img.shields.io/npm/v/tapeo-cli?label=tapeo-cli&logo=npm&color=cb3837" alt="tapeo-cli on npm"></a>
11
+ <a href="https://marketplace.visualstudio.com/items?itemName=tapeo.tapeo-vscode"><img src="https://img.shields.io/badge/VS%20Code%20Marketplace-Tapeo-0078d4?logo=visualstudiocode&logoColor=white" alt="Tapeo for VS Code"></a>
12
+ <img src="https://img.shields.io/node/v/tapeo-cli?logo=nodedotjs&logoColor=white&color=5fa04e" alt="Node.js version">
13
+ <a href="https://tapeo.net/docs#local-clients"><img src="https://img.shields.io/badge/docs-tapeo.net-e8914a" alt="Documentation"></a>
14
+ </p>
15
+
16
+ ## Quick start
17
+
18
+ ```sh
19
+ npm install -g tapeo-cli
20
+ tapeo login # approve this device in your browser
21
+ tapeo new "A 4-bit counter with enable" ./counter # design, build, and pull the files
22
+ cd counter && tapeo show # stage results of that build
23
+ # edit rtl/…, then: tapeo push --all && tapeo run --watch
24
+ ```
25
+
26
+ In VS Code, install **Tapeo** from the Extensions view and run **Tapeo: Connect to Server**.
27
+
28
+ | Feature | What you get |
29
+ |---|---|
30
+ | 🧭 **Design with the Architect** | `tapeo new` asks clarifying questions, proposes design options, shows the build plan, and builds. |
31
+ | 🔁 **Edit → run → results** | Push your files, run the full flow or one stage, and read stage results, tool logs, and goals. |
32
+ | 🎯 **Errors on the right line** | `tapeo problems`, or the VS Code Problems panel, points at the line a tool complained about. |
33
+ | 🛠️ **AI Engineer** | `tapeo ask` streams an answer; review the proposed diff and `tapeo apply` it locally. |
34
+ | 🔒 **Safe sync** | Every push names the version it was based on; your local edits are never overwritten. |
35
+
36
+ ## About
37
+
38
+ A terminal client and a VS Code extension for the same Tapeo projects as the web app. They call the same server, file storage, workflow queue, and plan allowances as the browser, through the token-authenticated API under `/api/client/v1`. Version 0.4.0 was published on 2026-10-24 (`tapeo-cli` on npm and **Tapeo**, `tapeo.tapeo-vscode`, on the VS Code Marketplace). They are early releases (0.x).
4
39
 
5
40
  The edit → run → results loop works without the browser: push your files, start a run, follow it stage by stage, and read stage results, tool logs, and goal results. You can also start a new design with the Architect, ask the AI Engineer and apply its proposed changes locally, and see tool errors on the lines they name. The 2D/3D layout views, Evaluation-plan review, and Architect image/PDF attachments stay in the web workspace.
6
41
 
7
- The server needs migrations `0008_client_tokens` and `0009_device_authorization` (`npx prisma migrate deploy`; the Docker deployment applies them on start).
42
+ Self-hosted servers need migrations `0008_client_tokens` and `0009_device_authorization` (`npx prisma migrate deploy`; the Docker deployment applies them on start). Maintainers: releasing is described in `docs/RELEASING.md` in the Tapeo repository.
8
43
 
9
44
  ## Sign in
10
45
 
11
46
  ```sh
12
- npm install -g ./clients # or: npm run tapeo -- <command> from the repository root
13
- # once published: npm install -g tapeo-cli
47
+ npm install -g tapeo-cli # from source: npm install -g ./clients, or npm run tapeo -- <command>
14
48
  tapeo login # https://tapeo.net; or: tapeo login https://your-own-server
15
49
  ```
16
50
 
@@ -22,6 +56,7 @@ tapeo login # https://tapeo.net; or: tapeo login https://y
22
56
  tapeo projects # your projects
23
57
  tapeo create "My sensor" ./my-sensor # new empty project, bound to the folder
24
58
  tapeo pull PROJECT_ID ./existing # or bind an existing project
59
+ tapeo delete PROJECT_ID # delete a project on the server for good (asks for its name)
25
60
  cd ./my-sensor
26
61
  tapeo status # what changed locally, and the latest run
27
62
  tapeo push --all # push every modified and new source file
@@ -42,6 +77,8 @@ tapeo open # the project's web address
42
77
  tapeo --version # this client's version (tapeo help lists every command)
43
78
  ```
44
79
 
80
+ `tapeo delete` removes a project on the server for good, like the delete button in the browser's project list: its files, checkpoints, runs, layouts, and Engineer history (a running workflow stops). In a linked folder it deletes that folder's project unless you name another. You confirm by typing the project name (`--yes` skips this, for scripts). Local files stay, and the folder is no longer linked.
81
+
45
82
  ### AI
46
83
 
47
84
  ```sh
@@ -64,6 +101,23 @@ The Engineer reads the saved server copy of your files (`tapeo ask` warns when l
64
101
 
65
102
  Stages: `simulation`, `synthesis`, `timing`, `physical`, `post-route-timing`, `drc`, `extraction`, `erc`, `lvs`. `--no-ai-repairs` turns off AI repairs for a stage run. A full run uses one build from your plan; a stage run does not. `tapeo run --watch` exits with code 3 when the run fails.
66
103
 
104
+ ### FPGA boards
105
+
106
+ For a project whose target is an FPGA board, or an FPGA board and a chip (the Architect asks where a design runs; you can change it on the Workflow tab), the workflow simulates the RTL, then builds and checks a bitstream for the board on the server. Supported boards: Sipeed Tang Nano 9K and Tang Nano 20K, 1BitSquared iCEBreaker, and Radiona ULX3S (12F and 85F). A design clock other than the board clock gets a PLL generated by the toolchain's own calculator.
107
+
108
+ ```sh
109
+ tapeo run --watch # simulate, build the bitstream, report logic use and Fmax
110
+ tapeo flash # download the bitstream and program the connected board
111
+ tapeo flash --flash # keep it after power-off (Tang Nano and ULX3S otherwise load to SRAM)
112
+ tapeo flash --board icebreaker # when bitstreams exist for several boards
113
+ tapeo monitor # the board's serial (UART) output; type a line to send it
114
+ tapeo monitor --port /dev/cu.usbserial-1101 --baud 9600
115
+ ```
116
+
117
+ `tapeo flash` runs the open-source `openFPGALoader` on your computer and tells you how to install it if it is missing (`brew install openfpgaloader` on macOS, `sudo apt install openfpgaloader` on Debian/Ubuntu, or OSS CAD Suite elsewhere). `TAPEO_OPENFPGALOADER` points to it when it is not on your PATH. The bitstream is saved read-only under `generated/fpga/<board>/`.
118
+
119
+ `tapeo monitor` needs no sign-in and works on macOS and Linux: it finds the USB serial port (name one with `--port` when there are several; a Tang Nano shows two and its serial link is usually the second) and uses 115200 baud unless you pass `--baud`. On Linux you may need to be in the `dialout` group. On Windows, use the **Serial monitor** on the project's Workflow tab in Chrome or Edge.
120
+
67
121
  ## VS Code
68
122
 
69
123
  Run `npm run build:vscode`, open `clients/vscode` in VS Code, and press F5; or run `npm run package:vscode` and install `clients/vscode/tapeo-vscode.vsix` with **Extensions: Install from VSIX...**. In the Command Palette:
@@ -75,6 +129,8 @@ Run `npm run build:vscode`, open `clients/vscode` in VS Code, and press F5; or r
75
129
  - **Show Workflow Runs**, **Show Run Details**, **Open a Tool Log**, **Show Goal Results**, **Cancel Workflow**, **Open Project in Browser**.
76
130
  - **New Design with the Architect** (in an empty folder): the Architect's questions appear as quick picks with "Not sure — you choose", then the design options and the build plan; **Build** creates the project, starts the first build, and pulls the generated files when it ends.
77
131
  - **Open AI Engineer** opens the **Tapeo Engineer** panel beside the editor: the project conversation, streamed answers, and the proposed changes with **Review** (a side-by-side diff), **Apply**, **Apply all**, and **Discard**. **Ask the AI Engineer**, **Review Proposed Changes**, **Apply All Proposed Changes**, and **Discard Proposed Changes** do the same from the Command Palette. Applying is refused while the file has unsaved edits; push afterwards.
132
+ - **Program FPGA Board** (FPGA projects): downloads the bitstream and programs the connected board with `openFPGALoader` in a **Tapeo FPGA** terminal.
133
+ - **Open Serial Monitor** (macOS and Linux): shows the board's serial output in a terminal and sends the lines you type.
78
134
  - **Show Tool Problems**: the latest run's tool errors and warnings in the Problems panel. This also happens automatically when a run the status bar follows ends, and starting a run clears them.
79
135
 
80
136
  The setting `tapeo.serverUrl` holds the server (Connect to Server sets it; remote servers must use HTTPS).
package/cli/tapeo.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
3
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, hostname } from "node:os";
5
5
  import path from "node:path";
@@ -8,6 +8,8 @@ import { assertUnbound, designFilesFrom, downloadFile, localChanges, moveProject
8
8
  import { applyProposals, changeDiff, describeChange, discardProposals, loadProposals, saveProposals } from "../shared/proposals.mjs";
9
9
  import { collectRunProblems, formatProblem } from "../shared/problems.mjs";
10
10
  import { NOT_SURE, clarificationAnswersMessage } from "../shared/stream.mjs";
11
+ import { chooseSerialPort, openSerialPort, parseBaud, serialCandidates } from "../shared/serial.mjs";
12
+ import { OPENFPGALOADER_INSTALL, chooseBitstream, downloadBitstream, flashArguments, openFpgaLoaderAvailable, openFpgaLoaderExecutable } from "../shared/fpga.mjs";
11
13
  import { createInterface } from "node:readline/promises";
12
14
 
13
15
  const configPath = path.join(homedir(), ".config", "tapeo", "client.json");
@@ -15,7 +17,7 @@ const clientVersion = JSON.parse(await readFile(new URL("../package.json", impor
15
17
  setClientIdentity("tapeo-cli", clientVersion);
16
18
 
17
19
  function usage() {
18
- console.log(`Tapeo terminal client (developer preview)
20
+ console.log(`Tapeo terminal client
19
21
 
20
22
  Sign in
21
23
  tapeo login [server-url] Sign in through your browser (approve this device there);
@@ -27,6 +29,9 @@ Sign in
27
29
  Projects and files
28
30
  tapeo projects List your projects
29
31
  tapeo create <name> [dir] Create an empty project and bind a local folder
32
+ tapeo delete [project-id] Delete a project on the server for good (this folder's
33
+ project by default; asks you to type its name, --yes
34
+ skips that); local files are kept
30
35
  tapeo pull <project-id> [dir] Pull source files without overwriting local edits
31
36
  tapeo pull --outputs Also download tool outputs (generated/, signoff/) read-only
32
37
  tapeo status What changed locally, and the latest run
@@ -57,6 +62,15 @@ Workflow
57
62
  tapeo cancel [run-id] Cancel a queued or running workflow
58
63
  tapeo open Print this project's web address
59
64
 
65
+ FPGA boards
66
+ tapeo flash [--board <id>] [--flash]
67
+ Download the bitstream and program the connected board with
68
+ openFPGALoader (--flash: keep it after power-off on boards
69
+ that load to SRAM by default, such as the Tang Nano 9K)
70
+ tapeo monitor [--port <path>] [--baud <n>]
71
+ Show the board's serial (UART) output and send what you
72
+ type; default 115200 baud (macOS and Linux)
73
+
60
74
  Stages: simulation, synthesis, timing, physical, post-route-timing, drc, extraction, erc, lvs.
61
75
  TAPEO_URL and TAPEO_TOKEN override the saved sign-in (for automation).`);
62
76
  }
@@ -121,7 +135,7 @@ async function configuredClient() {
121
135
  catch (error) { if (error.code !== "ENOENT") throw error; }
122
136
  const serverUrl = process.env.TAPEO_URL || saved.serverUrl;
123
137
  const token = process.env.TAPEO_TOKEN || saved.token;
124
- if (!serverUrl || !token) throw new Error("Run `tapeo login <server-url>` first, or set TAPEO_URL and TAPEO_TOKEN.");
138
+ if (!serverUrl || !token) throw new Error("Run `tapeo login` first (or `tapeo login <server-url>` for your own server), or set TAPEO_URL and TAPEO_TOKEN.");
125
139
  return new TapeoClient(serverUrl, token);
126
140
  }
127
141
 
@@ -283,8 +297,29 @@ async function main() {
283
297
  console.log("Signed out on this computer. Revoke the token on the Account page to end its access everywhere.");
284
298
  return;
285
299
  }
300
+ // The serial monitor talks only to the board, so it needs no sign-in.
301
+ if (command === "monitor") {
302
+ const { flags } = parseArguments(rest, ["port", "baud"]);
303
+ const port = chooseSerialPort(serialCandidates(), typeof flags.port === "string" ? flags.port : undefined);
304
+ const baud = parseBaud(flags.baud);
305
+ const serial = openSerialPort(port, baud);
306
+ console.error(`Connected to ${port} at ${baud} baud. Type a line and press Enter to send it; Ctrl+C stops.`);
307
+ serial.input.pipe(process.stdout);
308
+ process.stdin.setEncoding("utf8");
309
+ process.stdin.on("data", (text) => serial.write(text));
310
+ await new Promise((resolve) => {
311
+ const stop = () => { serial.close(); resolve(); };
312
+ process.once("SIGINT", stop);
313
+ // Ctrl+D at a keyboard stops; piped input just sends and keeps listening.
314
+ if (process.stdin.isTTY) process.stdin.once("end", stop);
315
+ serial.input.once("close", stop);
316
+ serial.input.once("error", (error) => { console.error(`The serial port closed: ${error.message}`); stop(); });
317
+ });
318
+ process.stdin.destroy();
319
+ return;
320
+ }
286
321
  const client = await configuredClient();
287
- const { flags, positional: args } = parseArguments(rest, ["stage", "from", "option", "limit"]);
322
+ const { flags, positional: args } = parseArguments(rest, ["stage", "from", "option", "limit", "board"]);
288
323
  if (command === "new") {
289
324
  await newDesign(client, args[0], path.resolve(args[1] || "."), flags);
290
325
  } else if (command === "ask") {
@@ -337,6 +372,24 @@ async function main() {
337
372
  const { projects } = await client.projects();
338
373
  for (const project of projects) console.log(`${project.id}\t${project.name}`);
339
374
  if (!projects.length) console.log("No projects yet. Run tapeo create <name> [directory].");
375
+ } else if (command === "delete") {
376
+ const directory = process.cwd();
377
+ const projectId = await projectIdFor(client, args[0]);
378
+ const { project } = await client.project(projectId);
379
+ if (!flags.yes) {
380
+ if (!process.stdin.isTTY) throw new Error(`Deleting "${project.name}" cannot be undone. Confirm with --yes.`);
381
+ const prompt = prompter();
382
+ try {
383
+ console.log(`This deletes "${project.name}" (${project.id}) on ${client.serverUrl} for good: its files, checkpoints, runs, layouts, and Engineer history. A running workflow stops. Local files are kept.`);
384
+ const typed = (await prompt.ask("Type the project name to confirm: ")).trim();
385
+ if (typed !== project.name.trim()) { console.log("The name did not match. Nothing was deleted."); return; }
386
+ } finally { prompt.close(); }
387
+ }
388
+ await client.deleteProject(projectId);
389
+ // A folder bound to the deleted project is unbound, so later commands do not point at it.
390
+ const bound = await readState(directory).catch(() => null);
391
+ if (bound?.projectId === projectId && bound.serverUrl === client.serverUrl) await rm(path.join(directory, ".tapeo"), { recursive: true, force: true });
392
+ console.log(`Deleted "${project.name}".${bound?.projectId === projectId ? " This folder is no longer linked to a Tapeo project; its files are unchanged." : ""}`);
340
393
  } else if (command === "create") {
341
394
  if (!args[0]?.trim()) throw new Error("Usage: tapeo create <name> [directory]");
342
395
  const directory = path.resolve(args[1] || ".");
@@ -435,6 +488,18 @@ async function main() {
435
488
  }
436
489
  const { run } = await client.cancelRun(projectId, runId);
437
490
  console.log(`Cancelled ${run.id}. A tool step already running finishes first; finished stages keep their results.`);
491
+ } else if (command === "flash") {
492
+ const directory = process.cwd();
493
+ const projectId = await projectIdFor(client);
494
+ const { files } = await client.project(projectId);
495
+ const bitstream = chooseBitstream(files, flags.board);
496
+ const executable = openFpgaLoaderExecutable();
497
+ if (!openFpgaLoaderAvailable(executable)) throw new Error(OPENFPGALOADER_INSTALL);
498
+ const local = await downloadBitstream(client, projectId, bitstream, directory);
499
+ console.log(`Programming the ${bitstream.label} with ${path.relative(directory, local)}${flags.flash ? " (writing to flash)" : ""}…`);
500
+ const result = spawnSync(executable, flashArguments(bitstream, local, { flash: Boolean(flags.flash) }), { stdio: "inherit" });
501
+ if (result.error || result.status !== 0) throw new Error("openFPGALoader could not program the board. Check the USB cable and that no other program is using the board; its messages are above.");
502
+ console.log(`Programmed the ${bitstream.label}.`);
438
503
  } else if (command === "open") {
439
504
  const projectId = await projectIdFor(client, args[0]);
440
505
  console.log(`${client.serverUrl}/?project=${encodeURIComponent(projectId)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tapeo-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Terminal client for Tapeo: edit hardware projects locally, run the server's EDA workflow, read results, and work with the AI Architect and Engineer.",
5
5
  "keywords": [
6
6
  "tapeo",
package/shared/api.mjs CHANGED
@@ -182,6 +182,16 @@ export class TapeoClient {
182
182
  projects() { return this.request("GET", "/projects"); }
183
183
  createProject(name, description = "", hardwareType = "Undecided") { return this.request("POST", "/projects", { name, description, hardwareType }); }
184
184
  project(id) { return this.request("GET", `/projects/${encodeURIComponent(id)}`); }
185
+ /** Delete a project on the server for good (files, runs, history). */
186
+ async deleteProject(id) {
187
+ try {
188
+ return await this.request("DELETE", `/projects/${encodeURIComponent(id)}`);
189
+ } catch (error) {
190
+ // Servers older than this client have no delete route for local clients.
191
+ if (error?.status === 405) throw new TapeoApiError(`This Tapeo server cannot delete projects from the terminal yet. Delete it in the browser: open ${this.serverUrl}, then use the delete button next to the project in your project list.`, 405);
192
+ throw error;
193
+ }
194
+ }
185
195
  file(id, path) { return this.request("GET", `/projects/${encodeURIComponent(id)}/files?path=${encodeURIComponent(path)}`); }
186
196
  saveFile(id, path, content, baseSha256, language) { return this.request("PUT", `/projects/${encodeURIComponent(id)}/files?path=${encodeURIComponent(path)}`, { content, baseSha256, language }); }
187
197
  deleteFile(id, path, baseSha256) { return this.request("DELETE", `/projects/${encodeURIComponent(id)}/files?path=${encodeURIComponent(path)}&baseSha256=${encodeURIComponent(baseSha256)}`); }
@@ -0,0 +1,72 @@
1
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { localTarget } from "./sync.mjs";
5
+
6
+ /**
7
+ * Programming an FPGA board from the local clients (tapeo flash, and Tapeo:
8
+ * Program FPGA Board in VS Code). The bitstream is built on the server; this
9
+ * downloads it and runs the open-source openFPGALoader on your computer.
10
+ * Board names mirror lib/fpga-boards.ts (tests/client-fpga.test.ts keeps them in sync).
11
+ */
12
+ export const FPGA_PROGRAMMERS = {
13
+ tangnano9k: { label: "Sipeed Tang Nano 9K", openFpgaLoaderBoard: "tangnano9k" },
14
+ tangnano20k: { label: "Sipeed Tang Nano 20K", openFpgaLoaderBoard: "tangnano20k" },
15
+ icebreaker: { label: "1BitSquared iCEBreaker", openFpgaLoaderBoard: "ice40_generic" },
16
+ ulx3s12f: { label: "Radiona ULX3S (12F)", openFpgaLoaderBoard: "ulx3s" },
17
+ ulx3s85f: { label: "Radiona ULX3S (85F)", openFpgaLoaderBoard: "ulx3s" }
18
+ };
19
+
20
+ export const OPENFPGALOADER_INSTALL = [
21
+ "openFPGALoader is needed to program the board. Install it, then run this again:",
22
+ " macOS: brew install openfpgaloader",
23
+ " Debian/Ubuntu: sudo apt install openfpgaloader",
24
+ " Windows/other: it is included in OSS CAD Suite (https://github.com/YosysHQ/oss-cad-suite-build)"
25
+ ].join("\n");
26
+
27
+ /** The bitstreams a project has, newest builds per board (paths from the server manifest). */
28
+ export function findBitstreams(files) {
29
+ return files.flatMap((file) => {
30
+ const match = /^generated\/fpga\/([a-z0-9]+)\/board_top\.(fs|bin|bit)$/.exec(file.path);
31
+ return match && FPGA_PROGRAMMERS[match[1]] ? [{ path: file.path, boardId: match[1], ...FPGA_PROGRAMMERS[match[1]] }] : [];
32
+ });
33
+ }
34
+
35
+ /** Pick the board to program: the only one built, or the one named with --board. */
36
+ export function chooseBitstream(files, boardId) {
37
+ const bitstreams = findBitstreams(files);
38
+ if (!bitstreams.length) throw new Error("This project has no bitstream yet. Run the workflow first (tapeo run --watch); FPGA projects build one for their board.");
39
+ if (boardId) {
40
+ const chosen = bitstreams.find((item) => item.boardId === boardId);
41
+ if (!chosen) throw new Error(`No bitstream for ${boardId}. Built for: ${bitstreams.map((item) => item.boardId).join(", ")}.`);
42
+ return chosen;
43
+ }
44
+ if (bitstreams.length > 1) throw new Error(`Bitstreams exist for several boards (${bitstreams.map((item) => item.boardId).join(", ")}). Name one with --board.`);
45
+ return bitstreams[0];
46
+ }
47
+
48
+ /** Download the bitstream into the project folder (read-only, like other tool outputs). */
49
+ export async function downloadBitstream(client, projectId, bitstream, directory) {
50
+ const { bytes } = await client.rawFile(projectId, bitstream.path);
51
+ const target = await localTarget(directory, bitstream.path);
52
+ await mkdir(path.dirname(target), { recursive: true });
53
+ await chmod(target, 0o644).catch(() => undefined);
54
+ await writeFile(target, bytes);
55
+ await chmod(target, 0o444).catch(() => undefined);
56
+ return target;
57
+ }
58
+
59
+ /** The programmer to run; TAPEO_OPENFPGALOADER overrides the executable (for example a full path). */
60
+ export function openFpgaLoaderExecutable() {
61
+ return process.env.TAPEO_OPENFPGALOADER || "openFPGALoader";
62
+ }
63
+
64
+ export function openFpgaLoaderAvailable(executable = openFpgaLoaderExecutable()) {
65
+ const result = spawnSync(executable, ["-V"], { encoding: "utf8" });
66
+ return !result.error && result.status === 0;
67
+ }
68
+
69
+ /** openFPGALoader arguments: SRAM by default (lost at power-off), flash with `flash: true`. */
70
+ export function flashArguments(bitstream, localPath, { flash = false } = {}) {
71
+ return ["-b", bitstream.openFpgaLoaderBoard, ...(flash ? ["--write-flash"] : []), localPath];
72
+ }
@@ -0,0 +1,77 @@
1
+ import { closeSync, constants, openSync, readdirSync, writeSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import tty from "node:tty";
4
+
5
+ /**
6
+ * A serial monitor for a connected FPGA board (tapeo monitor, and Tapeo: Open
7
+ * Serial Monitor in VS Code), without native modules: the port is opened as a
8
+ * file, configured with the system's stty on that open descriptor, and read as
9
+ * a terminal stream. macOS and Linux only; on Windows, use the browser's serial
10
+ * monitor on the Workflow tab (Chrome or Edge).
11
+ */
12
+
13
+ export const DEFAULT_BAUD = 115200;
14
+ export const SERIAL_UNSUPPORTED = "The serial monitor runs on macOS and Linux. On Windows, open the project's Workflow tab in Chrome or Edge and use its Serial monitor.";
15
+
16
+ /** USB serial ports that may be a board, newest naming first. macOS uses the cu.* (call-out) devices, which do not wait for a carrier. */
17
+ export function serialCandidates(platform = process.platform, names = safeList("/dev")) {
18
+ const pattern = platform === "darwin" ? /^cu\.(?:usbserial|usbmodem|wchusbserial|SLAB_USBtoUART)/ : platform === "linux" ? /^tty(?:USB|ACM)\d+$/ : null;
19
+ if (!pattern) return [];
20
+ return names.filter((name) => pattern.test(name)).sort().map((name) => `/dev/${name}`);
21
+ }
22
+
23
+ function safeList(directory) {
24
+ try { return readdirSync(directory); } catch { return []; }
25
+ }
26
+
27
+ /** The port to open: the one named, the only one found, or an explanation. */
28
+ export function chooseSerialPort(candidates, requested) {
29
+ if (requested) return requested;
30
+ if (!candidates.length) throw new Error("No USB serial port was found. Connect the board by USB, or name the port with --port.");
31
+ if (candidates.length > 1) throw new Error(`Several serial ports were found:\n${candidates.map((item) => ` ${item}`).join("\n")}\nName one with --port. A Tang Nano shows two; its serial link is usually the second.`);
32
+ return candidates[0];
33
+ }
34
+
35
+ export function parseBaud(value) {
36
+ if (value === undefined || value === true) return DEFAULT_BAUD;
37
+ const baud = Number(value);
38
+ if (!Number.isInteger(baud) || baud < 300 || baud > 4_000_000) throw new Error(`--baud must be a whole number from 300 to 4000000 (got ${value}).`);
39
+ return baud;
40
+ }
41
+
42
+ /** stty settings for 8N1 raw data at `baud`, no flow control, ignoring modem lines. */
43
+ export function sttyArguments(baud) {
44
+ return [String(baud), "raw", "-echo", "cs8", "-cstopb", "-parenb", "clocal", "-crtscts", "-ixon", "-ixoff"];
45
+ }
46
+
47
+ /**
48
+ * Open `portPath` at `baud`. Returns the incoming stream, a write function, and close.
49
+ * The settings are applied while the port is open, so the driver keeps them.
50
+ */
51
+ export function openSerialPort(portPath, baud = DEFAULT_BAUD, platform = process.platform) {
52
+ if (platform !== "darwin" && platform !== "linux") throw new Error(SERIAL_UNSUPPORTED);
53
+ let readFd;
54
+ try {
55
+ readFd = openSync(portPath, constants.O_RDWR | constants.O_NOCTTY | constants.O_NONBLOCK);
56
+ } catch (error) {
57
+ throw new Error(error?.code === "EBUSY" || error?.code === "EACCES"
58
+ ? `Could not open ${portPath}: ${error.code === "EBUSY" ? "another program is using it (close other serial monitors)" : "permission denied (on Linux, add yourself to the dialout group, then sign in again)"}.`
59
+ : `Could not open ${portPath}: ${error?.message ?? error}`);
60
+ }
61
+ if (!tty.isatty(readFd)) { closeSync(readFd); throw new Error(`${portPath} is not a serial port.`); }
62
+ const configured = spawnSync("stty", sttyArguments(baud), { stdio: [readFd, "ignore", "pipe"], encoding: "utf8" });
63
+ if (configured.error || configured.status !== 0) { closeSync(readFd); throw new Error(`Could not set ${portPath} to ${baud} baud: ${configured.stderr?.trim() || configured.error?.message || "stty failed"}.`); }
64
+ const writeFd = openSync(portPath, constants.O_WRONLY | constants.O_NOCTTY);
65
+ const input = new tty.ReadStream(readFd);
66
+ let closed = false;
67
+ return {
68
+ input,
69
+ write(data) { if (!closed) writeSync(writeFd, typeof data === "string" ? Buffer.from(data, "utf8") : data); },
70
+ close() {
71
+ if (closed) return;
72
+ closed = true;
73
+ input.destroy();
74
+ try { closeSync(writeFd); } catch { /* already closed */ }
75
+ }
76
+ };
77
+ }