@coderook/cli 0.33.1 → 0.34.1

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.
@@ -23,6 +23,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
23
23
  };
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.commandMcp = commandMcp;
26
+ const node_fs_1 = require("node:fs");
26
27
  const promises_1 = require("node:fs/promises");
27
28
  const node_os_1 = __importDefault(require("node:os"));
28
29
  const node_path_1 = __importDefault(require("node:path"));
@@ -236,9 +237,17 @@ const TOOLS = [
236
237
  : node_process_1.default.cwd());
237
238
  const link = await (0, config_js_1.readLink)(folder);
238
239
  const rules = await (0, worktree_js_1.readRules)(folder);
239
- const changed = await (0, worktree_js_1.changedFiles)(folder, rules, null);
240
+ /*
241
+ Measured against what the folder last held, as `cbx status` measures.
242
+ Passing nothing here called every file in the folder new.
243
+ */
244
+ const baseline = link?.manifest
245
+ ? new Map(Object.entries(link.local ?? link.manifest))
246
+ : null;
247
+ const changed = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
248
+ const saving = howToSave(folder, link?.slug ?? null);
240
249
  if (!changed.length)
241
- return "Nothing here has changed since the last version.";
250
+ return `Nothing here has changed since the last version.\n${saving}`;
242
251
  /*
243
252
  Said in the words the scan actually produces: a file the folder no
244
253
  longer has is a deletion, one with nothing removed is new, and the
@@ -256,10 +265,37 @@ const TOOLS = [
256
265
  lines.push(`… and ${changed.length - lines.length} more`);
257
266
  }
258
267
  const where = link ? `${link.slug} (v${link.sequence})` : "not linked to a project";
259
- return `${folder} — ${where}\n${changed.length} changed\n${lines.join("\n")}`;
268
+ return `${folder} — ${where}\n${changed.length} changed\n${lines.join("\n")}\n${saving}`;
260
269
  },
261
270
  },
262
271
  ];
272
+ /**
273
+ * Which way this folder is saved, said to the assistant reading the status.
274
+ *
275
+ * Saving stays something a person agrees to, so this names the command and
276
+ * runs nothing. A git repository is pointed at git: each commit then keeps its
277
+ * own message, where `cbx submit` would make one snapshot of all of them.
278
+ */
279
+ function howToSave(folder, slug) {
280
+ let config = null;
281
+ try {
282
+ config = (0, node_fs_1.readFileSync)(node_path_1.default.join(folder, ".git", "config"), "utf8");
283
+ }
284
+ catch {
285
+ /* Not a git repository. */
286
+ }
287
+ if (config !== null) {
288
+ const remote = config.match(/\[remote "([^"]+)"\][^[]*?url\s*=\s*coderook:/);
289
+ if (remote)
290
+ return `To save: commit, then git push ${remote[1]} <branch> (ask first).`;
291
+ return (`This is a git repository with no CodeRook remote. To save each commit: ` +
292
+ `git remote add coderook coderook://${slug ?? "<project>"}, then git push coderook <branch> ` +
293
+ `(git push -o adopt the first time if the project was saved with cbx submit). Ask first.`);
294
+ }
295
+ if (slug)
296
+ return `To save: cbx submit -m "…" (ask first).`;
297
+ return `Not linked. To save, ask which project it is: cbx submit --into <project> or cbx submit --new.`;
298
+ }
263
299
  /* ------------------------------------------------------------------ wire */
