tapeo-cli 0.3.0 โ†’ 0.5.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,27 +1,93 @@
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.5.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
 
17
51
  `tapeo login` opens `/device?code=ABCD-EFGH` in your browser. Check that the code matches, approve the device, and the terminal finishes signing in. Only a digest of the device code is stored, and no token exists until you approve. The device gets its own token: it expires after 90 days, appears on **Account โ†’ VS Code and terminal access**, and can be revoked there. Without a browser, create a token on that page and use `tapeo login <url> --token` (hidden prompt). The sign-in is saved in `~/.config/tapeo/client.json` (mode 0600); `tapeo logout` removes it. `TAPEO_URL` and `TAPEO_TOKEN` override it for automation. `TAPEO_NO_BROWSER=1` makes `tapeo login` only print the approval address instead of opening a browser. Remote servers must use HTTPS; HTTP is accepted for localhost only.
18
52
 
53
+ ## Bringing an existing project (tapeo.json)
54
+
55
+ Any folder layout works: `tapeo create "My design" .` then `tapeo push --all` keeps every path. By convention Tapeo treats every `.v`/`.sv` outside testbench folders as the design, picks the top module that nothing else instantiates, and finds the testbench by name. When that guess is wrong for your repository, add a `tapeo.json` at the project root:
56
+
57
+ ```json
58
+ {
59
+ "top": "soc_top",
60
+ "filelist": "hw/files.f",
61
+ "sources": ["hw/extra/**/*.sv", "!hw/legacy/**"],
62
+ "includeDirs": ["hw/include"],
63
+ "defines": ["WIDTH=32"],
64
+ "verification": ["verif/common/**/*.sv"],
65
+ "testbenches": { "default": "verif/tb_soc.sv", "uart": { "files": ["verif/uart/*.sv"], "top": "tb_uart" } },
66
+ "constraints": "constraints/soc.sdc"
67
+ }
68
+ ```
69
+
70
+ - `sources` are patterns (`**` matches folders, `*` part of a name, `!` removes); `filelist` reads an existing simulator file list (`.f`: files in compile order, `+incdir+`, `+define+`, `-I`, `-D`, `-f`, `-v`). Either one is required.
71
+ - Lint, simulation, synthesis, the FPGA build, and timing all use the same sources, top, defines, and include folders. Simulation compiles only the design, the `verification` files, and the chosen testbench; workflows run `default` (or the first), and `tapeo sim --testbench <name>` picks another.
72
+ - `tapeo manifest` (or **Show Project Manifest**) shows what Tapeo resolved. A broken `tapeo.json` stops the stages with a message that says what to fix; deleting it returns to the conventions.
73
+ - Include folders may use only letters, digits, `.`, `_`, `+`, `-`, and `/`; defines are `NAME` or `NAME=value` without spaces or quotes.
74
+ - Without `tapeo.json`, nothing changes.
75
+
19
76
  ## Terminal
20
77
 
21
78
  ```sh
22
79
  tapeo projects # your projects
23
80
  tapeo create "My sensor" ./my-sensor # new empty project, bound to the folder
24
81
  tapeo pull PROJECT_ID ./existing # or bind an existing project
82
+ tapeo delete PROJECT_ID # delete a project on the server for good (asks for its name)
83
+ tapeo lock rtl/top.sv # protect a file: the AI's fixes become suggestions (pushed files start protected)
84
+ tapeo unlock rtl/top.sv # let the AI Engineer repair it during runs again
85
+ tapeo checkpoints # saved checkpoints, newest first
86
+ tapeo checkpoint "before the FIFO" # save one (holds the files saved in Tapeo now)
87
+ tapeo restore 12 rtl/top.sv # put back a file's version from checkpoint 12 (saved as your edit)
88
+ tapeo target # where the design runs, with the boards
89
+ tapeo target fpga --board tangnano20k --clock 50
90
+ tapeo process sky130hs # change the chip process (old flows and layouts are rebuilt)
25
91
  cd ./my-sensor
26
92
  tapeo status # what changed locally, and the latest run
27
93
  tapeo push --all # push every modified and new source file
@@ -42,6 +108,8 @@ tapeo open # the project's web address
42
108
  tapeo --version # this client's version (tapeo help lists every command)
43
109
  ```
44
110
 
111
+ `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.
112
+
45
113
  ### AI
46
114
 
47
115
  ```sh
@@ -54,16 +122,47 @@ tapeo push --all # then save them in Tapeo
54
122
  tapeo discard # or forget them
55
123
  tapeo history # the Engineer conversation, shared with the browser (--limit N, default 10)
56
124
  tapeo problems # tool errors and warnings of the latest run, as file:line:col
125
+ tapeo lint # lint the pushed design RTL in seconds (no build), as file:line:col
126
+ tapeo sim # simulate the saved files now (no workflow run, no build)
127
+ tapeo sim --testbench uart # a testbench named in tapeo.json
128
+ tapeo manifest # what tapeo.json resolves to: sources, top, testbenches
129
+ tapeo sims # recent simulations
130
+ tapeo wave --open # save the latest waveform (.tapeo/waveforms/) and open it in Surfer or GTKWave
131
+ tapeo goals list # the goals and how each is measured
132
+ tapeo goals add fpga_logic_utilization between 0..80 % --note "room to grow"
133
+ tapeo goals remove fpga_logic_utilization
134
+ tapeo goals edit # write .tapeo/goals.json; edit it, then: tapeo goals save
57
135
  ```
58
136
 
137
+ Goals you add are measured once a full workflow run plans how (it keeps your goals and adds the measuring step). Checkpoints, restores, goal edits, and target or process changes are the same actions as in the browser and show up there.
138
+
59
139
  `tapeo new` asks the Architect's clarifying questions and lets you pick an option when run in a terminal. For scripts, `--auto-answers` answers "Not sure โ€” you choose", `--option N` picks a design, `--yes` confirms the build (one build from your plan), and `--no-build` creates the project without building. After the build, the generated files are pulled into the folder. The Architect conversation is not saved on the server; the project keeps the chosen requirements and design.
60
140
 
61
141
  The Engineer reads the saved server copy of your files (`tapeo ask` warns when local edits are not pushed). Its proposed changes are kept in `.tapeo/proposals.json` until you apply or discard them, and a new answer with changes replaces them. Applying follows the browser's rules: a new file must not exist yet, and the code being replaced must occur exactly once. A change that no longer fits is marked failed with the reason and can be retried after you fix the file. Changes to tool outputs (`generated/`, `signoff/`) or paths outside the project are refused. Deleting a file applies locally; `tapeo rm` deletes it on the server. If the Engineer also proposed an evaluation plan, review it in the web workspace.
62
142
 
63
- `tapeo problems [run-id]` reads the run's tool logs and lists file-level errors from every tool run, plus warnings from tool runs that failed. It recognizes Verilator, Icarus Verilog, Yosys, and Tcl script locations and matches log paths to project files by their ending. Messages without a project file and line stay in `tapeo logs`.
143
+ `tapeo lint` runs only the lint stage (Verilator on the design RTL; testbenches and FPGA board wrappers are left out) on the files saved in Tapeo, so push first. It prints errors, warnings that usually mean a bug (widths, latches, multiple drivers), and style notes such as unused signals, and exits with an error when lint finds errors. **Lint Design** does the same in VS Code and fills the Problems panel. Every full workflow run also lints first.
144
+
145
+ `tapeo problems [run-id]` reads the run's tool logs and lists file-level errors from every tool run, plus warnings from tool runs that failed and from lint. Lint style notes (unused signals, zero-extended widths) are labelled `note`, and appear as information in VS Code's Problems panel. It recognizes Verilator, Icarus Verilog, Yosys, and Tcl script locations and matches log paths to project files by their ending. Messages without a project file and line stay in `tapeo logs`.
64
146
 
65
147
  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
148
 
149
+ ### FPGA boards
150
+
151
+ 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.
152
+
153
+ ```sh
154
+ tapeo run --watch # simulate, build the bitstream, report logic use and Fmax
155
+ tapeo flash # download the bitstream and program the connected board
156
+ tapeo flash --flash # keep it after power-off (Tang Nano and ULX3S otherwise load to SRAM)
157
+ tapeo flash --board icebreaker # when bitstreams exist for several boards
158
+ tapeo monitor # the board's serial (UART) output; type a line to send it
159
+ tapeo monitor --port /dev/cu.usbserial-1101 --baud 9600
160
+ ```
161
+
162
+ `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>/`.
163
+
164
+ `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.
165
+
67
166
  ## VS Code
