tapeo-cli 0.0.0-stage → 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/LICENSE +11 -0
- package/README.md +157 -2
- package/cli/tapeo.mjs +509 -0
- package/package.json +33 -4
- package/shared/api.mjs +238 -0
- package/shared/fpga.mjs +72 -0
- package/shared/problems.mjs +82 -0
- package/shared/proposals.mjs +112 -0
- package/shared/serial.mjs +77 -0
- package/shared/stream.mjs +62 -0
- package/shared/sync.mjs +287 -0
package/cli/tapeo.mjs
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, hostname } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { ACTIVE_RUN_STATUSES, TapeoClient, describeGoals, describeRun, describeRunDetail, normalizeServerUrl, setClientIdentity, signInWithBrowser, stageScope, watchRun, DEFAULT_SERVER_URL } from "../shared/api.mjs";
|
|
7
|
+
import { assertUnbound, designFilesFrom, downloadFile, localChanges, moveProjectFile, pullOutputs, pullProject, pushChanges, pushProjectFile, readState, removeProjectFile } from "../shared/sync.mjs";
|
|
8
|
+
import { applyProposals, changeDiff, describeChange, discardProposals, loadProposals, saveProposals } from "../shared/proposals.mjs";
|
|
9
|
+
import { collectRunProblems, formatProblem } from "../shared/problems.mjs";
|
|
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";
|
|
13
|
+
import { createInterface } from "node:readline/promises";
|
|
14
|
+
|
|
15
|
+
const configPath = path.join(homedir(), ".config", "tapeo", "client.json");
|
|
16
|
+
const clientVersion = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
17
|
+
setClientIdentity("tapeo-cli", clientVersion);
|
|
18
|
+
|
|
19
|
+
function usage() {
|
|
20
|
+
console.log(`Tapeo terminal client
|
|
21
|
+
|
|
22
|
+
Sign in
|
|
23
|
+
tapeo login [server-url] Sign in through your browser (approve this device there);
|
|
24
|
+
the server is https://tapeo.net unless you name another
|
|
25
|
+
tapeo login [server-url] --token Paste a token from Account → VS Code and terminal access
|
|
26
|
+
tapeo logout Forget the saved sign-in on this computer
|
|
27
|
+
tapeo --version Show this client's version
|
|
28
|
+
|
|
29
|
+
Projects and files
|
|
30
|
+
tapeo projects List your projects
|
|
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
|
|
35
|
+
tapeo pull <project-id> [dir] Pull source files without overwriting local edits
|
|
36
|
+
tapeo pull --outputs Also download tool outputs (generated/, signoff/) read-only
|
|
37
|
+
tapeo status What changed locally, and the latest run
|
|
38
|
+
tapeo push <path...> | --all Push named files, or every modified and new file
|
|
39
|
+
tapeo rm <path> Delete a file on the server and locally
|
|
40
|
+
tapeo mv <from> <to> Rename a file on the server and locally
|
|
41
|
+
tapeo download <path> [dest] Download one project file (any size, e.g. a GDS layout)
|
|
42
|
+
|
|
43
|
+
AI
|
|
44
|
+
tapeo new [description] [dir] Design a new chip with the Architect, then build it
|
|
45
|
+
(--from-files starts from this folder's RTL; --option N,
|
|
46
|
+
--auto-answers, --yes, --no-build for scripts)
|
|
47
|
+
tapeo ask "<question>" Ask the AI Engineer about this project
|
|
48
|
+
tapeo history The recent Engineer conversation (shared with the browser)
|
|
49
|
+
tapeo proposals The Engineer's proposed file changes, with diffs
|
|
50
|
+
tapeo apply [n...] Apply proposed changes to local files (all by default)
|
|
51
|
+
tapeo discard Forget the proposed changes
|
|
52
|
+
tapeo problems [run-id] Tool errors and warnings as file:line (latest run by default)
|
|
53
|
+
|
|
54
|
+
Workflow
|
|
55
|
+
tapeo run [--stage <s> | --from <s>] [--no-ai-repairs] [--watch]
|
|
56
|
+
Start a server run (a full run uses one build)
|
|
57
|
+
tapeo runs Recent runs
|
|
58
|
+
tapeo watch [run-id] Follow a run until it ends, then show its results
|
|
59
|
+
tapeo show [run-id] Stage results, AI changes, and tool runs of a run
|
|
60
|
+
tapeo logs <tool-run-id> A tool run's saved output
|
|
61
|
+
tapeo goals The latest goal report
|
|
62
|
+
tapeo cancel [run-id] Cancel a queued or running workflow
|
|
63
|
+
tapeo open Print this project's web address
|
|
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
|
+
|
|
74
|
+
Stages: simulation, synthesis, timing, physical, post-route-timing, drc, extraction, erc, lvs.
|
|
75
|
+
TAPEO_URL and TAPEO_TOKEN override the saved sign-in (for automation).`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Split `--flag`, `--key value`, and positional arguments. */
|
|
79
|
+
function parseArguments(args, valueFlags = []) {
|
|
80
|
+
const flags = {}, positional = [];
|
|
81
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
82
|
+
const arg = args[index];
|
|
83
|
+
if (!arg.startsWith("--")) { positional.push(arg); continue; }
|
|
84
|
+
const name = arg.slice(2);
|
|
85
|
+
if (valueFlags.includes(name)) {
|
|
86
|
+
if (index + 1 >= args.length) throw new Error(`--${name} needs a value.`);
|
|
87
|
+
flags[name] = args[++index];
|
|
88
|
+
} else flags[name] = true;
|
|
89
|
+
}
|
|
90
|
+
return { flags, positional };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function hiddenToken() {
|
|
94
|
+
if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error("Run login in an interactive terminal, or set TAPEO_TOKEN in your environment.");
|
|
95
|
+
process.stdout.write("Paste project token (input hidden): ");
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
let value = "";
|
|
98
|
+
process.stdin.setRawMode(true);
|
|
99
|
+
process.stdin.resume();
|
|
100
|
+
const done = (error) => {
|
|
101
|
+
process.stdin.off("data", onData);
|
|
102
|
+
process.stdin.setRawMode(false);
|
|
103
|
+
process.stdin.pause();
|
|
104
|
+
process.stdout.write("\n");
|
|
105
|
+
if (error) reject(error); else resolve(value.trim());
|
|
106
|
+
};
|
|
107
|
+
const onData = (chunk) => {
|
|
108
|
+
for (const char of chunk.toString("utf8")) {
|
|
109
|
+
if (char === "\r" || char === "\n") return done();
|
|
110
|
+
if (char === "\u0003") return done(new Error("Canceled."));
|
|
111
|
+
if (char === "\u007f") value = value.slice(0, -1);
|
|
112
|
+
else if (value.length < 200) value += char;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
process.stdin.on("data", onData);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Open a page in the default browser; printing the address is the fallback. */
|
|
120
|
+
function openBrowser(url) {
|
|
121
|
+
if (process.env.TAPEO_NO_BROWSER) return;
|
|
122
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
123
|
+
try { spawn(command, args, { stdio: "ignore", detached: true }).on("error", () => undefined).unref(); } catch { /* the address is printed */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function saveLogin(serverUrl, token) {
|
|
127
|
+
await mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
128
|
+
await writeFile(configPath, JSON.stringify({ serverUrl, token }, null, 2) + "\n", { mode: 0o600 });
|
|
129
|
+
await chmod(configPath, 0o600);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function configuredClient() {
|
|
133
|
+
let saved = {};
|
|
134
|
+
try { saved = JSON.parse(await readFile(configPath, "utf8")); }
|
|
135
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
136
|
+
const serverUrl = process.env.TAPEO_URL || saved.serverUrl;
|
|
137
|
+
const token = process.env.TAPEO_TOKEN || saved.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.");
|
|
139
|
+
return new TapeoClient(serverUrl, token);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function projectIdFor(client, explicit) {
|
|
143
|
+
if (explicit) return explicit;
|
|
144
|
+
const state = await readState(process.cwd()).catch((error) => {
|
|
145
|
+
if (error.code === "ENOENT") throw new Error("This folder is not a Tapeo project. Run the command in a pulled folder, or name the project id.");
|
|
146
|
+
throw error;
|
|
147
|
+
});
|
|
148
|
+
if (state.serverUrl !== client.serverUrl) throw new Error("This folder belongs to another Tapeo server.");
|
|
149
|
+
return state.projectId;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function follow(client, projectId, runId) {
|
|
153
|
+
let lastStage = "";
|
|
154
|
+
const final = await watchRun(client, projectId, runId, {
|
|
155
|
+
onChange: (detail) => {
|
|
156
|
+
const label = `${detail.run.status}${detail.run.currentStage ? ` · ${detail.run.currentStage}` : ""}`;
|
|
157
|
+
if (label !== lastStage) { lastStage = label; console.log(`[${new Date().toLocaleTimeString()}] ${label}`); }
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
console.log("");
|
|
161
|
+
console.log(describeRunDetail(final));
|
|
162
|
+
const { report } = await client.goals(projectId).catch(() => ({ report: null }));
|
|
163
|
+
if (report) { console.log(""); console.log(describeGoals(report)); }
|
|
164
|
+
if (final.run.status === "failed") process.exitCode = 3;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Print an answer as it is written: only the new part each time. */
|
|
168
|
+
function streamPrinter() {
|
|
169
|
+
let printed = "";
|
|
170
|
+
return {
|
|
171
|
+
update(text) { if (text.startsWith(printed)) { process.stdout.write(text.slice(printed.length)); printed = text; } },
|
|
172
|
+
finish(text) { if (text.startsWith(printed)) process.stdout.write(text.slice(printed.length)); else if (text !== printed) process.stdout.write(`\n${text}`); process.stdout.write("\n"); }
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function showProposals(proposals) {
|
|
177
|
+
if (!proposals?.changes?.length) { console.log("No proposed file changes."); return; }
|
|
178
|
+
console.log(`Proposed changes (${proposals.changes.length}), from: "${proposals.question}"`);
|
|
179
|
+
proposals.changes.forEach((change, index) => {
|
|
180
|
+
console.log(`\n${describeChange(change, index)}`);
|
|
181
|
+
if (change.status === "pending" || change.status === "failed") console.log(changeDiff(change).split("\n").map((line) => ` ${line}`).join("\n"));
|
|
182
|
+
});
|
|
183
|
+
if (proposals.evaluationPlanProposed) console.log("\nThe Engineer also proposed an evaluation plan; review it in the web workspace (Evaluation).");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Questions on the terminal; scripts pass answers as flags instead. */
|
|
187
|
+
function prompter() {
|
|
188
|
+
if (!process.stdin.isTTY) return null;
|
|
189
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
190
|
+
return { ask: (question) => rl.question(question), close: () => rl.close() };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function newDesign(client, description, directory, flags) {
|
|
194
|
+
await assertUnbound(directory);
|
|
195
|
+
const prompt = flags["auto-answers"] && flags.yes && flags.option ? null : prompter();
|
|
196
|
+
try {
|
|
197
|
+
if (!description) {
|
|
198
|
+
if (!prompt) throw new Error("Describe the hardware: tapeo new \"A sensor that warns when a tank is full\"");
|
|
199
|
+
description = (await prompt.ask("Describe the hardware you want to build: ")).trim();
|
|
200
|
+
if (!description) throw new Error("No description given.");
|
|
201
|
+
}
|
|
202
|
+
const designFiles = flags["from-files"] ? await designFilesFrom(directory) : [];
|
|
203
|
+
if (flags["from-files"]) console.log(designFiles.length ? `Starting from ${designFiles.length} design file(s): ${designFiles.map((file) => file.path).join(", ")}` : "No design files found in this folder; starting from the description.");
|
|
204
|
+
const messages = [{ role: "user", content: description }];
|
|
205
|
+
let result;
|
|
206
|
+
for (let round = 0; round < 6; round += 1) {
|
|
207
|
+
const printer = streamPrinter();
|
|
208
|
+
process.stdout.write("\nArchitect: ");
|
|
209
|
+
result = await client.architect(messages, { designFiles }, (text) => printer.update(text));
|
|
210
|
+
printer.finish(result.message);
|
|
211
|
+
messages.push({ role: "assistant", content: result.message });
|
|
212
|
+
if (result.mode === "proposal" && result.options?.length) break;
|
|
213
|
+
const clarifications = result.clarifications ?? [];
|
|
214
|
+
if (!clarifications.length) {
|
|
215
|
+
if (!prompt) throw new Error("The Architect asked an open question; run tapeo new interactively to answer it.");
|
|
216
|
+
const reply = (await prompt.ask("\nYour answer: ")).trim();
|
|
217
|
+
messages.push({ role: "user", content: reply || "Choose sensible defaults and propose a design." });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const answers = {};
|
|
221
|
+
for (const [index, item] of clarifications.entries()) {
|
|
222
|
+
const choices = [...item.choices, NOT_SURE];
|
|
223
|
+
if (!prompt || flags["auto-answers"]) { answers[index] = NOT_SURE; continue; }
|
|
224
|
+
console.log(`\n${index + 1}. ${item.question}`);
|
|
225
|
+
choices.forEach((choice, number) => console.log(` ${number + 1}) ${choice}`));
|
|
226
|
+
const picked = Number((await prompt.ask(" Choose a number (Enter: not sure): ")).trim() || choices.length);
|
|
227
|
+
answers[index] = choices[Math.min(Math.max(1, picked), choices.length) - 1];
|
|
228
|
+
}
|
|
229
|
+
const answer = clarificationAnswersMessage(clarifications, answers);
|
|
230
|
+
console.log(`\nYou: ${answer.replaceAll("\n", "\n ")}`);
|
|
231
|
+
messages.push({ role: "user", content: answer });
|
|
232
|
+
}
|
|
233
|
+
if (result.mode !== "proposal" || !result.options?.length) throw new Error("The Architect did not propose a design yet. Run tapeo new again with more detail.");
|
|
234
|
+
if (result.brief?.summary) console.log(`\nBrief: ${result.brief.summary}${result.brief.successCriteria?.length ? `\n It works when: ${result.brief.successCriteria.join("; ")}` : ""}`);
|
|
235
|
+
console.log("\nOptions:");
|
|
236
|
+
result.options.forEach((option, index) => console.log(` ${index + 1}) ${option.name}${option.recommended ? " (recommended)" : ""}: ${option.summary}${option.tradeoffs?.length ? `\n Trade-offs: ${option.tradeoffs.join("; ")}` : ""}`));
|
|
237
|
+
const recommended = Math.max(0, result.options.findIndex((option) => option.recommended));
|
|
238
|
+
let chosen = flags.option ? Number(flags.option) - 1 : recommended;
|
|
239
|
+
if (!flags.option && prompt) chosen = Number((await prompt.ask(`Choose an option (Enter: ${recommended + 1}): `)).trim() || recommended + 1) - 1;
|
|
240
|
+
const option = result.options[chosen];
|
|
241
|
+
if (!option) throw new Error(`There is no option ${chosen + 1}.`);
|
|
242
|
+
const { plan } = await client.designPlan(result.requirements, designFiles.map((file) => file.path));
|
|
243
|
+
console.log(`\nBuild plan for "${option.name}" · ${plan.platformLabel}${plan.platformNote ? ` (${plan.platformNote})` : ""}`);
|
|
244
|
+
for (const stage of plan.stages) console.log(` ${stage.status === "runs" ? "✓" : stage.status === "conditional" ? "?" : "–"} ${stage.label} (${stage.tool})`);
|
|
245
|
+
if (plan.estimate) console.log(` ${plan.estimate}`);
|
|
246
|
+
const build = !flags["no-build"];
|
|
247
|
+
if (build && !flags.yes) {
|
|
248
|
+
if (!prompt) throw new Error("Confirm the build with --yes (it uses one build from your plan).");
|
|
249
|
+
if (!/^y/i.test((await prompt.ask("Build it now? This uses one build from your plan. [y/N] ")).trim())) { console.log("Not built. Nothing was created."); return; }
|
|
250
|
+
}
|
|
251
|
+
const created = await client.createFromDesign({ requirements: result.requirements, architecture: option, designFiles, build });
|
|
252
|
+
await pullProject(client, created.project.id, directory);
|
|
253
|
+
console.log(`\nCreated ${created.project.name} (${created.project.id}) in ${directory}.`);
|
|
254
|
+
if (created.buildError) { console.log(`The build did not start: ${created.buildError}\nStart it later with: tapeo run`); return; }
|
|
255
|
+
if (!created.run) { console.log("Start the build when ready with: tapeo run"); return; }
|
|
256
|
+
console.log(`Building (run ${created.run.id}). The first run writes the RTL and testbench, then runs every stage.`);
|
|
257
|
+
const previous = process.cwd();
|
|
258
|
+
process.chdir(directory);
|
|
259
|
+
try {
|
|
260
|
+
await follow(client, created.project.id, created.run.id);
|
|
261
|
+
const pulled = await pullProject(client, created.project.id, directory);
|
|
262
|
+
console.log(`\nPulled ${pulled.pulled.length} generated file(s) into ${directory}. Edit them, then tapeo push --all and tapeo run.`);
|
|
263
|
+
} finally { process.chdir(previous); }
|
|
264
|
+
} finally { prompt?.close(); }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function main() {
|
|
268
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
269
|
+
if (!command || command === "help" || command === "--help") return usage();
|
|
270
|
+
if (command === "--version" || command === "version") {
|
|
271
|
+
console.log(clientVersion);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (command === "login") {
|
|
275
|
+
const { flags, positional } = parseArguments(rest);
|
|
276
|
+
const serverUrl = normalizeServerUrl(positional[0] || DEFAULT_SERVER_URL);
|
|
277
|
+
if (flags.token || process.env.TAPEO_TOKEN) {
|
|
278
|
+
const token = process.env.TAPEO_TOKEN || await hiddenToken();
|
|
279
|
+
const client = new TapeoClient(serverUrl, token);
|
|
280
|
+
await client.projects();
|
|
281
|
+
await saveLogin(serverUrl, token);
|
|
282
|
+
console.log(`Connected to ${serverUrl}. Token stored in ${configPath}.`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const signedIn = await signInWithBrowser(serverUrl, `${hostname()} · terminal`, {
|
|
286
|
+
onCode: ({ userCode, verificationUriComplete }) => {
|
|
287
|
+
console.log(`Approve this device in your browser:\n ${verificationUriComplete}\nConfirm the code ${userCode} matches. Waiting…`);
|
|
288
|
+
openBrowser(verificationUriComplete);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
await saveLogin(signedIn.serverUrl, signedIn.token);
|
|
292
|
+
console.log(`Signed in to ${signedIn.serverUrl} as "${signedIn.name}". Token stored in ${configPath}; revoke it any time on the Account page.`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (command === "logout") {
|
|
296
|
+
await rm(configPath, { force: true });
|
|
297
|
+
console.log("Signed out on this computer. Revoke the token on the Account page to end its access everywhere.");
|
|
298
|
+
return;
|
|
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
|
+
}
|
|
321
|
+
const client = await configuredClient();
|
|
322
|
+
const { flags, positional: args } = parseArguments(rest, ["stage", "from", "option", "limit", "board"]);
|
|
323
|
+
if (command === "new") {
|
|
324
|
+
await newDesign(client, args[0], path.resolve(args[1] || "."), flags);
|
|
325
|
+
} else if (command === "ask") {
|
|
326
|
+
const directory = process.cwd();
|
|
327
|
+
const projectId = await projectIdFor(client);
|
|
328
|
+
let question = args.join(" ").trim();
|
|
329
|
+
if (!question) {
|
|
330
|
+
const prompt = prompter();
|
|
331
|
+
if (!prompt) throw new Error('Usage: tapeo ask "<question>"');
|
|
332
|
+
try { question = (await prompt.ask("Ask the Engineer: ")).trim(); } finally { prompt.close(); }
|
|
333
|
+
if (!question) throw new Error("No question given.");
|
|
334
|
+
}
|
|
335
|
+
const changes = await localChanges(directory).catch(() => null);
|
|
336
|
+
if (changes && (changes.modified.length || changes.added.length)) console.log(`Note: the Engineer sees the server copy. ${changes.modified.length + changes.added.length} local change(s) are not pushed (tapeo push --all).`);
|
|
337
|
+
const printer = streamPrinter();
|
|
338
|
+
process.stdout.write("Engineer: ");
|
|
339
|
+
const result = await client.askEngineer(projectId, question, (text) => printer.update(text));
|
|
340
|
+
printer.finish(result.message);
|
|
341
|
+
if (result.proposedChanges?.length || result.proposedEvaluationPlan) {
|
|
342
|
+
const proposals = await saveProposals(directory, { question, result });
|
|
343
|
+
console.log("");
|
|
344
|
+
showProposals(proposals);
|
|
345
|
+
if (proposals.changes.some((change) => change.status === "pending")) console.log(`\nApply with: tapeo apply${proposals.changes.length > 1 ? " (or only some: tapeo apply 1 2)" : ""}, then tapeo push --all. Or: tapeo discard`);
|
|
346
|
+
}
|
|
347
|
+
if (result.invalidChangeCount) console.log(`(${result.invalidChangeCount} malformed change(s) from the Engineer were left out.)`);
|
|
348
|
+
} else if (command === "history") {
|
|
349
|
+
const projectId = await projectIdFor(client);
|
|
350
|
+
const { messages } = await client.engineerHistory(projectId);
|
|
351
|
+
for (const message of messages.slice(-Number(flags.limit ?? 10))) console.log(`${message.role === "user" ? "You" : "Engineer"} · ${new Date(message.createdAt).toLocaleString()}\n${message.content}\n`);
|
|
352
|
+
if (!messages.length) console.log('No Engineer conversation yet. Start one with: tapeo ask "<question>"');
|
|
353
|
+
} else if (command === "proposals") {
|
|
354
|
+
showProposals(await loadProposals(process.cwd()));
|
|
355
|
+
} else if (command === "apply") {
|
|
356
|
+
const results = await applyProposals(process.cwd(), args.length ? args : "all");
|
|
357
|
+
for (const result of results) console.log(result.ok ? `Applied ${result.file}${result.deleted ? " (deleted locally; run tapeo rm " + result.file + " to delete it on the server)" : ""}.` : `Not applied${result.file ? ` (${result.file})` : ""}: ${result.message}`);
|
|
358
|
+
if (results.some((result) => result.ok)) console.log("Review the files, then save them to Tapeo with: tapeo push --all");
|
|
359
|
+
if (results.some((result) => !result.ok)) process.exitCode = 2;
|
|
360
|
+
} else if (command === "discard") {
|
|
361
|
+
await discardProposals(process.cwd());
|
|
362
|
+
console.log("Proposed changes discarded.");
|
|
363
|
+
} else if (command === "problems") {
|
|
364
|
+
const projectId = await projectIdFor(client);
|
|
365
|
+
const runId = await client.latestRunId(projectId, args[0]);
|
|
366
|
+
const { files } = await client.project(projectId);
|
|
367
|
+
const problems = await collectRunProblems(client, projectId, runId, files.map((file) => file.path));
|
|
368
|
+
for (const problem of problems) console.log(formatProblem(problem));
|
|
369
|
+
const errors = problems.filter((problem) => problem.severity === "error").length;
|
|
370
|
+
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.`);
|
|
371
|
+
} else if (command === "projects") {
|
|
372
|
+
const { projects } = await client.projects();
|
|
373
|
+
for (const project of projects) console.log(`${project.id}\t${project.name}`);
|
|
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." : ""}`);
|
|
393
|
+
} else if (command === "create") {
|
|
394
|
+
if (!args[0]?.trim()) throw new Error("Usage: tapeo create <name> [directory]");
|
|
395
|
+
const directory = path.resolve(args[1] || ".");
|
|
396
|
+
await assertUnbound(directory);
|
|
397
|
+
const { project } = await client.createProject(args[0].trim());
|
|
398
|
+
await pullProject(client, project.id, directory);
|
|
399
|
+
console.log(`Created ${project.name} (${project.id}) in ${directory}. Add RTL and a testbench, then run tapeo push --all.`);
|
|
400
|
+
} else if (command === "pull") {
|
|
401
|
+
const bound = await readState(process.cwd()).catch(() => null);
|
|
402
|
+
const projectId = args[0] ?? (bound && bound.serverUrl === client.serverUrl ? bound.projectId : null);
|
|
403
|
+
if (!projectId) throw new Error("Usage: tapeo pull <project-id> [directory] [--outputs]");
|
|
404
|
+
const directory = path.resolve(args[0] ? args[1] || "." : ".");
|
|
405
|
+
const result = await pullProject(client, projectId, directory);
|
|
406
|
+
console.log(`Pulled ${result.pulled.length} files. Skipped ${result.skipped.length} generated/large files.`);
|
|
407
|
+
if (result.conflicts.length) {
|
|
408
|
+
console.error(`Local changes kept: ${result.conflicts.join(", ")}`);
|
|
409
|
+
for (const [file, copy] of Object.entries(result.remoteCopies)) console.error(`Compare ${file} with ${copy}`);
|
|
410
|
+
process.exitCode = 2;
|
|
411
|
+
}
|
|
412
|
+
if (flags.outputs) {
|
|
413
|
+
const outputs = await pullOutputs(client, projectId, directory);
|
|
414
|
+
console.log(`Outputs: ${outputs.downloaded.length} downloaded, ${outputs.unchanged.length} unchanged (read-only, under generated/ and signoff/).`);
|
|
415
|
+
for (const item of outputs.skipped) console.log(` Skipped ${item.path} (${(item.sizeBytes / 1048576).toFixed(1)} MB). Download it with: tapeo download ${item.path}`);
|
|
416
|
+
}
|
|
417
|
+
} else if (command === "status") {
|
|
418
|
+
const changes = await localChanges(process.cwd());
|
|
419
|
+
if (changes.serverUrl !== client.serverUrl) throw new Error("This folder belongs to another Tapeo server.");
|
|
420
|
+
const lines = [...changes.modified.map((file) => ` modified ${file}`), ...changes.added.map((file) => ` new ${file}`), ...changes.deleted.map((file) => ` deleted ${file} (run tapeo rm to delete it on the server)`), ...changes.tooLarge.map((file) => ` too large ${file} (over 2 MB; not pushed)`)];
|
|
421
|
+
console.log(lines.length ? `Local changes:\n${lines.join("\n")}\nPush them with: tapeo push --all` : "No local changes since the last pull or push.");
|
|
422
|
+
const { runs } = await client.runs(changes.projectId);
|
|
423
|
+
console.log(runs.length ? `\nLatest run: ${describeRun(runs[0])}` : "\nNo workflow runs yet.");
|
|
424
|
+
} else if (command === "push") {
|
|
425
|
+
if (flags.all) {
|
|
426
|
+
const result = await pushChanges(client, process.cwd());
|
|
427
|
+
console.log(result.saved.length ? `Saved ${result.saved.length} file(s): ${result.saved.join(", ")}` : "Nothing to push.");
|
|
428
|
+
for (const conflict of result.conflicts) console.error(`Not pushed, server changed: ${conflict.path}. ${conflict.message}`);
|
|
429
|
+
for (const failure of result.failed) console.error(`Not pushed: ${failure.path}: ${failure.message}`);
|
|
430
|
+
if (result.deletedLocally.length) console.log(`Deleted locally, still on the server: ${result.deletedLocally.join(", ")} (use tapeo rm).`);
|
|
431
|
+
if (result.conflicts.length || result.failed.length) process.exitCode = 2;
|
|
432
|
+
} else {
|
|
433
|
+
if (!args.length) throw new Error("Usage: tapeo push <project-relative-path...> or tapeo push --all");
|
|
434
|
+
for (const file of args) {
|
|
435
|
+
const result = await pushProjectFile(client, process.cwd(), file);
|
|
436
|
+
console.log(`Saved ${result.path} (${result.sha256.slice(0, 12)}).`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
} else if (command === "rm") {
|
|
440
|
+
if (!args[0]) throw new Error("Usage: tapeo rm <project-relative-path>");
|
|
441
|
+
await removeProjectFile(client, process.cwd(), args[0]);
|
|
442
|
+
console.log(`Deleted ${args[0]} on the server and locally.`);
|
|
443
|
+
} else if (command === "mv") {
|
|
444
|
+
if (!args[0] || !args[1]) throw new Error("Usage: tapeo mv <from> <to>");
|
|
445
|
+
await moveProjectFile(client, process.cwd(), args[0], args[1]);
|
|
446
|
+
console.log(`Renamed ${args[0]} to ${args[1]}.`);
|
|
447
|
+
} else if (command === "download") {
|
|
448
|
+
if (!args[0]) throw new Error("Usage: tapeo download <project-path> [destination]");
|
|
449
|
+
const projectId = await projectIdFor(client);
|
|
450
|
+
const result = await downloadFile(client, projectId, args[0], args[1] || path.basename(args[0]));
|
|
451
|
+
console.log(`Downloaded ${result.path} (${result.bytes.toLocaleString()} bytes) to ${result.destination}.`);
|
|
452
|
+
} else if (command === "run") {
|
|
453
|
+
const projectId = await projectIdFor(client, args[0]);
|
|
454
|
+
const scope = stageScope({ stage: flags.stage, from: flags.from, aiRepairs: !flags["no-ai-repairs"] });
|
|
455
|
+
const { run } = await client.run(projectId, scope);
|
|
456
|
+
console.log(`Queued workflow ${run.id}${scope ? ` (${scope.from === scope.to ? `${scope.from} only` : `${scope.from} → ${scope.to}`})` : " (full run: uses one build)"}.`);
|
|
457
|
+
if (flags.watch) await follow(client, projectId, run.id);
|
|
458
|
+
else console.log("Follow it with: tapeo watch");
|
|
459
|
+
} else if (command === "runs") {
|
|
460
|
+
const projectId = await projectIdFor(client, args[0]);
|
|
461
|
+
const { runs } = await client.runs(projectId);
|
|
462
|
+
for (const run of runs) console.log(describeRun(run));
|
|
463
|
+
if (!runs.length) console.log("No workflow runs yet.");
|
|
464
|
+
} else if (command === "watch") {
|
|
465
|
+
const projectId = await projectIdFor(client);
|
|
466
|
+
await follow(client, projectId, await client.latestRunId(projectId, args[0]));
|
|
467
|
+
} else if (command === "show") {
|
|
468
|
+
const projectId = await projectIdFor(client);
|
|
469
|
+
console.log(describeRunDetail(await client.runDetail(projectId, await client.latestRunId(projectId, args[0]))));
|
|
470
|
+
} else if (command === "logs") {
|
|
471
|
+
if (!args[0]) throw new Error("Usage: tapeo logs <tool-run-id> (ids are listed by tapeo show)");
|
|
472
|
+
const projectId = await projectIdFor(client);
|
|
473
|
+
const { toolRun } = await client.toolRunLog(projectId, args[0]);
|
|
474
|
+
console.log(`${toolRun.tool} · ${toolRun.status} · ${new Date(toolRun.createdAt).toLocaleString()}\n${toolRun.summary}\n`);
|
|
475
|
+
if (toolRun.stdout) console.log(`--- stdout ---\n${toolRun.stdout}`);
|
|
476
|
+
if (toolRun.stderr) console.log(`--- stderr ---\n${toolRun.stderr}`);
|
|
477
|
+
if (!toolRun.stdout && !toolRun.stderr) console.log("This tool run saved no output.");
|
|
478
|
+
} else if (command === "goals") {
|
|
479
|
+
const projectId = await projectIdFor(client, args[0]);
|
|
480
|
+
console.log(describeGoals((await client.goals(projectId)).report));
|
|
481
|
+
} else if (command === "cancel") {
|
|
482
|
+
const projectId = await projectIdFor(client);
|
|
483
|
+
let runId = args[0];
|
|
484
|
+
if (!runId) {
|
|
485
|
+
const { runs } = await client.runs(projectId);
|
|
486
|
+
runId = runs.find((run) => ACTIVE_RUN_STATUSES.includes(run.status))?.id;
|
|
487
|
+
if (!runId) throw new Error("No queued or running workflow to cancel.");
|
|
488
|
+
}
|
|
489
|
+
const { run } = await client.cancelRun(projectId, runId);
|
|
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}.`);
|
|
503
|
+
} else if (command === "open") {
|
|
504
|
+
const projectId = await projectIdFor(client, args[0]);
|
|
505
|
+
console.log(`${client.serverUrl}/?project=${encodeURIComponent(projectId)}`);
|
|
506
|
+
} else usage();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
main().catch((error) => { console.error(`Tapeo: ${error.message}`); process.exitCode = process.exitCode || 1; });
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tapeo-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"tapeo",
|
|
7
|
+
"hardware",
|
|
8
|
+
"rtl",
|
|
9
|
+
"verilog",
|
|
10
|
+
"systemverilog",
|
|
11
|
+
"eda",
|
|
12
|
+
"asic",
|
|
13
|
+
"fpga",
|
|
14
|
+
"cli"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://tapeo.net",
|
|
17
|
+
"license": "UNLICENSED",
|
|
18
|
+
"author": "Tapeo",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"tapeo": "./cli/tapeo.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"cli",
|
|
25
|
+
"shared",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|