@coderook/cli 0.12.0 → 0.14.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 +71 -2
- package/dist/cli/src/cli.js +167 -10
- package/dist/cli/src/mcp.js +349 -0
- package/dist/cli/src/track_commands.js +157 -0
- package/dist/desktop-app/src/main/compress.js +95 -0
- package/dist/desktop-app/src/main/detect.js +137 -0
- package/dist/desktop-app/src/main/download.js +162 -15
- package/dist/desktop-app/src/main/profile.js +73 -0
- package/dist/desktop-app/src/main/retry.js +96 -0
- package/dist/desktop-app/src/main/solid.js +103 -0
- package/dist/desktop-app/src/main/staging.js +140 -0
- package/dist/desktop-app/src/main/tracks.js +132 -0
- package/dist/desktop-app/src/main/upload.js +1039 -55
- package/dist/desktop-app/src/main/worktree.js +214 -25
- package/package.json +1 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CodeRook as a tool an assistant can use.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code and Codex both take integrations the same way: a small process
|
|
6
|
+
* speaking JSON-RPC over its own stdin and stdout, launched by the assistant
|
|
7
|
+
* and shut down with it. One server therefore covers both, and shipping it
|
|
8
|
+
* inside the CLI means the install is the install somebody already did — no
|
|
9
|
+
* second package, no second sign-in, no second copy of the upload engine.
|
|
10
|
+
*
|
|
11
|
+
* Written against the wire rather than against a library. The protocol here
|
|
12
|
+
* is a handshake and two methods; the CLI has no runtime dependencies at all,
|
|
13
|
+
* which is a large part of why installing it globally is safe, and adding a
|
|
14
|
+
* dependency tree to gain three hundred lines is a poor trade.
|
|
15
|
+
*
|
|
16
|
+
* What it will not do matters as much as what it will. Everything here reads.
|
|
17
|
+
* Nothing writes to an account, nothing deletes, and nothing signs anything
|
|
18
|
+
* in or out — an assistant that has gone wrong can waste your time but cannot
|
|
19
|
+
* cost you work. Saving a version stays a thing a person types.
|
|
20
|
+
*/
|
|
21
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
22
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
23
|
+
};
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.commandMcp = commandMcp;
|
|
26
|
+
const promises_1 = require("node:fs/promises");
|
|
27
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
28
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
29
|
+
const node_process_1 = __importDefault(require("node:process"));
|
|
30
|
+
const download_js_1 = require("../../desktop-app/src/main/download.js");
|
|
31
|
+
const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
|
|
32
|
+
const api_js_1 = require("./api.js");
|
|
33
|
+
const config_js_1 = require("./config.js");
|
|
34
|
+
/** What the protocol calls itself. Both clients accept this revision. */
|
|
35
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
36
|
+
/** Beyond this a file is described rather than pasted into a conversation. */
|
|
37
|
+
const READ_LIMIT = 256 * 1024;
|
|
38
|
+
function text(value) {
|
|
39
|
+
return { content: [{ type: "text", text: value }] };
|
|
40
|
+
}
|
|
41
|
+
function bytes(value) {
|
|
42
|
+
if (value >= 1024 ** 3)
|
|
43
|
+
return `${(value / 1024 ** 3).toFixed(2)} GB`;
|
|
44
|
+
if (value >= 1024 ** 2)
|
|
45
|
+
return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
|
46
|
+
if (value >= 1024)
|
|
47
|
+
return `${(value / 1024).toFixed(0)} KB`;
|
|
48
|
+
return `${value} B`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The project a call is about, named or taken from the folder.
|
|
52
|
+
*
|
|
53
|
+
* Assistants are usually already sitting in a project directory, so a tool
|
|
54
|
+
* that insists on being told which project every time is a tool that gets
|
|
55
|
+
* called wrongly. Naming one still wins when it is given.
|
|
56
|
+
*/
|
|
57
|
+
async function resolve(named) {
|
|
58
|
+
const wanted = (typeof named === "string" && named.trim()) ||
|
|
59
|
+
(await (0, config_js_1.readLink)(node_process_1.default.cwd()))?.slug;
|
|
60
|
+
if (!wanted) {
|
|
61
|
+
throw new Error("No project named, and this folder is not linked to one. Pass `project`.");
|
|
62
|
+
}
|
|
63
|
+
const found = await (0, api_js_1.findProject)(wanted);
|
|
64
|
+
if (!found)
|
|
65
|
+
throw new Error(`No project matching "${wanted}" on your account.`);
|
|
66
|
+
return { id: found.id, slug: found.slug };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether this project's owner allows machines to read it.
|
|
70
|
+
*
|
|
71
|
+
* The four switches on a project are the owner's answer about automated
|
|
72
|
+
* access, and an assistant reading on somebody's behalf is exactly what they
|
|
73
|
+
* describe. Checked here rather than left to the service, so the refusal says
|
|
74
|
+
* why in words the assistant can pass on rather than arriving as a status
|
|
75
|
+
* code it will most likely retry.
|
|
76
|
+
*/
|
|
77
|
+
async function assertMayRead(repositoryId, slug) {
|
|
78
|
+
const access = await (0, api_js_1.aiAccess)(repositoryId);
|
|
79
|
+
if (!access.aiRead) {
|
|
80
|
+
throw new Error(`The owner of "${slug}" has asked that machines not read it. ` +
|
|
81
|
+
"A person can still open it in the desktop application or the browser.");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function withVersion(projectId, wanted) {
|
|
85
|
+
const saved = await (0, api_js_1.versions)(projectId);
|
|
86
|
+
if (!saved.length)
|
|
87
|
+
throw new Error("This project has no saved versions yet.");
|
|
88
|
+
if (wanted === undefined || wanted === null || wanted === "latest") {
|
|
89
|
+
return { id: saved[0].id, sequence: saved[0].sequence };
|
|
90
|
+
}
|
|
91
|
+
const sequence = Number(wanted);
|
|
92
|
+
const found = saved.find((one) => one.sequence === sequence);
|
|
93
|
+
if (!found)
|
|
94
|
+
throw new Error(`No version ${wanted} in this project.`);
|
|
95
|
+
return { id: found.id, sequence: found.sequence };
|
|
96
|
+
}
|
|
97
|
+
const TOOLS = [
|
|
98
|
+
{
|
|
99
|
+
name: "coderook_projects",
|
|
100
|
+
description: "Every project on the signed-in CodeRook account, with how much each " +
|
|
101
|
+
"holds and when it last changed.",
|
|
102
|
+
inputSchema: { type: "object", properties: {} },
|
|
103
|
+
run: async () => {
|
|
104
|
+
const all = await (0, api_js_1.projects)();
|
|
105
|
+
if (!all.length)
|
|
106
|
+
return "No projects on this account yet.";
|
|
107
|
+
return all
|
|
108
|
+
.map((one) => `${one.slug} ${one.visibility} ${one.versionCount} version${one.versionCount === 1 ? "" : "s"} ${bytes(one.storedBytes ?? 0)}`)
|
|
109
|
+
.join("\n");
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "coderook_versions",
|
|
114
|
+
description: "What has been saved to a project, newest first. Each version is a " +
|
|
115
|
+
"complete snapshot, not a difference from the one before it.",
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: "object",
|
|
118
|
+
properties: {
|
|
119
|
+
project: { type: "string", description: "Slug. Defaults to this folder's project." },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
run: async (input) => {
|
|
123
|
+
const project = await resolve(input.project);
|
|
124
|
+
await assertMayRead(project.id, project.slug);
|
|
125
|
+
const saved = await (0, api_js_1.versions)(project.id);
|
|
126
|
+
if (!saved.length)
|
|
127
|
+
return "No versions saved yet.";
|
|
128
|
+
return saved
|
|
129
|
+
.map((one) => `v${one.sequence} ${one.createdAt.slice(0, 10)} ${one.fileCount} files ` +
|
|
130
|
+
`${bytes(one.storedSize)} ${one.message || "(no message)"}`)
|
|
131
|
+
.join("\n");
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "coderook_files",
|
|
136
|
+
description: "The files a version contains, with their sizes. Use before reading " +
|
|
137
|
+
"one, so a path is known to exist rather than guessed at.",
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: "object",
|
|
140
|
+
properties: {
|
|
141
|
+
project: { type: "string" },
|
|
142
|
+
version: {
|
|
143
|
+
type: ["number", "string"],
|
|
144
|
+
description: 'Version number, or "latest". Defaults to latest.',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
run: async (input) => {
|
|
149
|
+
const project = await resolve(input.project);
|
|
150
|
+
await assertMayRead(project.id, project.slug);
|
|
151
|
+
const version = await withVersion(project.id, input.version);
|
|
152
|
+
const files = await new download_js_1.Downloader(config_js_1.credentials).files(project.id, version.id);
|
|
153
|
+
if (!files.length)
|
|
154
|
+
return `v${version.sequence} holds no files.`;
|
|
155
|
+
const shown = files.slice(0, 2000);
|
|
156
|
+
const lines = shown.map((file) => `${file.path} ${bytes(file.sourceSize)}`);
|
|
157
|
+
if (files.length > shown.length) {
|
|
158
|
+
lines.push(`… and ${files.length - shown.length} more`);
|
|
159
|
+
}
|
|
160
|
+
return `v${version.sequence} · ${files.length} files\n${lines.join("\n")}`;
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "coderook_read_file",
|
|
165
|
+
description: "The contents of one file at one version, as stored. Verified against " +
|
|
166
|
+
"the digest the version recorded.",
|
|
167
|
+
inputSchema: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {
|
|
170
|
+
path: { type: "string", description: "Path within the project." },
|
|
171
|
+
project: { type: "string" },
|
|
172
|
+
version: { type: ["number", "string"] },
|
|
173
|
+
},
|
|
174
|
+
required: ["path"],
|
|
175
|
+
},
|
|
176
|
+
run: async (input) => {
|
|
177
|
+
const wanted = String(input.path ?? "").trim();
|
|
178
|
+
if (!wanted)
|
|
179
|
+
throw new Error("Which file? Pass `path`.");
|
|
180
|
+
const project = await resolve(input.project);
|
|
181
|
+
await assertMayRead(project.id, project.slug);
|
|
182
|
+
const version = await withVersion(project.id, input.version);
|
|
183
|
+
const downloader = new download_js_1.Downloader(config_js_1.credentials);
|
|
184
|
+
const files = await downloader.files(project.id, version.id);
|
|
185
|
+
const file = files.find((one) => one.path === wanted);
|
|
186
|
+
if (!file)
|
|
187
|
+
throw new Error(`No file "${wanted}" in v${version.sequence}.`);
|
|
188
|
+
if (file.sourceSize > READ_LIMIT) {
|
|
189
|
+
return (`${wanted} is ${bytes(file.sourceSize)}, larger than this tool will ` +
|
|
190
|
+
`paste into a conversation. Fetch the project with \`coderook get\` ` +
|
|
191
|
+
`and read it from disk.`);
|
|
192
|
+
}
|
|
193
|
+
/*
|
|
194
|
+
Through the same download path a person uses, temporary file and all,
|
|
195
|
+
rather than a second way of fetching bytes. That path verifies each
|
|
196
|
+
piece against the digest the version recorded — the check that caught
|
|
197
|
+
a 7.34 GB file arriving as 5.09 GB under a clean 200.
|
|
198
|
+
*/
|
|
199
|
+
const scratch = await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), "coderook-mcp-"));
|
|
200
|
+
try {
|
|
201
|
+
const target = node_path_1.default.join(scratch, node_path_1.default.basename(wanted) || "file");
|
|
202
|
+
await downloader.fileTo(project.id, version.id, file, target);
|
|
203
|
+
const held = await (0, promises_1.readFile)(target);
|
|
204
|
+
if (held.includes(0)) {
|
|
205
|
+
return `${wanted} is binary (${bytes(file.sourceSize)}); not shown.`;
|
|
206
|
+
}
|
|
207
|
+
return held.toString("utf8");
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
await (0, promises_1.rm)(scratch, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: "coderook_status",
|
|
216
|
+
description: "What has changed in a local folder since its last saved version — " +
|
|
217
|
+
"added, edited and removed files. Reads the folder; changes nothing.",
|
|
218
|
+
inputSchema: {
|
|
219
|
+
type: "object",
|
|
220
|
+
properties: {
|
|
221
|
+
folder: { type: "string", description: "Defaults to the working directory." },
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
run: async (input) => {
|
|
225
|
+
const folder = node_path_1.default.resolve(typeof input.folder === "string" && input.folder.trim()
|
|
226
|
+
? input.folder
|
|
227
|
+
: node_process_1.default.cwd());
|
|
228
|
+
const link = await (0, config_js_1.readLink)(folder);
|
|
229
|
+
const rules = await (0, worktree_js_1.readRules)(folder);
|
|
230
|
+
const changed = await (0, worktree_js_1.changedFiles)(folder, rules, null);
|
|
231
|
+
if (!changed.length)
|
|
232
|
+
return "Nothing here has changed since the last version.";
|
|
233
|
+
/*
|
|
234
|
+
Said in the words the scan actually produces: a file the folder no
|
|
235
|
+
longer has is a deletion, one with nothing removed is new, and the
|
|
236
|
+
rest are edits with their line counts, because "changed" on its own
|
|
237
|
+
tells an assistant nothing about how much.
|
|
238
|
+
*/
|
|
239
|
+
const lines = changed.slice(0, 500).map((file) => {
|
|
240
|
+
if (file.deleted)
|
|
241
|
+
return `deleted ${file.path}`;
|
|
242
|
+
if (!file.removed)
|
|
243
|
+
return `added ${file.path} +${file.added}`;
|
|
244
|
+
return `edited ${file.path} +${file.added} -${file.removed}`;
|
|
245
|
+
});
|
|
246
|
+
if (changed.length > lines.length) {
|
|
247
|
+
lines.push(`… and ${changed.length - lines.length} more`);
|
|
248
|
+
}
|
|
249
|
+
const where = link ? `${link.slug} (v${link.sequence})` : "not linked to a project";
|
|
250
|
+
return `${folder} — ${where}\n${changed.length} changed\n${lines.join("\n")}`;
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
];
|
|
254
|
+
/* ------------------------------------------------------------------ wire */
|
|
255
|
+
function reply(id, result) {
|
|
256
|
+
node_process_1.default.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
|
|
257
|
+
}
|
|
258
|
+
function fail(id, code, message) {
|
|
259
|
+
node_process_1.default.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`);
|
|
260
|
+
}
|
|
261
|
+
async function handle(request) {
|
|
262
|
+
const { id, method, params } = request;
|
|
263
|
+
/*
|
|
264
|
+
Notifications carry no id and expect no answer. Replying to one is a
|
|
265
|
+
protocol error, and the client that receives it is entitled to hang up.
|
|
266
|
+
*/
|
|
267
|
+
const isNotification = id === undefined || id === null;
|
|
268
|
+
if (method === "initialize") {
|
|
269
|
+
reply(id, {
|
|
270
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
271
|
+
capabilities: { tools: {} },
|
|
272
|
+
serverInfo: { name: "coderook", version: node_process_1.default.env.CODEROOK_CLI_VERSION ?? "0.13.0" },
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (method === "notifications/initialized" || method === "notifications/cancelled") {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (method === "tools/list") {
|
|
280
|
+
reply(id, {
|
|
281
|
+
tools: TOOLS.map(({ name, description, inputSchema }) => ({
|
|
282
|
+
name,
|
|
283
|
+
description,
|
|
284
|
+
inputSchema,
|
|
285
|
+
})),
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (method === "tools/call") {
|
|
290
|
+
const name = String(params?.name ?? "");
|
|
291
|
+
const tool = TOOLS.find((one) => one.name === name);
|
|
292
|
+
if (!tool) {
|
|
293
|
+
if (!isNotification)
|
|
294
|
+
fail(id, -32602, `No tool called "${name}".`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const answer = await tool.run(params?.arguments ?? {});
|
|
299
|
+
reply(id, text(answer));
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
/*
|
|
303
|
+
Returned as a result rather than a protocol error. A refusal — the
|
|
304
|
+
owner does not allow machines, the file is not there — is an answer
|
|
305
|
+
the assistant should read and act on, where a JSON-RPC error is
|
|
306
|
+
something it will most likely retry or surface as a crash.
|
|
307
|
+
*/
|
|
308
|
+
reply(id, {
|
|
309
|
+
...text(error instanceof Error ? error.message : String(error)),
|
|
310
|
+
isError: true,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (!isNotification)
|
|
316
|
+
fail(id, -32601, `Unsupported method "${method}".`);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Read newline-delimited JSON from stdin until it closes.
|
|
320
|
+
*
|
|
321
|
+
* Buffered by line rather than by chunk, because a chunk boundary falls
|
|
322
|
+
* wherever the pipe decides and a half-read message parses as nothing.
|
|
323
|
+
*/
|
|
324
|
+
async function commandMcp() {
|
|
325
|
+
node_process_1.default.stdin.setEncoding("utf8");
|
|
326
|
+
let buffer = "";
|
|
327
|
+
await new Promise((done) => {
|
|
328
|
+
node_process_1.default.stdin.on("data", (piece) => {
|
|
329
|
+
buffer += piece;
|
|
330
|
+
let cut = buffer.indexOf("\n");
|
|
331
|
+
while (cut >= 0) {
|
|
332
|
+
const line = buffer.slice(0, cut).trim();
|
|
333
|
+
buffer = buffer.slice(cut + 1);
|
|
334
|
+
cut = buffer.indexOf("\n");
|
|
335
|
+
if (!line)
|
|
336
|
+
continue;
|
|
337
|
+
try {
|
|
338
|
+
void handle(JSON.parse(line));
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
/* Not JSON. Nothing to answer, and nobody to answer to. */
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
node_process_1.default.stdin.on("end", () => done());
|
|
346
|
+
node_process_1.default.stdin.on("close", () => done());
|
|
347
|
+
});
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Lines of work, from a terminal.
|
|
4
|
+
*
|
|
5
|
+
* The desktop grew tracks — a project can have more than one line, so two
|
|
6
|
+
* people can save without one of them landing on top of the other — and the
|
|
7
|
+
* command line was left knowing only how to finish a merge somebody else had
|
|
8
|
+
* started. It could see the wreckage and not the thing that caused it: no way
|
|
9
|
+
* to list the lines, none to start one, and no way to say which line a save
|
|
10
|
+
* was meant for. A folder driven from a terminal could only ever write to the
|
|
11
|
+
* default.
|
|
12
|
+
*
|
|
13
|
+
* The service and the desktop already agree about all of this; what follows
|
|
14
|
+
* is the same three ideas said in a shell. The `Tracks` client is the
|
|
15
|
+
* desktop's own, imported rather than reimplemented, because two clients for
|
|
16
|
+
* one API is two sets of behaviour to keep in step.
|
|
17
|
+
*/
|
|
18
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
19
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
20
|
+
};
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.DEFAULT_TRACK = void 0;
|
|
23
|
+
exports.trackFor = trackFor;
|
|
24
|
+
exports.commandTracks = commandTracks;
|
|
25
|
+
exports.commandTrack = commandTrack;
|
|
26
|
+
const node_process_1 = __importDefault(require("node:process"));
|
|
27
|
+
const tracks_js_1 = require("../../desktop-app/src/main/tracks.js");
|
|
28
|
+
const config_js_1 = require("./config.js");
|
|
29
|
+
const project_commands_js_1 = require("./project_commands.js");
|
|
30
|
+
const dim = (value) => `[2m${value}[0m`;
|
|
31
|
+
const bold = (value) => `[1m${value}[0m`;
|
|
32
|
+
const red = (value) => `[31m${value}[0m`;
|
|
33
|
+
const green = (value) => `[32m${value}[0m`;
|
|
34
|
+
const accent = (value) => `[33m${value}[0m`;
|
|
35
|
+
/** The line a folder saves to when nobody has said otherwise. */
|
|
36
|
+
exports.DEFAULT_TRACK = "main";
|
|
37
|
+
function client() {
|
|
38
|
+
return new tracks_js_1.Tracks(config_js_1.credentials);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Which line this folder is on.
|
|
42
|
+
*
|
|
43
|
+
* Read from the folder's own record rather than from the account, because it
|
|
44
|
+
* is a fact about this copy: two checkouts of the same project can sit on
|
|
45
|
+
* different lines, which is most of the point of having them.
|
|
46
|
+
*/
|
|
47
|
+
async function trackFor(localPath) {
|
|
48
|
+
const link = await (0, config_js_1.readLink)(localPath);
|
|
49
|
+
return link?.track?.trim() || exports.DEFAULT_TRACK;
|
|
50
|
+
}
|
|
51
|
+
function describe(track, current) {
|
|
52
|
+
const here = track.name === current;
|
|
53
|
+
const mark = here ? accent("*") : " ";
|
|
54
|
+
const name = here ? bold(track.name) : track.name;
|
|
55
|
+
const notes = [];
|
|
56
|
+
if (track.kind === "merge")
|
|
57
|
+
notes.push("waiting on a decision");
|
|
58
|
+
if (track.protected)
|
|
59
|
+
notes.push("protected");
|
|
60
|
+
if (!track.headVersionId)
|
|
61
|
+
notes.push("nothing saved yet");
|
|
62
|
+
return `${mark} ${name}${notes.length ? dim(` ${notes.join(" · ")}`) : ""}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Every line and unfinished merge on the project.
|
|
66
|
+
*
|
|
67
|
+
* Merges are listed beside the lines rather than hidden, because a merge is
|
|
68
|
+
* a line the service is holding open until somebody decides — and somebody
|
|
69
|
+
* looking for "where can I save" needs to see the one they cannot.
|
|
70
|
+
*/
|
|
71
|
+
async function commandTracks(parsed) {
|
|
72
|
+
const project = await (0, project_commands_js_1.resolveProject)(parsed.positional[0]);
|
|
73
|
+
if (!project)
|
|
74
|
+
return 1;
|
|
75
|
+
const tracks = await client().list(project.id);
|
|
76
|
+
if (!tracks.length) {
|
|
77
|
+
console.log(dim("No lines recorded yet. The first save creates ") + bold(exports.DEFAULT_TRACK) + dim("."));
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
const current = await trackFor(node_process_1.default.cwd());
|
|
81
|
+
const lines = tracks.filter((track) => track.kind === "line");
|
|
82
|
+
const merges = tracks.filter((track) => track.kind === "merge");
|
|
83
|
+
console.log(bold(`Lines on ${project.slug}`));
|
|
84
|
+
for (const track of lines)
|
|
85
|
+
console.log(` ${describe(track, current)}`);
|
|
86
|
+
if (merges.length) {
|
|
87
|
+
console.log("");
|
|
88
|
+
console.log(bold("Waiting on a decision"));
|
|
89
|
+
for (const track of merges)
|
|
90
|
+
console.log(` ${describe(track, current)}`);
|
|
91
|
+
console.log(dim(" Finish one with ") + "coderook merge");
|
|
92
|
+
}
|
|
93
|
+
console.log("");
|
|
94
|
+
console.log(dim("This folder saves to ") + accent(current));
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Show, switch, or start the line this folder saves to.
|
|
99
|
+
*
|
|
100
|
+
* Switching is a statement about where the next save goes and nothing else:
|
|
101
|
+
* no files move and nothing is fetched, which is why it is instant and why
|
|
102
|
+
* it is safe to change your mind. Getting the other line's files is
|
|
103
|
+
* `coderook get`, deliberately a separate act.
|
|
104
|
+
*/
|
|
105
|
+
async function commandTrack(parsed) {
|
|
106
|
+
const folder = node_process_1.default.cwd();
|
|
107
|
+
const link = await (0, config_js_1.readLink)(folder);
|
|
108
|
+
if (!link) {
|
|
109
|
+
console.error(red("This folder is not linked to a project. Run ") + "coderook get" + red(" first."));
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
const wanted = parsed.positional[0];
|
|
113
|
+
const current = link.track?.trim() || exports.DEFAULT_TRACK;
|
|
114
|
+
if (!wanted) {
|
|
115
|
+
console.log(accent(current));
|
|
116
|
+
console.log(dim("Switch with ") + "coderook track <name>");
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
const starting = parsed.flags.has("new") || parsed.flags.has("n");
|
|
120
|
+
const tracks = await client().list(link.repositoryId);
|
|
121
|
+
const existing = tracks.find((track) => track.name === wanted);
|
|
122
|
+
if (existing && starting) {
|
|
123
|
+
console.error(red(`There is already a line called "${wanted}".`));
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
if (!existing && !starting) {
|
|
127
|
+
/*
|
|
128
|
+
Refused rather than created quietly. A typo in a branch name is a
|
|
129
|
+
normal thing to do, and silently starting a line called `mian` puts
|
|
130
|
+
work somewhere nobody will look for it.
|
|
131
|
+
*/
|
|
132
|
+
console.error(red(`No line called "${wanted}" on this project.`));
|
|
133
|
+
console.error(dim("Start one with ") + `coderook track ${wanted} --new`);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
if (existing?.kind === "merge") {
|
|
137
|
+
console.error(red(`"${wanted}" is a merge waiting on a decision, not a line to save onto.`));
|
|
138
|
+
console.error(dim("Finish it with ") + "coderook merge");
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
if (starting) {
|
|
142
|
+
try {
|
|
143
|
+
await client().create(link.repositoryId, wanted);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.error(red(error instanceof Error ? error.message : String(error)));
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
console.log(green(`Started ${bold(wanted)} from where the project is now.`));
|
|
150
|
+
}
|
|
151
|
+
await (0, config_js_1.writeLink)(folder, { ...link, track: wanted });
|
|
152
|
+
console.log(dim("This folder now saves to ") + accent(wanted));
|
|
153
|
+
if (existing) {
|
|
154
|
+
console.log(dim("Its files are not here yet — fetch them with ") + `coderook get`);
|
|
155
|
+
}
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.looksCompressed = looksCompressed;
|
|
4
|
+
exports.encodeForUpload = encodeForUpload;
|
|
5
|
+
/**
|
|
6
|
+
* Compressing an object before it is sent.
|
|
7
|
+
*
|
|
8
|
+
* The service stores an object either as it arrived or gzipped, and records
|
|
9
|
+
* which on the row: the digest in the path is always the *original* bytes, so
|
|
10
|
+
* an object keeps one identity however it happened to travel. That is what
|
|
11
|
+
* lets the same file be sent compressed by one client and plain by another
|
|
12
|
+
* and still be recognised as the file it already has.
|
|
13
|
+
*
|
|
14
|
+
* The desktop app never used it. Every upload went as raw bytes, which is why
|
|
15
|
+
* the account overview reports nothing saved — there was nothing to report.
|
|
16
|
+
* The cost was paid twice: once in the person's upload time, and again in the
|
|
17
|
+
* storage they are charged for.
|
|
18
|
+
*
|
|
19
|
+
* Two rules keep this from being a pessimisation:
|
|
20
|
+
*
|
|
21
|
+
* Already-compressed formats are left alone. Gzipping a PNG, a zip or an mp4
|
|
22
|
+
* spends real time to make the file slightly bigger, and those are exactly the
|
|
23
|
+
* large files where the wasted effort is most noticeable.
|
|
24
|
+
*
|
|
25
|
+
* And a result that did not save anything is thrown away. The check is on the
|
|
26
|
+
* outcome rather than on the guess — a ".bin" that turns out to be text gets
|
|
27
|
+
* compressed, a ".txt" full of random data does not stay compressed.
|
|
28
|
+
*/
|
|
29
|
+
const node_crypto_1 = require("node:crypto");
|
|
30
|
+
const node_zlib_1 = require("node:zlib");
|
|
31
|
+
const node_util_1 = require("node:util");
|
|
32
|
+
const deflate = (0, node_util_1.promisify)(node_zlib_1.gzip);
|
|
33
|
+
/*
|
|
34
|
+
Extensions whose contents are already compressed. Matching on the name is a
|
|
35
|
+
guess, but it is the cheap half of the decision — the expensive half, below,
|
|
36
|
+
is measured rather than guessed, so a wrong entry here costs a little time
|
|
37
|
+
and never a wrong result.
|
|
38
|
+
*/
|
|
39
|
+
const ALREADY_COMPRESSED = new Set([
|
|
40
|
+
".7z", ".aac", ".avi", ".br", ".bz2", ".cbx", ".docx", ".flac", ".gif",
|
|
41
|
+
".gz", ".heic", ".jpeg", ".jpg", ".jxl", ".m4a", ".m4v", ".mkv", ".mov",
|
|
42
|
+
".mp3", ".mp4", ".odp", ".ods", ".odt", ".ogg", ".opus", ".pdf", ".png",
|
|
43
|
+
".pptx", ".rar", ".safetensors", ".webm", ".webp", ".xlsx", ".xz", ".zip",
|
|
44
|
+
".zst",
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* Below this, compression is not worth the round trip through zlib — the
|
|
48
|
+
* saving is measured in bytes and every file pays the CPU.
|
|
49
|
+
*/
|
|
50
|
+
const SMALLEST_WORTH_TRYING = 4 * 1024;
|
|
51
|
+
/**
|
|
52
|
+
* Keep the compressed form only if it saved something worth having. A file
|
|
53
|
+
* that shrinks by two percent is not worth the service decompressing it on
|
|
54
|
+
* every read for the rest of its life.
|
|
55
|
+
*/
|
|
56
|
+
const WORTHWHILE = 0.95;
|
|
57
|
+
function looksCompressed(relativePath) {
|
|
58
|
+
const dot = relativePath.lastIndexOf(".");
|
|
59
|
+
if (dot < 0)
|
|
60
|
+
return false;
|
|
61
|
+
return ALREADY_COMPRESSED.has(relativePath.slice(dot).toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Decide how one object should travel.
|
|
65
|
+
*
|
|
66
|
+
* Never throws for compression reasons: if anything goes wrong the original
|
|
67
|
+
* bytes are sent, because a failed optimisation must not become a failed
|
|
68
|
+
* upload.
|
|
69
|
+
*/
|
|
70
|
+
async function encodeForUpload(original, relativePath, allowGzip) {
|
|
71
|
+
const plain = {
|
|
72
|
+
body: original,
|
|
73
|
+
encoding: "identity",
|
|
74
|
+
storedSha256: "",
|
|
75
|
+
};
|
|
76
|
+
if (!allowGzip)
|
|
77
|
+
return plain;
|
|
78
|
+
if (original.byteLength < SMALLEST_WORTH_TRYING)
|
|
79
|
+
return plain;
|
|
80
|
+
if (looksCompressed(relativePath))
|
|
81
|
+
return plain;
|
|
82
|
+
try {
|
|
83
|
+
const packed = await deflate(original, { level: 6 });
|
|
84
|
+
if (packed.byteLength >= original.byteLength * WORTHWHILE)
|
|
85
|
+
return plain;
|
|
86
|
+
return {
|
|
87
|
+
body: new Uint8Array(packed),
|
|
88
|
+
encoding: "gzip",
|
|
89
|
+
storedSha256: (0, node_crypto_1.createHash)("sha256").update(packed).digest("hex"),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return plain;
|
|
94
|
+
}
|
|
95
|
+
}
|