lecodes-cli 0.1.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.
Files changed (3) hide show
  1. package/README.md +45 -0
  2. package/dist/index.js +625 -0
  3. package/package.json +27 -0
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # lecodes-cli
2
+
3
+ Clone, edit and push [LeCodes](https://le.codes) projects from your machine, with full TypeScript
4
+ IntelliSense for the platform API in your editor.
5
+
6
+ The package is `lecodes-cli`; the command it installs is **`lecodes`**.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ npm i -g lecodes-cli # then run: lecodes <command>
12
+ # or without installing:
13
+ npx lecodes-cli <command>
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```sh
19
+ lecodes login # paste a token, or sign in with email/password
20
+ lecodes clone <project-uuid> myapp # download files + types into ./myapp
21
+ cd myapp
22
+ # …edit files in VS Code (IntelliSense works out of the box)…
23
+ lecodes status # see what changed
24
+ lecodes push -m "Tweak the menu" # push as a new checkpoint
25
+ ```
26
+
27
+ ## Commands
28
+
29
+ | Command | What it does |
30
+ | --- | --- |
31
+ | `login` | Store a personal access token in `~/.lecodes/config.json`. `--token <pat>` to use a token created in the web UI, `--api <url>` to set the server. |
32
+ | `clone <uuid\|url> [dir]` | Download a project's file tree + binary resources, plus `.d.ts` types and a `tsconfig.json` for IntelliSense. `--no-types` to skip types. |
33
+ | `status` | Show added / modified / moved / deleted files vs the last sync. |
34
+ | `pull` | Overwrite local files with the project's current remote state. `--force` to discard local edits. |
35
+ | `push` | Push all local changes as **one** new checkpoint. `-m`/`--message` for the message, `--force` to override the "server moved on" guard. |
36
+
37
+ ## How it works
38
+
39
+ - Project metadata and the path → asset map live in `.lecodes/` (commit it or not — it's like `.git`).
40
+ - A **push** sends your whole working tree to the server, which atomically rebuilds the project's
41
+ file tree (adds, edits, deletes, renames and moves all at once) and freezes a single checkpoint.
42
+ Renames/moves keep their history; unchanged binaries are never re-uploaded.
43
+ - Types are fetched from the server on `clone`/`pull`, so they always match the deployed runtime.
44
+
45
+ Config can also be supplied via the `LECODES_API` and `LECODES_TOKEN` environment variables (useful in CI).
package/dist/index.js ADDED
@@ -0,0 +1,625 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/commands/clone.ts
4
+ import { existsSync as existsSync2, mkdirSync as mkdirSync5, readdirSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+
7
+ // src/util.ts
8
+ import { createHash } from "node:crypto";
9
+ import { createInterface } from "node:readline";
10
+ var useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
11
+ var paint = (code, s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
12
+ var c = {
13
+ bold: (s) => paint("1", s),
14
+ dim: (s) => paint("2", s),
15
+ red: (s) => paint("31", s),
16
+ green: (s) => paint("32", s),
17
+ yellow: (s) => paint("33", s),
18
+ cyan: (s) => paint("36", s)
19
+ };
20
+ var log = (msg = "") => process.stdout.write(msg + `
21
+ `);
22
+ var info = (msg) => log(msg);
23
+ var success = (msg) => log(c.green("✓ ") + msg);
24
+ class CliError extends Error {
25
+ }
26
+ var sha256 = (data) => createHash("sha256").update(data).digest("hex");
27
+ var toKey = (path) => {
28
+ const parts = path.replace(/\\/g, "/").split("/").map((p) => p.trim()).filter((p) => p && p !== ".");
29
+ if (parts.some((p) => p === ".."))
30
+ throw new CliError(`Unsafe path: ${path}`);
31
+ return "/" + parts.join("/");
32
+ };
33
+ var prompt = (question, fallback) => new Promise((resolve) => {
34
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
35
+ const suffix = fallback ? c.dim(` (${fallback})`) : "";
36
+ rl.question(`${question}${suffix}: `, (answer) => {
37
+ rl.close();
38
+ resolve(answer.trim() || fallback || "");
39
+ });
40
+ });
41
+ var promptHidden = (question) => new Promise((resolve) => {
42
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
43
+ rl.question(`${question}: `, (answer) => {
44
+ rl.close();
45
+ process.stdout.write(`
46
+ `);
47
+ resolve(answer.trim());
48
+ });
49
+ rl._writeToOutput = () => {};
50
+ });
51
+ var parseArgs = (argv) => {
52
+ const out = { _: [], flags: {} };
53
+ for (let i = 0;i < argv.length; i++) {
54
+ const arg = argv[i];
55
+ if (arg.startsWith("--")) {
56
+ const eq = arg.indexOf("=");
57
+ if (eq >= 0) {
58
+ out.flags[arg.slice(2, eq)] = arg.slice(eq + 1);
59
+ continue;
60
+ }
61
+ const key = arg.slice(2);
62
+ const next = argv[i + 1];
63
+ if (next !== undefined && !next.startsWith("-")) {
64
+ out.flags[key] = next;
65
+ i++;
66
+ } else
67
+ out.flags[key] = true;
68
+ } else if (arg.startsWith("-") && arg.length > 1) {
69
+ const key = arg.slice(1);
70
+ const next = argv[i + 1];
71
+ if (next !== undefined && !next.startsWith("-")) {
72
+ out.flags[key] = next;
73
+ i++;
74
+ } else
75
+ out.flags[key] = true;
76
+ } else {
77
+ out._.push(arg);
78
+ }
79
+ }
80
+ return out;
81
+ };
82
+ var flagStr = (args, ...keys) => {
83
+ for (const key of keys) {
84
+ const v = args.flags[key];
85
+ if (typeof v === "string")
86
+ return v;
87
+ }
88
+ return;
89
+ };
90
+ var flagBool = (args, ...keys) => keys.some((key) => args.flags[key] === true || args.flags[key] === "true");
91
+
92
+ // src/api.ts
93
+ var errorMessage = (body, status) => {
94
+ if (typeof body === "string" && body)
95
+ return body;
96
+ if (body && typeof body === "object") {
97
+ const b = body;
98
+ if (typeof b.error === "string")
99
+ return b.error;
100
+ if (b.error && typeof b.error === "object") {
101
+ const first = Object.values(b.error)[0];
102
+ if (typeof first === "string")
103
+ return first;
104
+ if (first && typeof first === "object" && typeof first.message === "string") {
105
+ return first.message;
106
+ }
107
+ }
108
+ if (typeof b.message === "string")
109
+ return b.message;
110
+ }
111
+ return `Request failed with status ${status}`;
112
+ };
113
+ var apiRequest = async (apiUrl, path, opts = {}) => {
114
+ const headers = {};
115
+ const isForm = opts.body instanceof FormData;
116
+ if (opts.token)
117
+ headers["Authorization"] = "Bearer " + opts.token;
118
+ if (opts.body !== undefined && !isForm)
119
+ headers["Content-Type"] = "application/json";
120
+ let resp;
121
+ try {
122
+ resp = await fetch(`${apiUrl}/api${path}`, {
123
+ method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
124
+ headers,
125
+ body: opts.body === undefined ? undefined : isForm ? opts.body : JSON.stringify(opts.body)
126
+ });
127
+ } catch (e) {
128
+ throw new CliError(`Cannot reach ${apiUrl} — is the server URL correct and online?`);
129
+ }
130
+ const contentType = resp.headers.get("content-type") ?? "";
131
+ const parsed = contentType.startsWith("application/json") ? await resp.json() : contentType.startsWith("text/") ? await resp.text() : null;
132
+ if (resp.status >= 400)
133
+ throw new CliError(errorMessage(parsed, resp.status));
134
+ return parsed;
135
+ };
136
+ var login = (apiUrl, login2, password) => apiRequest(apiUrl, "/account/login", { body: { login: login2, password } });
137
+ var createAccessToken = (apiUrl, jwt, name) => apiRequest(apiUrl, "/account/tokens", { token: jwt, body: { name } });
138
+ var getAccount = (apiUrl, token) => apiRequest(apiUrl, "/account", { token });
139
+ var getProject = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}`, { token });
140
+ var getCommits = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}/commits`, { token });
141
+ var getTypes = (apiUrl) => apiRequest(apiUrl, "/types");
142
+ var uploadBlob = (apiUrl, token, uuid, name, bytes) => {
143
+ const form = new FormData;
144
+ form.append("file", new Blob([bytes]), name);
145
+ return apiRequest(apiUrl, `/projects/${uuid}/blobs`, { token, body: form });
146
+ };
147
+ var sync = (apiUrl, token, uuid, payload) => apiRequest(apiUrl, `/projects/${uuid}/sync`, { token, body: payload });
148
+ var downloadBlob = async (apiUrl, src) => {
149
+ const resp = await fetch(`${apiUrl}${src}`);
150
+ if (!resp.ok)
151
+ throw new CliError(`Failed to download ${src} (status ${resp.status})`);
152
+ return new Uint8Array(await resp.arrayBuffer());
153
+ };
154
+
155
+ // src/config.ts
156
+ import { homedir } from "node:os";
157
+ import { join } from "node:path";
158
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
159
+ var DEFAULT_API = "https://le.codes";
160
+ var CONFIG_DIR = join(homedir(), ".lecodes");
161
+ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
162
+ var loadConfig = () => {
163
+ let stored = {};
164
+ try {
165
+ stored = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
166
+ } catch {}
167
+ return {
168
+ apiUrl: process.env.LECODES_API ?? stored.apiUrl,
169
+ token: process.env.LECODES_TOKEN ?? stored.token
170
+ };
171
+ };
172
+ var saveConfig = (config) => {
173
+ mkdirSync(CONFIG_DIR, { recursive: true });
174
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
175
+ };
176
+ var normalizeApiUrl = (url) => url.trim().replace(/\/+$/, "");
177
+ var requireToken = (config) => {
178
+ if (!config.token)
179
+ throw new CliError("Not logged in. Run `lecodes login` first.");
180
+ return config.token;
181
+ };
182
+ var requireApiUrl = (config) => {
183
+ if (!config.apiUrl)
184
+ throw new CliError("No server URL configured. Run `lecodes login` first.");
185
+ return normalizeApiUrl(config.apiUrl);
186
+ };
187
+
188
+ // src/manifest.ts
189
+ import { dirname, join as join2 } from "node:path";
190
+ import { existsSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
191
+ var LECODES_DIR = ".lecodes";
192
+ var manifestPath = (root) => join2(root, LECODES_DIR, "manifest.json");
193
+ var findProjectRoot = (start) => {
194
+ let dir = start;
195
+ while (true) {
196
+ if (existsSync(manifestPath(dir)))
197
+ return dir;
198
+ const parent = dirname(dir);
199
+ if (parent === dir)
200
+ throw new CliError("Not inside a lecodes project (no .lecodes/manifest.json found).");
201
+ dir = parent;
202
+ }
203
+ };
204
+ var readManifest = (root) => {
205
+ try {
206
+ return JSON.parse(readFileSync2(manifestPath(root), "utf8"));
207
+ } catch {
208
+ throw new CliError("Could not read .lecodes/manifest.json — is this a lecodes project?");
209
+ }
210
+ };
211
+ var writeManifest = (root, manifest) => {
212
+ mkdirSync2(join2(root, LECODES_DIR), { recursive: true });
213
+ writeFileSync2(manifestPath(root), JSON.stringify(manifest, null, 2));
214
+ };
215
+
216
+ // src/project.ts
217
+ import { dirname as dirname2, join as join3 } from "node:path";
218
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
219
+ var kindOf = (type) => type === "file" ? "resource" : type;
220
+ var absOf = (root, key) => join3(root, ...key.replace(/^\//, "").split("/"));
221
+ var buildPaths = (assets) => {
222
+ const pathById = new Map;
223
+ for (const asset of assets) {
224
+ const parent = asset.parentId !== null ? pathById.get(asset.parentId) : undefined;
225
+ pathById.set(asset.id, `${parent ?? ""}/${asset.name}`);
226
+ }
227
+ return pathById;
228
+ };
229
+ var materializeAssets = async (root, project, apiUrl, token) => {
230
+ const pathById = buildPaths(project.assets);
231
+ const files = {};
232
+ for (const asset of project.assets) {
233
+ const key = pathById.get(asset.id);
234
+ const abs = absOf(root, key);
235
+ if (asset.type === "folder") {
236
+ mkdirSync3(abs, { recursive: true });
237
+ continue;
238
+ }
239
+ mkdirSync3(dirname2(abs), { recursive: true });
240
+ let sha;
241
+ if (asset.type === "file") {
242
+ if (!asset.file?.src)
243
+ continue;
244
+ const bytes = await downloadBlob(apiUrl, asset.file.src);
245
+ writeFileSync3(abs, bytes);
246
+ sha = sha256(bytes);
247
+ } else {
248
+ const text = asset.text ?? "";
249
+ writeFileSync3(abs, text);
250
+ sha = sha256(text);
251
+ }
252
+ files[key] = { id: asset.id, type: kindOf(asset.type), sha };
253
+ }
254
+ return files;
255
+ };
256
+
257
+ // src/types.ts
258
+ import { join as join4 } from "node:path";
259
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "node:fs";
260
+ var wrapGlobal = (text) => {
261
+ const marker = text.indexOf("declare global");
262
+ if (marker < 0)
263
+ return text;
264
+ const importLines = [];
265
+ const bodyLines = [];
266
+ for (const line of text.split(`
267
+ `)) {
268
+ if (/^\s*import\s.*\sfrom\s/.test(line))
269
+ importLines.push(line);
270
+ else
271
+ bodyLines.push(line);
272
+ }
273
+ const rest = bodyLines.join(`
274
+ `);
275
+ const open = rest.indexOf("{", rest.indexOf("declare global"));
276
+ const close = rest.lastIndexOf("}");
277
+ const preamble = rest.slice(0, rest.indexOf("declare global"));
278
+ const inner = rest.slice(open + 1, close);
279
+ const stripDeclare = (s) => s.replace(/(^|\n)([ \t]*)declare\s+/g, "$1$2");
280
+ const merged = stripDeclare(preamble) + `
281
+ ` + stripDeclare(inner);
282
+ return `${importLines.join(`
283
+ `)}
284
+
285
+ declare global {
286
+ ${merged}
287
+ }
288
+ `;
289
+ };
290
+ var TSCONFIG = {
291
+ compilerOptions: {
292
+ target: "ES2020",
293
+ module: "ESNext",
294
+ moduleResolution: "bundler",
295
+ lib: ["ES2020"],
296
+ types: [],
297
+ allowJs: true,
298
+ checkJs: false,
299
+ strict: false,
300
+ noEmit: true,
301
+ skipLibCheck: true
302
+ },
303
+ include: [`${LECODES_DIR}/types/**/*.d.ts`, "**/*.ts", "**/*.js"],
304
+ exclude: ["node_modules"]
305
+ };
306
+ var materializeTypes = async (root, apiUrl) => {
307
+ const bundle = await getTypes(apiUrl);
308
+ const typesDir = join4(root, LECODES_DIR, "types");
309
+ mkdirSync4(typesDir, { recursive: true });
310
+ for (const [name, text] of Object.entries(bundle)) {
311
+ const content = name === "global" ? wrapGlobal(text) : text;
312
+ writeFileSync4(join4(typesDir, `${name}.d.ts`), content);
313
+ }
314
+ };
315
+ var writeTsconfig = (root) => {
316
+ writeFileSync4(join4(root, "tsconfig.json"), JSON.stringify(TSCONFIG, null, 2));
317
+ };
318
+
319
+ // src/commands/clone.ts
320
+ var parseUuid = (input) => {
321
+ if (!input)
322
+ throw new CliError("Usage: lecodes clone <project-uuid|url> [directory]");
323
+ if (!input.includes("/"))
324
+ return input;
325
+ const noQuery = input.split(/[?#]/)[0].replace(/\/+$/, "");
326
+ return noQuery.slice(noQuery.lastIndexOf("/") + 1);
327
+ };
328
+ var slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
329
+ var clone = async (args) => {
330
+ const config = loadConfig();
331
+ const apiUrl = requireApiUrl(config);
332
+ const token = requireToken(config);
333
+ const uuid = parseUuid(args._[0]);
334
+ info(c.dim(`Fetching project ${uuid}…`));
335
+ const project = await getProject(apiUrl, token, uuid);
336
+ const dir = args._[1] ?? slug(project.name);
337
+ const root = resolve(dir);
338
+ if (existsSync2(root) && readdirSync(root).length > 0) {
339
+ throw new CliError(`Directory "${dir}" already exists and is not empty.`);
340
+ }
341
+ mkdirSync5(root, { recursive: true });
342
+ info(c.dim(`Writing ${project.assets.filter((a) => a.type !== "folder").length} files…`));
343
+ const files = await materializeAssets(root, project, apiUrl, token);
344
+ if (!flagBool(args, "no-types")) {
345
+ info(c.dim("Fetching type definitions…"));
346
+ await materializeTypes(root, apiUrl);
347
+ writeTsconfig(root);
348
+ }
349
+ const commits = await getCommits(apiUrl, token, uuid);
350
+ writeManifest(root, { uuid, apiUrl, name: project.name, baseHeadId: commits.headId, files });
351
+ success(`Cloned ${c.bold(project.name)} into ${c.bold(dir)}/`);
352
+ info(c.dim(` cd ${dir} — edit, then \`lecodes push -m "your message"\``));
353
+ };
354
+
355
+ // src/commands/login.ts
356
+ import { hostname } from "node:os";
357
+ var login2 = async (args) => {
358
+ const config = loadConfig();
359
+ const apiUrl = normalizeApiUrl(flagStr(args, "api") ?? await prompt("Server URL", config.apiUrl ?? DEFAULT_API));
360
+ let token = flagStr(args, "token");
361
+ if (!token) {
362
+ const email = flagStr(args, "email") ?? await prompt("Email");
363
+ const password = await promptHidden("Password");
364
+ info(c.dim("Authenticating…"));
365
+ const { accessToken } = await login(apiUrl, email, password);
366
+ const created = await createAccessToken(apiUrl, accessToken, `lecodes CLI (${hostname()})`);
367
+ token = created.token;
368
+ }
369
+ const account = await getAccount(apiUrl, token);
370
+ saveConfig({ apiUrl, token });
371
+ success(`Logged in as ${c.bold(account.email)} on ${apiUrl}`);
372
+ };
373
+
374
+ // src/commands/pull.ts
375
+ import { existsSync as existsSync3, rmSync } from "node:fs";
376
+ import { join as join6 } from "node:path";
377
+
378
+ // src/localFiles.ts
379
+ import { readdirSync as readdirSync2, readFileSync as readFileSync3 } from "node:fs";
380
+ import { join as join5, relative } from "node:path";
381
+ var SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
382
+ var SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".DS_Store"]);
383
+ var TEXT_EXTS = new Set([
384
+ "ts",
385
+ "tsx",
386
+ "js",
387
+ "jsx",
388
+ "mjs",
389
+ "cjs",
390
+ "json",
391
+ "svg",
392
+ "glsl",
393
+ "vert",
394
+ "frag",
395
+ "txt",
396
+ "md",
397
+ "css",
398
+ "html",
399
+ "xml",
400
+ "yml",
401
+ "yaml",
402
+ "csv"
403
+ ]);
404
+ var ext = (name) => {
405
+ const i = name.lastIndexOf(".");
406
+ return i < 0 ? "" : name.slice(i + 1).toLowerCase();
407
+ };
408
+ var classifyKind = (name) => {
409
+ const e = ext(name);
410
+ if (e === "mat")
411
+ return "shader";
412
+ if (e === "scene")
413
+ return "scene";
414
+ return TEXT_EXTS.has(e) ? "text" : "resource";
415
+ };
416
+ var isTextKind = (kind) => kind !== "resource";
417
+ var scanLocal = (root) => {
418
+ const files = [];
419
+ const dirs = [];
420
+ const walk = (dir) => {
421
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
422
+ if (entry.isDirectory()) {
423
+ if (SKIP_DIRS.has(entry.name))
424
+ continue;
425
+ const abs = join5(dir, entry.name);
426
+ dirs.push(toKey(relative(root, abs)));
427
+ walk(abs);
428
+ } else if (entry.isFile()) {
429
+ if (SKIP_FILES.has(entry.name))
430
+ continue;
431
+ const abs = join5(dir, entry.name);
432
+ const bytes = readFileSync3(abs);
433
+ files.push({ path: toKey(relative(root, abs)), absPath: abs, sha: sha256(bytes) });
434
+ }
435
+ }
436
+ };
437
+ walk(root);
438
+ return { files, dirs };
439
+ };
440
+ var diffLocal = (manifest, scan) => {
441
+ const added = [];
442
+ const modified = [];
443
+ const localByPath = new Map(scan.files.map((f) => [f.path, f]));
444
+ for (const file of scan.files) {
445
+ const known = manifest.files[file.path];
446
+ if (!known)
447
+ added.push(file);
448
+ else if (known.sha !== file.sha)
449
+ modified.push(file);
450
+ }
451
+ const deleted = Object.keys(manifest.files).filter((path) => !localByPath.has(path));
452
+ const moves = new Map;
453
+ const claimed = new Set;
454
+ for (const file of added) {
455
+ const from = deleted.find((path) => !claimed.has(path) && manifest.files[path].sha === file.sha);
456
+ if (from) {
457
+ moves.set(file.path, from);
458
+ claimed.add(from);
459
+ }
460
+ }
461
+ return { added, modified, deleted, moves };
462
+ };
463
+ var hasChanges = (diff) => diff.added.length + diff.modified.length + diff.deleted.length > 0;
464
+
465
+ // src/commands/pull.ts
466
+ var pull = async (args) => {
467
+ const root = findProjectRoot(process.cwd());
468
+ const manifest = readManifest(root);
469
+ const config = loadConfig();
470
+ const apiUrl = requireApiUrl(config);
471
+ const token = requireToken(config);
472
+ if (!flagBool(args, "force") && hasChanges(diffLocal(manifest, scanLocal(root)))) {
473
+ throw new CliError("You have local changes. Push them first, or run `lecodes pull --force` to discard them.");
474
+ }
475
+ info(c.dim("Fetching project…"));
476
+ const project = await getProject(apiUrl, token, manifest.uuid);
477
+ const remotePaths = new Set([...buildPaths(project.assets).entries()].filter(([id]) => project.assets.find((a) => a.id === id)?.type !== "folder").map(([, path]) => path));
478
+ for (const path of Object.keys(manifest.files)) {
479
+ if (!remotePaths.has(path)) {
480
+ const abs = join6(root, ...path.replace(/^\//, "").split("/"));
481
+ if (existsSync3(abs))
482
+ rmSync(abs);
483
+ }
484
+ }
485
+ const files = await materializeAssets(root, project, apiUrl, token);
486
+ if (!flagBool(args, "no-types")) {
487
+ await materializeTypes(root, apiUrl);
488
+ writeTsconfig(root);
489
+ }
490
+ const commits = await getCommits(apiUrl, token, manifest.uuid);
491
+ writeManifest(root, { ...manifest, name: project.name, baseHeadId: commits.headId, files });
492
+ success(`Pulled latest into ${c.bold(manifest.name)}.`);
493
+ };
494
+
495
+ // src/commands/push.ts
496
+ import { basename } from "node:path";
497
+ import { readFileSync as readFileSync4 } from "node:fs";
498
+ var push = async (args) => {
499
+ const root = findProjectRoot(process.cwd());
500
+ const manifest = readManifest(root);
501
+ const config = loadConfig();
502
+ const apiUrl = requireApiUrl(config);
503
+ const token = requireToken(config);
504
+ const scan = scanLocal(root);
505
+ const diff = diffLocal(manifest, scan);
506
+ if (!hasChanges(diff)) {
507
+ info(c.dim("Working tree clean — nothing to push."));
508
+ return;
509
+ }
510
+ if (!flagBool(args, "force") && manifest.baseHeadId !== null) {
511
+ const commits = await getCommits(apiUrl, token, manifest.uuid);
512
+ if (commits.headId !== manifest.baseHeadId) {
513
+ throw new CliError("The project changed on the server since your last sync. Run `lecodes pull` first, or `lecodes push --force` to overwrite.");
514
+ }
515
+ }
516
+ const message = (flagStr(args, "m", "message") ?? await prompt("Checkpoint message")) || "Update from lecodes CLI";
517
+ const sourceFor = (path) => manifest.files[path] ?? (diff.moves.has(path) ? manifest.files[diff.moves.get(path)] : undefined);
518
+ const files = [];
519
+ let uploaded = 0;
520
+ for (const file of scan.files) {
521
+ const source = sourceFor(file.path);
522
+ const kind = source?.type ?? classifyKind(basename(file.path));
523
+ const entry = { path: file.path, type: kind };
524
+ if (source)
525
+ entry.assetId = source.id;
526
+ if (isTextKind(kind)) {
527
+ entry.text = readFileSync4(file.absPath, "utf8");
528
+ } else if (!source || source.sha !== file.sha) {
529
+ const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename(file.path), readFileSync4(file.absPath));
530
+ entry.blobFileId = blob.id;
531
+ uploaded++;
532
+ }
533
+ files.push(entry);
534
+ }
535
+ const folders = scan.dirs.map((path) => ({ path }));
536
+ if (uploaded > 0)
537
+ info(c.dim(`Uploaded ${uploaded} binary file${uploaded === 1 ? "" : "s"}.`));
538
+ const res = await sync(apiUrl, token, manifest.uuid, { message, files, folders });
539
+ if (!res.created) {
540
+ info(c.dim("Server reported no changes."));
541
+ return;
542
+ }
543
+ const shaByPath = new Map(scan.files.map((f) => [f.path, f.sha]));
544
+ const newFiles = {};
545
+ for (const node of res.nodes) {
546
+ newFiles[node.path] = { id: node.id, type: node.type, sha: shaByPath.get(node.path) ?? "" };
547
+ }
548
+ writeManifest(root, { ...manifest, baseHeadId: res.headId, files: newFiles });
549
+ const counts = [
550
+ diff.added.length - diff.moves.size && `${diff.added.length - diff.moves.size} added`,
551
+ diff.modified.length && `${diff.modified.length} modified`,
552
+ diff.moves.size && `${diff.moves.size} moved`,
553
+ diff.deleted.length - diff.moves.size && `${diff.deleted.length - diff.moves.size} deleted`
554
+ ].filter(Boolean).join(", ");
555
+ success(`Pushed checkpoint${res.seq ? ` #${res.seq}` : ""}${counts ? ` (${counts})` : ""}: ${message}`);
556
+ };
557
+
558
+ // src/commands/status.ts
559
+ var status = async (_args) => {
560
+ const root = findProjectRoot(process.cwd());
561
+ const manifest = readManifest(root);
562
+ const diff = diffLocal(manifest, scanLocal(root));
563
+ info(`Project ${c.bold(manifest.name)} (${manifest.uuid})`);
564
+ if (!hasChanges(diff)) {
565
+ info(c.dim("Working tree clean — nothing to push."));
566
+ return;
567
+ }
568
+ for (const f of diff.added) {
569
+ if (!diff.moves.has(f.path))
570
+ log(c.green(` added ${f.path}`));
571
+ }
572
+ for (const f of diff.modified)
573
+ log(c.yellow(` modified ${f.path}`));
574
+ for (const [to, from] of diff.moves)
575
+ log(c.cyan(` moved ${from} → ${to}`));
576
+ const movedFrom = new Set(diff.moves.values());
577
+ for (const path of diff.deleted) {
578
+ if (!movedFrom.has(path))
579
+ log(c.red(` deleted ${path}`));
580
+ }
581
+ };
582
+
583
+ // src/index.ts
584
+ var HELP = `${c.bold("lecodes")} — clone, edit and push LeCodes projects from your machine.
585
+
586
+ ${c.bold("Usage:")} lecodes <command> [options]
587
+
588
+ ${c.bold("Commands:")}
589
+ login Store a personal access token (prompts email/password)
590
+ --token <pat> use an existing token instead
591
+ --api <url> set the server URL
592
+ clone <uuid|url> [dir] Download a project's files + types into a new folder
593
+ --no-types skip the .d.ts / tsconfig generation
594
+ status Show local changes vs the last sync
595
+ pull Overwrite local files with the project's current state
596
+ --force discard uncommitted local changes
597
+ push Push local changes as one new checkpoint
598
+ -m, --message checkpoint message
599
+ --force push even if the server moved on
600
+
601
+ Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKEN).`;
602
+ var commands = { login: login2, clone, status, pull, push };
603
+ var main = async () => {
604
+ const argv = process.argv.slice(2);
605
+ const command = argv[0];
606
+ if (!command || command === "help" || command === "--help" || command === "-h") {
607
+ log(HELP);
608
+ return;
609
+ }
610
+ const run = commands[command];
611
+ if (!run) {
612
+ log(c.red(`Unknown command: ${command}`));
613
+ log(HELP);
614
+ process.exitCode = 1;
615
+ return;
616
+ }
617
+ await run(parseArgs(argv.slice(1)));
618
+ };
619
+ main().catch((e) => {
620
+ if (e instanceof CliError)
621
+ log(c.red("✗ ") + e.message);
622
+ else
623
+ log(c.red("✗ Unexpected error: ") + (e instanceof Error ? e.message : String(e)));
624
+ process.exitCode = 1;
625
+ });
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "lecodes-cli",
3
+ "version": "0.1.0",
4
+ "description": "Clone, edit and push LeCodes projects from your machine.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "lecodes",
9
+ "cli"
10
+ ],
11
+ "bin": {
12
+ "lecodes": "dist/index.js"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "scripts": {
22
+ "build": "bun build src/index.ts --target=node --outfile dist/index.js --banner=\"#!/usr/bin/env node\"",
23
+ "typecheck": "tsc --noEmit",
24
+ "dev": "bun run src/index.ts",
25
+ "prepublishOnly": "bun run build"
26
+ }
27
+ }