Repository navigation
Expand file tree
/
Copy pathhopper.js
More file actions
1381 lines (1233 loc) · 53.1 KB
/
Copy pathhopper.js
File metadata and controls
1381 lines (1233 loc) · 53.1 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
/**
* Hopper is a package manager for TVDOS
* Created by CuriousTorvald on 2026-04-16
*/
const THE_VERSION = "1.1"
const SYSTEM_PACKAGE_DEF_DIR = "A:/tvdos/hopper"
const USER_BASE_DIR = "A:/hopper"
const USER_PACKAGE_DEF_DIR = `${USER_BASE_DIR}/manifests`
const USER_PACKAGE_BIN_DIR = `${USER_BASE_DIR}/bin`
const USER_PACKAGE_INCLUDE_DIR = `${USER_BASE_DIR}/include`
const MANIFEST_EXT = "hop.per"
const MIRROR_LIST_PATH = `${SYSTEM_PACKAGE_DEF_DIR}/mirrors.list`
const net = require("net")
// SYNOPSIS
// hopper {search,se} [--provides, --requires, --description, --author] query
//// default searches from ProperName
// hopper {install,in} query [-v version]
// hopper {upgrade,up} [package...]
//// no package names upgrades every user-installed package
// hopper {remove,rm} query
// hopper {list,ls}
//// lists every installed package (system and user)
// ============================================================
// Manifest parsing
// ============================================================
function splitList(s) {
if (!s) return []
return s.split(";").map(it => it.trim()).filter(it => it.length > 0)
}
function parseManifest(text) {
const m = {}
text.split("\n").forEach(rawLine => {
const line = rawLine.replace(/\r$/, "")
if (line.length === 0) return
const idx = line.indexOf(":")
if (idx < 0) return
const key = line.substring(0, idx).trim()
const value = line.substring(idx + 1).trim()
m[key] = value
})
return m
}
function readManifestFile(path) {
const f = files.open(path)
if (!f.exists || f.isDirectory) return undefined
const m = parseManifest(f.sread())
m._manifestPath = path
return m
}
function _listManifestsFrom(dirPath, origin) {
const dir = files.open(dirPath)
if (!dir.exists || !dir.isDirectory) return []
const out = []
dir.list().forEach(entry => {
if (entry.isDirectory) return
if (!entry.name.toLowerCase().endsWith(MANIFEST_EXT)) return
const m = readManifestFile(entry.fullPath)
if (m !== undefined) {
m._origin = origin
out.push(m)
}
})
return out
}
// System packages (shipped with TVDOS) live in SYSTEM_PACKAGE_DEF_DIR
// and are read-only as far as hopper is concerned. User packages,
// installed by `hopper install`, live under USER_PACKAGE_DEF_DIR. The
// resolver treats both as "installed", but the install/remove paths
// refuse to modify anything tagged `_origin === "system"`.
function listInstalledManifests() {
return _listManifestsFrom(SYSTEM_PACKAGE_DEF_DIR, "system")
.concat(_listManifestsFrom(USER_PACKAGE_DEF_DIR, "user"))
}
function findInstalledManifest(name) {
// Prefer user-installed copy when a system package with the same name
// also exists -- but that combination is normally refused at install.
const userDirect = `${USER_PACKAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
let m = readManifestFile(userDirect)
if (m !== undefined) { m._origin = "user"; return m }
const sysDirect = `${SYSTEM_PACKAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
m = readManifestFile(sysDirect)
if (m !== undefined) { m._origin = "system"; return m }
const all = listInstalledManifests()
for (let i = 0; i < all.length; i++) {
if ((all[i].HopperPackageName || "") === name) return all[i]
}
return undefined
}
// Yes/no prompt. Empty input falls back to `defaultYes`.
function confirm(prompt, defaultYes) {
const hint = defaultYes ? "[Y/n]" : "[y/N]"
print(`${prompt} ${hint} `)
const ans = (read() || "").trim().toLowerCase()
if (ans === "") return !!defaultYes
return ans === "y" || ans === "yes"
}
// ============================================================
// Install layout helpers
// ============================================================
//
// User-installed packages live under `A:/hopper/`. By default files are
// routed by extension: `.mjs` includes go under `include/`, everything
// else (`.js`, `.alias`, `.lfs`, data blobs, ...) lands in `bin/`. A
// manifest may override this with `PackageFileInstallPrefix`, which keeps
// a package's whole payload together in one subdirectory (see
// `classifyInstallPrefix`). Either way the downloaded manifest is saved
// under `manifests/` with a `SystemPackagePath` field appended that lists
// every resulting path (payload plus any planted aliases).
// Strip query/fragment and take the last `/`-separated component of `url`.
function urlBasename(url) {
let s = String(url || "")
const qm = s.indexOf("?"); if (qm >= 0) s = s.substring(0, qm)
const hash = s.indexOf("#"); if (hash >= 0) s = s.substring(0, hash)
const slash = s.lastIndexOf("/")
return (slash < 0) ? s : s.substring(slash + 1)
}
function routeForBasename(name) {
return (String(name || "").toLowerCase().endsWith(".mjs"))
? USER_PACKAGE_INCLUDE_DIR
: USER_PACKAGE_BIN_DIR
}
// Convert a USER_BASE_DIR-relative absolute path ("A:/hopper/bin/foo.js")
// into its declarable form ("/hopper/bin/foo.js"), matching the
// `SystemPackagePath` convention used by the system manifests.
function declarablePath(absPath) {
let p = String(absPath || "").replace(/\\/g, "/")
if (/^[A-Za-z]:/.test(p)) p = p.substring(2)
return p
}
// File-type predicates that drive prefix routing and alias planting.
// Executables are launched by name from the shell, so when a prefix moves
// them out of bin/ we plant an alias back in bin/; `.mjs` files are
// includes; `.alias` files are shell aliases that always live in bin/.
const EXECUTABLE_EXTS = ["js", "app"]
function fileExt(name) {
const s = String(name || "")
const dot = s.lastIndexOf(".")
return (dot < 0) ? "" : s.substring(dot + 1).toLowerCase()
}
function stripExt(name) {
const s = String(name || "")
const dot = s.lastIndexOf(".")
return (dot < 0) ? s : s.substring(0, dot)
}
function isAliasFile(name) { return fileExt(name) === "alias" }
function isExecutableFile(name) { return EXECUTABLE_EXTS.indexOf(fileExt(name)) >= 0 }
// PackageFileInstallPrefix drops a package's whole payload into one
// subdirectory (so a program's modules and data files sit next to it)
// instead of fanning files out by extension. Three shapes are accepted:
//
// LOCAL "foo", "./foo", ".\foo" -> under the routed base dir
// (A:/hopper/bin or .../include)
// GLOBAL "/foo", "\foo" -> under the system drive root A:/
// INVALID "../x", "..\x", "C:x" -> escapes the install root; refused
//
// Returns { kind:"none"|"local"|"global"|"invalid", dir, reason } where
// `dir` is normalised: forward slashes, no drive letter, no leading or
// trailing slash, with "." and empty segments dropped.
function classifyInstallPrefix(raw) {
const s = String(raw || "").trim()
if (s.length === 0) return { kind: "none", dir: "" }
const norm = s.replace(/\\/g, "/")
// Drive-letter prefix (A:..Z:, $:) is absolute on some drive -> refused.
if (/^[A-Za-z$]:/.test(norm)) {
return { kind: "invalid", reason: "must not start with a drive letter" }
}
const segments = norm.split("/").filter(seg => seg.length > 0)
// Parent-directory escape anywhere in the path -> refused.
if (segments.some(seg => seg === "..")) {
return { kind: "invalid", reason: "must not contain a '..' segment" }
}
const isGlobal = (norm[0] === "/")
const clean = segments.filter(seg => seg !== ".").join("/")
if (clean.length === 0) {
return { kind: "invalid", reason: "resolves to an empty directory" }
}
return { kind: isGlobal ? "global" : "local", dir: clean }
}
// Create `absPath` and any missing parents on the A: drive, one component
// at a time (the runtime's mkDir does not create intermediate directories).
function ensureDir(absPath) {
const p = String(absPath || "").replace(/\\/g, "/").replace(/\/+$/, "")
const m = p.match(/^([A-Za-z$]:)(.*)$/)
if (!m) return
let cur = m[1]
m[2].split("/").filter(seg => seg.length > 0).forEach(seg => {
cur = `${cur}/${seg}`
const d = files.open(cur)
if (!d.exists) d.mkDir()
})
}
function ensureUserDirs() {
[USER_BASE_DIR, USER_PACKAGE_BIN_DIR, USER_PACKAGE_INCLUDE_DIR, USER_PACKAGE_DEF_DIR].forEach(p => {
const d = files.open(p)
if (!d.exists) d.mkDir()
})
}
// Re-emit a parsed manifest, preserving insertion order, dropping
// internal `_*` keys, and replacing any pre-existing SystemPackagePath
// with the locally-computed one so the field always reflects what is
// actually on disk.
function serializeManifest(manifestObj, installedPathStr) {
const lines = []
Object.keys(manifestObj).forEach(k => {
if (k.length > 0 && k[0] === "_") return
if (k === "SystemPackagePath") return
lines.push(`${k}:${manifestObj[k]}`)
})
lines.push(`SystemPackagePath:${installedPathStr}`)
return lines.join("\n") + "\n"
}
// Delete every file declared in `manifest.SystemPackagePath` plus the
// manifest file itself. Wildcards are expanded via `expandSystemPath`.
function deleteInstalledFiles(manifest) {
const removed = []
splitList(manifest.SystemPackagePath || "").forEach(p => {
expandSystemPath(p).forEach(abs => {
const fd = files.open(abs)
if (!fd.exists) return
try { fd.remove(); removed.push(abs) }
catch (e) { printerrln(` ! failed to remove ${abs}: ${e}`) }
})
})
if (manifest._manifestPath) {
const mfd = files.open(manifest._manifestPath)
if (mfd.exists) {
try { mfd.remove(); removed.push(manifest._manifestPath) }
catch (e) { printerrln(` ! failed to remove ${manifest._manifestPath}: ${e}`) }
}
}
return removed
}
// ============================================================
// SemVer (strict X.Y.Z) and constraint matching
// ============================================================
//
// Versions are strict Semantic Versioning: three non-negative integer
// components MAJOR.MINOR.PATCH. No pre-release / build metadata.
//
// Constraint grammar (intentionally small, expandable later):
// * any version
// X.* major X, any minor/patch
// X.Y.* major X, minor Y, any patch
// X.Y.Z exact
// ^X.Y.Z >= X.Y.Z and < (X+1).0.0 (major-compatible)
// ~X.Y.Z >= X.Y.Z and < X.(Y+1).0 (minor-compatible)
// >=X.Y.Z / >X.Y.Z / <=X.Y.Z / <X.Y.Z / =X.Y.Z
//
// Multiple comma-separated constraints are AND-ed: "^1.2.0,<1.5.0".
function parseVersion(v) {
const m = String(v || "0.0.0").trim().match(/^(\d+)\.(\d+)\.(\d+)$/)
if (!m) return [0, 0, 0]
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
}
function compareVersion(a, b) {
const A = parseVersion(a), B = parseVersion(b)
for (let i = 0; i < 3; i++) {
if (A[i] !== B[i]) return (A[i] < B[i]) ? -1 : 1
}
return 0
}
function _matchSingleConstraint(version, c) {
c = c.trim()
if (c === "" || c === "*") return true
// Operator form: ^, ~, >=, <=, >, <, =
let opMatch = c.match(/^(\^|~|>=|<=|>|<|=)\s*(\d+\.\d+\.\d+)$/)
if (opMatch) {
const op = opMatch[1]
const target = opMatch[2]
const cmp = compareVersion(version, target)
const [tM, tm] = parseVersion(target)
switch (op) {
case "=": return cmp === 0
case ">": return cmp > 0
case ">=": return cmp >= 0
case "<": return cmp < 0
case "<=": return cmp <= 0
case "^": return cmp >= 0 && compareVersion(version, `${tM + 1}.0.0`) < 0
case "~": return cmp >= 0 && compareVersion(version, `${tM}.${tm + 1}.0`) < 0
}
}
// Wildcard form: X.*, X.Y.*, X.x, X.Y.x, or exact X.Y.Z
const parts = c.split(".")
const vparts = parseVersion(version)
for (let i = 0; i < parts.length && i < 3; i++) {
if (parts[i] === "*" || parts[i] === "x" || parts[i] === "X") return true
const expected = parseInt(parts[i], 10)
if (isNaN(expected) || vparts[i] !== expected) return false
}
// All listed parts matched literally; remaining parts (if any) must be 0
for (let i = parts.length; i < 3; i++) {
if (vparts[i] !== 0) return false
}
return true
}
function satisfies(version, constraint) {
if (!constraint) return true
return constraint.split(",").every(c => _matchSingleConstraint(version, c))
}
function parseRequires(s) {
const out = []
splitList(s || "").forEach(entry => {
// "<name>" or "<name> <constraint>"
const idx = entry.search(/\s+/)
if (idx < 0) {
out.push({ name: entry, constraint: "*" })
} else {
out.push({ name: entry.substring(0, idx), constraint: entry.substring(idx + 1).trim() })
}
})
return out
}
// HopperProvides entries are "<name>" or "<name> <version>". A bare name
// falls back to the package's own HopperPackageVersion — the same idea
// as RPM's `Provides: aalib = 1.2.0` (where the package's real name and
// version may differ from the virtual identity it exposes).
function parseProvides(s, fallbackVersion) {
const out = []
splitList(s || "").forEach(entry => {
const idx = entry.search(/\s+/)
if (idx < 0) {
out.push({ name: entry, version: fallbackVersion })
} else {
const v = entry.substring(idx + 1).trim()
out.push({ name: entry.substring(0, idx), version: v || fallbackVersion })
}
})
return out
}
// Look up the version a candidate exposes for `name`. If `name` matches
// the package's own name (or isn't declared in HopperProvides at all),
// returns the package's own version.
function providedVersionOf(candidate, name) {
if (candidate.provides) {
for (let i = 0; i < candidate.provides.length; i++) {
if (candidate.provides[i].name === name) return candidate.provides[i].version
}
}
return candidate.version
}
// ============================================================
// App / manifest version compatibility
// ============================================================
//
// Hopper itself has an app version (THE_VERSION); every manifest declares the
// Hopper manifest version it targets (HopperManifestVersion). A manifest whose
// declared version is NEWER than this Hopper (THE_VERSION < HopperManifestVersion)
// was written for a future Hopper we cannot safely understand: `search` still
// surfaces it, but install/upgrade refuse it and tell the user to update Hopper.
// Loose numeric dotted-version compare that tolerates differing component
// counts (e.g. THE_VERSION "1.1" vs HopperManifestVersion "1"). Missing or
// non-numeric components count as 0. Returns -1, 0 or 1 like compareVersion.
function compareDotted(a, b) {
const A = String(a == null ? "" : a).trim().split(".")
const B = String(b == null ? "" : b).trim().split(".")
const n = Math.max(A.length, B.length)
for (let i = 0; i < n; i++) {
const x = parseInt(A[i], 10) || 0
const y = parseInt(B[i], 10) || 0
if (x !== y) return (x < y) ? -1 : 1
}
return 0
}
// True when a manifest targets a Hopper newer than this one. A manifest that
// omits HopperManifestVersion is assumed compatible (treated as version 0).
function manifestRequiresNewerHopper(m) {
if (!m) return false
const declared = m.HopperManifestVersion
if (declared === undefined || String(declared).trim() === "") return false
return compareDotted(THE_VERSION, declared) < 0
}
// ============================================================
// Candidate index (installed + upstream)
// ============================================================
function _manifestToCandidate(m, source) {
const name = m.HopperPackageName || ""
const version = m.HopperPackageVersion || "0.0.0"
const provides = parseProvides(m.HopperProvides || "", version)
// Every package implicitly provides itself at its own version. Only
// synthesise this when the manifest didn't declare it explicitly.
if (name && !provides.some(p => p.name === name)) {
provides.unshift({ name: name, version: version })
}
return {
name: name,
version: version,
requires: parseRequires(m.HopperRequires || ""),
provides: provides,
source: source, // "installed" | "upstream"
manifest: m
}
}
// Returns map: packageName -> array<Candidate>
function buildCandidateIndex() {
const idx = new Map()
function add(c) {
if (!idx.has(c.name)) idx.set(c.name, [])
// De-dupe (name+version+source)
const arr = idx.get(c.name)
if (arr.some(x => x.version === c.version && x.source === c.source)) return
arr.push(c)
}
listInstalledManifests().forEach(m => add(_manifestToCandidate(m, "installed")))
fetchRemoteCandidates().forEach(m => add(_manifestToCandidate(m, "upstream")))
return idx
}
// Anything that satisfies a requirement on `name`: a package whose own
// HopperPackageName matches OR whose HopperProvides declares `name`.
// Each candidate now carries `provides` as {name, version} pairs; the
// package's own (name, version) is always present (see
// _manifestToCandidate), so a single pass over `provides` is enough.
function findProviders(idx, name) {
const out = []
const seen = new Set()
idx.forEach(candidates => {
candidates.forEach(c => {
if (seen.has(c)) return
if (c.provides.some(p => p.name === name)) {
out.push(c)
seen.add(c)
}
})
})
return out
}
// Sort candidates by preference. Normally installed-first (no churn) then
// highest version; with `preferNewest` (used by `upgrade`) the installed
// bias is dropped so the newest package version wins regardless of source.
function sortCandidates(cands, preferNewest) {
return cands.slice().sort((a, b) => {
if (!preferNewest && a.source !== b.source) return (a.source === "installed") ? -1 : 1
return -compareVersion(a.version, b.version)
})
}
// Highest *package* version (HopperPackageVersion) among the candidates that
// ARE the package `name` -- installed or upstream -- or null if there are
// none. `upgrade` uses this to decide whether a newer build exists. It
// deliberately ignores HopperProvides versions (which advance independently
// of the package version) and other packages that merely provide `name`;
// the candidate index is keyed by package name, so idx.get(name) is exactly
// the builds of that package.
function latestInstallableVersion(idx, name) {
const arr = idx.get(name)
if (!arr || arr.length === 0) return null
let best = null
arr.forEach(c => { if (best === null || compareVersion(c.version, best) > 0) best = c.version })
return best
}
// ============================================================
// Resolver (snapshot-based backtracking; precursor to a SAT solver)
// ============================================================
//
// State: chosen :: Map<packageName, Candidate>
// At every choice point we snapshot the whole map so that backtracking
// also undoes any transitive picks. The candidate ordering encodes the
// preference policy:
//
// 1. Keep installed if it satisfies the constraint.
// 2. Otherwise pick the newest upstream version that satisfies.
// 3. If newer versions cause downstream conflicts, walk older versions
// (downgrade) until either something fits or candidates are exhausted.
//
// The structure is intentionally close to DPLL: each "decision" is the
// candidate we assign to a variable, and "unit propagation" is the
// recursive resolve() call over each requirement. Replacing this with
// clause learning / a watched-literals scheme later would be local.
function resolveAll(idx, requirements, upgradeSet, pkgVersionPins) {
const chosen = new Map()
const issues = []
function snapshot() { return new Map(chosen) }
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
function _resolve(reqName, constraint, trail) {
// A package-version pin (from `install -v`) constrains the chosen
// build's HopperPackageVersion -- the version `search` shows -- and is
// matched separately from `constraint`, which always works in the
// HopperProvides capability space.
const pin = (pkgVersionPins && pkgVersionPins.get(reqName)) || null
const existing = chosen.get(reqName)
if (existing !== undefined) {
const v = providedVersionOf(existing, reqName)
return (satisfies(v, constraint) && (!pin || satisfies(existing.version, pin)))
? { ok: true }
: { ok: false, reason: `${reqName} pinned to ${v}, but ${trail.join(" -> ")} requires ${constraint}` }
}
const providers = findProviders(idx, reqName)
if (providers.length === 0) {
return { ok: false, reason: `no package provides "${reqName}" (required by ${trail.join(" -> ") || "<root>"})` }
}
// Satisfaction checks the virtual version the candidate exposes
// for `reqName` (HopperProvides), not necessarily the package's
// own HopperPackageVersion. A package-version pin is applied on top,
// against the build's own version.
const preferNewest = !!(upgradeSet && upgradeSet.has(reqName))
const provMatched = providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint))
const matching = sortCandidates(provMatched.filter(c => !pin || satisfies(c.version, pin)), preferNewest)
if (matching.length === 0) {
// When the package-version pin is what eliminated the candidates,
// report it in package-version space (matching `search`); a plain
// capability mismatch stays in HopperProvides space.
if (pin && provMatched.length > 0) {
const versions = providers.map(p => `${p.version}[${p.source}]`).join(", ")
return { ok: false, reason: `no build of "${reqName}" has version ${pin} (available: ${versions})` }
}
const versions = providers.map(p => `${providedVersionOf(p, reqName)}[${p.source}]`).join(", ")
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
}
let lastReason = null
for (let i = 0; i < matching.length; i++) {
const cand = matching[i]
const snap = snapshot()
chosen.set(cand.name, cand)
let allOk = true
const subTrail = trail.concat([`${cand.name}@${cand.version}`])
for (let j = 0; j < cand.requires.length; j++) {
const req = cand.requires[j]
const r = _resolve(req.name, req.constraint, subTrail)
if (!r.ok) {
allOk = false
lastReason = r.reason
break
}
}
if (allOk) return { ok: true }
restore(snap)
}
return { ok: false, reason: lastReason || `no working candidate for "${reqName}"` }
}
requirements.forEach(req => {
const r = _resolve(req.name, req.constraint, [])
if (!r.ok) issues.push(r.reason)
})
return { chosen, issues }
}
// Compare resolved assignment against currently-installed state.
function classifyPlan(idx, chosen) {
const installedByName = new Map()
listInstalledManifests().forEach(m => installedByName.set(m.HopperPackageName, m))
const actions = []
chosen.forEach((cand, name) => {
const inst = installedByName.get(name)
if (cand.source === "installed") {
actions.push({ action: "keep", name, version: cand.version })
}
else if (inst === undefined) {
actions.push({ action: "install", name, version: cand.version })
}
else {
const cmp = compareVersion(cand.version, inst.HopperPackageVersion)
if (cmp > 0) actions.push({ action: "upgrade", name, from: inst.HopperPackageVersion, to: cand.version })
else if (cmp < 0) actions.push({ action: "downgrade", name, from: inst.HopperPackageVersion, to: cand.version })
else actions.push({ action: "reinstall", name, version: cand.version })
}
})
return actions
}
function printPlan(actions, target) {
const changing = actions.filter(a => a.action !== "keep")
if (changing.length === 0) {
println(`Nothing to do: ${target} is already installed and satisfied.`)
return
}
println("Plan:")
changing.forEach(a => {
switch (a.action) {
case "install": println(` + install ${a.name} ${a.version}`); break
case "upgrade": println(` ^ upgrade ${a.name} ${a.from} -> ${a.to}`); break
case "downgrade": println(` v downgrade ${a.name} ${a.from} -> ${a.to}`); break
case "reinstall": println(` = reinstall ${a.name} ${a.version}`); break
}
})
}
// ============================================================
// Remote mirrors
// ============================================================
//
// `mirrors.list` lives next to the installed package manifests.
// Each non-empty, non-`#` line is the URL prefix of a Hopper mirror.
// The mirror MUST expose `<prefix>mirror_manifest` (key:value pairs
// describing the mirror) and `<prefix>filelist` (CSV with rows of
// `packagename,version,hoppermanifest-filename`).
//
// Trailing slash on the prefix is optional and will be added if missing.
function loadMirrorList() {
const f = files.open(MIRROR_LIST_PATH)
if (!f.exists || f.isDirectory) return []
return f.sread().split("\n")
.map(line => line.replace(/\r$/, "").trim())
.filter(line => line.length > 0 && line[0] !== "#")
.map(line => line.endsWith("/") ? line : (line + "/"))
}
function parseFileList(text) {
const out = []
text.split("\n").forEach(raw => {
const line = raw.replace(/\r$/, "").trim()
if (line.length === 0 || line[0] === "#") return
const parts = line.split(",")
if (parts.length < 3) return
out.push({
name: parts[0].trim(),
version: parts[1].trim(),
file: parts[2].trim(),
})
})
return out
}
function fetchManifestsFromMirror(prefix) {
const mfText = net.fetchText(prefix + "mirror_manifest")
if (mfText === null) {
printerrln(` ! could not reach mirror: ${prefix}`)
return []
}
const mirror = parseManifest(mfText)
const mirrorName = mirror.HopperMirrorName || prefix
const flText = net.fetchText(prefix + "filelist")
if (flText === null) {
printerrln(` ! mirror "${mirrorName}" has no filelist`)
return []
}
const out = []
parseFileList(flText).forEach(entry => {
const manifestText = net.fetchText(prefix + entry.file)
if (manifestText === null) {
printerrln(` ! mirror "${mirrorName}" missing ${entry.file}`)
return
}
const m = parseManifest(manifestText)
m._mirrorName = mirrorName
m._mirrorPrefix = prefix
m._manifestUrl = prefix + entry.file
out.push(m)
})
return out
}
// Per-invocation memoisation. Search and install both pull the same
// data; we only want to hit the network once per `hopper ...` call.
let _remoteCache = null
function fetchRemoteCandidates() {
if (_remoteCache !== null) return _remoteCache
const mirrors = loadMirrorList()
if (mirrors.length === 0) {
_remoteCache = []
return _remoteCache
}
if (!net.isAvailable()) {
printerrln("Warning: no HTTP modem attached; remote mirrors will be skipped.")
_remoteCache = []
return _remoteCache
}
const out = []
mirrors.forEach(prefix => {
fetchManifestsFromMirror(prefix).forEach(m => out.push(m))
})
_remoteCache = out
return _remoteCache
}
// ============================================================
// Search
// ============================================================
function fieldCandidates(manifest, field) {
switch (field) {
case "provides": return splitList(manifest.HopperProvides || "")
case "requires": return splitList(manifest.HopperRequires || "")
case "description": return [manifest.ProperDescription || ""]
case "author": return [manifest.ProperAuthor || ""]
default: return [manifest.ProperName || "", manifest.HopperPackageName || ""]
}
}
function matchesQuery(manifest, field, query) {
const q = query.toLowerCase()
return fieldCandidates(manifest, field).some(c => c.toLowerCase().indexOf(q) >= 0)
}
function printSearchResult(m, origin) {
const name = m.ProperName || m.HopperPackageName || "(unnamed)"
const ver = m.HopperPackageVersion || "?"
const note = manifestRequiresNewerHopper(m) ? " (requires a newer Hopper -- update to install)" : ""
println(` [${origin}] ${name} -- ${m.HopperPackageName} ${ver}${note}`)
if (m.ProperDescription) println(` ${m.ProperDescription}`)
}
function cmdSearch(args) {
let field = "name"
let query = undefined
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === "--provides") field = "provides"
else if (a === "--requires") field = "requires"
else if (a === "--description") field = "description"
else if (a === "--author") field = "author"
else if (a.startsWith("--")) { printerrln(`Unknown option: ${a}`); return 1 }
else query = a
}
if (query === undefined) {
printerrln("Usage: hopper search [--provides|--requires|--description|--author] <query>")
return 1
}
println(`Searching installed packages in ${SYSTEM_PACKAGE_DEF_DIR} ...`)
const sysHits = listInstalledManifests().filter(m => matchesQuery(m, field, query))
if (sysHits.length === 0) println(" (no matches)")
else sysHits.forEach(m => printSearchResult(m, "installed"))
println("")
println("Searching remote mirrors ...")
const remote = fetchRemoteCandidates()
if (remote.length === 0) {
println(" (no mirrors configured or reachable)")
}
else {
const netHits = remote.filter(m => matchesQuery(m, field, query))
if (netHits.length === 0) println(" (no matches)")
else netHits.forEach(m => printSearchResult(m, m._mirrorName || "remote"))
}
return 0
}
// ============================================================
// Install
// ============================================================
//
// Each upstream manifest declares its payload via `PackageFileList`. Hopper
// fetches each entry and drops the result under `/hopper/` -- by extension
// (`.mjs` -> include, else bin) or, when the manifest sets
// `PackageFileInstallPrefix`, together in one subdirectory. Executables that
// a prefix moves out of bin/ get an alias planted back in bin/ so they stay
// launchable; a shipped `.alias` is planted in bin/ too and retargeted at
// the real install path. The locally-saved manifest gets a
// `SystemPackagePath` listing every written path, which `cmdRemove` walks.
// Rewrite a shell alias body so its leading command token points at the
// executable's absolute install path under `installDir`, preserving any
// arguments. `payloadBasenames` are the non-alias files installed into
// `installDir`; the leading token is resolved against them.
function rewriteAliasBody(body, installDir, payloadBasenames) {
const text = String(body || "").replace(/\r/g, "")
const m = text.match(/^(\s*)(\S+)([\s\S]*)$/)
if (!m) return body
const lead = m[1], token = m[2], rest = m[3]
const tokenBase = urlBasename(token.replace(/\\/g, "/"))
// Resolve the token to a concrete payload file: exact basename first,
// then stem-match against an executable, then the sole executable.
let target = payloadBasenames.find(b => b === tokenBase)
if (!target) {
const stem = stripExt(tokenBase)
target = payloadBasenames.find(b => isExecutableFile(b) && stripExt(b) === stem)
}
if (!target) {
const exes = payloadBasenames.filter(isExecutableFile)
if (exes.length === 1) target = exes[0]
}
return `${lead}${installDir}/${target || tokenBase}${rest}`
}
// True if `absPath` is occupied by a file that is NOT part of this package's
// previous install (whose files, listed in `oldDeclared`, are about to be
// removed and so do not count as a collision).
function _binNameTaken(absPath, oldDeclared) {
const fd = files.open(absPath)
if (!fd.exists) return false
return !oldDeclared.has(declarablePath(absPath))
}
// Decide where an alias lands in bin/. On a collision with another
// package's file, offer the conventional rename (<package>-<file>) or
// abort. Returns the absolute destination path, or null to abort.
function _resolveAliasDest(packageName, aliasBasename, oldDeclared) {
const natural = `${USER_PACKAGE_BIN_DIR}/${aliasBasename}`
if (!_binNameTaken(natural, oldDeclared)) return natural
const renamed = `${packageName}-${aliasBasename}`
println(` ! A:/hopper/bin/${aliasBasename} already exists (from another package).`)
if (!confirm(` Install it as ${renamed} instead?`, false)) return null
const dest = `${USER_PACKAGE_BIN_DIR}/${renamed}`
if (_binNameTaken(dest, oldDeclared)) {
printerrln(` ! A:/hopper/bin/${renamed} also exists; aborting.`)
return null
}
return dest
}
function _installOne(action, candidate) {
const m = candidate.manifest
const rawList = splitList(m.PackageFileList || "")
if (rawList.length === 0) {
printerrln(` ! ${candidate.name}: upstream manifest has no PackageFileList; cannot install`)
return false
}
const prefixInfo = classifyInstallPrefix(m.PackageFileInstallPrefix)
if (prefixInfo.kind === "invalid") {
printerrln(` ! ${candidate.name}: invalid PackageFileInstallPrefix "${m.PackageFileInstallPrefix}" (${prefixInfo.reason})`)
return false
}
const hasPrefix = (prefixInfo.kind === "local" || prefixInfo.kind === "global")
// Payload descriptors.
const entries = []
rawList.forEach(url => {
const base = urlBasename(url)
if (base.length === 0) return
entries.push({ url: url, basename: base, alias: isAliasFile(base), exe: isExecutableFile(base) })
})
// Directory non-alias payload files install into. Without a prefix this
// is decided per file (legacy routing). With a prefix every non-alias
// file shares one directory; for a LOCAL prefix the base is include/
// only when the package is purely `.mjs` (a library), else bin/ so an
// application's modules and data sit beside the executable.
let installDir = null
if (prefixInfo.kind === "global") {
installDir = `A:/${prefixInfo.dir}`
} else if (prefixInfo.kind === "local") {
const nonAlias = entries.filter(e => !e.alias)
const libraryOnly = nonAlias.length > 0 && nonAlias.every(e => fileExt(e.basename) === "mjs")
const base = libraryOnly ? USER_PACKAGE_INCLUDE_DIR : USER_PACKAGE_BIN_DIR
installDir = `${base}/${prefixInfo.dir}`
}
entries.forEach(e => {
if (e.alias) e.localPath = `${USER_PACKAGE_BIN_DIR}/${e.basename}`
else if (installDir === null) e.localPath = `${routeForBasename(e.basename)}/${e.basename}`
else e.localPath = `${installDir}/${e.basename}`
})
// Fetch first, write second: a single 404 should not leave a
// half-installed package behind.
const fetched = []
for (let i = 0; i < entries.length; i++) {
const e = entries[i]
println(` fetch ${e.url}`)
const body = net.fetchText(e.url)
if (body === null || body === undefined) {
printerrln(` ! failed to fetch ${e.url}`)
return false
}
e.body = body
fetched.push(e)
}
// Files from this package's previous install: used both to clean up
// before writing and to recognise our own files (vs. a real collision)
// when planting aliases in bin/.
const oldManifest = (action !== "install")
? readManifestFile(`${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`)
: undefined
const oldDeclared = new Set()
if (oldManifest !== undefined) {
splitList(oldManifest.SystemPackagePath || "").forEach(p => oldDeclared.add(p))
}
// Plan alias files. Only meaningful when a prefix moves executables out
// of bin/; otherwise legacy routing already drops them straight in.
const payloadBasenames = fetched.filter(e => !e.alias).map(e => e.basename)
const aliasPlan = [] // { localPath, body }
const aliasNames = new Set() // alias filenames already shipped or generated
if (hasPrefix) {
// Shipped aliases: plant in bin/, retargeted at the install path.
for (let i = 0; i < fetched.length; i++) {
const e = fetched[i]
if (!e.alias) continue
aliasNames.add(e.basename)
const body = rewriteAliasBody(e.body, installDir, payloadBasenames)
const dest = _resolveAliasDest(candidate.name, e.basename, oldDeclared)
if (dest === null) { println("Aborted."); return false }
aliasPlan.push({ localPath: dest, body: body })
}
// Auto-generate an alias for every executable so it stays launchable
// from bin/. Skip a name the package already ships (or already
// generated, e.g. foo.js and foo.app both wanting foo.alias).
for (let i = 0; i < fetched.length; i++) {
const e = fetched[i]
if (!e.exe) continue
const aliasName = `${stripExt(e.basename)}.alias`
if (aliasNames.has(aliasName)) continue
aliasNames.add(aliasName)
const dest = _resolveAliasDest(candidate.name, aliasName, oldDeclared)
if (dest === null) { println("Aborted."); return false }
aliasPlan.push({ localPath: dest, body: `${installDir}/${e.basename} $0` })
}
}
// Remove the previous install's files so a renamed payload doesn't leave
// orphans behind.
if (oldManifest !== undefined) {
splitList(oldManifest.SystemPackagePath || "").forEach(p => {
expandSystemPath(p).forEach(abs => {
const fd = files.open(abs)
if (fd.exists) {
try { fd.remove() }
catch (e) { printerrln(` ! could not remove old ${abs}: ${e}`) }
}
})
})
}
// Ensure destination directories, then write everything.
ensureUserDirs()
if (installDir !== null) ensureDir(installDir)
const written = []
fetched.forEach(e => {
if (hasPrefix && e.alias) return // handled via aliasPlan
const fd = files.open(e.localPath)
if (!fd.exists) fd.mkFile()
fd.swrite(e.body)
println(` write ${e.localPath}`)
written.push(e.localPath)
})
aliasPlan.forEach(a => {
const fd = files.open(a.localPath)
if (!fd.exists) fd.mkFile()
fd.swrite(a.body)
println(` write ${a.localPath}`)
written.push(a.localPath)
})
// Save the manifest with SystemPackagePath listing every written path.
const sysPath = written.map(declarablePath).join(";")
const manifestPath = `${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`
const mfd = files.open(manifestPath)