-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathdepctrl.lua
More file actions
962 lines (823 loc) · 42.7 KB
/
Copy pathdepctrl.lua
File metadata and controls
962 lines (823 loc) · 42.7 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
#!/usr/bin/env luajit
-- DependencyControl CLI toolbox
local ffi = require "ffi"
local lfs = require "lfs"
local argparse = require "argparse"
require "moonscript" -- installs moonscript's package.moonpath loader for .moon files
-- ── Path utilities ────────────────────────────────────────────────────────────
local isWindows = ffi.os == "Windows"
local pathSep = isWindows and "\\" or "/"
local function dirname(path)
return (path or ""):match("^(.*)[/\\][^/\\]*$") or "."
end
local function isAbsolute(path)
return path:match("^%a:[/\\]") ~= nil -- C:\...
or path:match("^[/\\]") ~= nil -- /... or \...
end
local function resolveAbsPath(path)
if not isAbsolute(path) then
return lfs.currentdir() .. pathSep .. path
end
return path
end
-- ── Argument parsing ──────────────────────────────────────────────────────────
local parser = argparse("depctrl", "DependencyControl CLI toolbox")
:epilog("See README.md for detailed instructions.")
parser:command_target("command")
-- Selector options shared by all commands: repeat --target-module / --target-macro to pick
-- packages by namespace. With none given, a command operates on every package in the feed.
local function addTargets(cmd)
cmd:option("--target-module", "Module namespace to operate on (repeatable; default: all)")
:argname("<ns>"):count("*")
cmd:option("--target-macro", "Macro namespace to operate on (repeatable; default: all)")
:argname("<ns>"):count("*")
end
local testCmd = parser:command("test", "Run the unit test suite(s) for packages in a feed")
testCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json")
testCmd:option("-r --report-dir", "Directory for per-package CTRF JSON reports"):default("ctrf")
addTargets(testCmd)
local bundleCmd = parser:command("bundle", "Build a dist/ release bundle and zip archive")
bundleCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json")
bundleCmd:option("-o --out-dir", "Output directory; script files go into its dist/ subfolder"):default(".")
addTargets(bundleCmd)
local deployCmd = parser:command("deploy", "Deploy files directly to an output directory")
deployCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json")
deployCmd:option("-o --out-dir", "Output directory"):default(".")
deployCmd:flag("--clobber", "Overwrite existing files (default)"):target("clobber")
deployCmd:flag("--no-clobber", "Skip files that already exist at the destination"):target("clobber"):action("store_false")
addTargets(deployCmd)
local validateCmd = parser:command("validate-schema",
"Validate a config or feed JSON file against its DependencyControl JSON schema")
validateCmd:option("-f --file", "JSON file to validate"):argname("<path>")
validateCmd:option("-t --type", "Schema family to validate against: 'config' or 'feed'"):argname("<type>")
validateCmd:option("--schema-version",
"Validate against a specific schema version (e.g. 0.7.0) instead of auto-selecting the best match"):argname("<ver>")
local updateFeedCmd = parser:command("update-feed",
"Refresh SHA-1 hashes, version info, and file presence in a feed channel")
updateFeedCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json")
updateFeedCmd:option("-c --channel", "Channel to update (default: the channel marked default: true)")
:argname("<name>")
updateFeedCmd:flag("-n --dry-run", "Print what would change without writing back")
updateFeedCmd:flag("-a --add-files",
"Discover files on disk that the targeted channel doesn't list and add entries for them")
updateFeedCmd:flag("--mark-released",
"Stamp the release date on targeted channels still marked unreleased")
updateFeedCmd:option("--release-date",
"Date to stamp with --mark-released (default: today, UTC)"):argname("<date>")
addTargets(updateFeedCmd)
local resolveFeedCmd = parser:command("resolve-feed",
"Expand a feed's templates into a static feed with no template variables. Useful to downgrade a feed that uses template variables added in feed version 0.4.0 that older DependencyControl versions don't support.")
resolveFeedCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json")
resolveFeedCmd:option("--file-base-url",
"Override the feed's fileBaseUrl before expansion, re-homing every file download (e.g. onto a local mirror)")
:argname("<url>")
resolveFeedCmd:option("--feed-schema-version",
"Stamp this dependencyControlFeedFormatVersion on the output (default: keep the source's)"):argname("<ver>")
resolveFeedCmd:option("--out-file",
"Destination path (default: DependencyControl.resolved.json beside the feed)"):argname("<path>")
local serveCmd = parser:command("serve-updates",
"Serve a resolved feed and its working-copy files over local HTTP, for end-to-end update testing in a real Aegisub install")
serveCmd:option("-f --feed",
"Source feed JSON path, next to its files (a resolvable-channel feed, e.g. alpha)"):default("DependencyControl.json")
serveCmd:option("--lifetime", "Seconds to serve before the server self-terminates"):argname("<seconds>"):default("3600")
serveCmd:option("--serve-channel",
"Graft the feed's dev channel onto this channel before serving, so a client tracking it matches")
:argname("<name>")
serveCmd:option("--from-channel", "Dev channel to graft from when --serve-channel is given"):argname("<name>"):default("main")
local bumpCmd = parser:command("bump-version",
"Bump package version(s) to the feed's lockstep version, then refresh the feed")
bumpCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json")
bumpCmd:option("-c --channel",
"Channel whose versions and release state drive the bump (default: the channel marked default: true)")
:argname("<name>")
bumpCmd:argument("namespace", "Package namespace(s) to bump; omit when using --all-changed"):args("*")
bumpCmd:flag("--all-changed", "Bump every package changed since its last release (release date cleared)")
bumpCmd:flag("--major", "Start a new release cycle by bumping the major component")
bumpCmd:flag("--minor", "Start a new release cycle by bumping the minor component")
bumpCmd:flag("--patch", "Start a new release cycle by bumping the patch component")
local mergeCmd = parser:command("merge-feed",
"Copy channel(s) from one feed into another (e.g. publishing a dev channel to release/alpha)")
mergeCmd:option("-f --feed", "Source feed JSON path"):default("DependencyControl.json")
mergeCmd:option("--into", "Destination feed JSON path (updated in place)"):argname("<path>")
mergeCmd:option("--from", "Source channel to copy from"):argname("<name>")
mergeCmd:option("--to", "Destination channel(s), space-separated"):argname("<names>")
mergeCmd:option("--default-channel", "Which destination channel becomes default: true"):argname("<name>")
mergeCmd:option("--released", "Release date to stamp on the destination channel(s)"):argname("<date>")
mergeCmd:flag("-n --dry-run", "Print what would change without writing")
local notesCmd = parser:command("release-notes",
"Render grouped markdown release notes from a feed's changelog for one version")
notesCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json")
notesCmd:option("-c --channel", "Channel whose version's changelog to render (default: the channel marked default: true)")
:argname("<name>")
notesCmd:option("--version", "Version whose changelog to render (overrides --channel)"):argname("<ver>")
notesCmd:option("--title", "Optional top-level heading to prepend"):argname("<text>")
notesCmd:option("-o --output", "Write the notes to this file instead of stdout"):argname("<path>")
local typesCmd = parser:command("generate-types",
"Extract LuaCATS annotations from module sources into LuaLS .d.lua type-definition files")
typesCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json")
typesCmd:option("-o --out-dir", "Root directory for the generated definition tree"):default("types")
typesCmd:flag("--check",
"Lint annotations only: report findings and write nothing; exits nonzero on error findings")
addTargets(typesCmd)
local docsCmd = parser:command("generate-docs",
"Render API documentation from module sources' LuaCATS annotations")
docsCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json")
docsCmd:option("-o --out-dir", "Where the docs are written (the embeddable reference dir, or a standalone site's root)"):default("docs/reference")
docsCmd:option("--site", "'none' = embeddable reference section (flat pages + literate-nav SUMMARY.md); 'mkdocs'/'mdbook' = standalone site"):default("none")
docsCmd:option("--site-name", "Site title"):default("DependencyControl API")
docsCmd:flag("--include-private", "Render private members (badged) instead of omitting them")
addTargets(docsCmd)
local args = parser:parse()
-- ── Resolve the launcher directory ───────────────────────────────────────────
-- Made absolute up-front so nothing downstream can be confused by CWD changes.
local launcherDir = dirname(arg and arg[0])
if launcherDir == "." then
launcherDir = lfs.currentdir()
elseif not isAbsolute(launcherDir) then
launcherDir = lfs.currentdir() .. pathSep .. launcherDir
end
-- ── Module resolution ─────────────────────────────────────────────────────────
-- The repo's modules/ tree is namespaced (modules/l0/…), so l0.* require paths map
-- straight onto it: moonscript's loader resolves .moon via package.moonpath, the
-- stock searcher the vendored .lua via package.path. No custom searcher needed.
local depCtrlModulesDir = launcherDir .. pathSep .. "modules"
package.path = ("%s/?.lua;%s/?/init.lua;"):format(depCtrlModulesDir, depCtrlModulesDir) .. package.path
package.moonpath = ("%s/?.moon;%s/?/init.moon;"):format(depCtrlModulesDir, depCtrlModulesDir) .. (package.moonpath or "")
if isWindows then
require("l0.DependencyControl.helpers.ffi-windows").setConsoleOutputUTF8()
end
-- ── Aegisub shims ─────────────────────────────────────────────────────────────
local shims = require "l0.AegisubShims"
local aegisub = shims.aegisub -- pulled into local scope; global is set by the shim for sub-modules
-- ── Shared: workspace + DepCtrl bootstrap ────────────────────────────────────
local function setupDepCtrl(taskName)
local tempBase = shims.getPathToken("temp")
local workspace = tempBase .. pathSep .. ("depctrl-" .. taskName .. "-%x"):format(os.time() % 0x100000)
for _, token in ipairs({ "user", "local", "data", "temp" }) do
shims.setPathToken(token, workspace .. pathSep .. token)
end
local FileOps = require "l0.DependencyControl.file-ops"
FileOps.mkdir("?temp", false, true)
FileOps.mkdir("?user/log", false, true)
-- Disable the self-updater so loading DepCtrl does not trigger a network
-- fetch of its own feed (slow, flaky, pointless outside Aegisub).
local constants = require "l0.DependencyControl.Constants"
local globalConfigPath = aegisub.decode_path("?user/config/" .. constants.DEPCTRL_NAMESPACE .. ".json")
FileOps.mkdir(globalConfigPath, true, true)
do
local json = require "l0.dkjson"
local h = assert(io.open(globalConfigPath, "w"))
h:write(json.encode({ config = { updates = { mode = "off" } } }))
h:close()
end
return require "l0.DependencyControl"
end
-- ── Shared: feed loading, target filtering, source resolution ────────────────
-- Loads and expands a feed (Local mode resolves each file's on-disk source path).
local function loadFeed(feedPath)
local UpdateFeed = require "l0.DependencyControl.UpdateFeed"
local feed = UpdateFeed(nil, false, feedPath)
local ok, err = feed:loadFile(feedPath, UpdateFeed.ExpansionMode.Local)
if not ok then
io.stderr:write("Error loading feed '" .. feedPath .. "': " .. tostring(err) .. "\n")
os.exit(1)
end
return feed
end
-- Builds a ScriptTargetFilter from the --target-module/--target-macro selectors. With no
-- selectors it includes everything; otherwise just the named packages, by type.
local function buildFilter(cliArgs)
local domain = require "l0.DependencyControl.domain"
local filter = require("l0.DependencyControl.ScriptTargetFilter")()
local mods, macros = cliArgs.target_module or {}, cliArgs.target_macro or {}
if #mods == 0 and #macros == 0 then return filter:includeAll() end
for _, ns in ipairs(mods) do filter:include(domain.ScriptType.Module, ns) end
for _, ns in ipairs(macros) do filter:include(domain.ScriptType.Automation, ns) end
return filter
end
-- Builds a `requireId -> source path` map from every file in the feed and registers it as a
-- fallback module searcher (after the standard ones), so packages whose source layout isn't
-- namespaced (e.g. a flat repo root) still resolve straight from the checkout. Namespaced
-- repos keep resolving via the stock moonpath/path searchers, which run first.
local function registerFeedSearcher(feed)
local moonbase = require "moonscript.base"
-- ".moon" -> "", "/Common.moon" -> ".Common", "/test/Common.moon" -> ".test.Common"
local function leafSuffix(name)
return (name:gsub("%.moon$", ""):gsub("%.lua$", ""):gsub("/", "."))
end
local sourceById = {}
for file, _, pkg in feed:walkFiles() do
local src = file.localFilePath
if src then
local base = file.type == "test" and (pkg.namespace .. ".test") or pkg.namespace
local id = base .. leafSuffix(file.name)
sourceById[id] = sourceById[id] or src -- first channel wins; sources are channel-agnostic
end
end
table.insert(package.loaders or package.searchers, function(modName)
local src = sourceById[modName]
if not src then return "\n\tno source mapped in feed for '" .. modName .. "'" end
if src:match("%.moon$") then
local chunk, err = moonbase.loadfile(src)
if not chunk then error("error compiling " .. src .. ": " .. tostring(err)) end
return chunk
end
return assert(loadfile(src))
end)
return sourceById
end
-- Collects the selected module packages' non-test .moon sources from a feed, keyed by require
-- id, for annotation extraction. Vendored .lua files have no annotations and are skipped; a
-- warning is printed for any unreadable source.
local function collectModuleSources(feed, filter)
local domain = require "l0.DependencyControl.domain"
local FileOps = require "l0.DependencyControl.file-ops"
local selected = {}
for pkg, scriptType in feed:walkPackages(filter) do
if scriptType == domain.ScriptType.Module then selected[pkg.namespace] = true end
end
local function leafSuffix(name)
return (name:gsub("%.moon$", ""):gsub("%.lua$", ""):gsub("/", "."))
end
local sources, seen = {}, {}
for file, _, pkg in feed:walkFiles() do
local src = file.localFilePath
if selected[pkg.namespace] and src and file.type ~= "test" and file.name:match("%.moon$") then
local requireId = pkg.namespace .. leafSuffix(file.name)
if not seen[requireId] then
seen[requireId] = true
local text, readErr = FileOps.readFile(src)
if text then
sources[#sources + 1] = { requireId = requireId, source = text }
else
io.stderr:write(("! %s: couldn't read source '%s': %s\n"):format(requireId, src, tostring(readErr)))
end
end
end
end
return sources, selected
end
-- ── Command dispatch ──────────────────────────────────────────────────────────
-- ─── test ─────────────────────────────────────────────────────────────────────
if args.command == "test" then
-- Resolve every test suite by its source require identifier, "<namespace>.test".
-- Standard searchers resolve namespaced repos (e.g. DepCtrl's own modules/ tree);
-- the feed searcher registered below catches non-namespaced ones. Set before any
-- package is required, since requiring a managed module triggers test registration.
DEPCTRL_UNIT_TEST_SUITE_REQUIRE_IDENTIFIER = function(scriptType, namespace)
return namespace .. ".test"
end
local DepCtrl = setupDepCtrl("tests")
local FileOps = require "l0.DependencyControl.file-ops"
local feedPath = resolveAbsPath(args.feed)
local feed = loadFeed(feedPath)
local selected = {}
for pkg, scriptType in feed:walkPackages(buildFilter(args)) do
selected[#selected + 1] = { namespace = pkg.namespace, scriptType = scriptType }
end
table.sort(selected, function(a, b) return a.namespace < b.namespace end)
if #selected == 0 then
io.stderr:write("No packages matched in feed '" .. feedPath .. "'.\n")
os.exit(1)
end
registerFeedSearcher(feed)
local reportDir = resolveAbsPath(args.report_dir)
local ran, skipped, failed = 0, 0, 0
local allFailures = {} -- accumulated across packages for the end-of-run summary
for _, pkg in ipairs(selected) do
local ns = pkg.namespace
local okRequire, mod = xpcall(require, debug.traceback, ns)
local record = okRequire and DepCtrl:getRegisteredRecord(ns) or nil
if not okRequire then
io.stderr:write(("! %s: failed to load (%s)\n"):format(ns, tostring(mod)))
failed = failed + 1
elseif not (record and record.__class and record.__class.__name == "DependencyControl") then
io.stderr:write(("~ %s: not a DependencyControl-managed package, skipping\n"):format(ns))
skipped = skipped + 1
elseif record.haveTestSuite == false then
if record.testSuiteLoadError then
io.stderr:write(("! %s: test suite failed to load (%s)\n"):format(ns, tostring(record.testSuiteLoadError)))
failed = failed + 1
else
io.stderr:write(("~ %s: no test suite found, skipping\n"):format(ns))
skipped = skipped + 1
end
elseif not record.testSuiteInitialized then
io.stderr:write(("! %s: test suite failed to initialize (%s)\n"):format(ns, tostring(record.testSuiteInitializeError)))
failed = failed + 1
else
io.stdout:write(("\n=== Testing %s ===\n"):format(ns))
local success = record.tests:run()
ran = ran + 1
if not success then
failed = failed + 1
for _, f in ipairs(record.tests.failures) do
f.namespace = ns
allFailures[#allFailures + 1] = f
end
end
local reportPath = FileOps.joinPath(reportDir, ns .. ".json")
local wrote, writeErr = record.tests:writeResults(reportPath)
io.stderr:write(wrote and ("Wrote CTRF report to " .. reportPath .. "\n")
or ("Warning: couldn't write CTRF report for " .. ns .. ": " .. tostring(writeErr) .. "\n"))
end
end
if #allFailures > 0 then
io.stdout:write("\n—— Failures ——\n")
for _, f in ipairs(allFailures) do
io.stdout:write(("\n%s > %s > %s [%s]\n"):format(
f.namespace, f.classname, f.name, f.isAssertion and "assertion" or "error"))
local err = tostring(f.error or ""):gsub("%s+$", "")
io.stdout:write(" " .. err:gsub("\n", "\n ") .. "\n")
end
end
io.stdout:write(("\n%d package(s) tested, %d skipped, %d failed.\n"):format(ran, skipped, failed))
os.exit(failed > 0 and 1 or 0)
-- ─── bundle ───────────────────────────────────────────────────────────────────
elseif args.command == "bundle" then
local feedPath = resolveAbsPath(args.feed)
local outputDir = resolveAbsPath(args.out_dir)
setupDepCtrl("bundle")
local FileOps = require "l0.DependencyControl.file-ops"
local ZipArchiver = require "l0.DependencyControl.ZipArchiver"
local GitRepository = require "l0.DependencyControl.GitRepository"
local canonicalOut, outErr = FileOps.validateFullPath({ outputDir }, false, lfs.currentdir())
if not canonicalOut then
io.stderr:write("Error resolving output directory '" .. outputDir .. "': " .. tostring(outErr) .. "\n")
os.exit(1)
end
outputDir = canonicalOut
local feed = loadFeed(feedPath)
local filter = buildFilter(args)
local distDir = outputDir .. pathSep .. "dist"
local cleared, _, clearErr = FileOps.remove(distDir, true)
if not cleared then
io.stderr:write("Error clearing dist directory '" .. distDir .. "': " .. tostring(clearErr) .. "\n")
os.exit(1)
end
FileOps.mkdir(distDir, false, true)
local fileCount, errCount = feed:deployFiles(distDir, filter, false)
-- Name the archive after the feed's headline module (DepCtrl's own feed) where present,
-- otherwise fall back to the first module version so other feeds still bundle.
local mainVersion = feed:getModuleVersion("l0.DependencyControl")
if not mainVersion then
for ns in pairs(feed.data.modules or {}) do
mainVersion = feed:getModuleVersion(ns)
if mainVersion then break end
end
mainVersion = mainVersion or "0.0.0"
end
local suffix = GitRepository(feed.feedDir):getVersionSuffix()
local zipPath = outputDir .. pathSep .. (feed.data.name .. "-v%s%s.zip"):format(mainVersion, suffix)
local zipOk = false
if fileCount > 0 then
local success, archiveErr = ZipArchiver(zipPath):addDirectory(distDir):write()
if success then
zipOk = true
else
io.stderr:write("Warning: archive creation failed: " .. tostring(archiveErr) .. "\n")
end
end
local status = fileCount > 0 and "Bundle complete" or "Bundle produced no files"
io.stdout:write(("\n%s: %d file(s) in %s, %d error(s)\n"):format(status, fileCount, distDir, errCount))
if zipOk then io.stdout:write(("Archive: %s\n"):format(zipPath)) end
os.exit(errCount > 0 and 1 or 0)
-- ─── deploy ───────────────────────────────────────────────────────────────────
elseif args.command == "deploy" then
local feedPath = resolveAbsPath(args.feed)
local outputDir = resolveAbsPath(args.out_dir)
local clobber = args.clobber == true
setupDepCtrl("deploy")
local feed = loadFeed(feedPath)
local filter = buildFilter(args)
local fileCount, errCount = feed:deployFiles(outputDir, filter, clobber)
local status = fileCount > 0 and "Deploy complete" or "Deploy produced no files"
io.stdout:write(("\n%s: %d file(s) deployed to %s, %d error(s)\n"):format(status, fileCount, outputDir, errCount))
os.exit(errCount > 0 and 1 or 0)
-- ─── update-feed ──────────────────────────────────────────────────────────────
elseif args.command == "update-feed" then
local feedPath = resolveAbsPath(args.feed)
setupDepCtrl("update-feed")
local UpdateFeed = require "l0.DependencyControl.UpdateFeed"
local feed = UpdateFeed(nil, false, feedPath)
registerFeedSearcher(feed)
local stats, err = feed:updateFeed({
channel = args.channel,
filter = buildFilter(args),
schemaDir = table.concat({ launcherDir, "schemas", "feed" }, pathSep),
outPath = not args.dry_run, -- false = dry run; true = write back to the feed's own path
addFiles = args.add_files,
-- a date implies stamping; --mark-released alone stamps today (handled inside updateFeed)
markReleased = args.release_date or (args.mark_released and true) or nil,
})
if not stats then
io.stderr:write("Error updating feed: " .. tostring(err) .. "\n")
os.exit(1)
end
-- Per-package breakdown: one status line per package, with any errors indented beneath it.
local changedWord = args.dry_run and "would change" or "updated"
for _, pkg in ipairs(stats.packages) do
local label = pkg.namespace .. (pkg.channel and (" (" .. pkg.channel .. ")") or "")
local status
if #pkg.errors > 0 then
status = ("%d error%s"):format(#pkg.errors, #pkg.errors == 1 and "" or "s")
elseif pkg.changed then
status = changedWord
else
status = "no changes"
end
io.stdout:write((" %-48s %s\n"):format(label, status))
for _, added in ipairs(pkg.addedFiles or {}) do
io.stdout:write((" + %s%s\n"):format(added.name, added.type and (" [" .. added.type .. "]") or ""))
end
for _, e in ipairs(pkg.errors) do
io.stderr:write(" ! " .. (tostring(e):gsub("\n", "\n ")) .. "\n")
end
end
-- Summary
local total = #stats.packages
if stats.changed > 0 then
local verb = args.dry_run and "would change — dry run, nothing written" or ("updated in " .. feedPath)
io.stdout:write(("\n%d of %d package(s) %s\n"):format(stats.changed, total, verb))
else
io.stdout:write("\nFeed is already up to date.\n")
end
if stats.errored > 0 then
io.stdout:write(("%d package(s) had errors (see above).\n"):format(stats.errored))
end
os.exit(stats.errored > 0 and 1 or 0)
-- ─── resolve-feed ─────────────────────────────────────────────────────────────
elseif args.command == "resolve-feed" then
local feedPath = resolveAbsPath(args.feed)
setupDepCtrl("resolve-feed")
local UpdateFeed = require "l0.DependencyControl.UpdateFeed"
local feed = UpdateFeed(nil, false, feedPath)
local outPath, err = feed:resolve({
fileBaseUrl = args.file_base_url,
feedSchemaVersion = args.feed_schema_version,
outPath = args.out_file and resolveAbsPath(args.out_file) or nil,
})
if not outPath then
io.stderr:write("resolve-feed: " .. tostring(err) .. "\n")
os.exit(1)
end
io.stdout:write(("Resolved feed written to %s\n"):format(outPath))
os.exit(0)
-- ─── serve-updates ────────────────────────────────────────────────────────────
elseif args.command == "serve-updates" then
local feedPath = resolveAbsPath(args.feed)
local lifetime = tonumber(args.lifetime) or 3600
setupDepCtrl("serve-updates")
local UpdateFeed = require "l0.DependencyControl.UpdateFeed"
local FileOps = require "l0.DependencyControl.file-ops"
local json = require "l0.dkjson"
local socket = require "socket"
local copas = require "copas"
local Handler = require "pegasus.handler"
-- Refresh the feed's hashes/versions from the working copy into a throwaway copy (never the input), so
-- the served feed matches what's on disk and vanished files drop out; then resolve it into a static
-- feed an older client can read.
local sourceFeed = UpdateFeed(nil, false, feedPath)
registerFeedSearcher(sourceFeed)
local refreshedPath = FileOps.joinPath(assert(FileOps.createTempDir()), "refreshed.json")
local refreshed, refreshErr = sourceFeed:updateFeed({ outPath = refreshedPath })
if not refreshed then
io.stderr:write("serve-updates: couldn't refresh the feed: " .. tostring(refreshErr) .. "\n")
os.exit(1)
end
-- Optionally graft the dev channel onto a channel a client tracks (e.g. main -> alpha), so an older
-- Aegisub can be tested against this feed without merging channels by hand first.
local feedToServe = refreshedPath
if args.serve_channel then
local mergedPath = FileOps.joinPath(dirname(refreshedPath), "merged.json")
local merged, mergeErr = UpdateFeed(nil, false, refreshedPath):mergeChannels(UpdateFeed(nil, false, refreshedPath), {
from = args.from_channel,
to = { args.serve_channel },
outPath = mergedPath,
})
if not merged or #merged == 0 then
io.stderr:write(("serve-updates: couldn't graft channel '%s' -> '%s' (%s)\n"):format(
args.from_channel, args.serve_channel, merged and "no package uses that channel" or tostring(mergeErr)))
os.exit(1)
end
feedToServe = mergedPath
end
-- Bind first so the feed's file URLs can carry the real port.
local listener = assert(socket.bind("127.0.0.1", 0))
local _, port = listener:getsockname()
local base = "http://127.0.0.1:" .. port
local feed = UpdateFeed(nil, false, feedToServe)
local resolved, resolveErr = feed:resolve({ fileBaseUrl = base .. "/", outPath = false })
if not resolved then
io.stderr:write("serve-updates: " .. tostring(resolveErr) .. "\n")
os.exit(1)
end
-- the refreshed feed sits in a temp dir; point its file lookups back at the working copy
feed.feedDir = dirname(feedPath)
-- Map each URL path to the on-disk source it serves live (no copies); the feed itself is served from
-- memory. A file dropped on refresh (missing source) simply isn't mapped.
local manifest = { ["/DependencyControl.json"] = { body = json.encode(resolved, { indent = true }), mime = "application/json" } }
local served = 0
for file in feed:walkFiles() do
if not (file.delete or not file.localFilePath) then
manifest[file.url:sub(#base + 1)] = { path = file.localFilePath }
served = served + 1
end
end
local feedUrl = base .. "/DependencyControl.json"
local function handleRequest(req, res)
local entry = manifest[req:path()]
local body = entry and (entry.body or FileOps.readFile(entry.path))
if not body then
res:statusCode(404):write("not found")
return res:close()
end
res:statusCode(200)
res:addHeader("Content-Type", entry.mime or "application/octet-stream")
res:write(body)
res:close()
end
-- Self-terminate after the lifetime so a forgotten server can't linger.
copas.addthread(function()
local startedAt = os.time()
while os.time() - startedAt < lifetime do copas.sleep(1) end
os.exit(0)
end)
-- Point pegasus' static doc-root at an empty dir: it serves real files there before falling to our
-- handler, and a repo-root doc-root would shadow our in-memory feed with the on-disk DependencyControl.json.
local handler = Handler:new(handleRequest, assert(FileOps.createTempDir()), {}, nil)
copas.addserver(listener, copas.handler(function(client) handler:processRequest(port, client) end))
io.stdout:write(("\nServing %d file(s) live from %s\n"):format(served, dirname(feedPath)))
io.stdout:write("\n Feed URL:\n " .. feedUrl .. "\n")
io.stdout:write("\n Point a package at it via its userFeed in the DepCtrl config, e.g.:\n")
io.stdout:write((' "userFeed": "%s"\n'):format(feedUrl))
io.stdout:write("\n For a 0.7.0+ client, also set updates.blockPrivateHosts = false (loopback is blocked by default).\n")
io.stdout:write(("\nServing for up to %d s. Press Ctrl-C to stop.\n"):format(lifetime))
io.stdout:flush() -- flush so a caller reading our output sees the URL before copas blocks
copas.loop()
-- ─── bump-version ─────────────────────────────────────────────────────────────
elseif args.command == "bump-version" then
local levels = {}
for _, l in ipairs({ "major", "minor", "patch" }) do if args[l] then levels[#levels + 1] = l end end
if #levels ~= 1 then
io.stderr:write("Specify exactly one of --major, --minor, --patch.\n"); os.exit(2)
end
local haveNamespaces = #(args.namespace or {}) > 0
if haveNamespaces == args.all_changed then -- both given, or neither
io.stderr:write("Pass package namespace(s), or --all-changed, but not both.\n"); os.exit(2)
end
setupDepCtrl("bump-version")
local feed = loadFeed(resolveAbsPath(args.feed))
local stats, err = feed:bumpVersions({
channel = args.channel,
level = levels[1],
namespaces = haveNamespaces and args.namespace or nil,
allChanged = args.all_changed,
})
if not stats then
io.stderr:write("bump-version: " .. tostring(err) .. "\n"); os.exit(1)
end
if #stats.bumped == 0 then
io.stdout:write("Nothing to bump.\n"); os.exit(0)
end
for _, b in ipairs(stats.bumped) do
io.stdout:write((" %-40s %s -> %s\n"):format(b.namespace, b.from, b.to))
end
io.stdout:write(("\nBumped %d package(s) to %s on channel '%s'.\n"):format(
#stats.bumped, stats.target, stats.channel))
os.exit(0)
-- ─── merge-feed ───────────────────────────────────────────────────────────────
elseif args.command == "merge-feed" then
if not (args.into and args.from and args.to and args.default_channel) then
io.stderr:write("merge-feed requires --into, --from, --to and --default-channel.\n"); os.exit(2)
end
local intoPath = resolveAbsPath(args.into)
setupDepCtrl("merge-feed")
local source = loadFeed(resolveAbsPath(args.feed))
local dest = loadFeed(intoPath)
local to = {}
for word in args.to:gmatch("%S+") do to[#to + 1] = word end
local merged, err = dest:mergeChannels(source, {
from = args.from,
to = to,
defaultChannel = args.default_channel,
released = args.released,
outPath = not args.dry_run,
})
if not merged then
io.stderr:write("merge-feed: " .. tostring(err) .. "\n"); os.exit(1)
end
io.stdout:write(("Merged '%s' -> [%s] for %d package(s)%s\n"):format(
args.from, args.to, #merged, args.dry_run and " — dry run, nothing written" or (" into " .. intoPath)))
os.exit(0)
-- ─── release-notes ──────────────────────────────────────────────────────────────
elseif args.command == "release-notes" then
setupDepCtrl("release-notes")
local feed = loadFeed(resolveAbsPath(args.feed))
local notes, err = feed:formatReleaseNotes({
version = args.version,
channel = args.channel,
title = args.title,
})
if not notes then
io.stderr:write("release-notes: " .. tostring(err) .. "\n"); os.exit(1)
end
local body = #notes > 0 and (notes .. "\n") or notes
if args.output then
local path = resolveAbsPath(args.output)
local fh, fileErr = io.open(path, "w")
if not fh then
io.stderr:write("release-notes: can't write " .. path .. ": " .. tostring(fileErr) .. "\n"); os.exit(1)
end
fh:write(body); fh:close()
io.stdout:write("Wrote release notes to " .. path .. "\n")
else
io.stdout:write(body)
end
os.exit(0)
elseif args.command == "validate-schema" then
if args.type ~= "config" and args.type ~= "feed" then
io.stderr:write("--type must be 'config' or 'feed'.\n"); os.exit(2)
end
if not args.file then
io.stderr:write("--file <path> is required.\n"); os.exit(2)
end
local filePath = resolveAbsPath(args.file)
-- Loads DependencyControl, which registers its provides searcher so the vendored dkjson resolves as
-- 'json' — the module JsonSchema (and hence lua-schema) needs to parse schema files.
setupDepCtrl("validate-schema")
local FileOps = require "l0.DependencyControl.file-ops"
local json = require "l0.dkjson"
local JsonSchema = require "l0.DependencyControl.JsonSchema"
local raw, readErr = FileOps.readFile(filePath)
if not raw then
io.stderr:write(("Couldn't read '%s': %s\n"):format(filePath, tostring(readErr))); os.exit(1)
end
local data, _, decErr = json.decode(raw)
if type(data) ~= "table" then
io.stderr:write(("Couldn't parse '%s' as JSON: %s\n"):format(filePath, tostring(decErr or "not a JSON object")))
os.exit(1)
end
local schemaDir = table.concat({ launcherDir, "schemas", args.type }, pathSep)
local schemasByVersion, schemasErr = JsonSchema:getSchemasInDirectory(schemaDir)
if not schemasByVersion then
io.stderr:write(tostring(schemasErr) .. "\n"); os.exit(1)
end
local valid, version, message
if args.schema_version then
local schemaPath = schemasByVersion[args.schema_version]
if not schemaPath then
io.stderr:write(("No %s schema for version '%s' in %s\n"):format(args.type, args.schema_version, schemaDir))
os.exit(1)
end
version = args.schema_version
valid, message = JsonSchema(schemaPath):validate(data)
else
-- validateAny selects the schema from the file itself: a config by its root `$schema`; a feed carries no
-- `$schema`, so it's given a function that reads the feed's legacy `dependencyControlFeedFormatVersion`
-- field instead. A file with neither falls back through the available schemas, highest version first.
local hint
if args.type == "feed" then
hint = function(feed) return feed.dependencyControlFeedFormatVersion end
end
valid, version, message = JsonSchema:validateAny(data, schemasByVersion, hint)
end
if valid then
io.stdout:write(("'%s' is valid against the %s schema v%s.\n"):format(filePath, args.type, tostring(version)))
os.exit(0)
else
io.stderr:write(("'%s' failed %s schema validation%s:\n%s\n"):format(
filePath, args.type, version and (" (v" .. version .. ")") or "", tostring(message)))
os.exit(1)
end
-- ─── generate-types ───────────────────────────────────────────────────────────
elseif args.command == "generate-types" then
local feedPath = resolveAbsPath(args.feed)
local outDir = resolveAbsPath(args.out_dir)
setupDepCtrl("generate-types")
local FileOps = require "l0.DependencyControl.file-ops"
local feed = loadFeed(feedPath)
local filter = buildFilter(args)
if #(args.target_macro or {}) > 0 then
io.stderr:write("Note: macros are not require-able and have no type definitions; --target-macro selectors are ignored.\n")
end
local sources = collectModuleSources(feed, filter)
if #sources == 0 then
io.stderr:write("No module sources matched in feed '" .. feedPath .. "'.\n")
os.exit(1)
end
local MoonCats = require "l0.MoonCats"
local result = MoonCats():extractPackage(sources)
local diagnostics = result.diagnostics
local written, writeErrors = 0, 0
if not args.check then
for _, def in ipairs(result.definitions) do
local outPath = FileOps.getNamespacedPath(outDir, def.requireId, ".d.lua")
FileOps.mkdir(outPath, true, true)
local ok, writeErr = FileOps.writeFile(outPath, def.text, true)
if ok then
written = written + 1
io.stdout:write((" %-52s -> %s\n"):format(def.requireId, outPath))
else
writeErrors = writeErrors + 1
io.stderr:write(("! %s: couldn't write '%s': %s\n"):format(def.requireId, outPath, tostring(writeErr)))
end
end
end
local report = diagnostics:format()
if #report > 0 then
io.stdout:write("\n" .. report .. "\n")
end
local counts = diagnostics:getCounts()
local Severity = MoonCats.Diagnostics.Severity
io.stdout:write(("\n%d module(s) processed: %d error(s), %d warning(s), %d note(s)%s\n"):format(
#sources, counts[Severity.Error], counts[Severity.Warning], counts[Severity.Info],
args.check and " — check mode, nothing written" or (", %d definition(s) written to %s"):format(written, outDir)))
if args.check then
os.exit(diagnostics:hasCheckFailures() and 1 or 0)
end
-- generation only fails on parse/emit-level breakage or write errors; lint gating is --check's job
local hardFailure = writeErrors > 0
for _, finding in ipairs(diagnostics.findings) do
local FindingCode = MoonCats.Diagnostics.FindingCode
if finding.code == FindingCode.ParseFailure or finding.code == FindingCode.EmitFailure then
hardFailure = true
end
end
os.exit(hardFailure and 1 or 0)
-- ─── generate-docs ────────────────────────────────────────────────────────────
elseif args.command == "generate-docs" then
local feedPath = resolveAbsPath(args.feed)
local outDir = resolveAbsPath(args.out_dir)
if args.site ~= "mkdocs" and args.site ~= "mdbook" and args.site ~= "none" then
io.stderr:write("--site must be 'mkdocs', 'mdbook', or 'none'.\n"); os.exit(2)
end
setupDepCtrl("generate-docs")
local domain = require "l0.DependencyControl.domain"
local FileOps = require "l0.DependencyControl.file-ops"
local feed = loadFeed(feedPath)
local filter = buildFilter(args)
if #(args.target_macro or {}) > 0 then
io.stderr:write("Note: macros are not require-able and have no API docs; --target-macro selectors are ignored.\n")
end
local sources, selected = collectModuleSources(feed, filter)
if #sources == 0 then
io.stderr:write("No module sources matched in feed '" .. feedPath .. "'.\n")
os.exit(1)
end
-- Feed package info groups the index page: name/version/description per namespace,
-- plus the require ids of the modules each package owns.
local packages = {}
for pkg, scriptType in feed:walkPackages(filter) do
if scriptType == domain.ScriptType.Module and selected[pkg.namespace] then
packages[pkg.namespace] = {
name = pkg.name,
description = pkg.description,
version = feed:getModuleVersion(pkg.namespace),
modules = {},
}
end
end
for _, source in ipairs(sources) do
for namespace, info in pairs(packages) do
if source.requireId == namespace or source.requireId:sub(1, #namespace + 1) == namespace .. "." then
info.modules[#info.modules + 1] = source.requireId
end
end
end
local MoonCats = require "l0.MoonCats"
local result, diagnostics = MoonCats():renderDocs(sources, {
includePrivate = args.include_private,
siteName = args.site_name,
site = args.site,
packages = packages,
})
local written, writeErrors = 0, 0
local function writePage(page)
local outPath = outDir .. pathSep .. page.path
FileOps.mkdir(outPath, true, true)
local ok, writeErr = FileOps.writeFile(outPath, page.text, true)
if ok then
written = written + 1
else
writeErrors = writeErrors + 1
io.stderr:write(("! couldn't write '%s': %s\n"):format(outPath, tostring(writeErr)))
end
end
writePage(result.indexPage)
for _, page in ipairs(result.pages) do writePage(page) end
for _, file in ipairs(result.scaffold) do writePage(file) end
local report = diagnostics:format()
if #report > 0 then
io.stdout:write(report .. "\n")
end
local counts = diagnostics:getCounts()
local Severity = MoonCats.Diagnostics.Severity
io.stdout:write(("\n%d module(s) documented: %d page(s) written to %s (%d error(s), %d warning(s))\n"):format(
#sources, written, outDir, counts[Severity.Error], counts[Severity.Warning]))
local hardFailure = writeErrors > 0
for _, finding in ipairs(diagnostics.findings) do
if finding.code == MoonCats.Diagnostics.FindingCode.ParseFailure then hardFailure = true end
end
os.exit(hardFailure and 1 or 0)
end