-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
5053 lines (4715 loc) · 232 KB
/
Copy pathserver.js
File metadata and controls
5053 lines (4715 loc) · 232 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Coding Drives — local project tracker. Single-file Express server: scans
// folders, persists status/notes, opens tools, runs robocopy backups.
import express from "express";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// When run inside Electron, electron.cjs points these at the user's writable
// userData folder so projects.json survives across portable .exe runs.
const DATA_DIR = process.env.PT_DATA_DIR || path.join(__dirname, "data");
const PUBLIC_DIR = path.join(__dirname, "public");
const ASSETS_DIR = path.join(__dirname, "assets");
const DS_OUT_DIR = process.env.PT_DS_DIR || path.join(PUBLIC_DIR, "ds");
// PT_CONFIG_PATH points at a different bundled-defaults file. Only the
// update-survival test sets it — it needs to stand in a "next version" whose
// defaults differ, without touching the real config.json in the working tree.
const CONFIG_PATH = process.env.PT_CONFIG_PATH || path.join(__dirname, "config.json");
const PROJECTS_DB = path.join(DATA_DIR, "projects.json");
// Scheduled tasks — recurring/one-off templates that fire concrete tasks onto
// projects. Its own file (parallel serialized queue) keeps the scheduler's
// frequent nextRunAt writes off the main projects.json contention path.
const SCHEDULES_DB = path.join(DATA_DIR, "schedules.json");
// Reference images attached to tasks live here, one file per task (named by
// task id). The spawned AI session reads them by absolute path.
const TASK_IMAGES_DIR = path.join(DATA_DIR, "task-images");
const DS_OUT_FILE = path.join(DS_OUT_DIR, "colors_and_type.css");
const USER_CONFIG_PATH = path.join(DATA_DIR, "user-config.json");
// ─── Config (bundled defaults + user overrides) ─────────────────────────────
// INVARIANT: config.json ships inside the install directory, which the NSIS
// installer wipes and replaces on every update. It therefore holds DEFAULTS
// ONLY — never user state, never an absolute path off this machine. Anything a
// user can change belongs in user-config.json under userData, which no install
// or uninstall touches. Put user state here and it silently reverts on the next
// version bump, for every user. See tests/update-survival.test.mjs, which fails
// if this invariant is broken.
function loadBundledConfig() {
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
}
function loadUserConfig() {
try { return JSON.parse(fs.readFileSync(USER_CONFIG_PATH, "utf8")); }
catch { return {}; }
}
// A usable status list needs at least one entry, and every entry needs a
// string id + label. Anything else (hand-edited user-config, a half-written
// file, a future shape change) falls back to the bundled defaults rather than
// booting into a filter row with no chips and no way back.
function isValidStatusList(list) {
if (!Array.isArray(list) || list.length === 0) return false;
const ids = new Set();
for (const s of list) {
if (!s || typeof s.id !== "string" || !s.id.trim()) return false;
if (typeof s.label !== "string" || !s.label.trim()) return false;
if (ids.has(s.id)) return false; // duplicate ids would alias in the filter
ids.add(s.id);
}
return true;
}
function loadConfig() {
const base = loadBundledConfig();
const user = loadUserConfig();
// Shallow merge — user overrides win for top-level keys.
const cfg = { ...base, ...user };
// extraProjectPaths is union'd so neither side wipes the other.
cfg.extraProjectPaths = Array.from(new Set([
...(base.extraProjectPaths || []),
...(user.extraProjectPaths || []),
]));
// Statuses. The shallow merge above already lets user-config carry a complete
// replacement list (Settings → Statuses writes one) — this just makes that
// explicit and refuses an unusable list.
if (!isValidStatusList(cfg.statuses)) cfg.statuses = base.statuses;
// statusOverrides predates the editable list — it patched label/color of the
// bundled ids back when that was the only way to customise them. Apply it
// ONLY when the user has no explicit list, so pre-existing tweaks still show
// for anyone who never opened the new screen. Once a list is saved it IS the
// source of truth: re-applying overrides on top would silently revert the
// user's own edit (rename "Done"→"Finished", boot, and an old override
// renaming it "Complete" would win).
if (!isValidStatusList(user.statuses) && user.statusOverrides && Array.isArray(cfg.statuses)) {
cfg.statuses = cfg.statuses.map((s) => {
const o = user.statusOverrides[s.id];
return o ? { ...s, ...(o.label ? { label: o.label } : {}), ...(o.color ? { color: o.color } : {}) } : s;
});
}
return cfg;
}
// Default backup destination — the user's Documents folder when nothing else is configured.
function defaultBackupPath() {
const home = process.env.USERPROFILE || process.env.HOME || "";
return path.join(home, "Documents", "Coding Drives Backups");
}
// Serialized read-merge-write for user-config.json — same reasoning as
// updateDB below. Concurrent writers are real (Settings save, card reorder,
// the projects fetch registering new arrivals in projectOrders): without the
// queue two callers read the same base object and the last write silently
// drops the other's change. `mutate` receives the freshest on-disk config and
// returns the object to persist.
let _userCfgQueue = Promise.resolve();
function updateUserConfig(mutate) {
const next = _userCfgQueue.then(async () => {
const merged = await mutate(loadUserConfig());
await fsp.mkdir(DATA_DIR, { recursive: true });
await fsp.writeFile(USER_CONFIG_PATH, JSON.stringify(merged, null, 2));
return merged;
});
// Keep the chain alive even if one write fails.
_userCfgQueue = next.catch(() => {});
return next;
}
function saveUserConfig(patch) {
return updateUserConfig((cur) => ({ ...cur, ...patch }));
}
// ─── Shared exclusion lists ─────────────────────────────────────────────────
// Heavy folders skipped by both backup and GitHub-prep mirrors. Robocopy /XD
// expects bare folder names, not paths.
const HEAVY_DIRS = [
"node_modules", ".next", "dist", "build", "out", ".turbo",
".vercel", "target", "Pods", ".gradle", ".dart_tool",
];
// Files that almost never belong in a public repo. Checked against basename.
const SECRET_FILE_PATTERNS = [
/^\.env(\..+)?$/i,
/\.pem$/i, /\.key$/i, /\.pfx$/i, /\.p12$/i,
/^id_(rsa|ed25519|ecdsa|dsa)$/i,
/^firebase-adminsdk.*\.json$/i,
/^service-account.*\.json$/i,
/^\.npmrc$/i,
/^credentials(\.json|\.txt)?$/i,
];
function matchesSecret(basename) {
return SECRET_FILE_PATTERNS.some((re) => re.test(basename));
}
// ─── Slug helpers (round-trippable folder paths) ────────────────────────────
function toSlug(absPath) {
return Buffer.from(absPath, "utf8").toString("base64url");
}
function fromSlug(slug) {
return Buffer.from(slug, "base64url").toString("utf8");
}
// ─── Status DB ──────────────────────────────────────────────────────────────
async function ensureDataDir() {
await fsp.mkdir(DATA_DIR, { recursive: true });
if (!fs.existsSync(PROJECTS_DB)) await fsp.writeFile(PROJECTS_DB, "{}");
}
// ─── Schema version + migrations ────────────────────────────────────────────
// The version lives in a sidecar (data/schema.json), deliberately NOT inside
// projects.json. projects.json is a bare slug→project map that several call
// sites iterate with Object.keys(); wrapping it in a { schemaVersion, projects }
// envelope would mean any older build reading this data back sees the envelope
// keys as project slugs. Users do roll back. A sidecar keeps projects.json
// readable by every past and future build — old builds just ignore a file they
// don't know about.
//
// Version 0 = data written before this framework existed (no sidecar).
const SCHEMA_PATH = path.join(DATA_DIR, "schema.json");
const SCHEMA_VERSION = 1;
// Status ids renamed across versions. Adding an entry here is NOT enough to
// make it run — migrations are version-gated and run once, not every boot, so
// a new rename also needs a MIGRATIONS entry and a SCHEMA_VERSION bump.
const STATUS_MIGRATIONS = {
idea: "in-progress", // "Idea" was removed; default is now In Progress
paused: "on-hold", // "Paused" renamed to "On Hold"
};
// Ordered by `to`. Each run(db) mutates the bare map in place and returns
// whether it changed anything. Every migration must be safe to re-run: a crash
// between writeDB() and writeSchemaVersion() replays it on the next boot.
const MIGRATIONS = [
{
to: 1,
name: "rename legacy status ids",
run(db) {
let changed = false;
for (const slug of Object.keys(db)) {
const cur = db[slug]?.status;
if (cur && STATUS_MIGRATIONS[cur]) {
db[slug].status = STATUS_MIGRATIONS[cur];
changed = true;
}
}
return changed;
},
},
];
function readSchemaVersion() {
try { return JSON.parse(fs.readFileSync(SCHEMA_PATH, "utf8")).projects ?? 0; }
catch { return 0; } // no sidecar = pre-framework data
}
async function writeSchemaVersion(v) {
await ensureDataDir();
await fsp.writeFile(SCHEMA_PATH, JSON.stringify({ projects: v }, null, 2));
}
// Deleting a status from Settings → Statuses strands every project still
// sitting in it: the id matches no chip, so those projects disappear from the
// filter row entirely. Move them to `fallback` (the first remaining status)
// so a delete can never lose track of a project. Returns how many moved.
async function reassignOrphanedStatuses(statuses) {
const valid = new Set(statuses.map((s) => s.id));
const fallback = statuses[0].id;
let moved = 0;
// Through updateDB, NOT a raw readDB→writeDB: this is a full-DB
// read-modify-write, and running it outside the serialized queue could
// write back a stale snapshot over a status flip / task change / staleness
// sweep landing concurrently — silently reverting that other write.
await updateDB((db) => {
for (const slug of Object.keys(db)) {
const cur = db[slug]?.status;
if (cur && !valid.has(cur)) {
db[slug].status = fallback;
moved++;
}
}
});
if (moved) {
console.log(`[statuses] moved ${moved} project(s) to "${fallback}" after a status was removed`);
}
return moved;
}
// Runs on boot, before the server listens. Data written by an OLDER build gets
// upgraded here. Data written by a NEWER build is left alone rather than
// downgraded — rewriting fields this build doesn't understand would drop them.
async function migrateDB() {
const from = readSchemaVersion();
if (from >= SCHEMA_VERSION) return;
const db = await readDB();
let changed = false;
for (const m of MIGRATIONS) {
if (m.to <= from) continue;
const did = await m.run(db);
if (did) changed = true;
console.log(`[migrate] v${from} → v${m.to}: ${m.name}${did ? "" : " (nothing to do)"}`);
}
// Data first, stamp second. A crash in between replays the migrations next
// boot, which is safe because each is idempotent. Stamping first would skip
// them and leave the data half-upgraded.
if (changed) await writeDB(db);
await writeSchemaVersion(SCHEMA_VERSION);
}
async function readDB() {
await ensureDataDir();
try { return JSON.parse(await fsp.readFile(PROJECTS_DB, "utf8")); }
catch { return {}; }
}
async function writeDB(db) {
await ensureDataDir();
await fsp.writeFile(PROJECTS_DB, JSON.stringify(db, null, 2));
}
// Serialized read-modify-write for projects.json. Long-running operations
// (backup, github publish) can complete out of order with quick endpoint
// writes (e.g. status flips); without this queue the slow op's readDB()
// snapshot would clobber the fast op's writeDB(). Mutator can be sync or
// async; mutating `db` in place is fine. Returns the final db.
let _dbWriteQueue = Promise.resolve();
function updateDB(mutator) {
const next = _dbWriteQueue.then(async () => {
const db = await readDB();
await mutator(db);
await writeDB(db);
return db;
});
// Keep the chain alive even if a mutator throws — otherwise one rejected
// promise poisons every subsequent updateDB call.
_dbWriteQueue = next.catch(() => {});
return next;
}
// ─── Design system CSS sync ─────────────────────────────────────────────────
// Order of preference for the colors/type CSS that powers the app's theme:
// 1. cfg.designSystemCss — an external path the user may have set in config
// 2. assets/design-system.css — the bundled default that ships with the repo
// 3. a stub so the app at least boots
const BUNDLED_DS_CSS = path.join(ASSETS_DIR, "design-system.css");
async function syncDesignSystem(cfg) {
await fsp.mkdir(DS_OUT_DIR, { recursive: true });
if (cfg.designSystemCss) {
try {
const css = await fsp.readFile(cfg.designSystemCss, "utf8");
await fsp.writeFile(DS_OUT_FILE, css);
console.log("[ds] synced colors_and_type.css from", cfg.designSystemCss);
return;
} catch (err) {
console.warn("[ds] external design system CSS not found at", cfg.designSystemCss);
}
}
try {
const css = await fsp.readFile(BUNDLED_DS_CSS, "utf8");
await fsp.writeFile(DS_OUT_FILE, css);
console.log("[ds] using bundled design-system.css");
return;
} catch (err) {
if (!fs.existsSync(DS_OUT_FILE)) {
await fsp.writeFile(DS_OUT_FILE, "/* fallback — design system not found */\n");
console.warn("[ds] no bundled or external design system CSS found");
}
}
}
// ─── Language detection (GitHub-Linguist-lite) ──────────────────────────────
// Walks the project tree, counts bytes per language by file extension, returns
// a GitHub-style ranked list. Deliberately approximate — we don't tokenise
// shebangs or parse heredocs — but accurate enough to flag a Next.js+Python
// repo as "TypeScript 62% · Python 28% · CSS 10%" instead of just "Next.js".
const LANG_EXT = {
// JS / TS
".js": "JavaScript", ".jsx": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript",
".ts": "TypeScript", ".tsx": "TypeScript", ".mts": "TypeScript", ".cts": "TypeScript",
// File-based frameworks GitHub treats as their own language
".vue": "Vue", ".svelte": "Svelte", ".astro": "Astro",
// Web markup / styling
".html": "HTML", ".htm": "HTML",
".css": "CSS",
".scss": "SCSS", ".sass": "SCSS",
".less": "Less",
".styl": "Stylus",
// Backend
".py": "Python", ".pyi": "Python",
".rs": "Rust",
".go": "Go",
".rb": "Ruby", ".rake": "Ruby",
".php": "PHP",
".java": "Java",
".kt": "Kotlin", ".kts": "Kotlin",
".scala": "Scala",
".cs": "C#",
".fs": "F#", ".fsx": "F#",
".cpp": "C++", ".cc": "C++", ".cxx": "C++", ".hpp": "C++", ".hh": "C++", ".hxx": "C++",
".c": "C", ".h": "C",
".swift": "Swift",
".m": "Objective-C", ".mm": "Objective-C++",
".dart": "Dart",
".lua": "Lua",
".pl": "Perl", ".pm": "Perl",
".r": "R",
".jl": "Julia",
".ex": "Elixir", ".exs": "Elixir",
".erl": "Erlang",
".hs": "Haskell",
".clj": "Clojure", ".cljs": "Clojure",
".zig": "Zig",
".nim": "Nim",
".sol": "Solidity",
// Shell / scripts
".sh": "Shell", ".bash": "Shell", ".zsh": "Shell", ".fish": "Shell",
".ps1": "PowerShell", ".psm1": "PowerShell", ".psd1": "PowerShell",
".bat": "Batchfile", ".cmd": "Batchfile",
// Data / config-y but still useful signal
".sql": "SQL",
".graphql": "GraphQL", ".gql": "GraphQL",
".md": "Markdown", ".mdx": "MDX",
".tex": "TeX",
".dockerfile": "Dockerfile",
};
// Special-case filenames without extensions that should still count.
const LANG_BY_BASENAME = {
"Dockerfile": "Dockerfile",
"Makefile": "Makefile",
"GNUmakefile": "Makefile",
"Rakefile": "Ruby",
"Gemfile": "Ruby",
};
// Mirrors github/linguist's published colours. Keeps the language strip looking
// like a github.com repo card instead of a uniform grey.
// Language palette tuned for the dark card surface used by `.lang-badge`.
// Each badge renders as:
// background = color @ 15% alpha
// border = color @ 55% alpha
// text = full color
// which means any hex with Rec. 709 luma below ~110 falls into a band where
// the border and label disappear into the surface. Linguist's canonical
// palette (the source many of these started from) was designed for white
// chips on github.com and ships several values that just don't read on
// dark. Those have been lifted into a luma ≥ ~125 range while keeping each
// language's hue identity intact — TypeScript stays sky-blue, Python stays
// steel-blue, Ruby stays red, etc. — so the row still reads as the
// expected language colors, just legibly.
const LANG_COLORS = {
"JavaScript": "#f1e05a",
"TypeScript": "#4d96d6",
"Python": "#79b1d6",
"Rust": "#dea584",
"Go": "#00ADD8",
"Ruby": "#e57373",
"PHP": "#8b9bdc",
"Java": "#e8a652",
"Kotlin": "#A97BFF",
"Scala": "#e0567a",
"C": "#a0a0a8",
"C++": "#f34b7d",
"C#": "#5cba47",
"F#": "#b845fc",
"Swift": "#F05138",
"Objective-C": "#438eff",
"Objective-C++": "#6866fb",
"Dart": "#00B4AB",
"HTML": "#e34c26",
"CSS": "#a78bfa",
"SCSS": "#c6538c",
"Less": "#6a86c5",
"Stylus": "#ff6347",
"Vue": "#41b883",
"Svelte": "#ff3e00",
"Astro": "#ff5d01",
"Shell": "#89e051",
"PowerShell": "#5e8ed4",
"Batchfile": "#C1F12E",
"Lua": "#7e7eff",
"Perl": "#0298c3",
"R": "#198CE7",
"Julia": "#a270ba",
"Elixir": "#b08fc0",
"Erlang": "#d870b8",
"Haskell": "#a08bc6",
"Clojure": "#db5855",
"Zig": "#ec915c",
"Nim": "#ffc200",
"Solidity": "#d18966",
"Markdown": "#60a5fa",
"MDX": "#fcb32c",
"SQL": "#e38c00",
"GraphQL": "#ed5fbf",
"TeX": "#7eb247",
"Dockerfile": "#7faab8",
"Makefile": "#7fb84e",
};
// Directories never recursed into. Two camps: vendored deps (node_modules,
// venv, vendor) and build outputs (dist, .next, target). Skipping these is the
// difference between a 30s scan and a 300ms one.
const LANG_IGNORE_DIRS = new Set([
"node_modules", "bower_components", "jspm_packages",
"dist", "build", "out", "bin", "obj", "target", "Pods", "DerivedData",
".next", ".nuxt", ".svelte-kit", ".astro", ".turbo", ".vercel", ".vite",
".cache", ".parcel-cache", "coverage", ".nyc_output",
".git", ".hg", ".svn",
"venv", ".venv", "env", ".env", "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache",
".tox", ".eggs",
"vendor", ".gradle", ".idea", ".vscode", ".dart_tool", ".pub-cache",
".expo", ".pnp", ".yarn",
"node_modules.nosync",
]);
// Files we explicitly skip — lockfiles, minified bundles, source maps, vendored
// libs. Including these inflates JavaScript/JSON byte counts and pushes real
// source down the chart.
const LANG_IGNORE_FILENAMES = new Set([
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json",
"bun.lockb", "deno.lock",
"Cargo.lock", "poetry.lock", "Pipfile.lock", "composer.lock", "Gemfile.lock",
"go.sum",
]);
const LANG_IGNORE_SUFFIXES = [
".min.js", ".min.css", ".map", ".bundle.js", ".chunk.js",
];
// Hard cap on the walk. The user has many drives — without this, a stray
// monorepo with a vendored Chromium would burn seconds per refresh. mtime
// caching means we pay this once per project until something changes.
const LANG_MAX_FILES = 8000;
const LANG_MAX_BYTES = 80 * 1024 * 1024;
const LANG_MAX_DEPTH = 12;
function languageForFile(name) {
if (LANG_BY_BASENAME[name]) return LANG_BY_BASENAME[name];
// Dockerfile.dev, Dockerfile.prod, etc.
if (name.startsWith("Dockerfile")) return "Dockerfile";
for (const suf of LANG_IGNORE_SUFFIXES) {
if (name.endsWith(suf)) return null;
}
const ext = path.extname(name).toLowerCase();
if (!ext) return null;
return LANG_EXT[ext] || null;
}
async function detectLanguages(dir) {
const bytesByLang = new Map();
let filesSeen = 0;
let bytesSeen = 0;
let truncated = false;
// Files within a directory are stat-ed in parallel; subdirectories also
// recurse concurrently. The mutating counters (filesSeen / bytesSeen /
// truncated) are touched only after each await resolves and JS is
// single-threaded so the reads are race-free. Without this, a project with
// twenty src/ subdirs serialises the entire walk and a cold scan blocks
// the event loop for seconds.
async function walk(d, depth) {
if (truncated || depth > LANG_MAX_DEPTH) return;
let entries;
try { entries = await fsp.readdir(d, { withFileTypes: true }); }
catch { return; }
const files = [];
const subdirs = [];
for (const ent of entries) {
const name = ent.name;
if (ent.isSymbolicLink()) continue;
if (ent.isDirectory()) {
if (LANG_IGNORE_DIRS.has(name)) continue;
// Skip dotted dirs by default — .github is fine (workflows are YAML
// we'd skip anyway), .storybook etc. usually mirror src so dropping
// them avoids double-counting.
if (name.startsWith(".") && name !== ".github") continue;
subdirs.push(name);
continue;
}
if (!ent.isFile()) continue;
if (LANG_IGNORE_FILENAMES.has(name)) continue;
const lang = languageForFile(name);
if (!lang) continue;
files.push({ name, lang });
}
await Promise.all(files.map(async ({ name, lang }) => {
if (truncated) return;
let size = 0;
try { size = (await fsp.stat(path.join(d, name))).size; } catch { return; }
if (truncated) return;
bytesByLang.set(lang, (bytesByLang.get(lang) || 0) + size);
filesSeen++;
bytesSeen += size;
if (filesSeen >= LANG_MAX_FILES || bytesSeen >= LANG_MAX_BYTES) truncated = true;
}));
if (truncated) return;
await Promise.all(subdirs.map((name) => walk(path.join(d, name), depth + 1)));
}
await walk(dir, 0);
const total = [...bytesByLang.values()].reduce((a, b) => a + b, 0);
if (total === 0) return { languages: [], truncated };
const languages = [...bytesByLang.entries()]
.map(([name, bytes]) => ({
name,
bytes,
pct: bytes / total,
color: LANG_COLORS[name] || "#8b8b8b",
}))
.sort((a, b) => b.bytes - a.bytes);
return { languages, truncated };
}
// ─── Project scanner + stack detection ──────────────────────────────────────
async function detectStack(dir) {
const has = (f) => fs.existsSync(path.join(dir, f));
const indicators = {
git: has(".git"),
claude: has(".claude") || has("CLAUDE.md"),
vercel: has(".vercel") || has("vercel.json") || has("vercel.ts"),
env: has(".env") || has(".env.local"),
};
// Detection runs in priority order — the first stack listed is the
// "primary" badge. We also collect every other matching stack so
// polyglot projects (Tauri = Rust + Node, Electron with Python sidecar,
// Next.js + Python backend, etc.) don't silently lose their secondary
// identity. The renderer shows up to two badges per card.
const stacks = [];
const add = (s) => { if (s && !stacks.includes(s)) stacks.push(s); };
// Node family — `next.config.*` wins over plain package.json. Inside
// package.json we still inspect deps so a React/Vite/Express project
// gets a more specific label than just "Node".
if (has("next.config.js") || has("next.config.ts") || has("next.config.mjs")) {
add("Next.js");
} else if (has("package.json")) {
try {
const pkg = JSON.parse(await fsp.readFile(path.join(dir, "package.json"), "utf8"));
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
if (deps.next) add("Next.js");
else if (deps.react) add("React");
else if (deps.express || deps.hono || deps.fastify) add("Node API");
else if (deps.vite) add("Vite");
else add("Node");
} catch { add("Node"); }
}
if (has("Cargo.toml")) add("Rust");
if (has("pyproject.toml") || has("requirements.txt")) add("Python");
if (has("go.mod")) add("Go");
if (has("pubspec.yaml")) add("Flutter");
// index.html alone is only meaningful when nothing else matched —
// otherwise it's just a Next.js / Vite public/ page.
if (stacks.length === 0 && has("index.html")) add("Static");
if (stacks.length === 0) add("Unknown");
return { stack: stacks[0], stacks, indicators };
}
// Normalises a raw remote URL from .git/config to a clean
// "https://github.com/<owner>/<repo>" form. Returns null if not a GitHub URL.
// Handles SSH (git@github.com:owner/repo.git), HTTPS (https://github.com/...
// optional .git suffix), and the rarely-used ssh:// scheme.
function normalizeGithubUrl(raw) {
if (typeof raw !== "string") return null;
const u = raw.trim();
let m;
// git@github.com:owner/repo(.git)?
if ((m = u.match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i))) {
return `https://github.com/${m[1]}/${m[2]}`;
}
// https://github.com/owner/repo(.git)? or http://...
if ((m = u.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i))) {
return `https://github.com/${m[1]}/${m[2]}`;
}
// ssh://git@github.com/owner/repo(.git)?
if ((m = u.match(/^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i))) {
return `https://github.com/${m[1]}/${m[2]}`;
}
return null;
}
// Reads .git/config (synchronously — describeProject runs many of these in
// the scan loop and a sync read of a tiny file is cheaper than the async
// overhead). Returns the normalised github URL of the "origin" remote, or
// null if the project has no .git, no origin, or origin isn't a github URL.
function detectGithubUrl(dir) {
const cfg = path.join(dir, ".git", "config");
if (!fs.existsSync(cfg)) return null;
try {
const txt = fs.readFileSync(cfg, "utf8");
// Match the "origin" remote section and pull its url=. The [\s\S]*? lazy
// body lets us span the few lines between [remote "origin"] and url=.
const m = txt.match(/\[remote "origin"\][\s\S]*?url\s*=\s*(\S+)/);
return m ? normalizeGithubUrl(m[1]) : null;
} catch { return null; }
}
// Local .git/config tells us where origin *was*, not whether it still
// exists on github.com. Without this check, deleting a repo on GitHub
// leaves a stale "Visit" chip on the card forever. We hit
// api.github.com/repos/{owner}/{repo} (with the gh CLI token if installed,
// so private repos resolve correctly) and cache the result. TTLs are
// asymmetric on purpose: hold "exists" for 30 min to avoid rate limits on
// repeated refreshes, but only hold "gone" for 5 min so a re-publish
// recovers the chip quickly.
const GH_VERIFY_TTL_OK_MS = 30 * 60 * 1000;
const GH_VERIFY_TTL_GONE_MS = 5 * 60 * 1000;
const ghVerifyCache = new Map(); // url -> { exists: true|false|null, until: ms }
let cachedGhToken = { value: null, until: 0 };
async function getGhTokenCached() {
const now = Date.now();
if (cachedGhToken.until > now) return cachedGhToken.value;
let tok = null;
try {
const r = await runCapture("gh", ["auth", "token"]);
if (r.code === 0) tok = r.stdout.trim() || null;
} catch {}
cachedGhToken = { value: tok, until: now + 5 * 60 * 1000 };
return tok;
}
async function verifyGithubUrl(url) {
const m = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)\/?$/);
if (!m) return null;
const apiUrl = `https://api.github.com/repos/${m[1]}/${m[2]}`;
const headers = { "User-Agent": "Coding-Drives", "Accept": "application/vnd.github+json" };
const tok = await getGhTokenCached();
if (tok) headers["Authorization"] = `Bearer ${tok}`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 6000);
try {
const res = await fetch(apiUrl, { method: "GET", headers, signal: ctrl.signal });
if (res.status === 404) return false;
if (res.status >= 200 && res.status < 400) return true;
// 401/403/5xx — inconclusive; don't drop the chip on a transient blip.
return null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
// Walks the merged project list, drops the githubUrl on any project whose
// remote 404s, and tags it with githubMissing:true so the renderer can
// optionally surface a "repo not found — re-publish?" hint.
async function annotateGithubExistence(projects) {
const now = Date.now();
const work = [];
for (const p of projects) {
if (!p.githubUrl) continue;
const cached = ghVerifyCache.get(p.githubUrl);
if (cached && cached.until > now) {
if (cached.exists === false) { p.githubMissing = true; p.githubUrl = null; }
continue;
}
const url = p.githubUrl;
work.push((async () => {
const exists = await verifyGithubUrl(url);
const ttl = exists === false ? GH_VERIFY_TTL_GONE_MS : GH_VERIFY_TTL_OK_MS;
ghVerifyCache.set(url, { exists, until: Date.now() + ttl });
if (exists === false) { p.githubMissing = true; p.githubUrl = null; }
})());
}
if (work.length) { try { await Promise.all(work); } catch {} }
}
// Stack detection cache. Keyed by full project path; invalidates when the
// folder's mtime changes (a new manifest file or a touched config). Without
// this, /api/projects re-runs ~10-15 sync fs.existsSync per project on every
// poll — quickly hundreds of stat calls per refresh once the user has 50+
// projects, all on disks that may be spinning rust.
const _stackCache = new Map(); // path → { mtime, cachedAt, stack, stacks, indicators, languages, languagesTruncated }
// On Windows, a directory's mtime only updates when entries are added or
// removed *directly* inside it — editing src/index.ts two levels deep doesn't
// touch the project root's mtime, so a pure mtime-keyed cache silently serves
// stale language stats. Pair it with a short TTL so deep edits get picked up
// on the next poll without forcing a per-refresh re-walk.
const STACK_CACHE_TTL_MS = 5 * 60 * 1000;
async function describeProject(full, root, source) {
let stat;
try { stat = await fsp.stat(full); } catch { return null; }
if (!stat.isDirectory()) return null;
const cached = _stackCache.get(full);
let stack, stacks, indicators, languages, languagesTruncated;
const fresh = cached
&& cached.mtime === stat.mtimeMs
&& cached.languages
&& (Date.now() - (cached.cachedAt || 0)) < STACK_CACHE_TTL_MS;
if (fresh) {
({ stack, stacks, indicators, languages, languagesTruncated } = cached);
} else {
({ stack, stacks, indicators } = await detectStack(full));
({ languages, truncated: languagesTruncated } = await detectLanguages(full));
_stackCache.set(full, {
mtime: stat.mtimeMs,
cachedAt: Date.now(),
stack, stacks, indicators, languages, languagesTruncated,
});
}
// Distinguish a genuinely empty folder (brand-new project, nothing in it)
// from a folder with content whose language just couldn't be detected —
// the card hides the language row for the former and shows a "No code
// detected" chip for the latter. Top-level readdir only; cheap next to the
// stat + stack walk above.
let empty = false;
try { empty = (await fsp.readdir(full)).length === 0; } catch {}
return {
slug: toSlug(full),
name: path.basename(full),
path: full,
root,
source, // "scan" | "extra"
stack,
stacks,
indicators,
languages,
languagesTruncated,
empty,
githubUrl: indicators.git ? detectGithubUrl(full) : null,
mtime: stat.mtimeMs,
};
}
async function scanProjects(cfg) {
const exclude = new Set(cfg.excludeFolders || []);
const seen = new Set();
const out = [];
// Scan root paths. Each root's children are describe-d in parallel so a
// cold cache (every project does fsp.stat + fsp.readdir + per-file size
// stats for language detection) doesn't serialise into a multi-second
// event-loop stall that blocks unrelated requests like status flips.
// Roots themselves stay sequential — they're typically 1-2 paths and
// serial reads avoid hammering different physical disks at once.
for (const root of cfg.scanPaths || []) {
let entries = [];
try { entries = await fsp.readdir(root, { withFileTypes: true }); }
catch (err) { console.warn("[scan] cannot read", root, err.message); continue; }
const candidates = entries.filter((ent) =>
!ent.name.startsWith(".") && !exclude.has(ent.name)
);
const descs = await Promise.all(
candidates.map((ent) => describeProject(path.join(root, ent.name), root, "scan"))
);
for (const desc of descs) {
if (!desc) continue;
const key = desc.path.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(desc);
}
}
// Manually-added projects living outside the scan roots. Also parallel —
// same reasoning as above, with the added wrinkle that extras may live on
// entirely different drives so concurrent IO is even more beneficial.
const extras = (cfg.extraProjectPaths || []).filter((full) => !seen.has(full.toLowerCase()));
const extraDescs = await Promise.all(
extras.map((full) => describeProject(full, path.dirname(full), "extra"))
);
for (const desc of extraDescs) {
if (!desc) continue;
const key = desc.path.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(desc);
}
out.sort((a, b) => b.mtime - a.mtime);
return out;
}
// ─── Express app ────────────────────────────────────────────────────────────
const app = express();
app.use(express.json({ limit: "1mb" }));
// Task report callbacks arrive from the AI CLI as `curl --data-urlencode`
// (form-encoded) because that's the most quote-proof shape to put inside an
// injected prompt — accept it alongside JSON.
app.use(express.urlencoded({ extended: false }));
// No-cache headers on every static asset. Electron's embedded Chromium will
// otherwise hold onto cached app.css / app.js / index.html across relaunches
// — visible as "I made a change but the UI didn't update" after rebuilds.
// Cache-Control: no-store forces a fresh fetch every window load.
const noCache = (_req, res, next) => {
res.set("Cache-Control", "no-store, must-revalidate");
res.set("Pragma", "no-cache");
res.set("Expires", "0");
next();
};
app.use("/ds", noCache, express.static(DS_OUT_DIR));
app.use("/assets", noCache, express.static(ASSETS_DIR));
app.use(noCache, express.static(PUBLIC_DIR));
// Express 4 leaves an async handler's rejection unhandled — no error
// middleware runs, the socket just stays open, and the frontend's awaited
// fetch never resolves (stuck spinners, a poll that silently stops). Wrap
// async handlers so a throw becomes a 500 the client can surface instead.
const asyncRoute = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch((err) => {
console.error("[route]", req.method, req.path, "—", err?.message || err);
if (!res.headersSent) res.status(500).json({ error: err?.message || String(err) });
});
};
app.get("/api/config", (_req, res) => {
res.json(loadConfig());
});
app.post("/api/config", asyncRoute(async (req, res) => {
const patch = req.body || {};
// Flipping the stack-badge toggle ON forces a fresh stack/language scan on
// the next /api/projects so the badge row doesn't paint with stale cached
// results from before the user enabled it.
if (patch.showStackBadge === true) _stackCache.clear();
// Reject an unusable status list at the door. loadConfig() would quietly fall
// back to the bundled defaults on next boot, which reads as "my statuses
// reset themselves" — much better to refuse the save and say why.
if (patch.statuses !== undefined && !isValidStatusList(patch.statuses)) {
return res.status(400).json({
error: "Statuses need at least one entry, each with a unique id and a label.",
});
}
await saveUserConfig(patch);
// Rehome any project left behind by a deleted status before replying, so the
// projects list the client reloads next is already consistent.
let movedProjects = 0;
if (patch.statuses !== undefined) {
movedProjects = await reassignOrphanedStatuses(loadConfig().statuses);
}
res.json({ ...loadConfig(), movedProjects });
}));
// Native folder/file picker — only available when running inside Electron.
async function nativePicker(opts) {
if (process.env.PT_ELECTRON !== "1") {
throw new Error("Native picker only available in the desktop app.");
}
const electron = await import("electron");
const win = global.__codingDrivesWindow;
return win
? electron.dialog.showOpenDialog(win, opts)
: electron.dialog.showOpenDialog(opts);
}
app.post("/api/dialog/pick-folder", async (_req, res) => {
try {
const result = await nativePicker({ properties: ["openDirectory"] });
if (result.canceled || !result.filePaths?.[0]) return res.json({ canceled: true });
res.json({ path: result.filePaths[0] });
} catch (err) {
res.status(501).json({ error: err.message });
}
});
app.post("/api/dialog/pick-file", async (req, res) => {
try {
const filters = req.body?.filters || [];
const result = await nativePicker({ properties: ["openFile"], filters });
if (result.canceled || !result.filePaths?.[0]) return res.json({ canceled: true });
res.json({ path: result.filePaths[0] });
} catch (err) {
res.status(501).json({ error: err.message });
}
});
// Logo: serves the user's custom logo if uploaded, otherwise the bundled SVG.
app.get("/api/logo", (_req, res) => {
const userCfg = loadUserConfig();
if (userCfg.customLogo && fs.existsSync(userCfg.customLogo)) {
return res.sendFile(userCfg.customLogo);
}
res.sendFile(path.join(ASSETS_DIR, "logo.svg"));
});
// Credit-mark logo — ALWAYS the bundled creator avatar, never the user's
// custom logo. The "Made by @cleaneramade" credit is locked by design and
// must remain visible regardless of how a user rebrands the app icon.
app.get("/api/credit-logo", (_req, res) => {
res.sendFile(path.join(ASSETS_DIR, "logo.svg"));
});
// Updates the Coding Drives shortcut icons (Desktop + Start Menu, per-user
// and machine-wide) so Windows visually reflects the new brand without
// rebuilding the .exe.
//
// AWAITED (not fire-and-forget): the previous fire-and-forget pattern
// silently swallowed every PS failure (path issue, ICO conversion error,
// permission denial). We now capture stdout/stderr + exit code via
// runCapture(), persist a one-line trace to tracker.log, and return a
// structured result so the API endpoint can surface success/failure to
// the renderer as a toast. Adds ~1-3s to the upload response, which is
// fine — the relaunch flow already waits 900ms.
//
// Pass src="" to reset overrides (the script clears IconLocation on each
// .lnk so Windows falls back to the .exe's bundled icon).
async function updateShortcutIcons(srcImagePath) {
const script = path.join(ASSETS_DIR, "update-shortcut-icons.ps1");
if (!fs.existsSync(script)) return { ok: false, error: "helper script missing" };
const r = await runCapture("powershell.exe", [
"-NoProfile", "-NonInteractive",
"-ExecutionPolicy", "Bypass",
"-File", script,
"-ShortcutName", "Coding Drives",
"-SourceImage", srcImagePath || "",
// No -IcoCachePath: the script picks a timestamped path under userData
// so each upload writes a *new* file. Reusing the same path lets the
// Windows shell icon cache serve a stale thumbnail.
]);
// Append a single line to tracker.log so the user has a paper trail
// when something goes wrong. The logger lives in electron.cjs but the
// file path is stable — userData/tracker.log — so we write directly.
const userDataDir = process.env.PT_DATA_DIR ? path.dirname(process.env.PT_DATA_DIR) : DATA_DIR;
const logLine = `[${new Date().toISOString()}] [shortcut-icons] exit=${r.code} stdout=${(r.stdout || "").trim()} stderr=${(r.stderr || "").trim()}\n`;
try { fs.appendFileSync(path.join(userDataDir, "tracker.log"), logLine); } catch {}
if (r.code !== 0) {
return { ok: false, exitCode: r.code, error: ((r.stderr || r.stdout) || `PS exited ${r.code}`).trim() };
}
// Parse the JSON status line (last non-empty line of stdout).
const lastLine = (r.stdout || "").trim().split(/\r?\n/).filter(Boolean).pop() || "{}";
let parsed = null;
try { parsed = JSON.parse(lastLine); } catch {}
if (parsed?.ok) return { ok: true, updated: parsed.updated || 0, icoPath: parsed.icoPath || null };
return { ok: false, error: parsed?.error || "unparseable PS output" };
}
// SVG can't be wrapped into a Windows .ico via System.Drawing (vector input
// isn't supported). For the desktop-icon update we need a raster image; the
// in-app logo still works fine with SVG.
function isRasterIconFormat(ext) { return [".png", ".jpg", ".jpeg", ".ico"].includes(ext); }
// Upload (copy) a chosen file to userData and set it as the active logo.
app.post("/api/settings/logo", asyncRoute(async (req, res) => {
const src = String(req.body?.path || "");
if (!src || !fs.existsSync(src)) return res.status(400).json({ error: "File not found." });
const ext = path.extname(src).toLowerCase() || ".png";
if (![".svg", ".png", ".jpg", ".jpeg", ".ico"].includes(ext)) {
return res.status(400).json({ error: "Unsupported image format." });
}