264
300
  function reply(id, result) {
265
301
  node_process_1.default.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MINIMUM_NODE = void 0;
7
+ exports.nodeTooOld = nodeTooOld;
8
+ /*
9
+ The Node this needs, said before anything else runs.
10
+
11
+ The package said Node 20.11, and that was wrong: bundles and local histories
12
+ use zstd, which Node gained in 23.8 and 22.15. On Linux, Node 22.12 got as
13
+ far as reading a project and then died with
14
+ "(0 , node_zlib_1.zstdDecompressSync) is not a function", which names
15
+ nothing anybody can act on.
16
+ */
17
+ const node_process_1 = __importDefault(require("node:process"));
18
+ const node_zlib_1 = __importDefault(require("node:zlib"));
19
+ exports.MINIMUM_NODE = "22.15";
20
+ /** A sentence for a Node that is too old, or null when this one will do. */
21
+ function nodeTooOld() {
22
+ if (typeof node_zlib_1.default.zstdDecompressSync === "function")
23
+ return null;
24
+ return (`CodeRook needs Node ${exports.MINIMUM_NODE} or later; this is Node ${node_process_1.default.versions.node} (${node_process_1.default.execPath}).\n` +
25
+ `Install a current Node (https://nodejs.org), or put a newer one first on your PATH.`);
26
+ }
@@ -0,0 +1,370 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.UV_PLATFORM = exports.PYTHON_TRIPLE = void 0;
7
+ exports.tarEntry = tarEntry;
8
+ exports.renamedEntries = renamedEntries;
9
+ exports.scriptOf = scriptOf;
10
+ exports.runtimeRequirements = runtimeRequirements;
11
+ exports.buildPortablePython = buildPortablePython;
12
+ /**
13
+ * A Python program built for another system, without that system.
14
+ *
15
+ * PyInstaller freezes a program with the interpreter it runs under, so it
16
+ * only ever builds for the machine it is on — which left Python as the one
17
+ * language `cbx build` could not take to Linux from Windows. This assembles
18
+ * the same result from parts instead:
19
+ *
20
+ * - the target system's own CPython, from python-build-standalone (the
21
+ * relocatable builds uv and others use), checked against its published
22
+ * SHA-256;
23
+ * - the program's dependencies as the target system's wheels, resolved by
24
+ * uv for that platform — environment markers included, which pip with
25
+ * `--platform` gets wrong because it evaluates them for this machine;
26
+ * - the program's own files, and a launcher.
27
+ *
28
+ * Out comes `<name>-<target>.tar.gz`: one folder holding `python/`, `lib/`,
29
+ * `app/` and the launcher, with every program in it marked executable. The
30
+ * interpreter's archive is never unpacked here — its symlinks would not
31
+ * survive Windows — its entries are carried across with their paths renamed.
32
+ *
33
+ * The limit is a package that publishes no wheel for the target: there is no
34
+ * compiler for another system here, and uv says so by name.
35
+ */
36
+ const node_child_process_1 = require("node:child_process");
37
+ const node_crypto_1 = require("node:crypto");
38
+ const node_fs_1 = require("node:fs");
39
+ const promises_1 = require("node:fs/promises");
40
+ const node_os_1 = __importDefault(require("node:os"));
41
+ const node_path_1 = __importDefault(require("node:path"));
42
+ const node_process_1 = __importDefault(require("node:process"));
43
+ const node_zlib_1 = require("node:zlib");
44
+ const rules_js_1 = require("../../cbx/src/core/rules.js");
45
+ const worktree_js_1 = require("../../cbx/src/core/worktree.js");
46
+ /** python-build-standalone's name for each system. */
47
+ exports.PYTHON_TRIPLE = {
48
+ "windows-x64": "x86_64-pc-windows-msvc",
49
+ "windows-arm64": "aarch64-pc-windows-msvc",
50
+ "linux-x64": "x86_64-unknown-linux-gnu",
51
+ "linux-arm64": "aarch64-unknown-linux-gnu",
52
+ "macos-x64": "x86_64-apple-darwin",
53
+ "macos-arm64": "aarch64-apple-darwin",
54
+ };
55
+ /**
56
+ * uv's name for the platform wheels are chosen for. Linux asks for glibc 2.28
57
+ * (Debian 10, Ubuntu 20.04, RHEL 8 and later), which is where most projects'
58
+ * wheels are built now; older wheels still qualify.
59
+ */
60
+ exports.UV_PLATFORM = {
61
+ "windows-x64": "x86_64-pc-windows-msvc",
62
+ "windows-arm64": "aarch64-pc-windows-msvc",
63
+ "linux-x64": "x86_64-manylinux_2_28",
64
+ "linux-arm64": "aarch64-manylinux_2_28",
65
+ "macos-x64": "x86_64-apple-darwin",
66
+ "macos-arm64": "aarch64-apple-darwin",
67
+ };
68
+ /* ------------------------------------------------------------------ tar */
69
+ function octal(value, width) {
70
+ return `${value.toString(8).padStart(width - 1, "0")}\0`;
71
+ }
72
+ function checksum(header) {
73
+ header.fill(0x20, 148, 156);
74
+ let sum = 0;
75
+ for (const byte of header)
76
+ sum += byte;
77
+ header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8, "latin1");
78
+ }
79
+ /** Where a path goes in a ustar header: name alone, or prefix and name. */
80
+ function splitName(full) {
81
+ if (Buffer.byteLength(full) <= 100)
82
+ return { prefix: "", name: full };
83
+ for (let at = full.indexOf("/"); at !== -1; at = full.indexOf("/", at + 1)) {
84
+ const prefix = full.slice(0, at);
85
+ const name = full.slice(at + 1);
86
+ if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100 && name)
87
+ return { prefix, name };
88
+ }
89
+ return null;
90
+ }
91
+ /** A pax header carrying a path too long for ustar. */
92
+ function paxFor(full) {
93
+ let record = ` path=${full}\n`;
94
+ let length = Buffer.byteLength(record);
95
+ /* The record counts its own length digits. */
96
+ while (Buffer.byteLength(`${length}${record}`) !== length)
97
+ length = Buffer.byteLength(`${length}${record}`);
98
+ const body = Buffer.from(`${length}${record}`);
99
+ const header = Buffer.alloc(512);
100
+ header.write("PaxHeader", 0);
101
+ header.write(octal(0o644, 8), 100, "latin1");
102
+ header.write(octal(0, 8), 108, "latin1");
103
+ header.write(octal(0, 8), 116, "latin1");
104
+ header.write(octal(body.length, 12), 124, "latin1");
105
+ header.write(octal(0, 12), 136, "latin1");
106
+ header.write("x", 156, "latin1");
107
+ header.write("ustar\0", 257, "latin1");
108
+ header.write("00", 263, "latin1");
109
+ checksum(header);
110
+ return Buffer.concat([header, body, Buffer.alloc((512 - (body.length % 512)) % 512)]);
111
+ }
112
+ function setName(header, full) {
113
+ header.fill(0, 0, 100);
114
+ header.fill(0, 345, 500);
115
+ const split = splitName(full);
116
+ if (split) {
117
+ header.write(split.name, 0, 100, "utf8");
118
+ header.write(split.prefix, 345, 155, "utf8");
119
+ return [];
120
+ }
121
+ header.write(full.slice(-99), 0, 100, "utf8");
122
+ return [paxFor(full)];
123
+ }
124
+ /** One file, or a symlink, as tar bytes. */
125
+ function tarEntry(full, body, mode, link) {
126
+ const header = Buffer.alloc(512);
127
+ const before = setName(header, full);
128
+ header.write(octal(mode, 8), 100, "latin1");
129
+ header.write(octal(0, 8), 108, "latin1");
130
+ header.write(octal(0, 8), 116, "latin1");
131
+ header.write(octal(link ? 0 : body.length, 12), 124, "latin1");
132
+ header.write(octal(Math.floor(Date.now() / 1000), 12), 136, "latin1");
133
+ header.write(link ? "2" : "0", 156, "latin1");
134
+ if (link)
135
+ header.write(link, 157, 100, "utf8");
136
+ header.write("ustar\0", 257, "latin1");
137
+ header.write("00", 263, "latin1");
138
+ checksum(header);
139
+ const data = link ? [] : [body, Buffer.alloc((512 - (body.length % 512)) % 512)];
140
+ return Buffer.concat([...before, header, ...data]);
141
+ }
142
+ /**
143
+ * Every entry of a ustar archive, renamed under `top/`, header by header.
144
+ * Bodies and link targets are untouched, so symlinks and modes survive.
145
+ */
146
+ function renamedEntries(archive, top) {
147
+ const out = [];
148
+ let at = 0;
149
+ while (at + 512 <= archive.length) {
150
+ const header = Buffer.from(archive.subarray(at, at + 512));
151
+ if (header.every((byte) => byte === 0))
152
+ break;
153
+ const field = (start, length) => header.subarray(start, start + length).toString("utf8").replace(/\0.*$/s, "");
154
+ const size = parseInt(field(124, 12).trim() || "0", 8);
155
+ const type = String.fromCharCode(header[156] ?? 48);
156
+ const span = 512 + Math.ceil(size / 512) * 512;
157
+ if (type === "x" || type === "g" || type === "L" || type === "K") {
158
+ throw new Error("the interpreter archive uses long-name headers this does not rewrite");
159
+ }
160
+ const prefix = field(345, 155);
161
+ const name = prefix ? `${prefix}/${field(0, 100)}` : field(0, 100);
162
+ const before = setName(header, `${top}/${name}`);
163
+ checksum(header);
164
+ out.push(...before, header, Buffer.from(archive.subarray(at + 512, at + span)));
165
+ at += span;
166
+ }
167
+ return out;
168
+ }
169
+ const isProgram = (body) => (body[0] === 0x7f && body.toString("latin1", 1, 4) === "ELF") ||
170
+ body.toString("latin1", 0, 2) === "#!" ||
171
+ (body.length >= 4 && [0xfeedfacf, 0xcafebabe, 0xbebafeca].includes(body.readUInt32LE(0)));
172
+ /* ------------------------------------------------------------- runtime */
173
+ function run(program, args, options = {}) {
174
+ return new Promise((done) => {
175
+ let out = "";
176
+ const child = (0, node_child_process_1.spawn)(program, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
177
+ child.stdout.on("data", (chunk) => {
178
+ out += chunk;
179
+ if (!options.quiet)
180
+ node_process_1.default.stdout.write(chunk);
181
+ });
182
+ child.stderr.on("data", (chunk) => {
183
+ out += chunk;
184
+ if (!options.quiet)
185
+ node_process_1.default.stderr.write(chunk);
186
+ });
187
+ child.on("error", (error) => done({ code: 1, out: error.message }));
188
+ child.on("exit", (code) => done({ code: code ?? 1, out }));
189
+ });
190
+ }
191
+ /** uv on PATH, or one installed into the cache with this machine's pip. */
192
+ async function findUv(cache) {
193
+ if ((await run("uv", ["--version"], { quiet: true })).code === 0)
194
+ return "uv";
195
+ const local = node_path_1.default.join(cache, "uv", "bin", node_process_1.default.platform === "win32" ? "uv.exe" : "uv");
196
+ if ((0, node_fs_1.existsSync)(local))
197
+ return local;
198
+ for (const python of ["python3", "python"]) {
199
+ const installed = await run(python, ["-m", "pip", "install", "--quiet", "--disable-pip-version-check", "--target", node_path_1.default.join(cache, "uv"), "uv"], { quiet: true });
200
+ if (installed.code === 0 && (0, node_fs_1.existsSync)(local))
201
+ return local;
202
+ }
203
+ throw new Error("needs uv (https://docs.astral.sh/uv) or Python with pip to fetch it");
204
+ }
205
+ /** The target's CPython, downloaded once, checked against its published digest. */
206
+ async function pythonRuntime(cache, version, target, say) {
207
+ const folder = node_path_1.default.join(cache, "python");
208
+ await (0, promises_1.mkdir)(folder, { recursive: true });
209
+ const wanted = new RegExp(`^cpython-${version.replace(".", "\\.")}\\.\\d+\\+\\d+-${exports.PYTHON_TRIPLE[target]}-install_only\\.tar\\.gz$`);
210
+ const cached = (await (0, promises_1.readdir)(folder)).find((name) => wanted.test(name));
211
+ if (cached)
212
+ return (0, promises_1.readFile)(node_path_1.default.join(folder, cached));
213
+ const release = (await (await fetch("https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest", {
214
+ headers: { accept: "application/vnd.github+json", "user-agent": "coderook-cli" },
215
+ })).json());
216
+ const asset = (release.assets ?? []).find((one) => wanted.test(one.name));
217
+ if (!asset)
218
+ throw new Error(`no CPython ${version} build for ${target} in the latest python-build-standalone release`);
219
+ if (!asset.digest?.startsWith("sha256:"))
220
+ throw new Error("the release does not publish a digest for it");
221
+ say(`Fetching ${asset.name}…`);
222
+ const body = Buffer.from(await (await fetch(asset.browser_download_url)).arrayBuffer());
223
+ if ((0, node_crypto_1.createHash)("sha256").update(body).digest("hex") !== asset.digest.slice("sha256:".length)) {
224
+ throw new Error("the download does not match its published digest");
225
+ }
226
+ await (0, promises_1.writeFile)(node_path_1.default.join(folder, asset.name), body);
227
+ return body;
228
+ }
229
+ /* ------------------------------------------------------------- project */
230
+ /** The script PyInstaller would freeze: from a .spec's Analysis, or the file itself. */
231
+ function scriptOf(entry, specText = "") {
232
+ if (!entry.endsWith(".spec"))
233
+ return entry;
234
+ const found = specText.match(/Analysis\(\s*\[\s*(?:r)?['"]([^'"]+)['"]/);
235
+ if (!found)
236
+ throw new Error(`could not find the script in ${entry}; set "entry" to it`);
237
+ /* A spec written on Windows escapes its backslashes: `src\\app\\main.py`. */
238
+ return found[1].replace(/\\+/g, "/");
239
+ }
240
+ /** Requirements, minus the build tools that freeze rather than run the program. */
241
+ function runtimeRequirements(text) {
242
+ return text
243
+ .split(/\r?\n/)
244
+ .filter((line) => !/^\s*(pyinstaller|pyinstaller-hooks-contrib|nuitka|cx[-_]freeze)\b/i.test(line))
245
+ .join("\n");
246
+ }
247
+ const SKIP = new Set([...rules_js_1.UNCOUNTED_DIRECTORIES, "__pycache__", "build", "dist", "venv", ".venv", "env", "node_modules", ".mypy_cache", ".pytest_cache", ".tox"]);
248
+ /** The program's own files, as the project's ignore rules see them. */
249
+ async function appFiles(folder) {
250
+ const rules = await (0, worktree_js_1.readRules)(folder);
251
+ const layers = [{ base: "", rules: (0, rules_js_1.parseRules)(`${rules.shared}\n${rules.local}`) }];
252
+ const found = [];
253
+ const walk = async (relative) => {
254
+ for (const entry of await (0, promises_1.readdir)(node_path_1.default.join(folder, relative), { withFileTypes: true })) {
255
+ const rel = relative ? `${relative}/${entry.name}` : entry.name;
256
+ if (entry.isDirectory()) {
257
+ if (SKIP.has(entry.name) || (0, rules_js_1.excludes)(rel, true, layers))
258
+ continue;
259
+ await walk(rel);
260
+ }
261
+ else if (entry.isFile() && !(0, rules_js_1.excludes)(rel, false, layers) && !/\.(pyc|pyo|spec)$/.test(entry.name)) {
262
+ found.push(rel);
263
+ }
264
+ }
265
+ };
266
+ await walk("");
267
+ return found;
268
+ }
269
+ async function buildPortablePython(request) {
270
+ const say = request.say ?? ((text) => console.log(text));
271
+ const { folder, target } = request;
272
+ const windows = target.startsWith("windows");
273
+ const top = `${request.name}-${target}`;
274
+ const hostVersion = (await run("python3", ["-c", "import sys;print('%d.%d'%sys.version_info[:2])"], { quiet: true }));
275
+ const hostVersionAlt = hostVersion.code === 0 ? hostVersion : await run("python", ["-c", "import sys;print('%d.%d'%sys.version_info[:2])"], { quiet: true });
276
+ const version = request.python ?? (hostVersionAlt.code === 0 ? hostVersionAlt.out.trim() : "3.12");
277
+ const script = scriptOf(request.entry, request.entry.endsWith(".spec") ? await (0, promises_1.readFile)(node_path_1.default.join(folder, request.entry), "utf8") : "");
278
+ if (!(0, node_fs_1.existsSync)(node_path_1.default.join(folder, script)))
279
+ throw new Error(`${script} is not in the project`);
280
+ const runtime = (0, node_zlib_1.gunzipSync)(await pythonRuntime(request.cache, version, target, say));
281
+ const uv = await findUv(request.cache);
282
+ /* Dependencies, as the target's wheels. */
283
+ const scratch = await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), "coderook-pybuild-"));
284
+ try {
285
+ const lib = node_path_1.default.join(scratch, "lib");
286
+ await (0, promises_1.mkdir)(lib);
287
+ let requirements = "";
288
+ if ((0, node_fs_1.existsSync)(node_path_1.default.join(folder, "requirements.txt"))) {
289
+ requirements = runtimeRequirements(await (0, promises_1.readFile)(node_path_1.default.join(folder, "requirements.txt"), "utf8"));
290
+ }
291
+ if (requirements.trim()) {
292
+ const file = node_path_1.default.join(scratch, "requirements.txt");
293
+ await (0, promises_1.writeFile)(file, requirements);
294
+ say(`Resolving dependencies for ${target} (${exports.UV_PLATFORM[target]}, Python ${version})…`);
295
+ const installed = await run(uv, [
296
+ "pip", "install", "--quiet", "--target", lib,
297
+ "--python-platform", exports.UV_PLATFORM[target], "--python-version", version,
298
+ /* No building from source: it would build for this machine, not the target. */
299
+ "--no-build", "-r", file,
300
+ ]);
301
+ if (installed.code !== 0) {
302
+ throw new Error(/no wheels|--no-build|only-binary|has no wheel/i.test(installed.out)
303
+ ? `a dependency publishes no ${target} wheel, and it cannot be compiled for ${target} here; build on that system instead`
304
+ : "resolving the dependencies failed (above)");
305
+ }
306
+ }
307
+ const entries = renamedEntries(runtime, top);
308
+ const add = async (base, prefix, skipTop = new Set()) => {
309
+ const walk = async (relative) => {
310
+ for (const entry of await (0, promises_1.readdir)(node_path_1.default.join(base, relative), { withFileTypes: true })) {
311
+ const rel = relative ? `${relative}/${entry.name}` : entry.name;
312
+ if (!relative && skipTop.has(entry.name))
313
+ continue;
314
+ if (entry.isDirectory()) {
315
+ if (entry.name === "__pycache__")
316
+ continue;
317
+ await walk(rel);
318
+ }
319
+ else if (entry.isFile()) {
320
+ const body = await (0, promises_1.readFile)(node_path_1.default.join(base, rel));
321
+ entries.push(tarEntry(`${top}/${prefix}/${rel}`, body, isProgram(body) ? 0o755 : 0o644));
322
+ }
323
+ }
324
+ };
325
+ await walk("");
326
+ };
327
+ /* uv writes console scripts for this machine into bin/; they are not the target's. */
328
+ await add(lib, "lib", new Set(["bin"]));
329
+ for (const rel of await appFiles(folder)) {
330
+ const body = await (0, promises_1.readFile)(node_path_1.default.join(folder, rel));
331
+ entries.push(tarEntry(`${top}/app/${rel}`, body, isProgram(body) ? 0o755 : 0o644));
332
+ }
333
+ if (windows) {
334
+ const python = request.gui ? "pythonw.exe" : "python.exe";
335
+ const launcher = [
336
+ "@echo off",
337
+ "setlocal",
338
+ 'set "HERE=%~dp0"',
339
+ 'set "PYTHONPATH=%HERE%lib;%HERE%app"',
340
+ "set PYTHONNOUSERSITE=1",
341
+ `${request.gui ? 'start "" ' : ""}"%HERE%python\\${python}" "%HERE%app\\${script.replace(/\//g, "\\")}" %*`,
342
+ "",
343
+ ].join("\r\n");
344
+ entries.push(tarEntry(`${top}/${request.name}.cmd`, Buffer.from(launcher), 0o755));
345
+ }
346
+ else {
347
+ const launcher = [
348
+ "#!/bin/sh",
349
+ "# Runs the program with the Python beside it. Made by cbx build.",
350
+ 'here="$(cd "$(dirname "$0")" && pwd -P)"',
351
+ /* Exported, not prefixed: assignments before `exec` are not passed on by every sh. */
352
+ 'PYTHONPATH="$here/lib:$here/app${PYTHONPATH:+:$PYTHONPATH}"',
353
+ "PYTHONNOUSERSITE=1",
354
+ "export PYTHONPATH PYTHONNOUSERSITE",
355
+ `exec "$here/python/bin/python3" "$here/app/${script}" "$@"`,
356
+ "",
357
+ ].join("\n");
358
+ entries.push(tarEntry(`${top}/${request.name}`, Buffer.from(launcher), 0o755));
359
+ }
360
+ entries.push(Buffer.alloc(1024));
361
+ await (0, promises_1.mkdir)(request.outFolder, { recursive: true });
362
+ const archive = node_path_1.default.join(request.outFolder, `${top}.tar.gz`);
363
+ await (0, promises_1.writeFile)(archive, (0, node_zlib_1.gzipSync)(Buffer.concat(entries), { level: 9 }));
364
+ say(`Wrote ${node_path_1.default.basename(archive)} (${((await (0, promises_1.stat)(archive)).size / 1024 ** 2).toFixed(1)} MB): unpack it and run ${windows ? `${request.name}.cmd` : `./${request.name}`}.`);
365
+ return archive;
366
+ }
367
+ finally {
368
+ await (0, promises_1.rm)(scratch, { recursive: true, force: true });
369
+ }
370
+ }
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.likenessLine = likenessLine;
7
+ exports.chooseProject = chooseProject;
8
+ /*
9
+ Which project an unlinked folder's first save goes into, decided by the
10
+ person rather than by a name that happens to match.
11
+
12
+ Shared by `cbx submit` and a local history's first `cbx push`: both used to
13
+ name the project after the folder, join it if the account already had one
14
+ of that name, and start a new one otherwise, without a word either way.
15
+ */
16
+ const promises_1 = require("node:readline/promises");
17
+ const node_process_1 = __importDefault(require("node:process"));
18
+ const api_js_1 = require("./api.js");
19
+ const config_js_1 = require("./config.js");
20
+ const folder_match_js_1 = require("./folder_match.js");
21
+ const download_js_1 = require("../../cbx/src/core/download.js");
22
+ const ESC = String.fromCharCode(27);
23
+ const colour = node_process_1.default.stdout.isTTY && !node_process_1.default.env.NO_COLOR;
24
+ const paint = (code) => (value) => colour ? `${ESC}[${code}m${value}${ESC}[0m` : value;
25
+ const dim = paint("2");
26
+ const red = paint("31");
27
+ const accent = paint("33");
28
+ /**
29
+ * One line about a project an unlinked folder might be, with how much of it
30
+ * is here: the number that tells a stale copy from a separate project.
31
+ */
32
+ async function likenessLine(folder, like, width) {
33
+ const { project } = like;
34
+ let here = "";
35
+ if (project.versionCount) {
36
+ try {
37
+ const downloader = new download_js_1.Downloader(config_js_1.credentials);
38
+ const latest = (await downloader.versions(project.id))[0];
39
+ if (latest) {
40
+ const paths = (await downloader.files(project.id, latest.id)).map((file) => file.path);
41
+ here = ` · ${(0, folder_match_js_1.pathsPresent)(folder, paths)} of its ${paths.length} files are here at the same path`;
42
+ }
43
+ }
44
+ catch {
45
+ /* The list is still worth showing without the count. */
46
+ }
47
+ }
48
+ return (`${accent(project.slug.padEnd(width))} v${project.versionCount}` +
49
+ dim(`${here}${like.sameName ? " · same name as this folder" : ""}`));
50
+ }
51
+ /**
52
+ * `--into` and `--new` say it outright, which is what a script or an
53
+ * assistant must do. Without either, a folder whose name is like a project
54
+ * already on the account is asked about at a terminal and refused elsewhere,
55
+ * with the two commands that would settle it. A name like nothing on the
56
+ * account is a new project, as it always was.
57
+ *
58
+ * Returns the project to save into, a new project's name, or null to stop.
59
+ */
60
+ async function chooseProject(input) {
61
+ const { folder, name, into, startNew, command } = input;
62
+ if (into && startNew) {
63
+ console.error(red("Pass --into or --new, not both."));
64
+ return null;
65
+ }
66
+ if (into) {
67
+ const project = await (0, api_js_1.findProject)(into);
68
+ if (!project) {
69
+ console.error(red(`No project matching "${into}" on your account.`));
70
+ console.error(dim(` ${accent("cbx projects")} lists them.`));
71
+ return null;
72
+ }
73
+ return project;
74
+ }
75
+ const likes = (0, folder_match_js_1.similarProjects)(await (0, api_js_1.projects)(), name);
76
+ const taken = likes.find((like) => like.sameName);
77
+ if (startNew) {
78
+ if (!taken)
79
+ return { name };
80
+ console.error(red(`Your account already has a project called ${taken.project.slug}.`));
81
+ console.error(dim(" Save into it with ") + accent(`--into ${taken.project.slug}`) +
82
+ dim(", or give the new one another name with ") + accent("--name <name>") + dim("."));
83
+ return null;
84
+ }
85
+ if (!likes.length)
86
+ return { name };
87
+ const width = Math.max(...likes.map((like) => like.project.slug.length));
88
+ const lines = await Promise.all(likes.map((like) => likenessLine(folder, like, width)));
89
+ const interactive = Boolean(node_process_1.default.stdin.isTTY && node_process_1.default.stdout.isTTY);
90
+ const say = interactive ? console.log : console.error;
91
+ say(`This folder is not linked to a project, and your account has ${likes.length === 1 ? "one" : "some"} like it:`);
92
+ lines.forEach((line, at) => say(` ${interactive ? `${at + 1} ` : ""}${line}`));
93
+ if (!interactive) {
94
+ console.error(`\nNothing was sent. Say which:`);
95
+ console.error(` ${accent(`${command} --into ${likes[0].project.slug}`)} ${dim("save into that project")}`);
96
+ console.error(` ${accent(`${command} --new${taken ? " --name <another-name>" : ""}`)} ${dim("start a separate project")}`);
97
+ return null;
98
+ }
99
+ const reader = (0, promises_1.createInterface)({ input: node_process_1.default.stdin, output: node_process_1.default.stdout });
100
+ try {
101
+ const answer = (await reader.question(`\nSave into one of these (its number), start a new project (n), or stop (Enter): `)).trim().toLowerCase();
102
+ const picked = Number(answer);
103
+ if (Number.isInteger(picked) && picked >= 1 && picked <= likes.length) {
104
+ return likes[picked - 1].project;
105
+ }
106
+ if (answer !== "n" && answer !== "new")
107
+ return null;
108
+ if (!taken)
109
+ return { name };
110
+ const other = (await reader.question(`Name for the new project: `)).trim();
111
+ if (!other)
112
+ return null;
113
+ if (likes.some((like) => like.project.slug === (0, folder_match_js_1.slugFor)(other))) {
114
+ console.error(red(`${(0, folder_match_js_1.slugFor)(other)} is taken too.`));
115
+ return null;
116
+ }
117
+ return { name: other };
118
+ }
119
+ finally {
120
+ reader.close();
121
+ }
122
+ }
@@ -13,6 +13,7 @@ function uploadRequestFor(input) {
13
13
  message: input.message,
14
14
  projectName: input.projectName,
15
15
  repositoryId: input.repositoryId,
16
+ ...(input.joinExisting === false ? { joinExisting: false } : {}),
16
17
  baseVersionId: input.baseVersionId,
17
18
  ...(input.track ? { track: input.track } : {}),
18
19
  ...(input.allowIgnored ? { allowIgnored: true } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.33.1",
3
+ "version": "0.34.1",
4
4
  "description": "The command line for CodeRook (coderook.com): version control and project hosting for projects of any size. Every save is a compressed snapshot, private or public, never used to train AI.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://coderook.com",
@@ -20,7 +20,7 @@
20
20
  "gamedev"
21
21
  ],
22
22
  "engines": {
23
- "node": ">=20.11.0"
23
+ "node": ">=22.15.0"
24
24
  },
25
25
  "bin": {
26
26
  "cbx": "dist/cli/src/cli.js",