68
167
 
69
168
  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 +174,15 @@ Run `npm run build:vscode`, open `clients/vscode` in VS Code, and press F5; or r
75
174
  - **Show Workflow Runs**, **Show Run Details**, **Open a Tool Log**, **Show Goal Results**, **Cancel Workflow**, **Open Project in Browser**.
76
175
  - **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
176
  - **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.
177
+ - **Program FPGA Board** (FPGA projects): downloads the bitstream and programs the connected board with `openFPGALoader` in a **Tapeo FPGA** terminal.
178
+ - **Open Serial Monitor** (macOS and Linux): shows the board's serial output in a terminal and sends the lines you type.
179
+ - **Protect File from AI Repairs** / **Allow AI Repairs of File**: the open file's protection, like the lock icon in the browser.
180
+ - **Save Checkpoint** and **Restore File from Checkpoint** (pick a checkpoint; the open file's version from it is written and saved as your edit).
181
+ - **Show Project Manifest**: what `tapeo.json` resolves to. Editing `tapeo.json` in VS Code has completion and checks.
182
+ - **Simulate** (no build; the result in the Tapeo output) and **Open Waveform** (saves the VCD under `.tapeo/waveforms/`; a waveform extension such as VaporView or Surfer shows it as signals).
183
+ - **Edit Goals** opens `.tapeo/goals.json`; **Save Goals** uploads it.
184
+ - **Change Target** (simulation, an FPGA board with its design clock, a chip, or both) and **Change Process**.
185
+ - **Lint Design**: lint the pushed design RTL in seconds (no build) and show its errors and warnings in the Problems panel.
78
186
  - **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
187
 
80
188
  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,9 @@ 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 { GOALS_FILE, describeGoal, describeManifest, goalFromArguments, readGoalsFile, removeLocalOutputs, restoreFromCheckpoint, saveWaveform, writeGoalsFile } from "../shared/project-tools.mjs";
12
+ import { chooseSerialPort, openSerialPort, parseBaud, serialCandidates } from "../shared/serial.mjs";
13
+ import { OPENFPGALOADER_INSTALL, chooseBitstream, downloadBitstream, flashArguments, openFpgaLoaderAvailable, openFpgaLoaderExecutable } from "../shared/fpga.mjs";
11
14
  import { createInterface } from "node:readline/promises";
12
15
 
13
16
  const configPath = path.join(homedir(), ".config", "tapeo", "client.json");
@@ -15,7 +18,7 @@ const clientVersion = JSON.parse(await readFile(new URL("../package.json", impor
15
18
  setClientIdentity("tapeo-cli", clientVersion);
16
19
 
17
20
  function usage() {
18
- console.log(`Tapeo terminal client (developer preview)
21
+ console.log(`Tapeo terminal client
19
22
 
20
23
  Sign in
21
24
  tapeo login [server-url] Sign in through your browser (approve this device there);
@@ -27,6 +30,9 @@ Sign in
27
30
  Projects and files
28
31
  tapeo projects List your projects
29
32
  tapeo create <name> [dir] Create an empty project and bind a local folder
33
+ tapeo delete [project-id] Delete a project on the server for good (this folder's
34
+ project by default; asks you to type its name, --yes
35
+ skips that); local files are kept
30
36
  tapeo pull <project-id> [dir] Pull source files without overwriting local edits
31
37
  tapeo pull --outputs Also download tool outputs (generated/, signoff/) read-only
32
38
  tapeo status What changed locally, and the latest run
@@ -34,6 +40,20 @@ Projects and files
34
40
  tapeo rm <path> Delete a file on the server and locally
35
41
  tapeo mv <from> <to> Rename a file on the server and locally
36
42
  tapeo download <path> [dest] Download one project file (any size, e.g. a GDS layout)
43
+ tapeo manifest What tapeo.json resolves to: sources, top, testbenches
44
+ tapeo lock <path...> Protect files from automatic AI repairs (suggestions only)
45
+ tapeo unlock <path...> Let the AI Engineer repair these files during runs again
46
+
47
+ Checkpoints
48
+ tapeo checkpoints Saved checkpoints, newest first
49
+ tapeo checkpoint ["name"] Save a checkpoint of the files saved in Tapeo now
50
+ tapeo restore <n> <path...> Put back a file's version from checkpoint n (saved as your edit)
51
+
52
+ Settings
53
+ tapeo target [simulation|fpga|asic|fpga-asic] [--board <id>] [--clock <MHz>]
54
+ Show or change where the design runs (board and design clock)
55
+ tapeo process [id] Show or change the chip process (clears the old process's
56
+ flows, netlists, and layouts; the next run rebuilds them)
37
57
 
38
58
  AI
39
59
  tapeo new [description] [dir] Design a new chip with the Architect, then build it
@@ -45,6 +65,8 @@ AI
45
65
  tapeo apply [n...] Apply proposed changes to local files (all by default)
46
66
  tapeo discard Forget the proposed changes
47
67
  tapeo problems [run-id] Tool errors and warnings as file:line (latest run by default)
68
+ tapeo lint Lint the pushed design RTL with Verilator (seconds; does not
69
+ use a build) and list its problems as file:line
48
70
 
49
71
  Workflow
50
72
  tapeo run [--stage <s> | --from <s>] [--no-ai-repairs] [--watch]
@@ -54,10 +76,29 @@ Workflow
54
76
  tapeo show [run-id] Stage results, AI changes, and tool runs of a run
55
77
  tapeo logs <tool-run-id> A tool run's saved output
56
78
  tapeo goals The latest goal report
79
+ tapeo goals list The goals and how each is measured
80
+ tapeo goals add <key> <op> <value> [unit] [--note "โ€ฆ"]
81
+ Add a goal (op: "<=", ">=", "=", between low..high)
82
+ tapeo goals remove <key> Remove a goal
83
+ tapeo goals edit | save Write the goals to .tapeo/goals.json to edit, then save them
84
+ tapeo sim [--testbench <name>] Simulate the saved files now (no workflow run, no build);
85
+ --testbench picks one named in tapeo.json
86
+ tapeo sims Recent simulations
87
+ tapeo wave [sim-id] [--out <file>] [--open]
88
+ Save a simulation's waveform (VCD); --open starts Surfer or GTKWave
57
89
  tapeo cancel [run-id] Cancel a queued or running workflow
58
90
  tapeo open Print this project's web address
59
91
 
60
- Stages: simulation, synthesis, timing, physical, post-route-timing, drc, extraction, erc, lvs.
92
+ FPGA boards
93
+ tapeo flash [--board <id>] [--flash]
94
+ Download the bitstream and program the connected board with
95
+ openFPGALoader (--flash: keep it after power-off on boards
96
+ that load to SRAM by default, such as the Tang Nano 9K)
97
+ tapeo monitor [--port <path>] [--baud <n>]
98
+ Show the board's serial (UART) output and send what you
99
+ type; default 115200 baud (macOS and Linux)
100
+
101
+ Stages: lint, simulation, synthesis, timing, physical, post-route-timing, drc, extraction, erc, lvs.
61
102
  TAPEO_URL and TAPEO_TOKEN override the saved sign-in (for automation).`);
62
103
  }
63
104
 
@@ -121,7 +162,7 @@ async function configuredClient() {
121
162
  catch (error) { if (error.code !== "ENOENT") throw error; }
122
163
  const serverUrl = process.env.TAPEO_URL || saved.serverUrl;
123
164
  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.");
165
+ 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
166
  return new TapeoClient(serverUrl, token);
126
167
  }
127
168
 
@@ -283,8 +324,29 @@ async function main() {
283
324
  console.log("Signed out on this computer. Revoke the token on the Account page to end its access everywhere.");
284
325
  return;
285
326
  }
327
+ // The serial monitor talks only to the board, so it needs no sign-in.
328
+ if (command === "monitor") {
329
+ const { flags } = parseArguments(rest, ["port", "baud"]);
330
+ const port = chooseSerialPort(serialCandidates(), typeof flags.port === "string" ? flags.port : undefined);
331
+ const baud = parseBaud(flags.baud);
332
+ const serial = openSerialPort(port, baud);
333
+ console.error(`Connected to ${port} at ${baud} baud. Type a line and press Enter to send it; Ctrl+C stops.`);
334
+ serial.input.pipe(process.stdout);
335
+ process.stdin.setEncoding("utf8");
336
+ process.stdin.on("data", (text) => serial.write(text));
337
+ await new Promise((resolve) => {
338
+ const stop = () => { serial.close(); resolve(); };
339
+ process.once("SIGINT", stop);
340
+ // Ctrl+D at a keyboard stops; piped input just sends and keeps listening.
341
+ if (process.stdin.isTTY) process.stdin.once("end", stop);
342
+ serial.input.once("close", stop);
343
+ serial.input.once("error", (error) => { console.error(`The serial port closed: ${error.message}`); stop(); });
344
+ });
345
+ process.stdin.destroy();
346
+ return;
347
+ }
286
348
  const client = await configuredClient();
287
- const { flags, positional: args } = parseArguments(rest, ["stage", "from", "option", "limit"]);
349
+ const { flags, positional: args } = parseArguments(rest, ["stage", "from", "option", "limit", "board", "clock", "note", "out", "testbench"]);
288
350
  if (command === "new") {
289
351
  await newDesign(client, args[0], path.resolve(args[1] || "."), flags);
290
352
  } else if (command === "ask") {
@@ -325,6 +387,25 @@ async function main() {
325
387
  } else if (command === "discard") {
326
388
  await discardProposals(process.cwd());
327
389
  console.log("Proposed changes discarded.");
390
+ } else if (command === "lint") {
391
+ const directory = process.cwd();
392
+ const projectId = await projectIdFor(client);
393
+ const changes = await localChanges(directory).catch(() => null);
394
+ if (changes && (changes.modified.length || changes.added.length)) console.log(`Note: lint reads the files saved in Tapeo; ${changes.modified.length + changes.added.length} local change(s) are not pushed yet (tapeo push --all).`);
395
+ let run;
396
+ try {
397
+ ({ run } = await client.run(projectId, stageScope({ stage: "lint", aiRepairs: false })));
398
+ } catch (error) {
399
+ if (error?.status === 400 && /Unknown workflow stage/i.test(error.message)) throw new Error("This Tapeo server does not lint yet. Run the full workflow instead (tapeo run --watch), or ask the server owner to update it.");
400
+ throw error;
401
+ }
402
+ const final = await watchRun(client, projectId, run.id, {});
403
+ const stage = final.run.details?.stages?.find((item) => item.label === "RTL lint");
404
+ console.log(stage ? `${stage.status === "completed" ? "โœ“" : stage.status === "issues" ? "!" : stage.status === "skipped" ? "โ€“" : "โœ—"} ${stage.summary}` : `Lint run ${run.id} ended: ${final.run.status}.`);
405
+ const { files } = await client.project(projectId);
406
+ const problems = await collectRunProblems(client, projectId, run.id, files.map((file) => file.path));
407
+ for (const problem of problems) console.log(formatProblem(problem));
408
+ if (stage?.status === "failed") process.exitCode = 1;
328
409
  } else if (command === "problems") {
329
410
  const projectId = await projectIdFor(client);
330
411
  const runId = await client.latestRunId(projectId, args[0]);
@@ -332,11 +413,30 @@ async function main() {
332
413
  const problems = await collectRunProblems(client, projectId, runId, files.map((file) => file.path));
333
414
  for (const problem of problems) console.log(formatProblem(problem));
334
415
  const errors = problems.filter((problem) => problem.severity === "error").length;
335
- console.log(problems.length ? `\n${errors} error(s), ${problems.length - errors} warning(s) in run ${runId}.` : `No file-level problems in run ${runId}'s tool logs. See tapeo show for stage results.`);
416
+ const notes = problems.filter((problem) => problem.severity === "note").length;
417
+ console.log(problems.length ? `\n${errors} error(s), ${problems.length - errors - notes} warning(s)${notes ? `, ${notes} note(s)` : ""} in run ${runId}.` : `No file-level problems in run ${runId}'s tool logs. See tapeo show for stage results.`);
336
418
  } else if (command === "projects") {
337
419
  const { projects } = await client.projects();
338
420
  for (const project of projects) console.log(`${project.id}\t${project.name}`);
339
421
  if (!projects.length) console.log("No projects yet. Run tapeo create <name> [directory].");
422
+ } else if (command === "delete") {
423
+ const directory = process.cwd();
424
+ const projectId = await projectIdFor(client, args[0]);
425
+ const { project } = await client.project(projectId);
426
+ if (!flags.yes) {
427
+ if (!process.stdin.isTTY) throw new Error(`Deleting "${project.name}" cannot be undone. Confirm with --yes.`);
428
+ const prompt = prompter();
429
+ try {
430
+ 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.`);
431
+ const typed = (await prompt.ask("Type the project name to confirm: ")).trim();
432
+ if (typed !== project.name.trim()) { console.log("The name did not match. Nothing was deleted."); return; }
433
+ } finally { prompt.close(); }
434
+ }
435
+ await client.deleteProject(projectId);
436
+ // A folder bound to the deleted project is unbound, so later commands do not point at it.
437
+ const bound = await readState(directory).catch(() => null);
438
+ if (bound?.projectId === projectId && bound.serverUrl === client.serverUrl) await rm(path.join(directory, ".tapeo"), { recursive: true, force: true });
439
+ console.log(`Deleted "${project.name}".${bound?.projectId === projectId ? " This folder is no longer linked to a Tapeo project; its files are unchanged." : ""}`);
340
440
  } else if (command === "create") {
341
441
  if (!args[0]?.trim()) throw new Error("Usage: tapeo create <name> [directory]");
342
442
  const directory = path.resolve(args[1] || ".");
@@ -422,9 +522,128 @@ async function main() {
422
522
  if (toolRun.stdout) console.log(`--- stdout ---\n${toolRun.stdout}`);
423
523
  if (toolRun.stderr) console.log(`--- stderr ---\n${toolRun.stderr}`);
424
524
  if (!toolRun.stdout && !toolRun.stderr) console.log("This tool run saved no output.");
525
+ } else if (command === "goals" && ["list", "add", "remove", "edit", "save"].includes(args[0])) {
526
+ const directory = process.cwd();
527
+ const projectId = await projectIdFor(client);
528
+ const [action, ...rest] = args;
529
+ if (action === "edit") {
530
+ const { plan } = await client.goalPlan(projectId);
531
+ const target = await writeGoalsFile(directory, plan);
532
+ console.log(`Wrote ${plan.goals.length} goal(s) to ${path.relative(directory, target).split(path.sep).join("/")}. Edit it, then run tapeo goals save.`);
533
+ } else if (action === "save") {
534
+ const plan = await readGoalsFile(directory);
535
+ const saved = await client.saveGoalPlan(projectId, plan);
536
+ console.log(`Saved ${saved.plan.goals.length} goal(s). The goal report is out of date until the next run measures them.`);
537
+ } else {
538
+ const { plan } = await client.goalPlan(projectId);
539
+ if (action === "list") {
540
+ for (const goal of plan.goals) console.log(` ${describeGoal(goal)}`);
541
+ if (!plan.goals.length) console.log("No goals yet. Add one with tapeo goals add, or run the workflow to plan them.");
542
+ } else if (action === "add") {
543
+ const goal = goalFromArguments(rest, typeof flags.note === "string" ? flags.note : undefined);
544
+ if (plan.goals.some((item) => item.key === goal.key)) throw new Error(`A goal for ${goal.key} exists. Remove it first, or edit it with tapeo goals edit.`);
545
+ await client.saveGoalPlan(projectId, { ...plan, goals: [...plan.goals, goal] });
546
+ console.log(`Added: ${describeGoal(goal)}.`);
547
+ } else {
548
+ if (!rest[0]) throw new Error("Usage: tapeo goals remove <key>");
549
+ const kept = plan.goals.filter((goal) => goal.key !== rest[0] && goal.id !== rest[0]);
550
+ if (kept.length === plan.goals.length) throw new Error(`No goal named ${rest[0]}. List them with tapeo goals list.`);
551
+ await client.saveGoalPlan(projectId, { ...plan, goals: kept });
552
+ console.log(`Removed ${rest[0]}.`);
553
+ }
554
+ }
425
555
  } else if (command === "goals") {
426
556
  const projectId = await projectIdFor(client, args[0]);
427
557
  console.log(describeGoals((await client.goals(projectId)).report));
558
+ } else if (command === "lock" || command === "unlock") {
559
+ const projectId = await projectIdFor(client);
560
+ if (!args.length) throw new Error(`Usage: tapeo ${command} <path...>`);
561
+ for (const file of args) {
562
+ await client.lockFile(projectId, file, command === "lock");
563
+ console.log(command === "lock" ? `Protected ${file}: AI repairs become suggestions you review.` : `Unprotected ${file}: the AI Engineer may repair it during runs.`);
564
+ }
565
+ } else if (command === "checkpoints") {
566
+ const projectId = await projectIdFor(client);
567
+ const { checkpoints } = await client.checkpoints(projectId);
568
+ for (const item of checkpoints) console.log(` ${String(item.version).padStart(3)} ${new Date(item.createdAt).toLocaleString()} ${item.note ?? ""}`);
569
+ if (!checkpoints.length) console.log("No checkpoints yet. Save one with tapeo checkpoint \"name\".");
570
+ } else if (command === "checkpoint") {
571
+ const directory = process.cwd();
572
+ const projectId = await projectIdFor(client);
573
+ const changes = await localChanges(directory).catch(() => null);
574
+ if (changes && (changes.modified.length || changes.added.length)) console.log(`Note: the checkpoint holds the files saved in Tapeo; ${changes.modified.length + changes.added.length} local change(s) are not pushed yet.`);
575
+ const { checkpoint } = await client.saveCheckpoint(projectId, args.join(" ") || undefined);
576
+ console.log(`Saved checkpoint ${checkpoint.version}: ${checkpoint.note}.`);
577
+ } else if (command === "restore") {
578
+ const projectId = await projectIdFor(client);
579
+ const version = Number(args[0]);
580
+ if (!Number.isSafeInteger(version) || version < 1 || args.length < 2) throw new Error("Usage: tapeo restore <checkpoint> <path...> (list checkpoints with tapeo checkpoints)");
581
+ for (const item of await restoreFromCheckpoint(client, projectId, process.cwd(), version, args.slice(1))) console.log(`Restored ${item.path} from checkpoint ${version} (${item.sha256.slice(0, 12)}).`);
582
+ } else if (command === "sim") {
583
+ const directory = process.cwd();
584
+ const projectId = await projectIdFor(client);
585
+ const changes = await localChanges(directory).catch(() => null);
586
+ if (changes && (changes.modified.length || changes.added.length)) console.log(`Note: this simulates the files saved in Tapeo; ${changes.modified.length + changes.added.length} local change(s) are not pushed yet.`);
587
+ console.log("Simulatingโ€ฆ");
588
+ const result = await client.simulate(projectId, typeof flags.testbench === "string" ? flags.testbench : undefined);
589
+ console.log(`${result.status === "passed" ? "โœ“" : "โœ—"} ${result.message}${result.testbenchPath ? ` (${result.testbenchPath})` : ""}`);
590
+ for (const metric of result.metrics ?? []) console.log(` ${metric.key} = ${metric.value} ${metric.unit}`);
591
+ if (result.status !== "passed") console.log(String([result.stdout, result.stderr].filter(Boolean).join("\n")).split("\n").filter(Boolean).slice(-15).join("\n"));
592
+ if (result.hasWaveform) console.log(`Waveform: tapeo wave ${result.runId}`);
593
+ if (result.status !== "passed") process.exitCode = 1;
594
+ } else if (command === "manifest") {
595
+ const projectId = await projectIdFor(client);
596
+ const { manifest } = await client.manifest(projectId);
597
+ for (const line of describeManifest(manifest)) console.log(line);
598
+ if (manifest?.errors.length) process.exitCode = 1;
599
+ } else if (command === "sims") {
600
+ const projectId = await projectIdFor(client);
601
+ const { simulations } = await client.simulations(projectId);
602
+ for (const run of simulations) console.log(` ${run.runId} ${run.status.padEnd(7)} ${new Date(run.createdAt).toLocaleString()} ${run.message}${run.hasWaveform ? " ยท waveform" : ""}`);
603
+ if (!simulations.length) console.log("No simulations yet. Run tapeo sim.");
604
+ } else if (command === "wave") {
605
+ const directory = process.cwd();
606
+ const projectId = await projectIdFor(client);
607
+ let runId = args[0];
608
+ if (!runId) {
609
+ runId = (await client.simulations(projectId)).simulations.find((run) => run.hasWaveform)?.runId;
610
+ if (!runId) throw new Error("No simulation with a waveform yet. Run tapeo sim.");
611
+ }
612
+ const file = await saveWaveform(client, projectId, runId, directory, typeof flags.out === "string" ? flags.out : undefined);
613
+ console.log(`Saved the waveform to ${path.relative(directory, file).split(path.sep).join("/") || file}.`);
614
+ if (flags.open) {
615
+ const viewer = ["surfer", "gtkwave"].find((name) => spawnSync(name, ["--version"], { stdio: "ignore" }).status === 0);
616
+ if (!viewer) console.log("Install Surfer (https://surfer-project.org) or GTKWave to open it, or open it in the web workspace.");
617
+ else { spawn(viewer, [file], { detached: true, stdio: "ignore" }).unref(); console.log(`Opened it in ${viewer}.`); }
618
+ }
619
+ } else if (command === "target" || command === "process") {
620
+ const projectId = await projectIdFor(client);
621
+ const describe = (settings, choices) => {
622
+ const board = choices.boards.find((item) => item.id === settings.fpgaBoard);
623
+ const platform = choices.platforms.find((item) => item.id === settings.technologyPlatform);
624
+ return `Target: ${choices.targets.find((item) => item.id === settings.target)?.label ?? settings.target}${board && settings.target.startsWith("fpga") ? ` ยท ${board.label}${settings.fpgaClockMhz ? ` ยท ${settings.fpgaClockMhz} MHz design clock (PLL)` : ` ยท ${board.clockMhz} MHz board clock`}` : ""}${settings.target === "asic" || settings.target === "fpga-asic" ? ` ยท process ${platform?.label ?? (settings.technology || "not chosen")}` : ""}`;
625
+ };
626
+ const { settings, choices } = await client.settings(projectId);
627
+ if (!args.length) {
628
+ console.log(describe(settings, choices));
629
+ if (command === "target") {
630
+ console.log(`Targets: ${choices.targets.map((item) => item.id).join(", ")}`);
631
+ console.log(`Boards:\n${choices.boards.map((item) => ` ${item.id.padEnd(12)} ${item.label} ยท ${item.part} ยท ${item.clockMhz} MHz (PLL ${item.pllMinMhz}-${item.pllMaxMhz} MHz)`).join("\n")}`);
632
+ } else {
633
+ console.log(`Processes:\n${choices.platforms.map((item) => ` ${item.id.padEnd(14)} ${item.label}${item.oneClick ? "" : ` ยท ${item.blocker ?? "manual setup"}`}`).join("\n")}`);
634
+ }
635
+ } else {
636
+ const change = command === "target"
637
+ ? { target: args[0], ...(typeof flags.board === "string" ? { fpgaBoard: flags.board } : {}), ...(typeof flags.clock === "string" ? { fpgaClockMhz: Number(flags.clock) } : {}) }
638
+ : { technologyPlatform: args[0] };
639
+ const saved = await client.saveSettings(projectId, change);
640
+ console.log(`Saved. ${describe(saved.settings, choices)}`);
641
+ if (saved.removed?.length) {
642
+ const local = await removeLocalOutputs(process.cwd(), saved.removed);
643
+ console.log(`Removed ${saved.removed.length} file(s) from the old process: ${saved.removed.join(", ")}${local.length ? ` (and ${local.length} local cop${local.length === 1 ? "y" : "ies"})` : ""}. Run the workflow to rebuild them for the new process.`);
644
+ }
645
+ else console.log("Run the workflow to build for the new settings (tapeo run --watch).");
646
+ }
428
647
  } else if (command === "cancel") {
429
648
  const projectId = await projectIdFor(client);
430
649
  let runId = args[0];
@@ -435,6 +654,18 @@ async function main() {
435
654
  }
436
655
  const { run } = await client.cancelRun(projectId, runId);
437
656
  console.log(`Cancelled ${run.id}. A tool step already running finishes first; finished stages keep their results.`);
657
+ } else if (command === "flash") {
658
+ const directory = process.cwd();
659
+ const projectId = await projectIdFor(client);
660
+ const { files } = await client.project(projectId);
661
+ const bitstream = chooseBitstream(files, flags.board);
662
+ const executable = openFpgaLoaderExecutable();
663
+ if (!openFpgaLoaderAvailable(executable)) throw new Error(OPENFPGALOADER_INSTALL);
664
+ const local = await downloadBitstream(client, projectId, bitstream, directory);
665
+ console.log(`Programming the ${bitstream.label} with ${path.relative(directory, local)}${flags.flash ? " (writing to flash)" : ""}โ€ฆ`);
666
+ const result = spawnSync(executable, flashArguments(bitstream, local, { flash: Boolean(flags.flash) }), { stdio: "inherit" });
667
+ 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.");
668
+ console.log(`Programmed the ${bitstream.label}.`);
438
669
  } else if (command === "open") {
439
670
  const projectId = await projectIdFor(client, args[0]);
440
671
  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.5.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
@@ -5,7 +5,7 @@ export class TapeoApiError extends Error {
5
5
  }
6
6
 
7
7
  /** Workflow stages a run can start from or stop at (lib/workflow-stages.ts). */
8
- export const WORKFLOW_STAGE_KEYS = ["simulation", "synthesis", "timing", "physical", "post-route-timing", "drc", "extraction", "erc", "lvs"];
8
+ export const WORKFLOW_STAGE_KEYS = ["lint", "simulation", "synthesis", "timing", "physical", "post-route-timing", "drc", "extraction", "erc", "lvs"];
9
9
  export const ACTIVE_RUN_STATUSES = ["queued", "running"];
10
10
 
11
11
  /** Keep CLI and VS Code status wording consistent with the persisted run. */
@@ -78,7 +78,8 @@ export function normalizeServerUrl(value) {
78
78
  return url.origin;
79
79
  }
80
80
 
81
- async function send(serverUrl, method, path, { token, body, raw = false } = {}) {
81
+ async function send(serverUrl, method, path, { token, body, raw = false, timeoutMs } = {}) {
82
+ const limit = timeoutMs ?? (raw ? 300_000 : 20_000);
82
83
  let response;
83
84
  try {
84
85
  response = await fetch(new URL(`/api/client/v1${path}`, serverUrl), {
@@ -86,10 +87,10 @@ async function send(serverUrl, method, path, { token, body, raw = false } = {})
86
87
  headers: { "X-Tapeo-Client": clientIdentity, ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
87
88
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
88
89
  redirect: "error",
89
- signal: AbortSignal.timeout(raw ? 300_000 : 20_000)
90
+ signal: AbortSignal.timeout(limit)
90
91
  });
91
92
  } catch (error) {
92
- const detail = error?.name === "TimeoutError" ? `The server did not answer within ${raw ? 300 : 20} seconds.` : "Could not reach the server.";
93
+ const detail = error?.name === "TimeoutError" ? `The server did not answer within ${Math.round(limit / 1000)} seconds.` : "Could not reach the server.";
93
94
  throw new TapeoApiError(`${detail} Check ${serverUrl} and retry; local files were not changed.`, 0);
94
95
  }
95
96
  if (raw && response.ok) return response;
@@ -136,7 +137,7 @@ export class TapeoClient {
136
137
  this.token = token;
137
138
  }
138
139
 
139
- request(method, path, body) { return send(this.serverUrl, method, path, { token: this.token, body }); }
140
+ request(method, path, body, options = {}) { return send(this.serverUrl, method, path, { token: this.token, body, ...options }); }
140
141
  /** One file as bytes (text or binary), with its SHA-256 and origin. */
141
142
  async rawFile(id, path) {
142
143
  const response = await send(this.serverUrl, "GET", `/projects/${encodeURIComponent(id)}/files/raw?path=${encodeURIComponent(path)}`, { token: this.token, raw: true });
@@ -182,10 +183,51 @@ export class TapeoClient {
182
183
  projects() { return this.request("GET", "/projects"); }
183
184
  createProject(name, description = "", hardwareType = "Undecided") { return this.request("POST", "/projects", { name, description, hardwareType }); }
184
185
  project(id) { return this.request("GET", `/projects/${encodeURIComponent(id)}`); }
186
+ /** Delete a project on the server for good (files, runs, history). */
187
+ async deleteProject(id) {
188
+ try {
189
+ return await this.request("DELETE", `/projects/${encodeURIComponent(id)}`);
190
+ } catch (error) {
191
+ // Servers older than this client have no delete route for local clients.
192
+ 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);
193
+ throw error;
194
+ }
195
+ }
185
196
  file(id, path) { return this.request("GET", `/projects/${encodeURIComponent(id)}/files?path=${encodeURIComponent(path)}`); }
186
197
  saveFile(id, path, content, baseSha256, language) { return this.request("PUT", `/projects/${encodeURIComponent(id)}/files?path=${encodeURIComponent(path)}`, { content, baseSha256, language }); }
187
198
  deleteFile(id, path, baseSha256) { return this.request("DELETE", `/projects/${encodeURIComponent(id)}/files?path=${encodeURIComponent(path)}&baseSha256=${encodeURIComponent(baseSha256)}`); }
188
199
  runs(id) { return this.request("GET", `/projects/${encodeURIComponent(id)}/runs`); }
200
+
201
+ // ---- Project tools that were browser-only (lib/client-delegate.ts on the server) ----
202
+ /** Older servers have no route for these; say so plainly instead of a bare 404/405. */
203
+ async newerRequest(feature, method, path, body, options) {
204
+ try {
205
+ return await this.request(method, path, body, options);
206
+ } catch (error) {
207
+ if ((error?.status === 404 && !error?.data?.error) || error?.status === 405) throw new TapeoApiError(`This Tapeo server does not support ${feature} from local clients yet. Use the web workspace: ${this.serverUrl}`, error.status);
208
+ throw error;
209
+ }
210
+ }
211
+ /** Protect a file from automatic AI repairs, or allow them. */
212
+ lockFile(id, path, locked) { return this.newerRequest("protecting files", "PATCH", `/projects/${encodeURIComponent(id)}/files/lock`, { path, locked }); }
213
+ checkpoints(id) { return this.newerRequest("checkpoints", "GET", `/projects/${encodeURIComponent(id)}/checkpoints`); }
214
+ saveCheckpoint(id, note) { return this.newerRequest("checkpoints", "POST", `/projects/${encodeURIComponent(id)}/checkpoints`, { note }); }
215
+ checkpoint(id, version) { return this.newerRequest("checkpoints", "GET", `/projects/${encodeURIComponent(id)}/checkpoints/${encodeURIComponent(version)}`); }
216
+ checkpointFile(id, version, path) { return this.newerRequest("checkpoints", "GET", `/projects/${encodeURIComponent(id)}/checkpoints/${encodeURIComponent(version)}?path=${encodeURIComponent(path)}`); }
217
+ /** Simulate the saved files now, without a workflow run. */
218
+ /** A simulation can take minutes (a large design, or the runner starting), so it may wait 5 minutes. */
219
+ simulate(id, testbench) { return this.newerRequest("quick simulation", "POST", `/projects/${encodeURIComponent(id)}/simulations`, testbench ? { testbench } : {}, { timeoutMs: 300_000 }); }
220
+ /** What tapeo.json resolves to on the saved files (null when there is none). */
221
+ manifest(id) { return this.newerRequest("project manifests", "GET", `/projects/${encodeURIComponent(id)}/manifest`); }
222
+ simulations(id) { return this.newerRequest("quick simulation", "GET", `/projects/${encodeURIComponent(id)}/simulations`); }
223
+ async waveform(id, runId) {
224
+ const response = await send(this.serverUrl, "GET", `/projects/${encodeURIComponent(id)}/simulations/${encodeURIComponent(runId)}/waveform`, { token: this.token, raw: true });
225
+ return Buffer.from(await response.arrayBuffer());
226
+ }
227
+ goalPlan(id) { return this.newerRequest("editing goals", "GET", `/projects/${encodeURIComponent(id)}/goals?plan=1`); }
228
+ saveGoalPlan(id, evaluationPlan) { return this.newerRequest("editing goals", "PUT", `/projects/${encodeURIComponent(id)}/goals`, { evaluationPlan }); }
229
+ settings(id) { return this.newerRequest("project settings", "GET", `/projects/${encodeURIComponent(id)}/settings`); }
230
+ saveSettings(id, settings) { return this.newerRequest("project settings", "PUT", `/projects/${encodeURIComponent(id)}/settings`, settings); }
189
231
  runDetail(id, runId) { return this.request("GET", `/projects/${encodeURIComponent(id)}/runs/${encodeURIComponent(runId)}`); }
190
232
  cancelRun(id, runId) { return this.request("POST", `/projects/${encodeURIComponent(id)}/runs/${encodeURIComponent(runId)}/cancel`, {}); }
191
233
  toolRunLog(id, toolRunId) { return this.request("GET", `/projects/${encodeURIComponent(id)}/tool-runs/${encodeURIComponent(toolRunId)}`); }
@@ -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
+ }
@@ -10,9 +10,26 @@
10
10
  * Paths in logs may be absolute (the tool's workspace); they are matched to
11
11
  * project files by their ending. Lines without a project file are left out.
12
12
  */
13
+ /**
14
+ * Verilator warnings that are style rather than likely bugs, shown as notes.
15
+ * Mirrors LINT_STYLE_CODES in lib/rtl-lint.ts (tests/client-ai.test.ts keeps them equal).
16
+ */
17
+ export const VERILATOR_STYLE_CODES = new Set([
18
+ "UNUSED", "UNUSEDSIGNAL", "UNUSEDPARAM", "UNUSEDGENVAR", "DECLFILENAME", "PINCONNECTEMPTY", "PINNOCONNECT", "VARHIDDEN", "GENUNNAMED",
19
+ "WIDTHEXPAND", "EOFNEWLINE", "PROCASSINIT", "LITENDIAN", "ASSIGNDLY", "INCABSPATH", "IMPORTSTAR", "DEFPARAM", "STMTDLY", "BLKLOOPINIT", "SYMRSVDWORD", "TIMESCALEMOD", "REALCVT"
20
+ ]);
21
+
22
+ /** Verilator severity: errors; a warning that loses bits or is otherwise a likely bug; or a style note (as lib/rtl-lint.ts classifies them). */
23
+ function verilatorSeverity(kind, code, message) {
24
+ if (kind === "Error") return "error";
25
+ const width = code === "WIDTH" ? /expects (\d+) bits?.*generates (\d+) bits?/i.exec(message) : null;
26
+ const effective = width ? (Number(width[2]) > Number(width[1]) ? "WIDTHTRUNC" : "WIDTHEXPAND") : code;
27
+ return VERILATOR_STYLE_CODES.has(effective) ? "note" : "warning";
28
+ }
29
+
13
30
  const PATTERNS = [
14
31
  // Verilator: %Error: path:line:col: message (also %Warning-CODE)
15
- { regex: /%(Error|Warning)(?:-[A-Z0-9_]+)?:\s*([^\s:]+):(\d+):(?:(\d+):)?\s*(.*)$/, map: (m) => ({ severity: m[1], path: m[2], line: m[3], column: m[4], message: m[5] }) },
32
+ { regex: /%(Error|Warning)(?:-([A-Z0-9_]+))?:\s*([^\s:]+):(\d+):(?:(\d+):)?\s*(.*)$/, map: (m) => ({ severity: verilatorSeverity(m[1], m[2] ?? "", m[6]), path: m[3], line: m[4], column: m[5], message: m[6] }) },
16
33
  // path:line[:col]: [error|warning|ERROR|syntax error]: message (Icarus, Yosys, GCC-style)
17
34
  { regex: /(?:^|\s)([^\s:'"()]+\.[A-Za-z0-9]+):(\d+)(?::(\d+))?:\s*(?:(error|warning|ERROR|WARNING|Error|Warning)\b:?\s*)?(.*)$/, map: (m) => ({ severity: m[4] ?? (/warn/i.test(m[5]) ? "warning" : "error"), path: m[1], line: m[2], column: m[3], message: m[5] }) },
18
35
  // Yosys location at the end: "... at rtl/a.sv:12.3-12.10"
@@ -46,8 +63,8 @@ export function extractProblems(text, projectPaths, { tool = "", includeWarnings
46
63
  const found = pattern.map(match);
47
64
  const path = matchProjectPath(found.path, projectPaths);
48
65
  if (!path) continue;
49
- const severity = /warn/i.test(found.severity) ? "warning" : "error";
50
- if (severity === "warning" && !includeWarnings) break;
66
+ const severity = found.severity === "note" ? "note" : /warn/i.test(found.severity) ? "warning" : "error";
67
+ if (severity !== "error" && !includeWarnings) break;
51
68
  const message = (found.message || line).trim().replace(/^:\s*/, "") || line;
52
69
  const problem = { path, line: Math.max(1, Number(found.line)), ...(found.column ? { column: Math.max(1, Number(found.column)) } : {}), severity, message: message.slice(0, 500), ...(tool ? { tool } : {}) };
53
70
  const key = `${problem.path}:${problem.line}:${problem.message}`;
@@ -61,7 +78,8 @@ export function extractProblems(text, projectPaths, { tool = "", includeWarnings
61
78
 
62
79
  /**
63
80
  * Problems from a run's tool logs: every tool run's errors, and warnings
64
- * from the tool runs that failed. Returns them grouped by file order.
81
+ * from the tool runs that failed, or from lint (whose warnings are its result).
82
+ * Returns them grouped by file order.
65
83
  */
66
84
  export async function collectRunProblems(client, projectId, runId, projectPaths) {
67
85
  const { toolRuns = [] } = await client.runDetail(projectId, runId);
@@ -69,7 +87,7 @@ export async function collectRunProblems(client, projectId, runId, projectPaths)
69
87
  for (const tool of toolRuns.slice(-12)) {
70
88
  const failed = !/^(passed|completed|success)/i.test(tool.status ?? "");
71
89
  const { toolRun } = await client.toolRunLog(projectId, tool.id);
72
- problems.push(...extractProblems(`${toolRun.summary ?? ""}\n${toolRun.stdout ?? ""}\n${toolRun.stderr ?? ""}`, projectPaths, { tool: toolRun.tool, includeWarnings: failed }));
90
+ problems.push(...extractProblems(`${toolRun.summary ?? ""}\n${toolRun.stdout ?? ""}\n${toolRun.stderr ?? ""}`, projectPaths, { tool: toolRun.tool, includeWarnings: failed || /\blint\b/i.test(toolRun.tool ?? tool.tool ?? "") }));
73
91
  }
74
92
  const seen = new Set();
75
93
  return problems.filter((problem) => { const key = `${problem.path}:${problem.line}:${problem.message}`; if (seen.has(key)) return false; seen.add(key); return true; })
@@ -0,0 +1,113 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { localTarget, pushProjectFile, safeSourcePath } from "./sync.mjs";
4
+
5
+ /**
6
+ * Project tools shared by the CLI and VS Code that used to need the browser:
7
+ * restoring files from a checkpoint, editing goals as a file, and saving a
8
+ * simulation's waveform. Everything goes through the token API.
9
+ */
10
+
11
+ /** Put a checkpoint's version of each file back: written locally, then saved in Tapeo as your edit (like Restore in Project history). */
12
+ export async function restoreFromCheckpoint(client, projectId, directory, version, paths) {
13
+ const restored = [];
14
+ for (const projectPath of paths) {
15
+ if (!safeSourcePath(projectPath)) throw new Error(`${projectPath} cannot be restored: tool outputs (generated/, signoff/) are rebuilt by runs, not restored.`);
16
+ const { file } = await client.checkpointFile(projectId, version, projectPath);
17
+ const target = await localTarget(directory, projectPath);
18
+ await mkdir(path.dirname(target), { recursive: true });
19
+ await writeFile(target, file.content, "utf8");
20
+ const saved = await pushProjectFile(client, directory, projectPath);
21
+ restored.push({ path: projectPath, sha256: saved.sha256 });
22
+ }
23
+ return restored;
24
+ }
25
+
26
+ export const GOALS_FILE = ".tapeo/goals.json";
27
+
28
+ /** The goal plan as an editable file (.tapeo/goals.json, never pushed as a project file). */
29
+ export async function writeGoalsFile(directory, plan) {
30
+ const target = path.join(directory, GOALS_FILE);
31
+ await mkdir(path.dirname(target), { recursive: true });
32
+ await writeFile(target, `${JSON.stringify(plan, null, 2)}\n`, "utf8");
33
+ return target;
34
+ }
35
+
36
+ export async function readGoalsFile(directory) {
37
+ let text;
38
+ try { text = await readFile(path.join(directory, GOALS_FILE), "utf8"); }
39
+ catch (error) { if (error?.code === "ENOENT") throw new Error(`No ${GOALS_FILE} yet. Run tapeo goals edit first.`); throw error; }
40
+ let plan;
41
+ try { plan = JSON.parse(text); } catch (error) { throw new Error(`${GOALS_FILE} is not valid JSON: ${error.message}`); }
42
+ if (!plan || !Array.isArray(plan.goals)) throw new Error(`${GOALS_FILE} must hold { "schemaVersion": 1, "goals": [ ... ] }.`);
43
+ return { schemaVersion: 1, ...plan };
44
+ }
45
+
46
+ const COMPARISONS = { "<=": "less-than-or-equal", ">=": "greater-than-or-equal", "=": "equal", "==": "equal" };
47
+ const SYMBOLS = { "less-than-or-equal": "โ‰ค", "greater-than-or-equal": "โ‰ฅ", equal: "=", between: "between" };
48
+
49
+ /**
50
+ * A goal from the command line: `<key> <= 5 ns`, `<key> >= 50 MHz`, `<key> = 1 result`,
51
+ * or `<key> between 10..20 %`. Its measurement is planned by the next full workflow run.
52
+ */
53
+ export function goalFromArguments([key, operator, target, unit = "result"], note) {
54
+ if (!key || !/^[A-Za-z0-9_.-]{1,80}$/.test(key)) throw new Error("Name the goal's measurement, e.g. tapeo goals add lint_error_count \"<=\" 0 issues");
55
+ const id = `${key}-${Date.now().toString(36)}`;
56
+ if (operator === "between") {
57
+ const match = /^(-?\d+(?:\.\d+)?)\.\.(-?\d+(?:\.\d+)?)$/.exec(target ?? "");
58
+ if (!match || Number(match[1]) > Number(match[2])) throw new Error("A range is written low..high, e.g. tapeo goals add fpga_logic_utilization between 0..80 %");
59
+ return { id, key, comparison: "between", target: Number(match[1]), upperTarget: Number(match[2]), unit, source: "user", ...(note ? { note } : {}) };
60
+ }
61
+ const comparison = COMPARISONS[operator];
62
+ if (!comparison) throw new Error(`Use <=, >=, =, or between (got ${operator ?? "nothing"}). Quote < and > in a shell: "<=".`);
63
+ const number = Number(target);
64
+ if (target === undefined || target === "") throw new Error("Give the goal's target value.");
65
+ return { id, key, comparison, target: Number.isFinite(number) ? number : target, unit, source: "user", ...(note ? { note } : {}) };
66
+ }
67
+
68
+ export function describeGoal(goal) {
69
+ const target = goal.comparison === "between" ? `${goal.target}..${goal.upperTarget}` : goal.target;
70
+ return `${goal.key} ${SYMBOLS[goal.comparison] ?? goal.comparison} ${target} ${goal.unit}${goal.verification?.steps?.length ? ` ยท measured by ${goal.verification.steps.map((step) => step.tool || step.adapterId).join(", ")}` : " ยท measurement planned on the next full run"}`;
71
+ }
72
+
73
+ /** A resolved tapeo.json as readable lines (tapeo manifest, Tapeo: Show Project Manifest). */
74
+ export function describeManifest(manifest) {
75
+ if (!manifest) return ["This project has no tapeo.json: Tapeo finds the design, top, and testbench by convention."];
76
+ const lines = [];
77
+ if (manifest.errors.length) lines.push("tapeo.json has problems that stop the stages:", ...manifest.errors.map((error) => ` โœ— ${error}`));
78
+ else {
79
+ lines.push(`Design sources (${manifest.sources.length}, in compile order):`, ...manifest.sources.map((source) => ` ${source}`));
80
+ lines.push(`Top: ${manifest.top ?? "found by Tapeo (the module nothing else instantiates)"}`);
81
+ if (manifest.includeDirs.length) lines.push(`Include folders: ${manifest.includeDirs.join(", ")}`);
82
+ if (manifest.defines.length) lines.push(`Defines: ${manifest.defines.join(", ")}`);
83
+ if (manifest.verification.length) lines.push(`Shared verification files: ${manifest.verification.join(", ")}`);
84
+ lines.push(manifest.testbenches.length ? `Testbenches: ${manifest.testbenches.map((bench) => `${bench.name} (${bench.files.join(", ")}${bench.top ? `, top ${bench.top}` : ""})`).join("; ")}` : "Testbench: found by name (tb/, *_tb.sv, โ€ฆ)");
85
+ if (manifest.constraints) lines.push(`Constraints: ${manifest.constraints}`);
86
+ }
87
+ for (const warning of manifest.warnings) lines.push(` ! ${warning}`);
88
+ return lines;
89
+ }
90
+
91
+ /** Save a simulation's waveform under .tapeo/waveforms/ (local only). */
92
+ export async function saveWaveform(client, projectId, runId, directory, destination) {
93
+ const bytes = await client.waveform(projectId, runId);
94
+ const target = destination ? path.resolve(directory, destination) : path.join(directory, ".tapeo", "waveforms", `${runId}.vcd`);
95
+ await mkdir(path.dirname(target), { recursive: true });
96
+ await writeFile(target, bytes);
97
+ return target;
98
+ }
99
+
100
+ /** Delete local copies of tool outputs the server removed (a process change); only generated/ and signoff/ paths. */
101
+ export async function removeLocalOutputs(directory, paths) {
102
+ const removed = [];
103
+ for (const projectPath of paths) {
104
+ if (!/^(?:generated|signoff)\//.test(projectPath) || projectPath.split("/").includes("..")) continue;
105
+ const target = await localTarget(directory, projectPath);
106
+ try {
107
+ await chmod(target, 0o644);
108
+ await rm(target);
109
+ removed.push(projectPath);
110
+ } catch (error) { if (error?.code !== "ENOENT") throw error; }
111
+ }
112
+ return removed;
113
+ }
@@ -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
+ }
package/shared/sync.mjs CHANGED
@@ -271,12 +271,12 @@ export async function downloadFile(client, projectId, projectPath, destination)
271
271
  }
272
272
 
273
273
  /** Design files a new design can start from: the browser's upload types and limits (20 files, 300 KB). */
274
- export const DESIGN_EXTENSIONS = [".v", ".sv", ".vh", ".svh", ".vhd", ".vhdl", ".sdc", ".tcl", ".hex", ".mem"];
274
+ export const DESIGN_EXTENSIONS = [".v", ".sv", ".vh", ".svh", ".vhd", ".vhdl", ".sdc", ".tcl", ".hex", ".mem", ".f"];
275
275
  export async function designFilesFrom(directory, { maxFiles = 20, maxBytes = 300 * 1024 } = {}) {
276
276
  const files = [];
277
277
  let total = 0;
278
278
  for (const candidate of await sourceFiles(path.resolve(directory))) {
279
- if (!DESIGN_EXTENSIONS.includes(path.extname(candidate))) continue;
279
+ if (!DESIGN_EXTENSIONS.includes(path.extname(candidate)) && candidate !== "tapeo.json") continue;
280
280
  const content = await readFile(await localTarget(directory, candidate), "utf8");
281
281
  total += Buffer.byteLength(content, "utf8");
282
282
  files.push({ path: candidate, content });