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/shared/sync.mjs
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const sha256 = (content) => createHash("sha256").update(content, "utf8").digest("hex");
|
|
6
|
+
|
|
7
|
+
export function safeSourcePath(value) {
|
|
8
|
+
return typeof value === "string" && value.length <= 512 && value.length > 0 && !value.startsWith("/") && !value.includes("\\")
|
|
9
|
+
&& !value.split("/").some((part) => !part || part === "." || part === "..")
|
|
10
|
+
&& !value.startsWith("generated/") && !value.startsWith("signoff/") && value !== ".git" && !value.startsWith(".git/") && value !== ".tapeo" && !value.startsWith(".tapeo/");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function exists(file) { try { await stat(file); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; } }
|
|
14
|
+
|
|
15
|
+
/** A local symlink must not redirect a project file outside the chosen folder. */
|
|
16
|
+
export async function localTarget(directory, projectPath) {
|
|
17
|
+
let target = path.resolve(directory);
|
|
18
|
+
for (const part of projectPath.split("/")) {
|
|
19
|
+
target = path.join(target, part);
|
|
20
|
+
try { if ((await lstat(target)).isSymbolicLink()) throw new Error(`Refusing symlink in project path: ${projectPath}`); }
|
|
21
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
22
|
+
}
|
|
23
|
+
return target;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Preserve a remote revision beside the local edit so a conflict is resolvable offline. */
|
|
27
|
+
async function saveRemoteCopy(client, projectId, directory, projectPath) {
|
|
28
|
+
const { file } = await client.file(projectId, projectPath);
|
|
29
|
+
const suffix = file.sha256 || sha256(file.content);
|
|
30
|
+
const relative = `.tapeo/conflicts/${projectPath}.${suffix.slice(0, 12)}.server`;
|
|
31
|
+
const destination = await localTarget(directory, relative);
|
|
32
|
+
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
33
|
+
await writeFile(destination, file.content, { encoding: "utf8", mode: 0o600 });
|
|
34
|
+
return relative;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readState(directory) {
|
|
38
|
+
const state = JSON.parse(await readFile(await localTarget(directory, ".tapeo/state.json"), "utf8"));
|
|
39
|
+
if (!state || state.version !== 1 || typeof state.projectId !== "string" || typeof state.files !== "object") throw new Error("Invalid .tapeo/state.json.");
|
|
40
|
+
return state;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Creating a second project must never silently replace this folder's binding. */
|
|
44
|
+
export async function assertUnbound(directory) {
|
|
45
|
+
try {
|
|
46
|
+
const state = await readState(directory);
|
|
47
|
+
throw new Error(`This folder already belongs to Tapeo project ${state.projectId}. Choose another folder.`);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error?.code !== "ENOENT") throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function writeState(directory, state) {
|
|
54
|
+
const destination = await localTarget(directory, ".tapeo/state.json");
|
|
55
|
+
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
56
|
+
await writeFile(destination, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pull editable source text; leave local changes untouched and report conflicts. */
|
|
60
|
+
export async function pullProject(client, projectId, directory) {
|
|
61
|
+
const { files } = await client.project(projectId);
|
|
62
|
+
const state = await readState(directory).catch((error) => { if (error.code === "ENOENT") return { version: 1, serverUrl: client.serverUrl, projectId, files: {} }; throw error; });
|
|
63
|
+
if (state.projectId !== projectId || state.serverUrl !== client.serverUrl) throw new Error("This folder belongs to another Tapeo project or server.");
|
|
64
|
+
const pulled = [], skipped = [], conflicts = [], remoteCopies = {};
|
|
65
|
+
for (const remote of files) {
|
|
66
|
+
if (!safeSourcePath(remote.path) || Number(remote.sizeBytes ?? 0) > 2 * 1024 * 1024) { skipped.push(remote.path); continue; }
|
|
67
|
+
const destination = await localTarget(directory, remote.path);
|
|
68
|
+
const tracked = state.files[remote.path];
|
|
69
|
+
if (await exists(destination)) {
|
|
70
|
+
const local = await readFile(destination, "utf8");
|
|
71
|
+
const localSha = sha256(local);
|
|
72
|
+
if (remote.contentSha256 && localSha === remote.contentSha256) {
|
|
73
|
+
state.files[remote.path] = { sha256: localSha, language: remote.language };
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!tracked || localSha !== tracked.sha256) {
|
|
77
|
+
conflicts.push(remote.path);
|
|
78
|
+
if (remote.contentSha256 && remote.contentSha256 !== tracked?.sha256) {
|
|
79
|
+
remoteCopies[remote.path] = await saveRemoteCopy(client, projectId, directory, remote.path);
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (tracked?.sha256 === remote.contentSha256 && await exists(destination)) continue;
|
|
85
|
+
const { file } = await client.file(projectId, remote.path);
|
|
86
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
87
|
+
await writeFile(destination, file.content, "utf8");
|
|
88
|
+
state.files[remote.path] = { sha256: file.sha256, language: file.language };
|
|
89
|
+
pulled.push(remote.path);
|
|
90
|
+
}
|
|
91
|
+
await writeState(directory, state);
|
|
92
|
+
return { pulled, skipped, conflicts, remoteCopies };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Push one explicitly named file; a 409 means the server changed since pull. */
|
|
96
|
+
export async function pushProjectFile(client, directory, projectPath) {
|
|
97
|
+
if (!safeSourcePath(projectPath)) throw new Error("Use a safe project-relative source path.");
|
|
98
|
+
const state = await readState(directory);
|
|
99
|
+
if (state.serverUrl !== client.serverUrl) throw new Error("This folder belongs to another Tapeo server.");
|
|
100
|
+
const content = await readFile(await localTarget(directory, projectPath), "utf8");
|
|
101
|
+
if (Buffer.byteLength(content, "utf8") > 2 * 1024 * 1024) throw new Error("Local edits are limited to 2 MB per file.");
|
|
102
|
+
const base = state.files[projectPath]?.sha256 ?? null;
|
|
103
|
+
let result;
|
|
104
|
+
try {
|
|
105
|
+
result = await client.saveFile(state.projectId, projectPath, content, base, state.files[projectPath]?.language ?? languageFor(projectPath));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error?.status === 409) {
|
|
108
|
+
let location = "";
|
|
109
|
+
try { location = await saveRemoteCopy(client, state.projectId, directory, projectPath); }
|
|
110
|
+
catch { /* A second failed request must never hide the original conflict. */ }
|
|
111
|
+
throw new Error(`Server file changed. Your local edit is untouched.${location ? ` Compare it with ${location}, then resolve and pull again.` : " Fetch the server copy and resolve before retrying."}`);
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
state.files[projectPath] = { sha256: result.sha256, language: state.files[projectPath]?.language ?? languageFor(projectPath) };
|
|
116
|
+
await writeState(directory, state);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Source files a local folder can add to a project. Other files (builds, editor caches) stay local. */
|
|
121
|
+
export const SOURCE_EXTENSIONS = [".sv", ".svh", ".v", ".vh", ".vhd", ".vhdl", ".sdc", ".tcl", ".xdc", ".hex", ".mem", ".c", ".h", ".s", ".S", ".ld", ".py", ".md", ".txt", ".json", ".yaml", ".yml", ".f", ".lib"];
|
|
122
|
+
const SKIPPED_DIRECTORIES = new Set(["generated", "signoff", "node_modules", "build", "dist", "obj_dir", "sim_build"]);
|
|
123
|
+
const MAX_EDIT_BYTES = 2 * 1024 * 1024;
|
|
124
|
+
|
|
125
|
+
async function sourceFiles(root, relative = "") {
|
|
126
|
+
const found = [];
|
|
127
|
+
for (const entry of await readdir(path.join(root, relative), { withFileTypes: true })) {
|
|
128
|
+
if (entry.name.startsWith(".") || entry.isSymbolicLink()) continue;
|
|
129
|
+
const child = relative ? `${relative}/${entry.name}` : entry.name;
|
|
130
|
+
if (entry.isDirectory()) { if (!(relative === "" && SKIPPED_DIRECTORIES.has(entry.name)) && entry.name !== "node_modules") found.push(...await sourceFiles(root, child)); }
|
|
131
|
+
else if (entry.isFile() && SOURCE_EXTENSIONS.includes(path.extname(entry.name)) && safeSourcePath(child)) found.push(child);
|
|
132
|
+
}
|
|
133
|
+
return found;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What changed in a bound folder since the last pull or push: modified and
|
|
138
|
+
* deleted tracked files, and new source files (by extension, outside
|
|
139
|
+
* generated/, signoff/, hidden folders, and build folders).
|
|
140
|
+
*/
|
|
141
|
+
export async function localChanges(directory) {
|
|
142
|
+
const state = await readState(directory);
|
|
143
|
+
const modified = [], deleted = [], added = [], tooLarge = [];
|
|
144
|
+
for (const [projectPath, tracked] of Object.entries(state.files)) {
|
|
145
|
+
const target = await localTarget(directory, projectPath);
|
|
146
|
+
if (!await exists(target)) { deleted.push(projectPath); continue; }
|
|
147
|
+
if (sha256(await readFile(target, "utf8")) !== tracked.sha256) modified.push(projectPath);
|
|
148
|
+
}
|
|
149
|
+
for (const candidate of await sourceFiles(path.resolve(directory))) {
|
|
150
|
+
if (state.files[candidate]) continue;
|
|
151
|
+
if ((await stat(await localTarget(directory, candidate))).size > MAX_EDIT_BYTES) tooLarge.push(candidate);
|
|
152
|
+
else added.push(candidate);
|
|
153
|
+
}
|
|
154
|
+
return { projectId: state.projectId, serverUrl: state.serverUrl, modified: modified.sort(), added: added.sort(), deleted: deleted.sort(), tooLarge: tooLarge.sort() };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const LANGUAGES = { ".sv": "systemverilog", ".svh": "systemverilog", ".v": "verilog", ".vh": "verilog", ".vhd": "vhdl", ".vhdl": "vhdl", ".tcl": "tcl", ".sdc": "tcl", ".xdc": "tcl", ".c": "c", ".h": "c", ".s": "asm", ".S": "asm", ".py": "python", ".md": "markdown", ".json": "json", ".yaml": "yaml", ".yml": "yaml" };
|
|
158
|
+
/** The language recorded for a new file, from its extension. */
|
|
159
|
+
export function languageFor(projectPath) { return LANGUAGES[path.extname(projectPath)] ?? "plaintext"; }
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Push every modified and new file. Each file is checked against the server
|
|
163
|
+
* separately, so one conflict does not stop the rest. Deleted files are only
|
|
164
|
+
* reported: deleting on the server is an explicit `rm`.
|
|
165
|
+
*/
|
|
166
|
+
export async function pushChanges(client, directory) {
|
|
167
|
+
const changes = await localChanges(directory);
|
|
168
|
+
const saved = [], conflicts = [], failed = [];
|
|
169
|
+
for (const projectPath of [...changes.modified, ...changes.added]) {
|
|
170
|
+
try { saved.push((await pushProjectFile(client, directory, projectPath)).path); }
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (/Server file changed/.test(error.message)) conflicts.push({ path: projectPath, message: error.message });
|
|
173
|
+
else if (error?.status === 401 || error?.status === 0) throw error;
|
|
174
|
+
else failed.push({ path: projectPath, message: error.message });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { saved, conflicts, failed, deletedLocally: changes.deleted, tooLarge: changes.tooLarge };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Delete a tracked file on the server (only the version last pulled) and locally. */
|
|
181
|
+
export async function removeProjectFile(client, directory, projectPath) {
|
|
182
|
+
if (!safeSourcePath(projectPath)) throw new Error("Use a safe project-relative source path.");
|
|
183
|
+
const state = await readState(directory);
|
|
184
|
+
if (state.serverUrl !== client.serverUrl) throw new Error("This folder belongs to another Tapeo server.");
|
|
185
|
+
const tracked = state.files[projectPath];
|
|
186
|
+
if (!tracked) throw new Error(`${projectPath} is not tracked in this folder. Pull the project first.`);
|
|
187
|
+
try { await client.deleteFile(state.projectId, projectPath, tracked.sha256); }
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error?.status === 409) throw new Error(`Server file changed since your last pull, so it was not deleted. Pull it and decide.`);
|
|
190
|
+
if (error?.status !== 404) throw error;
|
|
191
|
+
}
|
|
192
|
+
await rm(await localTarget(directory, projectPath), { force: true });
|
|
193
|
+
delete state.files[projectPath];
|
|
194
|
+
await writeState(directory, state);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Rename a file: the new path is added on the server, then the old one is deleted. */
|
|
198
|
+
export async function moveProjectFile(client, directory, from, to) {
|
|
199
|
+
if (!safeSourcePath(from) || !safeSourcePath(to)) throw new Error("Use safe project-relative source paths.");
|
|
200
|
+
if (from === to) throw new Error("The new path is the same as the old one.");
|
|
201
|
+
const state = await readState(directory);
|
|
202
|
+
if (!state.files[from]) throw new Error(`${from} is not tracked in this folder. Pull the project first.`);
|
|
203
|
+
if (state.files[to]) throw new Error(`${to} already exists in the project.`);
|
|
204
|
+
const source = await localTarget(directory, from);
|
|
205
|
+
const destination = await localTarget(directory, to);
|
|
206
|
+
if (await exists(destination)) throw new Error(`${to} already exists locally.`);
|
|
207
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
208
|
+
await rename(source, destination);
|
|
209
|
+
// Keep the old file's language for the new path.
|
|
210
|
+
const language = state.files[from].language ?? languageFor(to);
|
|
211
|
+
try {
|
|
212
|
+
const content = await readFile(destination, "utf8");
|
|
213
|
+
const result = await client.saveFile(state.projectId, to, content, null, language);
|
|
214
|
+
state.files[to] = { sha256: result.sha256, language };
|
|
215
|
+
await writeState(directory, state);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
await rename(destination, source).catch(() => undefined);
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
await client.deleteFile(state.projectId, from, state.files[from].sha256);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
throw new Error(`${to} was added, but ${from} could not be removed on the server: ${error.message}`);
|
|
224
|
+
}
|
|
225
|
+
delete state.files[from];
|
|
226
|
+
await writeState(directory, state);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const OUTPUT_PREFIXES = ["generated/", "signoff/"];
|
|
230
|
+
/** Default size limit for `pull --outputs`; larger outputs (a layout) are listed for an explicit download. */
|
|
231
|
+
export const MAX_OUTPUT_BYTES = 25 * 1024 * 1024;
|
|
232
|
+
|
|
233
|
+
async function writeReadOnly(destination, bytes) {
|
|
234
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
235
|
+
await rm(destination, { force: true });
|
|
236
|
+
await writeFile(destination, bytes, { mode: 0o444 });
|
|
237
|
+
await chmod(destination, 0o444);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Download tool outputs (generated/ and signoff/) read-only, for reading
|
|
242
|
+
* reports, netlists, and layouts locally. They are never pushed back; a
|
|
243
|
+
* later pull refreshes only the ones that changed.
|
|
244
|
+
*/
|
|
245
|
+
export async function pullOutputs(client, projectId, directory, { maxBytes = MAX_OUTPUT_BYTES } = {}) {
|
|
246
|
+
const state = await readState(directory);
|
|
247
|
+
if (state.projectId !== projectId || state.serverUrl !== client.serverUrl) throw new Error("This folder belongs to another Tapeo project or server.");
|
|
248
|
+
const { files } = await client.project(projectId);
|
|
249
|
+
state.outputs ??= {};
|
|
250
|
+
const downloaded = [], unchanged = [], skipped = [];
|
|
251
|
+
for (const remote of files) {
|
|
252
|
+
if (!OUTPUT_PREFIXES.some((prefix) => remote.path.startsWith(prefix))) continue;
|
|
253
|
+
if (Number(remote.sizeBytes ?? 0) > maxBytes) { skipped.push({ path: remote.path, sizeBytes: Number(remote.sizeBytes) }); continue; }
|
|
254
|
+
const destination = await localTarget(directory, remote.path);
|
|
255
|
+
if (remote.contentSha256 && state.outputs[remote.path]?.sha256 === remote.contentSha256 && await exists(destination)) { unchanged.push(remote.path); continue; }
|
|
256
|
+
const { bytes } = await client.rawFile(projectId, remote.path);
|
|
257
|
+
await writeReadOnly(destination, bytes);
|
|
258
|
+
state.outputs[remote.path] = { sha256: remote.contentSha256 ?? null };
|
|
259
|
+
downloaded.push(remote.path);
|
|
260
|
+
}
|
|
261
|
+
await writeState(directory, state);
|
|
262
|
+
return { downloaded, unchanged, skipped };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Download one project file (any size, text or binary) to a local path. */
|
|
266
|
+
export async function downloadFile(client, projectId, projectPath, destination) {
|
|
267
|
+
const { bytes } = await client.rawFile(projectId, projectPath);
|
|
268
|
+
await mkdir(path.dirname(path.resolve(destination)), { recursive: true });
|
|
269
|
+
await writeFile(destination, bytes);
|
|
270
|
+
return { path: projectPath, bytes: bytes.length, destination };
|
|
271
|
+
}
|
|
272
|
+
|
|
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"];
|
|
275
|
+
export async function designFilesFrom(directory, { maxFiles = 20, maxBytes = 300 * 1024 } = {}) {
|
|
276
|
+
const files = [];
|
|
277
|
+
let total = 0;
|
|
278
|
+
for (const candidate of await sourceFiles(path.resolve(directory))) {
|
|
279
|
+
if (!DESIGN_EXTENSIONS.includes(path.extname(candidate))) continue;
|
|
280
|
+
const content = await readFile(await localTarget(directory, candidate), "utf8");
|
|
281
|
+
total += Buffer.byteLength(content, "utf8");
|
|
282
|
+
files.push({ path: candidate, content });
|
|
283
|
+
}
|
|
284
|
+
if (files.length > maxFiles) throw new Error(`Found ${files.length} design files; start from at most ${maxFiles}.`);
|
|
285
|
+
if (total > maxBytes) throw new Error(`Design files total ${Math.ceil(total / 1024)} KB; the limit is ${maxBytes / 1024} KB.`);
|
|
286
|
+
return files;
|
|
287
|
+
}
|