diff --git a/.github/workflows/reusable-publish-release.yml b/.github/workflows/reusable-publish-release.yml index aabef27..d0ccdad 100644 --- a/.github/workflows/reusable-publish-release.yml +++ b/.github/workflows/reusable-publish-release.yml @@ -200,8 +200,8 @@ jobs: # Copy the source channel onto the published channel(s), preserving the others, then guard that # every tag the published feed references exists before pushing. On the first release the publish - # branch doesn't exist yet, so seed the worktree from the released commit and let the final push - # create it. + # branch doesn't exist yet, so seed the worktree from the released commit with an empty feed and + # let the final push create it. - name: Publish to the publish branch run: | set -euo pipefail @@ -211,6 +211,10 @@ jobs: else echo "Publish branch '${{ inputs.publish-branch }}' doesn't exist yet — bootstrapping it from the released commit." git worktree add --detach _publish HEAD + # That tree holds the dev feed, whose channels releases are cut from, so hand the merge an + # empty feed to fill instead: a dev channel copied in here would keep the `default: true` + # it carries there and go on claiming it alongside every channel published afterwards. + jq '{dependencyControlFeedFormatVersion, macros: {}, modules: {}}' DependencyControl.json > _publish/DependencyControl.json fi rsync -a --delete \ diff --git a/DependencyControl.json b/DependencyControl.json index 2ea06ec..e0f0f3a 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -55,26 +55,26 @@ "url": "@{baseUrl}#@{namespace}", "channels": { "main": { - "version": "0.8.1", - "released": "2026-07-24", + "version": "0.9.0", + "released": null, "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "F7B62EAC79C4FBA001B50C12506231C204CC7E80" + "sha1": "9F1500392C356076F0DFF32EE9BA690366CE0379" }, { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "9329D7020A486DE3F5202963522327AB709FF815", + "sha1": "DED2B18E6F5C3BC5644FDA1E7DD514C8C579CE83", "type": "test" } ], "requiredModules": [ { "moduleName": "l0.DependencyControl", - "version": "0.7.0" + "version": "0.9.0" } ] } @@ -109,6 +109,10 @@ ], "0.8.1": [ "fix: The Install Script macro no longer fails while scanning feeds that set URL templates per section, DependencyControl's own feed among them." + ], + "0.9.0": [ + "feat: A new Package Source Configuration macro lets you choose where a package's next update comes from — the source kind, channel, how firmly the choice is kept, and either a known feed, a custom feed URL, or a providing module.", + "fix: The Install, Uninstall and Update pickers now show each package's update channel beside its version, which was missing from the lists in most circumstances." ] } } @@ -121,14 +125,14 @@ "url": "@{baseUrl}#@{namespace}", "channels": { "main": { - "version": "0.8.2", - "released": "2026-08-17", + "version": "0.9.0", + "released": null, "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "29CCC772940BFB3F7A92FD93EA0306905A05897F" + "sha1": "89BA192E65D3446B8A066F4A5816EBA8560773E0" }, { "name": "/Constants.moon", @@ -138,12 +142,12 @@ { "name": "/ConfigHandler.moon", "url": "@{fileBaseUrl}", - "sha1": "6C8ACA7DD2BBA8AD22AB7E488F918297B820EDF9" + "sha1": "B19475DF8C0D1C02491ED36BAB1355AC1016280E" }, { "name": "/ConfigView.moon", "url": "@{fileBaseUrl}", - "sha1": "BCCB1B7E7A85245BCDD87CE0CCCC1955CB161827" + "sha1": "2530A6192FCCBA3B9BE4D946BEAA359B429F14B4" }, { "name": "/hash.moon", @@ -173,7 +177,7 @@ { "name": "/FeedInventory.moon", "url": "@{fileBaseUrl}", - "sha1": "4EB32746AE7D048F15355350827C9A470CB5C384" + "sha1": "4E2075F12A5EB4A726A218F3F76CDEEAC6E0F104" }, { "name": "/FeedManager.moon", @@ -254,12 +258,12 @@ { "name": "/ScriptUpdateRecord.moon", "url": "@{fileBaseUrl}", - "sha1": "9553BD9771DFEF0E35BA04814E2EAFFA0E27C51A" + "sha1": "1D1BB6F7CEDCBDC92925E412D3EFD0DF3400353F" }, { "name": "/SemanticVersion.moon", "url": "@{fileBaseUrl}", - "sha1": "663BB4263B99C01E69AD897DAD7CF6420E17A666" + "sha1": "D13E41C35B1B287752420C73D124EC41D3861BF7" }, { "name": "/Stub.moon", @@ -279,12 +283,12 @@ { "name": "/UpdateFeed.moon", "url": "@{fileBaseUrl}", - "sha1": "521AD8915BF09A02167ACDF73CE4C39E928F504A" + "sha1": "F136EA50649C7A4056453CE08F8498102334294E" }, { "name": "/UpdateTask.moon", "url": "@{fileBaseUrl}", - "sha1": "4C1B8BFBE29120014A182F37F1D9E7AA0E456C07" + "sha1": "F0E0D701FD8325B407A508C5A6EEFA522158ECB3" }, { "name": "/Updater.moon", @@ -344,7 +348,7 @@ { "name": "/config-schema.moon", "url": "@{fileBaseUrl}", - "sha1": "CC74ACBE7A7CD8761AD714564F3805AC96F68067" + "sha1": "25A8986ED53685D995338E4FE0A9446D0A284E6B" }, { "name": "/Finalizer.moon", @@ -359,7 +363,7 @@ { "name": "/domain.moon", "url": "@{fileBaseUrl}", - "sha1": "C5D9BC686DFA4A85A28EE6E2E392B1E012A83909" + "sha1": "97AB343E57DACCD4CF7C97DA6CDBDC5F03CEC68E" }, { "name": "/environment.moon", @@ -392,7 +396,7 @@ { "name": "/ConfigView.moon", "url": "@{fileBaseUrl}", - "sha1": "A0E00272169CA1FFAADF0A672C468A975D9B9F82", + "sha1": "2B269DFD47F980F17C4A72A84BCA9255A2C214BC", "type": "test" }, { @@ -422,7 +426,7 @@ { "name": "/FeedInventory.moon", "url": "@{fileBaseUrl}", - "sha1": "5D820751E60E8609CD65427ED01DD312E869001B", + "sha1": "D4ADBFEFE046D722631BE0DB0A39D8DDC72E61C4", "type": "test" }, { @@ -524,13 +528,13 @@ { "name": "/ScriptUpdateRecord.moon", "url": "@{fileBaseUrl}", - "sha1": "287B0C04CC3A4BA92FE30E2258D7EFEA922AFE58", + "sha1": "1C7EB37806E609FC57ACE47BA96F0F268F3E205E", "type": "test" }, { "name": "/SemanticVersion.moon", "url": "@{fileBaseUrl}", - "sha1": "E242FE3C0332C60468E4B3F76DCFDE1D631172F9", + "sha1": "F6FBCEC0B847AA71A4CF58967C8D6AAC2D8E26EF", "type": "test" }, { @@ -548,13 +552,13 @@ { "name": "/UpdateFeed.moon", "url": "@{fileBaseUrl}", - "sha1": "4A3CE78253A52C70D6C28394BF724F9D0552DB0E", + "sha1": "6E2D5DDD51871129F3E4FCE504AFD8467B38AC61", "type": "test" }, { "name": "/UpdateTask.moon", "url": "@{fileBaseUrl}", - "sha1": "D1C04F53DE0609804FE0E9B63AF5DD1EEA2D1D5B", + "sha1": "E380E80D439F78699805E8A5FD9D9A38D84A82BF", "type": "test" }, { @@ -608,7 +612,7 @@ { "name": "/config-schema.moon", "url": "@{fileBaseUrl}", - "sha1": "C65BC75E4ACF11F2CA174255CB6792C3C4FAD7A7", + "sha1": "8EE4EA529ABF8F8AF4C386B4C4713D92ED163089", "type": "test" }, { @@ -786,6 +790,19 @@ "fix(CLI): `generate-types` and `generate-docs` skip files a feed marks deleted instead of reporting each one as an unreadable source", "fix(CLI): a deleted file's entry can no longer shadow the source that replaced it when a test suite resolves its modules from the feed.", "fix(Updater): Updates no longer fail with 'the update task is already running' for modules that require one another (such as Aegisub-Motion's 'Tags' and 'Transform')." + ], + "0.9.0": [ + "fix: A feed that flags more than one channel as its default no longer sends an install to a different channel from one session to the next. The pick is stable now and the feed is reported as faulty, and installs that a CI mishap left on the internal 'main' channel move to 'stable'.", + "fix(Updater): Asking for a specific update channel now takes effect on a package that already has one recorded, and a module that provides another is judged on the channel recorded for it rather than its feed's default. A recorded channel the feed no longer offers falls back to the default instead of failing the update — unless you pinned the package's source, which is reported as unavailable rather than quietly moved.", + "feat(Updater)!: Where a package will update from and where its installed copy came from are now recorded separately, as `configuredSource` and `currentSource`, so a source change you have made is distinguishable from one an update has applied. Both are written as soon as an install finishes — a fresh install used to record neither — and the origin stores the feed URL outright, so changing a feed override afterwards no longer rewrites where the copy came from. Existing configs migrate automatically.", + "fix(Updater): A second update attempt in the same session works again. A package remembered the feeds its first attempt had consulted and skipped them next time, so a retry passed over the very feed that offers it and reported that none was available.", + "fix(CLI): `merge-feed` now clears the `default` flag from every channel but the one it is told is the default, so publishing can't leave a feed with two default channels. It also copies each section's own file templates, which a feed merged into a fresh file used to lose.", + "fix(CLI): `serve-updates` no longer fails on a feed that already matches its working copy.", + "feat(CLI): `serve-updates` now accepts an optional `--port` argument, so a test feed URL stays valid across runs.", + "feat(CLI): `--serve-channel` can be given more than once to graft the dev build onto several channels at once, the first of which becomes the served feed's default.", + "fix: A package's settings are no longer reverted by a second DependencyControl instance writing the same config file (e.g. during a DependencyControl update, in another automation script context, or from another Aegisub process). Each save now writes only the fields it changed since it last read the file.", + "fix: A regression introduced in v0.7.0 was fixed that prevented the new version number of updated packages from being written into the DependencyControl config file when nothing else in its DependencyControl record changed.", + "fix: Changes to settings that affect feed fetching (private host blocking, feed size and timeout limits) now take effect immediately instead of requiring an automation scripts reload." ] } }, diff --git a/depctrl.lua b/depctrl.lua index 0a7b026..ba286cd 100644 --- a/depctrl.lua +++ b/depctrl.lua @@ -96,9 +96,12 @@ local serveCmd = parser:command("serve-updates", 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(""):default("3600") +serveCmd:option("--port", + "Port to listen on, so a feed URL stays valid across runs (default: any free port)"):argname("") serveCmd:option("--serve-channel", - "Graft the feed's dev channel onto this channel before serving, so a client tracking it matches") - :argname("") + "Graft the feed's dev channel onto this channel before serving, so a client tracking it matches. " + .. "When specified multiple times, the first is served as the default channel") + :argname(""):count("*") serveCmd:option("--from-channel", "Dev channel to graft from when --serve-channel is given"):argname(""):default("main") local bumpCmd = parser:command("bump-version", @@ -580,24 +583,39 @@ elseif args.command == "serve-updates" then -- 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 feedToServe = refreshed.changed > 0 and refreshedPath or feedPath + local serveChannels = args.serve_channel or {} + if #serveChannels > 0 then local mergedPath = FileOps.joinPath(dirname(refreshedPath), "merged.json") - local merged, mergeErr = UpdateFeed(nil, false, refreshedPath):mergeChannels(UpdateFeed(nil, false, refreshedPath), { + local merged, mergeErr = UpdateFeed(nil, false, feedToServe):mergeChannels(UpdateFeed(nil, false, feedToServe), { from = args.from_channel, - to = { args.serve_channel }, + to = serveChannels, + defaultChannel = serveChannels[1], 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))) + args.from_channel, table.concat(serveChannels, "', '"), + 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 requestedPort = 0 + if args.port then + requestedPort = tonumber(args.port) + if not requestedPort or requestedPort % 1 ~= 0 or requestedPort < 1 or requestedPort > 65535 then + io.stderr:write(("serve-updates: --port must be a whole number in [1, 65535], got '%s'.\n"):format(tostring(args.port))) + os.exit(2) + end + end + local listener, bindErr = socket.bind("127.0.0.1", requestedPort) + if not listener then + io.stderr:write(("serve-updates: couldn't listen on port %d (%s).\n"):format(requestedPort, tostring(bindErr))) + os.exit(1) + end local _, port = listener:getsockname() local base = "http://127.0.0.1:" .. port diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index 4c9bf38..3c7b2f6 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -1,19 +1,23 @@ export script_name = "DependencyControl Toolbox" export script_description = "Provides DependencyControl maintenance and configuration tools." -export script_version = "0.8.1" -- @{l0.DependencyControl.Toolbox:version} +export script_version = "0.9.0" -- @{l0.DependencyControl.Toolbox:version} export script_author = "line0" export script_namespace = "l0.DependencyControl.Toolbox" DepCtrl = require "l0.DependencyControl" -{:ScriptType, :ScriptTypeSection, :FetchUntrustedFeeds, terms} = DepCtrl.Domain +{:ScriptType, :ScriptTypeSection, :FetchUntrustedFeeds, :SourceChoiceStickiness, terms} = DepCtrl.Domain configSchema = require "l0.DependencyControl.config-schema" constants = require "l0.DependencyControl.Constants" FileCache = require "l0.DependencyControl.FileCache" +ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" UpdateTask = require "l0.DependencyControl.UpdateTask" +utils = require "l0.DependencyControl.utils" +FeedInventory = DepCtrl.FeedInventory +SourceFeedKind = UpdateTask.SourceFeedKind depRec = DepCtrl { feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/DependencyControl.json", { - {"l0.DependencyControl", version: "0.7.0"} + {"l0.DependencyControl", version: "0.9.0"} } } logger = DepCtrl.logger @@ -54,6 +58,36 @@ msgs = { saved: "Configuration saved." restored: "Settings restored to their defaults." } + sourceConfig: { + noSelection: "Select an automation script or module to configure." + header: "Update source for %s" + installedFrom: "Installed from: %s [%s]" + installedNever: "Installed from: not recorded yet" + updatesFrom: "Updates from: %s [%s]" + unresolved: "(unresolved)" + saved: "Update source saved for %s." + diverged: "This takes effect on the next update; the installed copy still comes from %s." + needFeed: "Pick one of the known feeds, or choose a different source kind." + needUrl: "Enter a custom feed URL, or choose a different source kind." + needProvider: "Pick a providing module, or choose a different source kind." + scanning: "Checking which feeds offer this package…" + consulting: "Consulting %s…" + consultFailed: "Couldn't load %s; leaving it out of the lists." + discovering: "Discovering feeds — this may take a moment…" + discovered: "Discovery finished. The lists now include every feed it found." + pickChannel: "Select a channel offered by %s." + needChannel: "Select a channel." + noProviders: "No known feed or installed module provides '%s'." + hints: { + kind: "Which source the next update comes from. The field it reads is listed beside each kind." + channel: "Channels the selected feed offers. Press 'Refresh Channels' after changing the feed or source kind." + providerChannel: "Managed by the providing module. Shown for information." + stickiness: "How firmly this choice is kept when the updater resolves a source next time." + knownFeed: "Known feeds that offer this package. Used when the source kind is 'other'; press 'Discover' to search all reachable feeds." + userFeed: "Saved as this package's feed override; used as the source when the kind is 'user feed'." + provider: "Modules that can fill in for this one, from the known feeds and your installed modules. Used when the source kind is 'provider'." + } + } } -- Shared Functions @@ -64,6 +98,7 @@ FeedAction = DepCtrl.FeedManager.FeedAction -- touching dispatch logic — and shared with the tests through testExports, so a rename needs no test edits. buttons = { apply: "Apply" + refreshChannels: "Refresh Channels" save: "Save" restoreDefaults: "Restore Defaults" close: "Close" @@ -95,12 +130,15 @@ feedActionByLabel = {label, action for action, label in pairs feedActionLabels} ---@param populate fun(add: fun(item: string, record: any)) Enumerates the rows, calling `add` once per (display string, record) pair. ---@return string[] list Display strings, sorted case-insensitively. ---@return table map Each display string mapped to its record. +-- every list the dialogs show is ordered this way, so a capitalized entry doesn't jump ahead of the rest +byDisplayString = (a, b) -> a\lower! < b\lower! + buildSortedDlgList = (populate) -> list, map = {}, {} populate (item, record) -> list[#list+1] = item map[item] = record - table.sort list, (a, b) -> a\lower! < b\lower! + table.sort list, byDisplayString return list, map buildInstalledDlgList = (scriptType, config, isUninstall) -> @@ -113,8 +151,9 @@ buildInstalledDlgList = (scriptType, config, isUninstall) -> for namespace, script in pairs config.c[scriptType] continue if protectedModules[namespace] -- config entries are on-disk data: an orphaned or unmanaged record may lack name/version + channel = ScriptUpdateRecord.getRecordedChannel script item = "%s v%s%s"\format script.name or namespace, DepCtrl.SemanticVersion\toString(script.version) or "?", - script.activeChannel and " [#{script.activeChannel}]" or "" + channel and " [#{channel}]" or "" add item, script getConfig = (section) -> @@ -763,15 +802,334 @@ globalConfig = -> logger\log msgs.globalConfig.saved break +-- Package Source Configuration macro: edits where a package's *next* update comes from, without running +-- one. The result is written to `configuredSource`, leaving the `currentSource` record of where the +-- installed copy came from untouched until an update actually applies the change. +-- +-- Two things a static Aegisub dialog can't do shape this: it can't grey out the fields a chosen kind +-- doesn't read, and it can't repopulate a dropdown when another changes. So the macro reopens itself — +-- Refresh Channels re-renders with the channels of whichever feed is now selected, and a selection that +-- no longer fits comes back with a placeholder selected and the reason in the dialog's message line. +sourceKindLabels = { + [SourceFeedKind.SelfDeclared]: "the package's own feed" + [SourceFeedKind.UserFeed]: "custom feed URL (below)" + [SourceFeedKind.Provider]: "providing module (below)" + [SourceFeedKind.Other]: "other known feed (below)" +} +stickinessLabels = { + [SourceChoiceStickiness.Unset]: "unset - resolve normally" + [SourceChoiceStickiness.Once]: "once - ask again, preselecting this" + [SourceChoiceStickiness.Retain]: "retain - reuse while it stays eligible" + [SourceChoiceStickiness.Pinned]: "pinned - always reuse; fail if it's gone" + [SourceChoiceStickiness.Auto]: "auto - never ask, always re-rank" +} +-- Marks an entry kept only because it is the current selection, and a dropdown with nothing valid to offer. +staleMark = "⚠ " +emptyChoice = "— none available —" +pickChoice = "— select one —" + +---Reports whether a package's feed entry declares a `provides` alias for the given namespace. +---@param pkg table A package's raw feed or config data. +---@param namespace string The alias to look for. +---@return boolean provides +providesAlias = (pkg, namespace) -> + for alias in *(pkg.provides or {}) + name = type(alias) == "table" and alias.name or alias + return true if name == namespace + false + +---Scans the given feeds' cached snapshots for the package. Reads only what the cache already holds, so a +---feed nobody has fetched is left out; the feeds this package could update from are loaded separately and +---Discover fills in the rest. Raw feed data is enough here, since channel names and `provides` aliases are +---literals that need no template expansion. +---@param namespace string The package being configured. +---@param scriptType ScriptType The package's script type, selecting the feed section to read. +---@param cache FileCache The feed cache to read snapshots from. +---@param feedUrls string[] The feeds to look in. +---@return table channelsByFeed Each feed that offers the package, mapped to its sorted channel names. +---@return table providers Each module that provides the package, mapped to its display name and the feed it was found in. +scanCachedFeeds = (namespace, scriptType, cache, feedUrls) -> + section = ScriptTypeSection[scriptType] + channelsByFeed, providers = {}, {} + for url in *feedUrls + data = cache\get url + continue unless type(data) == "table" + + pkg = data[section] and data[section][namespace] + if type(pkg) == "table" and type(pkg.channels) == "table" + channels = ScriptUpdateRecord(namespace, pkg, nil, scriptType, false, logger)\getChannels! + table.sort channels, byDisplayString + channelsByFeed[url] = channels + + for providerNamespace, providerPkg in pairs data.modules or {} + continue if providerNamespace == namespace or providers[providerNamespace] + continue unless type(providerPkg) == "table" and providesAlias providerPkg, namespace + providers[providerNamespace] = {name: providerPkg.name, feedUrl: url} + channelsByFeed, providers + +---Folds installed modules that provide this namespace into a provider map, so one stays on offer once +---installed even after its feed drops out of the cache. +---@param providers table The provider map to add to, mutated in place. +---@param namespace string The package being configured. +---@param modulesSection table The modules section of DependencyControl's config. +---@return table providers The same map. +addInstalledProviders = (providers, namespace, modulesSection) -> + for providerNamespace, pkg in pairs modulesSection + continue if providerNamespace == namespace or providers[providerNamespace] + continue unless providesAlias pkg, namespace + providers[providerNamespace] = {name: pkg.name, feedUrl: pkg.feed} + providers + +---A dropdown's items and the maps between its labels and values. The current value is kept even when it +---isn't among the entries, flagged as stale so a selection is never silently dropped; an empty list still +---gets one entry, since an Aegisub dropdown can't be empty. +---@param entries {value: any, label: string}[] The choices to offer. +---@param currentValue? any The value selected now, kept on offer whether or not the entries hold it. +---@param staleLabel? string Label for the current value when the entries don't hold it (defaults to the value). +---@param keepOrder? boolean Leave the entries in the order given instead of sorting them (default false). +---@return string[] items The labels, with a stale or placeholder entry first where there is one. +---@return table byLabel Each label mapped to its value. +---@return table labelByValue Each value mapped to its label. +buildChoiceList = (entries, currentValue, staleLabel, keepOrder) -> + items, byLabel, labelByValue = {}, {}, {} + for entry in *entries + items[#items + 1] = entry.label + byLabel[entry.label] = entry.value + labelByValue[entry.value] = entry.label + table.sort items, byDisplayString unless keepOrder + if currentValue and not labelByValue[currentValue] + label = staleMark .. (staleLabel or currentValue) + table.insert items, 1, label + byLabel[label] = currentValue + labelByValue[currentValue] = label + items[1] = emptyChoice if #items == 0 + items, byLabel, labelByValue + +---An ordered label list and its label to value map, so the dialog branches on a stable value (DLG1). +---@param enumValues any[] The values to offer, in the order they should appear. +---@param labels table Each value's display label. +---@return string[] items The labels, in the given order. +---@return table byLabel Each label mapped back to its value. +---@return table labelByValue Each value mapped to its label. +buildLabelChoices = (enumValues, labels) -> + buildChoiceList [{value: value, label: labels[value]} for value in *enumValues], nil, nil, true + +sourceConfig = -> + config = getConfig! + moduleList, moduleMap = buildInstalledDlgList "modules", config + macroList, macroMap = buildInstalledDlgList "macros", config + + btn, res = aegisub.dialog.display getScriptListDlg macroList, moduleList + return unless btn + pkg = moduleMap[res.module] or macroMap[res.macro] + unless pkg + logger\log msgs.sourceConfig.noSelection + return + + scriptType = moduleMap[res.module] and ScriptType.Module or ScriptType.Automation + sectionConfig = getConfig ScriptTypeSection[scriptType] + modulesSection = (getConfig ScriptTypeSection[ScriptType.Module]).c + feedLoader = DepCtrl.updater.feedLoader + installed = pkg.currentSource + installedUrl = installed and UpdateTask.resolveSourceUrl installed, pkg.feed, pkg.userFeed, modulesSection + describeUrl = (url) -> url and shortenUrl(url) or msgs.sourceConfig.unresolved + + local channelsByFeed, providers + rescanCache = -> + feedUrls = [entry.url for entry in *buildFeedInventory!\gather!] + channelsByFeed, providers = scanCachedFeeds pkg.namespace, scriptType, feedLoader.cache, feedUrls + addInstalledProviders providers, pkg.namespace, modulesSection + + logger\log msgs.sourceConfig.scanning + rescanCache! + + -- A feed a selection can name has to be current, not merely cached: an empty channel list would + -- otherwise look the same as a feed nobody has crawled. The record is built over the package's own + -- config, so the channel it settles on is the one an update would install from. Only the few feeds a + -- selection can name are ever fetched here; Discover is what widens the known-feed list. + loadUpdateRecord = (url, namespace = pkg.namespace) -> + return nil unless url + ownPackage = namespace == pkg.namespace + -- a provider is a module by definition; the package itself may be either + kind = ownPackage and scriptType or ScriptType.Module + installedConfig = ownPackage and pkg or modulesSection[namespace] + logger\log msgs.sourceConfig.consulting, shortenUrl url + loaded, feed = pcall -> feedLoader\load url + unless loaded and feed + logger\trace msgs.sourceConfig.consultFailed, shortenUrl url + return nil + record = feed\getScript namespace, kind, installedConfig and {c: installedConfig}, false + record or nil + + getChannelsFor = (url) -> + return channelsByFeed[url] if url and channelsByFeed[url] + record = loadUpdateRecord url + return nil unless record + channels = record\getChannels! + table.sort channels, byDisplayString + channelsByFeed[url] = channels + channels + + kindItems, kindByLabel = buildLabelChoices SourceFeedKind.values, sourceKindLabels + stickyItems, stickyByLabel = buildLabelChoices SourceChoiceStickiness.values, stickinessLabels + + -- the edited record, seeded from the configured source and carried across reopens + configured = ScriptUpdateRecord.getRecordedSource(pkg) or {} + _, effectiveKind = FeedInventory.getEffectiveSource pkg, modulesSection + state = { + -- with no source recorded (e.g. a config from before v0.7.0), the kind seeds from whatever + -- getEffectiveSource fell back on, so the dialog opens on the updater's actual first pick + kind: configured.feedSource or effectiveKind or SourceFeedKind.SelfDeclared + channel: configured.channel + stickiness: configured.stickiness or SourceChoiceStickiness.Unset + knownFeed: configured.feedUrl + userFeed: pkg.userFeed + provider: configured.provider and configured.provider.namespace + } + + -- the feed a selection resolves to, which decides both what an update will fetch and which channels are + -- on offer below + getSelectedFeedUrl = -> + switch state.kind + when SourceFeedKind.SelfDeclared then pkg.feed + when SourceFeedKind.UserFeed then state.userFeed + when SourceFeedKind.Other then state.knownFeed + when SourceFeedKind.Provider + entry = state.provider and providers[state.provider] + entry and entry.feedUrl + + -- A provider's channel is settled by the same resolution an update runs, over the providing module's own + -- config, so this package's dialog shows it and never asks for it. + getProviderChannel = -> + return nil unless state.provider + entry = providers[state.provider] + record = entry and loadUpdateRecord entry.feedUrl, state.provider + if record + _, channel = record\setChannel! + return channel + providerConfig = modulesSection[state.provider] + providerConfig and ScriptUpdateRecord.getRecordedChannel providerConfig + + -- the package's own candidate feeds are loaded up front, so the dialog opens with a usable channel list + -- whether or not anything has ever crawled them + getChannelsFor url for url in *{pkg.feed, pkg.userFeed, configured.feedUrl} + + message = nil + while true + feedEntries = [{value: url, label: shortenUrl url} for url in pairs channelsByFeed] + feedItems, feedByLabel, feedLabelByValue = buildChoiceList feedEntries, state.knownFeed, + state.knownFeed and shortenUrl state.knownFeed + providerEntries = [{value: ns, label: "#{entry.name or ns} (#{ns})"} for ns, entry in pairs providers] + providerItems, providerByLabel, providerLabelByValue = buildChoiceList providerEntries, state.provider, + state.provider + + feedUrl = getSelectedFeedUrl! + -- A provider manages the channel, so it is shown for information and never asked for. + providerManaged = state.kind == SourceFeedKind.Provider + local channelItems + if providerManaged + state.channel = getProviderChannel! + channelItems = {state.channel or emptyChoice} + else + channels = getChannelsFor(feedUrl) or pkg.channels or {} + channelItems = buildChoiceList [{value: name, label: name} for name in *channels], state.channel + unless state.channel and (utils.makeSet channelItems)[state.channel] + table.insert channelItems, 1, pickChoice + message or= msgs.sourceConfig.pickChannel\format describeUrl feedUrl + state.channel = nil + + configuredUrl = FeedInventory.getEffectiveSource pkg, modulesSection + dlg = { + {class: "label", x: 0, y: 0, width: 3, height: 1, + label: msgs.sourceConfig.header\format pkg.name or pkg.namespace} + {class: "label", x: 0, y: 1, width: 3, height: 1, label: installed and + msgs.sourceConfig.installedFrom\format(describeUrl(installedUrl), installed.channel or "?") or + msgs.sourceConfig.installedNever} + {class: "label", x: 0, y: 2, width: 3, height: 1, + label: msgs.sourceConfig.updatesFrom\format describeUrl(configuredUrl), configured.channel or "?"} + + {class: "label", x: 0, y: 4, width: 1, height: 1, label: "Source kind: "} + {class: "dropdown", name: "kind", x: 1, y: 4, width: 2, height: 1, items: kindItems, + value: sourceKindLabels[state.kind], hint: msgs.sourceConfig.hints.kind} + {class: "label", x: 0, y: 5, width: 1, height: 1, label: "Channel: "} + {class: "dropdown", name: "channel", x: 1, y: 5, width: 2, height: 1, items: channelItems, + value: state.channel or channelItems[1], + hint: providerManaged and msgs.sourceConfig.hints.providerChannel or msgs.sourceConfig.hints.channel} + {class: "label", x: 0, y: 6, width: 1, height: 1, label: "Keep this choice: "} + {class: "dropdown", name: "stickiness", x: 1, y: 6, width: 2, height: 1, items: stickyItems, + value: stickinessLabels[state.stickiness], hint: msgs.sourceConfig.hints.stickiness} + + {class: "label", x: 0, y: 8, width: 1, height: 1, label: "Known feed: "} + {class: "dropdown", name: "knownFeed", x: 1, y: 8, width: 2, height: 1, items: feedItems, + value: feedLabelByValue[state.knownFeed] or feedItems[1], hint: msgs.sourceConfig.hints.knownFeed} + {class: "label", x: 0, y: 9, width: 1, height: 1, label: "Custom feed URL: "} + {class: "edit", name: "userFeed", x: 1, y: 9, width: 2, height: 1, + text: state.userFeed and shortenUrl(state.userFeed) or "", hint: msgs.sourceConfig.hints.userFeed} + {class: "label", x: 0, y: 10, width: 1, height: 1, label: "Providing module: "} + {class: "dropdown", name: "provider", x: 1, y: 10, width: 2, height: 1, items: providerItems, + value: providerLabelByValue[state.provider] or providerItems[1], + hint: msgs.sourceConfig.hints.provider} + + {class: "label", x: 0, y: 12, width: 3, height: 1, label: message or ""} + } + + btn, res = aegisub.dialog.display dlg, + {buttons.refreshChannels, buttons.discover, buttons.save, buttons.close}, + {ok: buttons.save, cancel: buttons.close} + return unless btn and btn != buttons.close + + state.kind = kindByLabel[res.kind] + state.stickiness = stickyByLabel[res.stickiness] + state.channel = res.channel != pickChoice and res.channel != emptyChoice and res.channel or nil + state.knownFeed = feedByLabel[res.knownFeed] + state.provider = providerByLabel[res.provider] + state.userFeed = res.userFeed != "" and expandUrl(res.userFeed) or nil + + if btn == buttons.discover + logger\log msgs.sourceConfig.discovering + crawlWithPrompt buildFeedInventory! + rescanCache! + message = msgs.sourceConfig.discovered + continue + + -- reopen instead of writing a record whose kind has nothing to read, or a channel nothing offers + message = nil + switch state.kind + when SourceFeedKind.Other + message = msgs.sourceConfig.needFeed unless state.knownFeed + when SourceFeedKind.UserFeed + message = msgs.sourceConfig.needUrl unless state.userFeed + when SourceFeedKind.Provider + unless state.provider + message = #providerItems > 1 and msgs.sourceConfig.needProvider or + msgs.sourceConfig.noProviders\format pkg.namespace + message or= msgs.sourceConfig.needChannel unless state.channel or state.kind == SourceFeedKind.Provider + continue if message or btn == buttons.refreshChannels + + configuredSource = {feedSource: state.kind, channel: state.channel, stickiness: state.stickiness} + configuredSource.feedUrl = state.knownFeed if state.kind == SourceFeedKind.Other + configuredSource.provider = {namespace: state.provider} if state.kind == SourceFeedKind.Provider + + pkg.userFeed, pkg.configuredSource = state.userFeed, configuredSource + sectionConfig\save! + + logger\log msgs.sourceConfig.saved, pkg.name or pkg.namespace + if installed and installedUrl != FeedInventory.getEffectiveSource pkg, modulesSection + logger\log msgs.sourceConfig.diverged, describeUrl installedUrl + return + depRec\registerMacros { {"Install Script", "Installs an automation script or module on your system.", install}, {"Update Script", "Manually check and perform updates to any installed script.", update}, {"Uninstall Script", "Removes an automation script or module from your system.", uninstall}, {"Manage Feeds", "See and manage the feeds DependencyControl knows about and their trust status.", manageFeeds}, {"Macro Configuration", "Lets you change per-automation script settings.", macroConfig}, + {"Package Source Configuration", "Choose where a package's next update comes from, without running one.", sourceConfig}, {"Global Configuration", "View and edit DependencyControl's global settings.", globalConfig}, }, "DependencyControl", {:shortenUrl, :expandUrl, :formatAge, :buildInstalledDlgList, :promptUntrustedFeed, :confirmDialog, :manageExtraFeeds, :manageBlockList, :buttons, :feedActionLabels, :configFields, + :sourceKindLabels, :stickinessLabels, :buildLabelChoices, :buildChoiceList, :scanCachedFeeds, + :addInstalledProviders, :scheduleUpdatesAndRegisterTests} -- The startup sweep is an Aegisub-session concern; headless (CLI/test runner) has no session to diff --git a/macros/l0.DependencyControl.Toolbox/test.moon b/macros/l0.DependencyControl.Toolbox/test.moon index 6e4e426..a7d9371 100644 --- a/macros/l0.DependencyControl.Toolbox/test.moon +++ b/macros/l0.DependencyControl.Toolbox/test.moon @@ -11,6 +11,8 @@ DepCtrl = require "l0.DependencyControl" UnitTestSuite "l0.DependencyControl.Toolbox", (macros, dependencies, testExports, controls) -> {:shortenUrl, :expandUrl, :formatAge, :buildInstalledDlgList, :promptUntrustedFeed, :confirmDialog, :manageExtraFeeds, :manageBlockList, :buttons, :feedActionLabels, + :sourceKindLabels, :stickinessLabels, :buildLabelChoices, :buildChoiceList, :scanCachedFeeds, + :addInstalledProviders, :scheduleUpdatesAndRegisterTests} = testExports -- The UninstallFlow seam: its _setup swaps the DepCtrl class's __call for a constructor returning @@ -190,11 +192,16 @@ UnitTestSuite "l0.DependencyControl.Toolbox", (macros, dependencies, testExports list = buildInstalledDlgList "macros", config, false ut\assertEquals list, {"apple v1.0.0", "Mango v1.0.0", "Zebra v1.0.0"} - -- a package with a non-default active channel shows the channel in brackets - formatsActiveChannel: (ut) -> - config = makeConfig "macros", {"a.x": {name: "X", version: "2.0.0", activeChannel: "beta"}} + -- a package with a recorded channel shows the channel in brackets; the package source's channel + -- wins over the pre-0.7 lastChannel, which stands in alone on an install upgraded from v0.6.3 + formatsRecordedChannel: (ut) -> + config = makeConfig "macros", { + "a.x": {name: "X", version: "2.0.0", lastChannel: "beta", currentSource: {channel: "stable"}} + "a.y": {name: "Y", version: "1.0.0", lastChannel: "beta"} + } list = buildInstalledDlgList "macros", config, false - ut\assertEquals list[1], "X v2.0.0 [beta]" + ut\assertEquals list[1], "X v2.0.0 [stable]" + ut\assertEquals list[2], "Y v1.0.0 [beta]" -- an entry lacking name/version (e.g. an orphaned unmanaged record) still gets a row toleratesMissingVersionAndName: (ut) -> @@ -202,7 +209,93 @@ UnitTestSuite "l0.DependencyControl.Toolbox", (macros, dependencies, testExports list = buildInstalledDlgList "modules", config, false ut\assertEquals list[1], "a.orphan v0.0.0" - _order: {"uninstall_excludesProtected", "install_includesAll", "sortsByNameCaseInsensitively", "formatsActiveChannel", "toleratesMissingVersionAndName"} + _order: {"uninstall_excludesProtected", "install_includesAll", "sortsByNameCaseInsensitively", "formatsRecordedChannel", "toleratesMissingVersionAndName"} + } + + SourceConfig: { + _description: "Package Source Configuration helpers: label/value choice lists, the cached-feed scan, and the installed-provider merge." + + -- buildLabelChoices: pairs each enum value with its label, so the dialog offers labels and dispatch + -- branches on the value behind the chosen one + buildLabelChoices_pairsLabelsWithValues: (ut) -> + items, byLabel = buildLabelChoices domain.SourceChoiceStickiness.values, stickinessLabels + ut\assertEquals #items, #domain.SourceChoiceStickiness.values + ut\assertEquals byLabel[stickinessLabels[domain.SourceChoiceStickiness.Pinned]], + domain.SourceChoiceStickiness.Pinned + + -- every source kind and stickiness value carries a label, so no dropdown entry can come out blank + labels_coverEveryEnumValue: (ut) -> + ut\assertNotNil sourceKindLabels[kind], "no label for source kind '#{kind}'" for kind in *DepCtrl.UpdateTask.SourceFeedKind.values + ut\assertNotNil stickinessLabels[sticky], "no label for stickiness '#{sticky}'" for sticky in *domain.SourceChoiceStickiness.values + + -- buildChoiceList: the current value survives even when nothing on offer matches it, flagged so the + -- user can see their selection no longer holds rather than having it silently swapped + buildChoiceList_keepsUnofferedCurrentFlagged: (ut) -> + entries = {{value: "feed://b", label: "b"}, {value: "feed://a", label: "a"}} + items, byLabel = buildChoiceList entries, "feed://gone", "gone" + ut\assertEquals #items, 3 + ut\assertContains items[1], "gone" -- the stale entry leads, so it stays visible + ut\assertEquals byLabel[items[1]], "feed://gone" + ut\assertEquals items[2], "a" -- the rest stay sorted + ut\assertEquals items[3], "b" + + -- a current value that is on offer isn't duplicated as a stale entry + buildChoiceList_offeredCurrentNotDuplicated: (ut) -> + items = buildChoiceList {{value: "feed://a", label: "a"}}, "feed://a", "a" + ut\assertEquals #items, 1 + ut\assertEquals items[1], "a" + + -- an Aegisub dropdown can't be empty, so an empty list still yields one entry + buildChoiceList_emptyGetsPlaceholder: (ut) -> + items = buildChoiceList {}, nil + ut\assertEquals #items, 1 + ut\assertString items[1] + + -- scanCachedFeeds: reads only what the cache holds, mapping each feed that offers the package to the + -- channels it offers it on, and collecting the modules that provide it + scanCachedFeeds_mapsChannelsAndProviders: (ut) -> + cached = { + "feed://one": { + modules: { + "l0.Pkg": {channels: {stable: {version: "1.0.0"}, alpha: {version: "1.1.0"}}} + "l0.Provider": {name: "Provider", provides: {{name: "l0.Pkg"}}} + } + } + "feed://two": { + modules: {"l0.Other": {name: "Other", provides: {"l0.Pkg"}}} + } + "feed://nopkg": {modules: {"l0.Unrelated": {channels: {stable: {}}}}} + } + cache = {get: ((url) => cached[url])} + feedUrls = {"feed://one", "feed://two", "feed://nopkg", "feed://uncached"} + channelsByFeed, providers = scanCachedFeeds "l0.Pkg", domain.ScriptType.Module, cache, feedUrls + ut\assertNotNil channelsByFeed["feed://one"] + ut\assertEquals table.concat(channelsByFeed["feed://one"], ","), "alpha,stable" -- sorted + ut\assertNil channelsByFeed["feed://nopkg"] -- doesn't offer the package + ut\assertEquals providers["l0.Provider"].feedUrl, "feed://one" + ut\assertEquals providers["l0.Other"].feedUrl, "feed://two" -- the bare-string alias form + ut\assertNil providers["l0.Pkg"] -- a package never provides for itself + + -- addInstalledProviders: folds in installed modules that provide the package, without displacing one + -- already found in a feed + addInstalledProviders_mergesWithoutDisplacing: (ut) -> + providers = {"l0.FromFeed": {name: "FromFeed", feedUrl: "feed://one"}} + modulesSection = { + "l0.FromFeed": {name: "Renamed", feed: "feed://local", provides: {"l0.Pkg"}} + "l0.Installed": {name: "Installed", feed: "feed://local", provides: {{name: "l0.Pkg"}}} + "l0.Unrelated": {name: "Unrelated", provides: {"yaml"}} + } + addInstalledProviders providers, "l0.Pkg", modulesSection + ut\assertEquals providers["l0.FromFeed"].feedUrl, "feed://one" -- the feed entry wins + ut\assertEquals providers["l0.Installed"].feedUrl, "feed://local" + ut\assertNil providers["l0.Unrelated"] + + _order: { + "buildLabelChoices_pairsLabelsWithValues", "labels_coverEveryEnumValue", + "buildChoiceList_keepsUnofferedCurrentFlagged", "buildChoiceList_offeredCurrentNotDuplicated", + "buildChoiceList_emptyGetsPlaceholder", + "scanCachedFeeds_mapsChannelsAndProviders", "addInstalledProviders_mergesWithoutDisplacing" + } } UntrustedPrompt: { diff --git a/modules/l0/DependencyControl.moon b/modules/l0/DependencyControl.moon index d92e6d6..85c1ff8 100644 --- a/modules/l0/DependencyControl.moon +++ b/modules/l0/DependencyControl.moon @@ -108,7 +108,7 @@ Accessors.install DependencyControl rec = DependencyControl{ name: "DependencyControl", - version: "0.8.2", -- @{l0.DependencyControl:version} + version: "0.9.0", -- @{l0.DependencyControl:version} description: "Provides script management and auto-updating for Aegisub macros and modules.", author: "line0", url: "http://github.com/TypesettingTools/DependencyControl", diff --git a/modules/l0/DependencyControl/ConfigHandler.moon b/modules/l0/DependencyControl/ConfigHandler.moon index b6bb00d..758be6d 100644 --- a/modules/l0/DependencyControl/ConfigHandler.moon +++ b/modules/l0/DependencyControl/ConfigHandler.moon @@ -26,6 +26,7 @@ msgs = { } mergeHive: { badKey: "Can't merge hive because the path key #%d (%s) points to a %s." + missingSource: "Can't merge hive because the source tree is shallower than the hive path, holding nothing under path key #%d (%s)." } new: { badPath: "Couldn't validate specified config file path '%s': %s" @@ -125,6 +126,9 @@ class ConfigHandler new: (filePath, @logger = Logger(fileBaseName: "#{constants.DEPCTRL_SHORT_NAME}.#{@@__name}"), schemaOpts = {}) => @views = setmetatable {}, {__mode: 'k'} @config = {} + -- the config file's contents as of this handler's last load or save, per hive; used to determine what this handler + -- touched, so saving doesn't interfere with other writers (as long as they didn't touch the same regions) + @__baseline = {} -- the loaded file's `$schema`, exposed so views can see which schema their values conform to @schemaId = nil @__targetSchemaId = schemaOpts.schemaId @@ -245,6 +249,31 @@ class ConfigHandler return hive + ---Writes the fields this handler and its views changed since the last load, and only those, into the + ---copy the file holds now, so a field another writer changed in the meantime survives. A field dropped + ---here is removed from that copy as well. + ---@param heldValues table The hive as this handler holds it now. + ---@param valuesAsLoaded table The same hive as the file held it at the last load. + ---@param valuesOnFile table The same hive as the file holds it now, mutated in place. + ---@return boolean changed Whether anything was written. + applyChanges = (heldValues, valuesAsLoaded, valuesOnFile) -> + changed = false + for key, value in pairs heldValues + valueAsLoaded = valuesAsLoaded[key] + if type(value) == "table" and type(valueAsLoaded) == "table" + valueOnFile = type(valuesOnFile[key]) == "table" and valuesOnFile[key] or {} + if applyChanges value, valueAsLoaded, valueOnFile + valuesOnFile[key] = valueOnFile + changed = true + elseif value != valueAsLoaded + valuesOnFile[key] = utils.copyValue value + changed = true + for key in pairs valuesAsLoaded + continue unless heldValues[key] == nil and valuesOnFile[key] != nil + valuesOnFile[key] = nil + changed = true + return changed + traverseHive = (path, config, depth = #path) -> for i, key in ipairs path break if i > depth @@ -260,6 +289,7 @@ class ConfigHandler mergeHive = (path, source, target, depth = 1) -> + return nil, msgs.mergeHive.missingSource\format depth - 1, path[depth - 1] unless source -- merging in a root hive overwrites target with source if #path == 0 target[k] = nil for k, _ in pairs target @@ -396,6 +426,7 @@ class ConfigHandler @schemaId = config[JsonSchema.JSON_SCHEMA_ID_KEYWORD] @config = config + @__baseline = utils.deepCopy config view\refresh! for view, _ in pairs @views if migrated @@ -412,7 +443,10 @@ class ConfigHandler return nil, msg when false mergeHive view.__hivePath, makeHive(view.__hivePath), @config - else mergeHive view.__hivePath, makeHive(view.__hivePath, hiveConfig), @config + mergeHive view.__hivePath, makeHive(view.__hivePath), @__baseline + else + mergeHive view.__hivePath, makeHive(view.__hivePath, hiveConfig), @config + mergeHive view.__hivePath, makeHive(view.__hivePath, utils.deepCopy hiveConfig), @__baseline utils.makeSet @__getOverlappingViews(view), viewsToRefresh, false @@ -448,6 +482,7 @@ class ConfigHandler -- save the whole config file if desired if views == nil success, msg = @__writeFile @config, nil, true + @__baseline = utils.deepCopy @config if success @lock\release! return if success true @@ -455,10 +490,28 @@ class ConfigHandler -- otherwise only merge in the specified views for view in *views - success, msg = mergeHive view.__hivePath, @config, config - unless success + path = view.__hivePath + -- in order to merge this hive's changes into the config file, the hive's path must be reachable from the handler's tree + parent, msg = traverseHive path, @config, #path - 1 + unless type(parent) == "table" @lock\release! - return nil, msgs.save.failedMerge\format view.__hivePath, @filePath, msg + return nil, msgs.save.failedMerge\format path, @filePath, + msg or msgs.mergeHive.missingSource\format #path - 1, tostring path[#path - 1] + held = #path == 0 and parent or parent[path[#path]] + valueAsLoaded = traverseHive path, @__baseline + valueAsLoaded = {} unless type(valueAsLoaded) == "table" + + if type(held) == "table" + unless type(traverseHive path, config) == "table" + success, msg = mergeHive path, makeHive(path), config + unless success + @lock\release! + return nil, msgs.save.failedMerge\format path, @filePath, msg + applyChanges held, valueAsLoaded, traverseHive path, config + elseif next(valueAsLoaded) and #path > 0 + -- the hive was dropped from this tree, so drop it from the file too + target = traverseHive path, config, #path - 1 + target[path[#path]] = nil if type(target) == "table" success, msg = cleanHive view.__hivePath, config if success == nil @@ -466,6 +519,11 @@ class ConfigHandler return nil, msgs.save.failedClean\format view.__hivePath, @filePath, msg success, msg = @__writeFile config, nil, true + if success + for view in *views + written = traverseHive view.__hivePath, config + mergeHive view.__hivePath, makeHive(view.__hivePath, type(written) == "table" and utils.deepCopy(written) or nil), + @__baseline @lock\release! return if success true diff --git a/modules/l0/DependencyControl/ConfigView.moon b/modules/l0/DependencyControl/ConfigView.moon index 7e83fb3..845eace 100644 --- a/modules/l0/DependencyControl/ConfigView.moon +++ b/modules/l0/DependencyControl/ConfigView.moon @@ -194,19 +194,23 @@ class ConfigView ---@return string? err write: (waitLockTime) => @save waitLockTime - ---Attaches this view to a different config file, without loading it (the caller loads separately). + ---Attaches this view to a different config file, moving its hive to that file's handler, so the view + ---holds what that handler holds for this hive path. ---@param filePath string Full path to the config file. + ---@param noLoad? boolean Don't read the file when this call creates its handler (default false). A file already loaded is not read again either way. ---@return boolean? success ---@return string? err - setFile: (filePath) => + setFile: (filePath, noLoad = false) => ConfigHandler or= require "l0.DependencyControl.ConfigHandler" oldHandler = @__configHandler - handler, msg = ConfigHandler\get filePath, (oldHandler and oldHandler.logger), true -- noLoad: caller loads separately + handler, msg = ConfigHandler\get filePath, (oldHandler and oldHandler.logger), noLoad return nil, msg unless handler oldHandler.views[@] = nil if oldHandler -- detach from the previous handler @__configHandler = handler handler.views[@] = true -- register so the handler's whole-file refreshes reach this view @file = handler.filePath + moved, msg = @refresh! + return nil, msg unless moved return true ---Detaches this view from its config file, reverting to an in-memory (orphan) state. diff --git a/modules/l0/DependencyControl/FeedInventory.moon b/modules/l0/DependencyControl/FeedInventory.moon index a0ef633..b04ba76 100644 --- a/modules/l0/DependencyControl/FeedInventory.moon +++ b/modules/l0/DependencyControl/FeedInventory.moon @@ -1,6 +1,7 @@ constants = require "l0.DependencyControl.Constants" domain = require "l0.DependencyControl.domain" Enum = require "l0.DependencyControl.Enum" +ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" local UpdateTask @@ -77,14 +78,6 @@ appended = (list, item) -> -- Cap on how many dropped feed URLs one truncation event records; its `dropped` count stays exact. maxDropSample = 50 --- The feed a package's persisted `currentSource` resolves to (nil when absent), via UpdateTask's shared resolver. -resolveCurrentSource = (pkg, modulesSection) -> - src = pkg.currentSource - return nil unless type(src) == "table" - - UpdateTask or= require "l0.DependencyControl.UpdateTask" - UpdateTask.resolveSourceUrl src, pkg.feed, pkg.userFeed, modulesSection - ---A reachable feed with the sources it was discovered through and its trust status. ---@class FeedInventoryEntry ---@field url string The feed URL. @@ -140,13 +133,34 @@ class FeedInventory ---@param feedLoader FeedLoader Loads feeds during a crawl and holds the feed cache read for last-fetch times. new: (@config, @feedTrust, @feedLoader) => - ---The feed a package effectively updates from: its remembered `currentSource` (resolved), falling back to - ---its override (`userFeed`) or declared `feed`. + ---The feed a package's configured update source resolves to, via UpdateTask's shared resolver. + ---Answers where the next update is configured to come from, where `getEffectiveSource` answers which + ---feed it will actually use once the fallbacks are applied. + ---@param pkg table An installed package's config entry. + ---@param modulesSection? table The modules config section, for resolving a provider source. + ---@return string? url The resolved feed URL, or nil when the package records no source or it can't be resolved. + @resolveConfiguredSource = (pkg, modulesSection) -> + src = ScriptUpdateRecord.getRecordedSource pkg + return nil unless type(src) == "table" + + UpdateTask or= require "l0.DependencyControl.UpdateTask" + UpdateTask.resolveSourceUrl src, pkg.feed, pkg.userFeed, modulesSection + + ---Returns the feed a package will update from: either a previously configured or used source, + --- a user-set override (`userFeed`) or the package's own declared `feed`. ---@param pkg table An installed package's config entry. - ---@param modulesSection table The modules config section, for resolving a provider `currentSource`. + ---@param modulesSection table The modules config section, for resolving a provider source. ---@return string? url The feed the package updates from, or nil when it declares none. + ---@return SourceFeedKind? kind What the URL was taken from: the configured source's own kind, or the kind the fallback stands for. @getEffectiveSource = (pkg, modulesSection) -> - resolveCurrentSource(pkg, modulesSection) or pkg.userFeed or pkg.feed + url = @.resolveConfiguredSource pkg, modulesSection + if url + source = ScriptUpdateRecord.getRecordedSource pkg + return url, source.feedSource + UpdateTask or= require "l0.DependencyControl.UpdateTask" + return pkg.userFeed, UpdateTask.SourceFeedKind.UserFeed if pkg.userFeed + return pkg.feed, UpdateTask.SourceFeedKind.SelfDeclared if pkg.feed + nil ---Collects the feeds reachable from config, installed packages, and the official trust lists into a ---`url -> raw entry` map (provenance/packages/advertisedBy still as sets). Network-free. @@ -176,7 +190,7 @@ class FeedInventory tagPackage pkg.userFeed, Provenance.PackageOverride, namespace for dep in *(pkg.requiredModules or {}) tagPackage dep.feed, Provenance.DependencyAdvertised, namespace if type(dep) == "table" - effective = FeedInventory.getEffectiveSource pkg, modulesSection + effective = @@.getEffectiveSource pkg, modulesSection inventoryEntriesByUrl[effective].inUse = true if type(effective) == "string" and inventoryEntriesByUrl[effective] return inventoryEntriesByUrl @@ -214,7 +228,7 @@ class FeedInventory for scriptType in *domain.ScriptType.values for namespace, pkg in pairs (c[domain.ScriptTypeSection[scriptType]] or {}) continue unless type(pkg) == "table" - matched[#matched + 1] = namespace if FeedInventory.getEffectiveSource(pkg, modulesSection) == feedUrl + matched[#matched + 1] = namespace if @@.getEffectiveSource(pkg, modulesSection) == feedUrl table.sort matched matched diff --git a/modules/l0/DependencyControl/FeedLoader.moon b/modules/l0/DependencyControl/FeedLoader.moon index e50e3ae..7aa44dc 100644 --- a/modules/l0/DependencyControl/FeedLoader.moon +++ b/modules/l0/DependencyControl/FeedLoader.moon @@ -15,26 +15,27 @@ class FeedLoader @defaultMaxFeedSize = 5 * 10^6 @defaultFeedFetchTimeout = 15 - ---Reads the feed-fetch settings once and opens the shared feed cache under DepCtrl's namespace, wiring its - ---L1 layer to `UpdateFeed`'s decoder so a cache hit serves a ready-parsed feed base. - ---@param config ConfigView The global config view; reads `paths.cache`, `feeds.cacheMaxAge`/`feeds.maxFeedSize`/`feeds.feedFetchTimeout` and `updates.blockPrivateHosts`. + ---Opens the shared feed cache under DepCtrl's namespace, wiring its L1 layer to `UpdateFeed`'s decoder + ---so a cache hit serves a ready-parsed feed base. + ---@param config ConfigView The global config view; reads `paths.cache` and `feeds.cacheMaxAge` once, the fetch policy on every `load`. ---@param logger? Logger Logger for the cache and the feeds it loads. - new: (config, @logger = defaultLogger) => - c = config.c - @cache = FileCache.get c.paths.cache, constants.DEPCTRL_NAMESPACE, "feeds", - {maxAge: c.feeds.cacheMaxAge, logger: @logger, deserialize: UpdateFeed.deserialize} - @blockPrivateHosts = c.updates.blockPrivateHosts - @maxFeedSize = c.feeds.maxFeedSize or @@defaultMaxFeedSize - @feedFetchTimeout = c.feeds.feedFetchTimeout or @@defaultFeedFetchTimeout + new: (@config, @logger = defaultLogger) => + with @config.c + @cache = FileCache.get .paths.cache, constants.DEPCTRL_NAMESPACE, "feeds", + {maxAge: .feeds.cacheMaxAge, logger: @logger, deserialize: UpdateFeed.deserialize} - ---Builds an `UpdateFeed` for the given URL, injecting the shared cache and the configured fetch policy. + ---Builds an `UpdateFeed` for the given URL, injecting the shared cache and the fetch policy the config + ---holds at call time, so a saved settings change takes effect immediately. ---@param url string The feed URL to load. ---@param opts? { autoLoad?: boolean } `autoLoad` fetches immediately (default true). To refresh an entire update pass, expire the cache with `FileCache.expireAll`. ---@return UpdateFeed feed load: (url, opts = {}) => - autoLoad = opts.autoLoad - autoLoad = true if autoLoad == nil - feedConfig = {cache: @cache, blockPrivateHosts: @blockPrivateHosts, maxFeedSize: @maxFeedSize, feedFetchTimeout: @feedFetchTimeout} - UpdateFeed url, autoLoad, nil, feedConfig, @logger + with @config.c + return UpdateFeed url, opts.autoLoad == nil and true or opts.autoLoad, nil, { + cache: @cache + blockPrivateHosts: .updates.blockPrivateHosts + maxFeedSize: .feeds.maxFeedSize or @@defaultMaxFeedSize + feedFetchTimeout: .feeds.feedFetchTimeout or @@defaultFeedFetchTimeout + }, @logger return FeedLoader diff --git a/modules/l0/DependencyControl/PackageRecord.moon b/modules/l0/DependencyControl/PackageRecord.moon index 4658d5a..92894ec 100644 --- a/modules/l0/DependencyControl/PackageRecord.moon +++ b/modules/l0/DependencyControl/PackageRecord.moon @@ -291,6 +291,8 @@ class PackageRecord -- copy script information to the config @config\load! shouldWriteConfig = @config\import @, @@depConf.scriptFields, false, true + -- version isn't a scriptField, so the import above can't see a release that bumped only it + shouldWriteConfig or= @config.c.version != tostring @semanticVersion return shouldWriteConfig return false diff --git a/modules/l0/DependencyControl/ScriptUpdateRecord.moon b/modules/l0/DependencyControl/ScriptUpdateRecord.moon index aacfec0..71a2993 100644 --- a/modules/l0/DependencyControl/ScriptUpdateRecord.moon +++ b/modules/l0/DependencyControl/ScriptUpdateRecord.moon @@ -40,6 +40,12 @@ msgs = { errors: { noActiveChannel: "No active channel." } + getChannels: { + ambiguousDefault: "The feed marks several channels of '%s' as the default (%s); using '%s'." + } + setChannel: { + channelGone: "The feed no longer offers channel '%s' for '%s'; switching to '%s'." + } changelog: { header: "Changelog for %s v%s (released %s):" verTemplate: "v %s:" @@ -55,7 +61,7 @@ msgs = { ---@class ScriptUpdateRecord ---@field namespace string Script namespace. ---@field data FeedScriptData Shallow copy of the raw script entry from the feed. ----@field config {c: {activeChannel?: string, lastChannel?: string, channels?: string[]}} +---@field config {c: {configuredSource?: table, currentSource?: table, lastChannel?: string, channels?: string[]}} Per-package config; read for the configured channel and provenance metadata. ---@field moduleName string|false Namespace string for modules; false for automation scripts. ---@field logger Logger ---@field activeChannel? string Name of the currently active update channel. @@ -70,7 +76,7 @@ class ScriptUpdateRecord ---Creates an update record for a single script entry in a feed. ---@param namespace string ---@param data FeedScriptData - ---@param config? {c: {activeChannel?: string}} + ---@param config? {c: {configuredSource?: table, currentSource?: table, lastChannel?: string}} ---@param scriptType ScriptType ---@param autoChannel? boolean Select the default channel on construction (default true). ---@param logger? Logger @@ -91,31 +97,73 @@ class ScriptUpdateRecord @setChannel! if autoChannel + ---Picks the channel a package's feed entry flags as its default. + ---The feed format allows only one default channel, but in case there are multiple defaults, this + ---picks one deterministically and notifies callers of the conflict, so they can warn the user or + ---otherwise handle it as they see fit. + ---@param channels? table A package's `channels` map. + ---@return string? name The default channel's name, or nil when none is flagged. + ---@return string[]? conflicting Every flagged name, sorted, when more than one is flagged; nil otherwise. + @getDefaultChannel = (channels) -> + return nil unless type(channels) == "table" + names = [name for name, channel in pairs channels when channel.default] + return nil if #names == 0 + table.sort names + return names[1], #names > 1 and names or nil + + ---The source a package's config records for its next update: the source configured for it, or the one the + ---installed copy came from where no choice has been made. Configs before v0.9.0 hold only the latter. + ---@param packageConfig table An installed package's config entry. + ---@return table? source The recorded source, or nil when the package records none. + @getRecordedSource = (packageConfig) -> packageConfig.configuredSource or packageConfig.currentSource + + ---The update channel a package's config records, from its recorded source or, for a config written before + ---v0.7.0, the `lastChannel` key. + ---@param packageConfig table An installed package's config entry. + ---@return string? channel The recorded channel, or nil when the package records none. + @getRecordedChannel = (packageConfig) -> + source = @.getRecordedSource packageConfig + source and source.channel or packageConfig.lastChannel + ---Returns all available channel names for this script and the default channel. ---@return string[] channels Channel names, empty when the package declares none. ---@return string? defaultChannel getChannels: => - channels, default = {} + channels = {} return channels unless type(@data.channels) == "table" - for name, channel in pairs @data.channels - channels[#channels+1] = name - if channel.default and not default - default = name + channels[#channels+1] = name for name in pairs @data.channels + default, conflicting = @@.getDefaultChannel @data.channels + if conflicting + @logger\warn msgs.getChannels.ambiguousDefault, @namespace, table.concat(conflicting, ", "), default return channels, default ---Selects the active update channel and exposes its fields on this instance. - ---@param channelName? string Channel to activate; defaults to config.c.activeChannel. - ---@return boolean success - ---@return string activeChannel - setChannel: (channelName = @config.c.activeChannel) => - with @config.c - .channels, default = @getChannels! - .lastChannel or= channelName or default - channelData = @data.channels[.lastChannel] - @activeChannel = .lastChannel - return false, @activeChannel unless channelData - @[k] = v for k, v in pairs channelData + ---An explicitly requested channel wins over the channel recorded in the configured package source, + ---which wins over the feed's default. + ---This only affects the in-memory state of this ScriptUpdateRecord instance; persistence to the DepCtrl + ---config file is left to the caller. + ---@param channelName? string Channel to activate; must be offered by the feed. + ---@return boolean success False when the selected channel isn't offered by the feed. + ---@return string? activeChannel The selected channel name; nil when the package declares no channels at all. + setChannel: (channelName) => + _, default = @getChannels! + -- When no source is configured (<0.9.0), the source used for the last install (≥0.7.0) is used, and, + -- failing that, the `lastChannel` which has been recorded to the config since the pre-v0.7.0 days. + source = @@.getRecordedSource @config.c + recorded = @@.getRecordedChannel @config.c + selected = channelName or recorded or default + -- When the currently configured/last used channel is no longer offered by the feed, we fall back + -- to the feed's default channel, unless the package source has been explicitly pinned, in which case + -- we leave it to the user to rectify. + pinned = source and source.stickiness == domain.SourceChoiceStickiness.Pinned + if not pinned and default and not channelName and selected and not @data.channels[selected] + @logger\warn msgs.setChannel.channelGone, selected, @namespace, default + selected = default + @activeChannel = selected + channelData = selected and @data.channels[selected] + return false, @activeChannel unless channelData + @[k] = v for k, v in pairs channelData @files = @files and [file for file in *@files when not file.platform or file.platform == environment.platform] or {} return true, @activeChannel diff --git a/modules/l0/DependencyControl/SemanticVersion.moon b/modules/l0/DependencyControl/SemanticVersion.moon index b69af54..f654228 100644 --- a/modules/l0/DependencyControl/SemanticVersion.moon +++ b/modules/l0/DependencyControl/SemanticVersion.moon @@ -234,6 +234,9 @@ parseComparatorSet = (groupStr) -> return nil, errTo unless toVer return hyphenComparators fromVer, toVer + -- npm allows whitespace between an operator and its version, which the tokenizer below would read + -- as a bare operator followed by a bare version, leaving a range that matches nothing + groupStr = groupStr\gsub "([<>=~^]+)%s+", "%1" comparators = {} for token in groupStr\gmatch "%S+" parts, err = parseComparator token diff --git a/modules/l0/DependencyControl/UpdateFeed.moon b/modules/l0/DependencyControl/UpdateFeed.moon index 0998cec..5e22191 100644 --- a/modules/l0/DependencyControl/UpdateFeed.moon +++ b/modules/l0/DependencyControl/UpdateFeed.moon @@ -124,6 +124,7 @@ msgs = { __resolveChannel: { notFound: "channel '%s' not found." noDefault: "no default channel — specify one explicitly." + ambiguousDefault: "several channels are marked as the default (%s) — specify one explicitly." } trace: { usingCached: "Using cached feed." @@ -627,16 +628,19 @@ class UpdateFeed return ch.version if ch.default fallback - ---Returns the modules in this feed whose default channel `provides` the given name. Only the + ---Returns the modules in this feed whose selected channel `provides` the given name. Only the ---`modules` section is searched (automation scripts can't be `require`d). The feed must be loaded. ---@param alias string The required module name to find providers for. - ---@return ScriptUpdateRecord[] providers Update records (default channel selected) whose `provides` lists the name. - getProviders: (alias) => + ---@param depCtrlConfigModulesSection table The modules section of DependencyControl's config, keyed by namespace. Required to honor the settings (such as the update channel) of already installed + ---providing packages. + ---@return ScriptUpdateRecord[] providers Update records (channel selected) whose `provides` lists the name. + getProviders: (alias, depCtrlConfigModulesSection) => providers = {} return providers unless @data and @data.modules for namespace, pkg in pairs @data.modules continue unless type(pkg) == "table" and pkg.channels - record = ScriptUpdateRecord namespace, pkg, nil, ScriptType.Module, false, @logger + installed = depCtrlConfigModulesSection[namespace] + record = ScriptUpdateRecord namespace, pkg, installed and {c: installed}, ScriptType.Module, false, @logger continue unless (record\setChannel!) and record.provides for entry in *record.provides name = type(entry) == "table" and entry.name or entry @@ -647,7 +651,8 @@ class UpdateFeed ---Resolves which channel of a package to operate on. ---With an explicit name, that channel must exist; otherwise the channel flagged `default: true` - ---is used. + ---is used. Returns an error when multiple channels are flagged default to raise attention to the + ---invalid feed state rather than paper over it with a guess. ---@private ---@param channels? table The package's `channels` map. ---@param channelName? string An explicit channel name to select. @@ -657,9 +662,11 @@ class UpdateFeed if channelName return channelName if channels[channelName] return nil, msgs.__resolveChannel.notFound\format channelName - for name, channel in pairs channels - return name if channel.default - return nil, msgs.__resolveChannel.noDefault + + name, conflicting = ScriptUpdateRecord.getDefaultChannel channels + return nil, msgs.__resolveChannel.ambiguousDefault\format table.concat(conflicting, ", ") if conflicting + return nil, msgs.__resolveChannel.noDefault unless name + return name ---Writes the raw (unexpanded) feed data back to disk. ---@private @@ -917,13 +924,12 @@ class UpdateFeed rawPkg = @rawFeedData[domain.ScriptTypeSection[scriptType]] rawPkg = rawPkg and rawPkg[pkg.namespace] continue unless rawPkg and rawPkg.channels - channelName = @@__resolveChannel rawPkg.channels, opts.channel - rawChannel = channelName and rawPkg.channels[channelName] + result = resultsByPackage[scriptType .. "\0" .. pkg.namespace] + rawChannel = result and result.channel and rawPkg.channels[result.channel] continue unless rawChannel continue unless rawChannel.released == nil or rawChannel.released == dkjson.null rawChannel.released = releaseDate - result = resultsByPackage[scriptType .. "\0" .. pkg.namespace] - result.changed = true if result + result.changed = true for result in *stats.packages stats.changed += 1 if result.changed @@ -940,10 +946,11 @@ class UpdateFeed ---package in the source, its `from` channel's raw data is copied into every `to` channel here, ---with each `to` channel's `default` flag set (true only for the given default channel) and its ---release date set when one is supplied. Channels not among `to` are left as they are, so they - ---keep their previously published versions; a package missing here is added carrying only the - ---`to` channels. Top-level feed metadata and each package's shared (non-channel) fields track the - ---source. File hashes are copied verbatim — the `from` channel is assumed already in sync with - ---its own source — so no files are read. + ---keep their previously published versions, except that `default` is cleared from every channel + ---but the given default channel, so the result names exactly one. A package missing here is added + ---carrying only the `to` channels. Top-level feed metadata, each section's own template keys, and each + ---package's shared (non-channel) fields track the source. File hashes are copied verbatim — the `from` + ---channel is assumed already in sync with its own source — so no files are read. ---@param source UpdateFeed The loaded feed to copy channel data from. ---@param opts { from: string, to: string[], defaultChannel?: string, released?: string, outPath?: string|boolean } `outPath` false does a dry run; nil/true writes to this feed's own path. ---@return string[]? merged The namespaces whose channels were written, or nil on error. @@ -957,9 +964,6 @@ class UpdateFeed toChannels = opts.to or {} return nil, msgs.mergeChannels.noTo unless #toChannels > 0 - -- utils.deepCopy only accepts a table; feed values are a mix of tables and scalars - copyValue = (v) -> type(v) == "table" and utils.deepCopy(v) or v - merged = {} for section in *domain.ScriptTypeSection.values srcSection = source.rawFeedData[section] @@ -967,25 +971,33 @@ class UpdateFeed @rawFeedData[section] or= {} dstSection = @rawFeedData[section] for ns, srcPkg in pairs srcSection - continue unless type(srcPkg) == "table" and srcPkg.channels + -- a section's own `fileBaseUrls`/`localFileBasePaths` templates sit beside its packages + unless type(srcPkg) == "table" and srcPkg.channels + dstSection[ns] = utils.copyValue srcPkg + continue fromChannel = srcPkg.channels[opts.from] continue unless fromChannel dstPkg = dstSection[ns] unless type(dstPkg) == "table" and dstPkg.channels - dstPkg = {k, copyValue v for k, v in pairs srcPkg when k != "channels"} + dstPkg = {k, utils.copyValue v for k, v in pairs srcPkg when k != "channels"} dstPkg.channels = {} dstSection[ns] = dstPkg else - dstPkg[k] = copyValue v for k, v in pairs srcPkg when k != "channels" + dstPkg[k] = utils.copyValue v for k, v in pairs srcPkg when k != "channels" for toName in *toChannels entry = utils.deepCopy fromChannel entry.default = toName == opts.defaultChannel entry.released = opts.released if opts.released dstPkg.channels[toName] = entry + -- Only one channel is allowed to be flagged as default, so we clear it from any other + -- channel in the package if a default channel was specified. + if opts.defaultChannel + for name, entry in pairs dstPkg.channels + entry.default = false if name != opts.defaultChannel and type(entry) == "table" merged[#merged + 1] = ns -- top-level feed metadata (name, baseUrl, templates, vars, knownFeeds, …) tracks the source - @rawFeedData[k] = copyValue v for k, v in pairs source.rawFeedData when k != "macros" and k != "modules" + @rawFeedData[k] = utils.copyValue v for k, v in pairs source.rawFeedData when k != "macros" and k != "modules" return merged if opts.outPath == false wrote, writeErr = @__writeRawFeed (opts.outPath == true or opts.outPath == nil) and @feedPath or opts.outPath @@ -1035,14 +1047,9 @@ class UpdateFeed return nil, err unless loaded return nil, msgs.bumpVersions.badLevel unless opts.level - channel = opts.channel - unless channel - for section in *domain.ScriptTypeSection.values - for _, pkg in pairs @rawFeedData[section] or {} - continue unless type(pkg) == "table" and pkg.channels - for name, ch in pairs pkg.channels - channel = name if ch.default and not channel - return nil, msgs.bumpVersions.noChannel unless channel + channel, chanErr = opts.channel, nil + channel, chanErr = @__getDefaultChannelName! unless channel + return nil, chanErr or msgs.bumpVersions.noChannel unless channel stale = {} for file, _, pkg in @walkFiles! @@ -1173,8 +1180,9 @@ class UpdateFeed version = opts.version unless version - channel = opts.channel or @__defaultChannelName! - return nil, msgs.formatReleaseNotes.noChannel unless channel + channel, chanErr = opts.channel, nil + channel, chanErr = @__getDefaultChannelName! unless channel + return nil, chanErr or msgs.formatReleaseNotes.noChannel unless channel version = @getHighestVersionOnChannel channel return nil, msgs.formatReleaseNotes.channelEmpty\format channel unless version @@ -1201,7 +1209,7 @@ class UpdateFeed ---@param channel? string Channel to inspect; defaults to the channel marked default: true. ---@return string[] versions Distinct version strings, lowest first; empty when the channel is absent or unused. getVersionsOnChannel: (channel) => - channel or= @__defaultChannelName! + channel or= @__getDefaultChannelName! versions, seen = {}, {} return versions unless channel for section in *{"macros", "modules"} @@ -1228,7 +1236,7 @@ class UpdateFeed ---@param channel? string Channel to inspect; defaults to the channel marked default: true. ---@return string? version The version string, or nil when the package or channel is absent. getPackageVersionOnChannel: (namespace, channel) => - channel or= @__defaultChannelName! + channel or= @__getDefaultChannelName! return nil unless channel for section in *{"macros", "modules"} pkg = @rawFeedData[section] and @rawFeedData[section][namespace] @@ -1237,16 +1245,19 @@ class UpdateFeed return ch.version if ch and ch.version nil - ---Name of the channel marked default: true, taken from the first package that declares one. - ---@return string? channel The default channel name, or nil when no package marks a default. + ---Returns the name of the channel marked default: true, taken from the first package that declares one cleanly. + ---@return string? channel The default channel name, or nil when no package marks exactly one default channel. + ---@return string? err Why no default channel could be determined. ---@private - __defaultChannelName: => + __getDefaultChannelName: => + firstErr = nil for section in *{"macros", "modules"} for _, pkg in pairs @rawFeedData[section] or {} continue unless type(pkg) == "table" and pkg.channels - name = @@__resolveChannel pkg.channels + name, err = @@__resolveChannel pkg.channels return name if name - nil + firstErr or= err if err != msgs.__resolveChannel.noDefault + return nil, firstErr ---Copies every file listed in the feed to distDir using the Updater's install layout. A file the feed marks ---for deletion (`delete: true`) is removed from distDir if present, rather than deployed. diff --git a/modules/l0/DependencyControl/UpdateTask.moon b/modules/l0/DependencyControl/UpdateTask.moon index 6e0dc2d..1a7cde5 100644 --- a/modules/l0/DependencyControl/UpdateTask.moon +++ b/modules/l0/DependencyControl/UpdateTask.moon @@ -7,8 +7,10 @@ domain = require "l0.DependencyControl.domain" environment = require "l0.DependencyControl.environment" Enum = require "l0.DependencyControl.Enum" ModuleLoader = require "l0.DependencyControl.ModuleLoader" +ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" SemanticVersion = require "l0.DependencyControl.SemanticVersion" UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +utils = require "l0.DependencyControl.utils" ---The "installation"/"update" term for a record's task. domain.terms.isInstall is keyed by ---true/false, while an installed record leaves `virtual` nil. @@ -96,20 +98,7 @@ FeedTrustDecision = Enum "FeedTrustDecision", { Never: "never" } --- How sticky a remembered package-source choice is on subsequent resolutions of the same package. ----@alias SourceChoiceStickiness ----| "unset" # Unset: no preference recorded yet; resolve normally and prompt only if interactive ----| "once" # Once: prompt again whenever a choice remains, preselecting the remembered pick ----| "retain" # Retain: reuse the remembered pick whenever it's still eligible, without prompting ----| "pinned" # Pinned: always reuse the remembered pick; if it's gone, abort (required) or skip (optional) ----| "auto" # Auto: never prompt; always resolve via the ranking, refreshing the remembered pick for information -SourceChoiceStickiness = Enum "SourceChoiceStickiness", { - Unset: "unset" - Once: "once" - Retain: "retain" - Pinned: "pinned" - Auto: "auto" -} +SourceChoiceStickiness = domain.SourceChoiceStickiness -- Where a remembered package source came from. ---@alias SourceFeedKind @@ -124,10 +113,12 @@ SourceFeedKind = Enum "SourceFeedKind", { Other: "other" } ----A package's remembered source, persisted per-package as `currentSource`. +---A package source and how sticky the choice of it is. Persisted per-package twice: `configuredSource` +---holds the source the next update should use, `currentSource` the one the installed copy came from. +---The two diverge while a source change hasn't been applied by a completed update yet. ---@class SourceChoiceRecord ---@field feedSource SourceFeedKind Where the source came from. ----@field feedUrl? string The literal feed URL; only stored (and required) for the `other` feedSource. +---@field feedUrl? string The literal feed URL. A `configuredSource` stores it only for the `other` kind, and otherwise defers to the kind-specific properties elsewhere in the config. A `currentSource` always stores it, so provenance stays true when those fields change. ---@field channel string The update channel the source was resolved on. ---@field provider? { namespace: string, version?: string } The provider that satisfied the requirement, when resolved indirectly. ---@field stickiness SourceChoiceStickiness How sticky the choice is. @@ -357,7 +348,6 @@ class UpdateTask assert type(targetVersionNumber) == "number", msgs.new.badTargetVersion @logger = @updater.logger - @triedFeeds = {} @status = nil @targetVersion = targetVersionNumber @@ -399,13 +389,18 @@ class UpdateTask @record.name, currentChannel, tostring updateRecord.version return updateRecord, nil, version - ---Resolves the feed URL a persisted source record maps to, given the owning package's feed fields. + ---Resolves the feed URL a persisted source record maps to: its own `feedUrl` when it stores one, + ---otherwise derived from the owning package's feed fields by source kind. ---@param source SourceChoiceRecord The persisted source record. ---@param selfFeed? string The package's declared feed (used for a self-declared source). ---@param userFeed? string The package's per-package override feed (used for a user-feed source). ---@param modulesSection? table The modules config section (used to resolve a provider source). ---@return string? url The resolved feed URL, or nil if it can't be determined. @resolveSourceUrl = (source, selfFeed, userFeed, modulesSection) -> + -- Source records used for `configuredSource` omit the feed URL when that is also stored + -- elsewhere in the config to maintain a single source of truth. In contrast, `currentSource` + -- always stores it to preserve provenance when those fields change. + return source.feedUrl if source.feedUrl switch source.feedSource when SourceFeedKind.SelfDeclared then selfFeed when SourceFeedKind.UserFeed then userFeed @@ -454,33 +449,57 @@ class UpdateTask return SourceFeedKind.Other ---Records which source satisfied this task and how sticky the choice is, in the per-package - ---`currentSource` config, so later resolutions can honor it. Writes only when something changed. + ---`configuredSource` config, so later resolutions can honor it. The feed's channel lineup is + ---refreshed alongside. Writes only when something changed. ---@param selectedCandidate CandidatePackageSource The chosen candidate. ---@param stickiness? SourceChoiceStickiness The stickiness to record (defaults to the existing one, else `unset`). ---@private __persistSource: (selectedCandidate, stickiness) => return unless @record.config - existing = @record.config.c.currentSource + existing = ScriptUpdateRecord.getRecordedSource @record.config.c feedSource = @__feedSourceOf selectedCandidate - currentSource = { + configuredSource = { :feedSource channel: selectedCandidate.updateRecord.activeChannel or @channel stickiness: stickiness or (existing and existing.stickiness) or SourceChoiceStickiness.Unset } - currentSource.feedUrl = selectedCandidate.feedUrl if feedSource == SourceFeedKind.Other + configuredSource.feedUrl = selectedCandidate.feedUrl if feedSource == SourceFeedKind.Other unless selectedCandidate.isDirect - currentSource.provider = {namespace: selectedCandidate.updateRecord.namespace, version: selectedCandidate.providesVersion} - - unchanged = existing and existing.feedSource == currentSource.feedSource and - existing.channel == currentSource.channel and existing.stickiness == currentSource.stickiness and - existing.feedUrl == currentSource.feedUrl and - (existing.provider and existing.provider.namespace) == (currentSource.provider and currentSource.provider.namespace) and - (existing.provider and existing.provider.version) == (currentSource.provider and currentSource.provider.version) + configuredSource.provider = {namespace: selectedCandidate.updateRecord.namespace, version: selectedCandidate.providesVersion} + + -- sorted, so the persisted lineup doesn't ride on table order and rewrite the config every session + channelLineup = selectedCandidate.updateRecord\getChannels! + table.sort channelLineup + lineupUnchanged = utils.itemsEqual channelLineup, @record.config.c.channels or {} + + unchanged = lineupUnchanged and @record.config.c.configuredSource and existing and + existing.feedSource == configuredSource.feedSource and + existing.channel == configuredSource.channel and existing.stickiness == configuredSource.stickiness and + existing.feedUrl == configuredSource.feedUrl and + (existing.provider and existing.provider.namespace) == (configuredSource.provider and configuredSource.provider.namespace) and + (existing.provider and existing.provider.version) == (configuredSource.provider and configuredSource.provider.version) return if unchanged - @record.config.c.currentSource = currentSource + @record.config.c.channels = channelLineup + @record.config.c.configuredSource = configuredSource @record.config\save! + ---Persists the provenance of the package just installed or updated to the per-package `currentSource` + ---property in the DepCtrl config file. `lastChannel` kept in step as its pre-0.7 single-field form. + ---@private + __recordInstalledSource: => + return unless @record.config + source = ScriptUpdateRecord.getRecordedSource @record.config.c + return unless source + installed = utils.deepCopy source + -- the URL is stamped rather than left to be derived later, so changing a userFeed or a declared + -- feed afterwards doesn't rewrite where this copy is recorded as having come from + installed.feedUrl or= @__resolveRememberedFeedUrl source + with @record.config + .c.currentSource = installed + .c.lastChannel = installed.channel + \save! + ---The version this candidate is ranked by, or nil when it can't satisfy the task. A direct candidate ranks by ---its release version, a provider by the highest version its declared alias range covers (any version if none). ---@param candidate CandidatePackageSource @@ -670,6 +689,12 @@ class UpdateTask wasVirtual = @record.virtual if selectedSource.isDirect code, res = @performUpdate selectedSource.updateRecord + -- A fresh install resolves on a virtual record, whose config view has no file behind it, so + -- everything recorded about the source during the install went nowhere durable. The install + -- adopted the real record, whose config can hold it, so record the source and provenance again. + if wasVirtual and not @record.virtual + @__persistSource selectedSource, stickiness + @__recordInstalledSource! return @__logUpdateError code, res, wasVirtual -- for an indirect source, install the chosen provider in place of the required module @@ -679,6 +704,7 @@ class UpdateTask code, detail = @__reportNoSuitablePackage maxVersion return code, detail @ref, @updated = ref, true + @__recordInstalledSource! @logger\log msgs.run.providerResolved, @record.namespace, domain.terms.scriptType.singular[domain.ScriptType.Module], selectedSource.updateRecord.name or selectedSource.updateRecord.namespace, selectedSource.updateRecord.version return UpdateStatus.Installed, selectedSource.updateRecord.version @@ -749,8 +775,7 @@ class UpdateTask userFeedTrusted = userFeed and not isBlocked userFeed isTrusted = (url) -> feedTrust\isTrusted(url) or (userFeedTrusted and url == userFeed) - -- the remembered package source for this package, and how sticky the user's last choice was - remembered = @record.config.c.currentSource + remembered = ScriptUpdateRecord.getRecordedSource @record.config.c stickiness = remembered and remembered.stickiness or SourceChoiceStickiness.Unset -- a remembered provider stays pinned to its band so a version bump updates it in place instead of switching providers stickyProvider = remembered and remembered.provider and remembered.provider.namespace @@ -760,13 +785,13 @@ class UpdateTask return direct and (feedUrl == declaredFeed and TrustBand.DeclaredDirect or TrustBand.TrustedDirect) or TrustBand.TrustedProvider return direct and TrustBand.UntrustedDirect or TrustBand.UntrustedProvider - maxVer, candidates = 0, {} - + maxVer, candidates, triedFeeds = 0, {}, {} + moduleConfigView = @updater.config\getSectionHandler domain.ScriptTypeSection[domain.ScriptType.Module] -- Gather candidates from a list of feed URLs, skipping any that are blocked or already tried. gather = (feedUrls) -> for feedUrl in *(feedUrls or {}) - continue if not feedUrl or @triedFeeds[feedUrl] or isBlocked feedUrl - @triedFeeds[feedUrl] = true + continue if not feedUrl or triedFeeds[feedUrl] or isBlocked feedUrl + triedFeeds[feedUrl] = true @updater\renewLock! @logger\trace msgs.run.feedChecking, feedUrl feed, errMsg = @__loadFeed feedUrl @@ -781,7 +806,7 @@ class UpdateTask elseif errMsg @logger\log errMsg if @record.virtual - for provider in *feed\getProviders @record.namespace + for provider in *feed\getProviders @record.namespace, (moduleConfigView and moduleConfigView.c) or {} -- the version range this provider declares for the required alias, if any providesVersions = [e.version for e in *(provider.provides or {}) when type(e) == "table" and e.name == @record.namespace] -- a trusted candidate from the sticky (remembered/installed) provider stays pinned (declared-direct band) @@ -1015,6 +1040,8 @@ class UpdateTask else fileOps.rmdir tmpDir -- recurses by default: the temp dir still holds the per-type subdirectories os.remove file.fullName for file in *update.files when file.delete and not file.unknown + @__recordInstalledSource! + -- Nuke old module refs and reload oldVer, wasVirtual = @record.version, @record.virtual diff --git a/modules/l0/DependencyControl/config-schema.moon b/modules/l0/DependencyControl/config-schema.moon index 061043d..54235d7 100644 --- a/modules/l0/DependencyControl/config-schema.moon +++ b/modules/l0/DependencyControl/config-schema.moon @@ -7,7 +7,10 @@ domain = require "l0.DependencyControl.domain" utils = require "l0.DependencyControl.utils" SemanticVersion = require "l0.DependencyControl.SemanticVersion" -CONFIG_SCHEMA_ID_CURRENT = "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/schemas/config/v0.7.0.json" +CONFIG_SCHEMA_ID_V0_7_0 = "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/schemas/config/v0.7.0.json" +CONFIG_SCHEMA_ID_CURRENT = "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/schemas/config/v0.9.0.json" +-- the version a config written before the `$schema` key existed is measured as +PRE_SCHEMA_VERSION = "0.0.0" -- Per-section defaults. Each section under `sections` is loaded as its own ConfigView with these defaults -- and handed to the classes of that domain. A section key is kept even when the section has no shared @@ -50,27 +53,111 @@ keyMap = { -- v0.6.3 keys removed outright on migration: settings dropped in v0.7.0 with no sectioned replacement. droppedKeys = {"tryAllFeeds", "dumpFeeds"} --- pre-0.7 published these packages on an `alpha` channel only, pinning every install to it; v0.7.0 makes --- `stable` their default. rewrite the pin so those installs track `stable`, scoped to DependencyControl's --- own packages so a channel deliberately chosen for a third-party feed is left alone. -channelRename = { - packages: utils.makeSet {"l0.DependencyControl", "l0.DependencyControl.Toolbox"} - from: "alpha" - to: "stable" +-- DepCtrl pre-0.7 published these packages on an `alpha` channel only, pinning every install to it; v0.7.0 +-- makes `stable` their default. `main` is the feed maintenance process's own channel, published as a second +-- default by a CI mishap, so installs ended up tracking it — through the 0.7.0 schema as well, where an +-- `alpha` pin is one the Toolbox offers and so stays. +ownPackages = {"l0.DependencyControl", "l0.DependencyControl.Toolbox", "l0.dkjson", "l0.MoonCats"} + +---A channel the migration retires, and the schema step over which it does so. +---@class ConfigChannelRename +---@field fromSchemaVersion string Range the config's own schema version must satisfy. +---@field toSchemaVersion string Range the schema version being migrated to must satisfy. +---@field packages string[] The namespaces whose records are rewritten. +---@field from string[] The channel names to rewrite. +---@field to string The channel name they are rewritten to. + +---@type ConfigChannelRename[] +channelsToRename = { + { + fromSchemaVersion: "< 0.7.0" + toSchemaVersion: ">= 0.7.0" + packages: ownPackages + from: {"alpha", "main"} + to: "stable" + } + { + fromSchemaVersion: ">= 0.7.0 < 0.9.0" + toSchemaVersion: ">= 0.9.0" + packages: ownPackages + from: {"main"} + to: "stable" + } } +---Reports whether a list holds the given string. +---@param list string[] The list to search. +---@param value? string The value to look for; nil is never held. +---@return boolean holds Whether the value is in the list. +holds = (list, value) -> + return false unless value + for entry in *list + return true if entry == value + false + +---The schema version a config `$schema` id names, read from the `vX.Y.Z.json` its URL ends in. +---@param schemaId? string A config schema id, or nil for a config written before there were any. +---@return string version The version named, or `0.0.0` where there is no id to read one from. +schemaVersion = (schemaId) -> + type(schemaId) == "string" and schemaId\match("v(%d+%.%d+%.%d+)%.json$") or PRE_SCHEMA_VERSION + +---Reports whether a version satisfies a range, throwing on a range stated wrongly above. +---@param version string The version to test. +---@param range string The npm-style range it must satisfy. +---@return boolean satisfies Whether the version falls in the range. +satisfiesRange = (version, range) -> + satisfies, err = SemanticVersion\satisfiesRange version, range + assert satisfies != nil, err + satisfies + +---Rewrites the channel a record has recorded, and the one its configured source names, for every package +---a rename covers that spans the schema versions being migrated between. +---@param config table The whole config-file table, mutated in place. +---@param currentSchemaId? string The `$schema` the config holds, or nil for a pre-`$schema` config. +---@param targetSchemaId string The `$schema` being migrated to. +renameChannels = (config, currentSchemaId, targetSchemaId) -> + fromVersion = schemaVersion currentSchemaId + toVersion = schemaVersion targetSchemaId + for rename in *channelsToRename + applies = satisfiesRange(fromVersion, rename.fromSchemaVersion) and satisfiesRange toVersion, rename.toSchemaVersion + continue unless applies + for section in *domain.ScriptTypeSection.values + records = config[section] + continue unless type(records) == "table" + for namespace, record in pairs records + continue unless type(record) == "table" and holds rename.packages, namespace + record.lastChannel = rename.to if holds rename.from, record.lastChannel + configured = record.configuredSource + configured.channel = rename.to if type(configured) == "table" and holds rename.from, configured.channel + ---Migrates a whole config-file table up to the current schema, in place, when its root `$schema` predates it. ---For a pre-`$schema` config it lifts flat `config`-hive keys into topic sections and rewrites each record's ----pre-0.7 `unmanaged` flag into its recordType and its packed-integer version into a semver string; a config ----that already carries a `$schema`, and any keys none of these cover, are left untouched. +---pre-0.7 `unmanaged` flag into its recordType and its packed-integer version into a semver string. For a +---v0.7.0 config it seeds each record's `configuredSource` from its `currentSource`, which v0.9.0 splits into +---intent (`configuredSource`, the source the next update should use) and provenance (`currentSource`, the +---source the installed copy came from). Either step also rewrites our own packages' recorded update +---channel where the schema versions it spans retire one. +---A config already on the current schema, and any keys none of these cover, are left untouched. ---Shaped as ConfigHandler's migration callback: the handler stamps the new `$schema` when this returns true. ---@param config table The whole config-file table, mutated in place (the `config` hive and the record sections). ---@param currentSchemaId? string The `$schema` found in the file, or nil for a pre-`$schema` (flat) config. ----@param targetSchemaId? string The schema being migrated to (unused here; the handler applies it). +---@param targetSchemaId string The schema being migrated to. ---@return boolean migrated Whether a migration was applied. migrate = (config, currentSchemaId, targetSchemaId) -> - return false if currentSchemaId -- has a $schema → already sectioned; nothing to lift + -- ≥0.7.0/<0.9.0 to ≥0.9.0 migration + if currentSchemaId == CONFIG_SCHEMA_ID_V0_7_0 + for section in *domain.ScriptTypeSection.values + records = config[section] + continue unless type(records) == "table" + for _, record in pairs records + continue unless type(record) == "table" + if type(record.currentSource) == "table" + record.configuredSource or= utils.deepCopy record.currentSource + renameChannels config, currentSchemaId, targetSchemaId + return true + return false if currentSchemaId + -- ≤0.6.4 to ≥0.7.0 migration configHive = config.config if type(configHive) == "table" for oldKey, dest in pairs keyMap @@ -95,9 +182,7 @@ migrate = (config, currentSchemaId, targetSchemaId) -> record.recordType = domain.RecordType.Unmanaged if record.unmanaged record.unmanaged = nil record.version = SemanticVersion\toString record.version if type(record.version) == "number" - if channelRename.packages[namespace] - record.lastChannel = channelRename.to if record.lastChannel == channelRename.from - record.activeChannel = channelRename.to if record.activeChannel == channelRename.from + renameChannels config, currentSchemaId, targetSchemaId return true return { diff --git a/modules/l0/DependencyControl/domain.moon b/modules/l0/DependencyControl/domain.moon index 50e8f67..fe4b2d5 100644 --- a/modules/l0/DependencyControl/domain.moon +++ b/modules/l0/DependencyControl/domain.moon @@ -46,6 +46,21 @@ FetchUntrustedFeeds = Enum "FetchUntrustedFeeds", { Prompt: "prompt" } +-- How sticky a recorded package-source choice is on subsequent resolutions of the same package. +---@alias SourceChoiceStickiness +---| "unset" # Unset: no preference recorded yet; resolve normally and prompt only if interactive +---| "once" # Once: prompt again whenever a choice remains, preselecting the recorded pick +---| "retain" # Retain: reuse the recorded pick whenever it's still eligible, without prompting +---| "pinned" # Pinned: always reuse the recorded pick; if it's gone, abort (required) or skip (optional) +---| "auto" # Auto: never prompt; always resolve via the ranking, refreshing the recorded pick for information +SourceChoiceStickiness = Enum "SourceChoiceStickiness", { + Unset: "unset" + Once: "once" + Retain: "retain" + Pinned: "pinned" + Auto: "auto" +} + ---Shared vocabulary of DependencyControl's problem domain: the kinds of scripts and records it ---manages, the human-readable terms for them, namespace rules, and install/test locations. ---@class Domain @@ -54,6 +69,7 @@ Domain = { :RecordType :ScriptType :ScriptTypeSection + :SourceChoiceStickiness terms: { scriptType: { diff --git a/modules/l0/DependencyControl/test/ConfigHandler.moon b/modules/l0/DependencyControl/test/ConfigHandler.moon index c69dc03..b744e54 100644 --- a/modules/l0/DependencyControl/test/ConfigHandler.moon +++ b/modules/l0/DependencyControl/test/ConfigHandler.moon @@ -284,6 +284,39 @@ result = handlerB\save view ut\assertTrue result + -- A save writes what this handler changed since it read the file, not the hive it happens to hold, so + -- a second handler over the same file — another Aegisub, or the instance a self-update replaced — + -- keeps the keys it wrote in between. + save_keepsAnotherHandlersChanges: (ut) -> + path = "#{aegisub.decode_path '?temp'}/dc_m12a_baseline.json" + fileOps = require FILEOPS_MODULE_NAME + fileOps.writeFile path, [[{"modules": {"l0.Pkg": {"version": "1.0.0", "lastChannel": "main"}}}]], true + + first = ConfigHandler path + second = ConfigHandler path + firstView = first\getView {"modules", "l0.Pkg"} + secondView = second\getView {"modules", "l0.Pkg"} + ut\assertTrue first\load firstView + ut\assertTrue second\load secondView + + -- the second handler migrates the channel and writes it away + secondView.c.lastChannel = "stable" + secondView.c.configuredSource = {channel: "stable"} + ut\assertTrue second\save secondView + + -- the first handler, which never saw either key, records a completed install + firstView.c.version = "2.0.0" + firstView.c.currentSource = {channel: "main"} + ut\assertTrue first\save firstView + + written = ConfigHandler path + writtenView = written\getView {"modules", "l0.Pkg"} + ut\assertTrue written\load writtenView + ut\assertEquals writtenView.c.version, "2.0.0" -- the later save landed + ut\assertEquals writtenView.c.currentSource.channel, "main" + ut\assertEquals writtenView.c.lastChannel, "stable" -- and did not revert the earlier one + ut\assertEquals writtenView.c.configuredSource.channel, "stable" + save_withViewPopulatedHive: (ut) -> -- Normal path: cleanHive keeps a hive that has data and save succeeds. handler = ConfigHandler nil @@ -344,7 +377,7 @@ "load_noFilePath", "load_fileNotFound", "load_success", "load_migratesOnSchemaMismatch", "load_skipsMigrationWhenSchemaMatches", "save_noFilePath", "save_lockFailed", "save_success", - "save_withViewMissingHive", "save_withViewPopulatedHive", + "save_withViewMissingHive", "save_withViewPopulatedHive", "save_keepsAnotherHandlersChanges", "save_writesPrettyPrintedJson", "purgeHive_removesPath" } diff --git a/modules/l0/DependencyControl/test/ConfigView.moon b/modules/l0/DependencyControl/test/ConfigView.moon index 5cdc312..3b31814 100644 --- a/modules/l0/DependencyControl/test/ConfigView.moon +++ b/modules/l0/DependencyControl/test/ConfigView.moon @@ -3,6 +3,7 @@ -> ConfigHandler = require "l0.DependencyControl.ConfigHandler" ConfigView = require "l0.DependencyControl.ConfigView" + fileOps = require "l0.DependencyControl.file-ops" { _description: "Tests for the ConfigView hive accessor and defaults proxy." @@ -235,6 +236,39 @@ ut\assertTrue view.__configHandler.views[view] -- registered with the new handler ut\assertNil old.views[view] -- and detached from the old one + -- setFile moves the view's hive into the new handler's tree. A hive left in the old one sits + -- outside what a save on the new handler merges from, so nothing the view holds reaches the file. + setFile_movesHiveIntoNewHandler: (ut) -> + old = ConfigHandler nil + view = ConfigView old, {"modules", "l0.Pkg"} + old.views[view] = true + view.userConfig.name = "written while orphaned" + target = "#{aegisub.decode_path '?temp'}/dc_m12b_setFileHiveMove.json" + ut\assertTrue view\setFile target + handlerHive = view.__configHandler\getHive view.__hivePath + ut\assertIs view.userConfig, handlerHive -- the very table a save merges from + view.c.name = "written after binding" + ut\assertEquals handlerHive.name, "written after binding" -- so a later write lands in that tree + + -- setFile loads a file no handler has read yet, so the view starts out on what that file + -- stores and a later save adds to it + setFile_loadsUnreadFile: (ut) -> + target = "#{aegisub.decode_path '?temp'}/dc_m12b_setFileLoads.json" + fileOps.writeFile target, [[{"modules": {"l0.Pkg": {"stored": "from the file"}}}]], true + view = ConfigView ConfigHandler(nil), {"modules", "l0.Pkg"} + ut\assertTrue view\setFile target + ut\assertEquals view.c.stored, "from the file" + + -- noLoad keeps the read for the caller to make + setFile_noLoadLeavesFileUnread: (ut) -> + target = "#{aegisub.decode_path '?temp'}/dc_m12b_setFileNoLoad.json" + fileOps.writeFile target, [[{"modules": {"l0.Pkg": {"stored": "from the file"}}}]], true + view = ConfigView ConfigHandler(nil), {"modules", "l0.Pkg"} + ut\assertTrue view\setFile target, true + ut\assertNil view.c.stored + ut\assertTrue view\load! + ut\assertEquals view.c.stored, "from the file" -- and the caller's own load still gets there + _order: { "new_orphan", "new_withHandler", "new_stringHivePath", "new_tableHivePath", "isOverlappingView_differentHandler", "isOverlappingView_root", @@ -247,6 +281,7 @@ "import_simple", "import_updateOnly", "import_skipPrivate", "load_noFilePath", "load_delegatesToHandler", "save_noFilePath", "save_delegatesToHandler", - "delete_purgesAndSaves", "setFile_registersWithNewHandler" + "delete_purgesAndSaves", "setFile_registersWithNewHandler", "setFile_movesHiveIntoNewHandler", + "setFile_loadsUnreadFile", "setFile_noLoadLeavesFileUnread" } } diff --git a/modules/l0/DependencyControl/test/FeedInventory.moon b/modules/l0/DependencyControl/test/FeedInventory.moon index e6eaecb..9d87bcd 100644 --- a/modules/l0/DependencyControl/test/FeedInventory.moon +++ b/modules/l0/DependencyControl/test/FeedInventory.moon @@ -150,6 +150,25 @@ -- getPackagesSourcedFrom / getEffectiveSource: a package's remembered currentSource (resolved per kind), else its -- override, else its declared feed + -- getEffectiveSource's second return names the rung the URL came from, so a caller showing or + -- seeding a source kind reads the same fallback order the URL followed instead of restating it + getEffectiveSource_reportsKind: (ut) -> + recorded = {feed: 'feed://d', userFeed: 'feed://u', currentSource: {feedSource: SourceFeedKind.UserFeed}} + url, kind = FeedInventory.getEffectiveSource recorded, {} + ut\assertEquals url, 'feed://u' + ut\assertEquals kind, SourceFeedKind.UserFeed + overridden = {feed: 'feed://d', userFeed: 'feed://u'} + url, kind = FeedInventory.getEffectiveSource overridden, {} + ut\assertEquals url, 'feed://u' + ut\assertEquals kind, SourceFeedKind.UserFeed -- no record: the override is the first pick + plain = {feed: 'feed://d'} + url, kind = FeedInventory.getEffectiveSource plain, {} + ut\assertEquals url, 'feed://d' + ut\assertEquals kind, SourceFeedKind.SelfDeclared + url, kind = FeedInventory.getEffectiveSource {}, {} + ut\assertNil url + ut\assertNil kind + getPackagesSourcedFrom_getEffectiveSource: (ut) -> inv = makeInventory { macros: { @@ -314,7 +333,7 @@ _order: { "gather_tagsEachSource", "gather_mergesProvenance", "gather_surfacesTrustStatus", "gather_blockedByEntry" - "gather_marksInUse", "getPackagesSourcedFrom_getEffectiveSource", "gather_empty" + "gather_marksInUse", "getEffectiveSource_reportsKind", "getPackagesSourcedFrom_getEffectiveSource", "gather_empty" "crawl_discoversTransitively", "crawl_neverDoesNotFetchUntrusted", "crawl_blockedRootNotFetched" "crawl_promptFollowsOnlyConfirmedUntrusted" "crawl_boundsUntrustedPerRoot", "crawl_boundsUntrustedPerFeed", "crawl_reportsDepthTruncation" diff --git a/modules/l0/DependencyControl/test/FeedLoader.moon b/modules/l0/DependencyControl/test/FeedLoader.moon index eaf087f..afb9b4a 100644 --- a/modules/l0/DependencyControl/test/FeedLoader.moon +++ b/modules/l0/DependencyControl/test/FeedLoader.moon @@ -42,6 +42,15 @@ ut\assertEquals feed.config.maxFeedSize, FeedLoader.defaultMaxFeedSize ut\assertEquals feed.config.feedFetchTimeout, FeedLoader.defaultFeedFetchTimeout + -- load reads the fetch policy from the config at call time, so a settings change applies to the next fetch + load_readsFetchPolicyAtCallTime: (ut) -> + loader = make {blockPrivateHosts: true} + loader.config.c.updates.blockPrivateHosts = false + loader.config.c.feeds.maxFeedSize = 1234 + feed = loader\load url, {autoLoad: false} + ut\assertFalse feed.config.blockPrivateHosts + ut\assertEquals feed.config.maxFeedSize, 1234 + -- a configured feed-fetch cap overrides the default load_usesConfiguredFeedCaps: (ut) -> loader = make {maxFeedSize: 1000, feedFetchTimeout: 5} @@ -51,6 +60,6 @@ _order: { "new_opensFeedCacheUnderNamespace", "load_wiresSharedCacheAndPolicy", - "load_appliesFeedCapDefaults", "load_usesConfiguredFeedCaps" + "load_appliesFeedCapDefaults", "load_readsFetchPolicyAtCallTime", "load_usesConfiguredFeedCaps" } } diff --git a/modules/l0/DependencyControl/test/PackageRecord.moon b/modules/l0/DependencyControl/test/PackageRecord.moon index dd2994b..5d193b3 100644 --- a/modules/l0/DependencyControl/test/PackageRecord.moon +++ b/modules/l0/DependencyControl/test/PackageRecord.moon @@ -444,6 +444,20 @@ ut\assertEquals fakeSelf\getVersionString(SemanticVersion\toPacked "3.1.0"), "3.1.0" -- loadConfig imports recordType from the stored config like any other persisted field + -- A release that changes nothing but the version still has to reach the config. `version` is not a + -- scriptField, so the import reports no change and only an explicit comparison catches it. This is how + -- a package updated by an older DependencyControl records its new version on the next load. + loadConfig_bumpedVersionAloneWritesConfig: (ut) -> + SemanticVersion = require "l0.DependencyControl.SemanticVersion" + makeRecord = (recorded, declared) -> stubSelf PackageRecord, { + __class: PackageRecord, virtual: false, namespace: "l0.x", scriptType: domain.ScriptType.Module + semanticVersion: SemanticVersion declared + config: {load: (=> true), import: (=> false), c: {version: recorded}} + } + ut\assertTrue PackageRecord.__base.loadConfig makeRecord "1.0.0", "2.0.0" + ut\assertFalse PackageRecord.__base.loadConfig makeRecord "2.0.0", "2.0.0" + ut\assertTrue PackageRecord.__base.loadConfig makeRecord nil, "2.0.0" -- nothing recorded yet + loadConfig_importsRecordType: (ut) -> record = stubSelf PackageRecord, { __class: PackageRecord, virtual: false, namespace: "l0.x", scriptType: domain.ScriptType.Module @@ -493,7 +507,7 @@ _order: { "getFileCache_namespacedUnderConfigBase", "getVersion_compatMethods", - "loadConfig_importsRecordType", + "loadConfig_importsRecordType", "loadConfig_bumpedVersionAloneWritesConfig", "module_dataOnly", "module_initLayout", "module_alsoUnderUser", "module_userOnly", "notInstalled", "portable", "macro_dataOnly", "checkVersion_equal", "checkVersion_greater", "checkVersion_older", "checkVersion_recordArg", diff --git a/modules/l0/DependencyControl/test/ScriptUpdateRecord.moon b/modules/l0/DependencyControl/test/ScriptUpdateRecord.moon index c4b2210..cd1a454 100644 --- a/modules/l0/DependencyControl/test/ScriptUpdateRecord.moon +++ b/modules/l0/DependencyControl/test/ScriptUpdateRecord.moon @@ -43,6 +43,102 @@ ut\assertFalse success ut\assertEquals channel, "nonexistent" + -- getDefaultChannel is where both the client and the feed tooling get their answer, so the sorted + -- pick and the conflict report are pinned here rather than at either call site + getDefaultChannel_picksSortedAndReportsConflict: (ut) -> + ut\assertNil ScriptUpdateRecord.getDefaultChannel nil + ut\assertNil ScriptUpdateRecord.getDefaultChannel {alpha: {}, beta: {}} -- none flagged + name, conflicting = ScriptUpdateRecord.getDefaultChannel {stable: {default: true}, main: {}} + ut\assertEquals name, "stable" + ut\assertNil conflicting + name, conflicting = ScriptUpdateRecord.getDefaultChannel {stable: {default: true}, main: {default: true}} + ut\assertEquals name, "main" + ut\assertEquals table.concat(conflicting, ","), "main,stable" -- every flagged name, sorted + + -- a feed flagging several channels as the default gets the same answer every time, not one decided + -- by table order, so an install can't land on a different channel each session + getChannels_ambiguousDefaultIsDeterministic: (ut) -> + warned = 0 + data = {name: "TestScript", channels: { + stable: {default: true, version: "2.0.0", files: {}} + main: {default: true, version: "1.0.0", files: {}} + alpha: {version: "3.0.0", files: {}} + }} + logger = {warn: ((...) => warned += 1)} + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, domain.ScriptType.Module, false, logger + channels, default = sur\getChannels! + ut\assertEquals #channels, 3 + ut\assertEquals default, "main" -- sorted, so the pick doesn't move between runs + ut\assertEquals warned, 1 -- and the malformed feed is reported + + -- asked to, a package recorded against a channel the feed has since dropped moves to the default + -- channel instead of resolving to no candidate at all + setChannel_recordedChannelGoneFallsBack: (ut) -> + data = {name: "TestScript", channels: {release: {default: true, version: "1.0.0", files: {}}}} + config = {c: {lastChannel: "main"}} + sur = ScriptUpdateRecord "test.NS", data, config, domain.ScriptType.Module, false, {warn: (=>)} + success, channel = sur\setChannel! + ut\assertTrue success + ut\assertEquals channel, "release" + ut\assertEquals config.c.lastChannel, "main" -- selection only; the updater records the move when it persists the source + + -- an explicitly requested channel beats the recorded one, so a channel switch isn't silently undone + -- by whatever an earlier resolution recorded + setChannel_explicitRequestOverridesRecorded: (ut) -> + data = {name: "TestScript", channels: { + stable: {default: true, version: "2.0.0", files: {}} + alpha: {version: "3.0.0", files: {}} + }} + config = {c: {lastChannel: "stable"}} + sur = ScriptUpdateRecord "test.NS", data, config, domain.ScriptType.Module, false, {warn: (=>)} + success, channel = sur\setChannel "alpha" + ut\assertTrue success + ut\assertEquals channel, "alpha" + ut\assertEquals sur.version, "3.0.0" + ut\assertEquals config.c.lastChannel, "stable" -- untouched + ut\assertNil config.c.channels -- selection writes no config at all + + -- the configured source names the channel the next update should use, so it wins over the source the + -- installed copy came from while a channel change hasn't been applied by an update yet + setChannel_configuredChannelWinsOverInstalled: (ut) -> + data = {name: "TestScript", channels: { + stable: {default: true, version: "2.0.0", files: {}} + alpha: {version: "3.0.0", files: {}} + }} + config = {c: { + currentSource: {channel: "stable", stickiness: domain.SourceChoiceStickiness.Retain} + configuredSource: {channel: "alpha", stickiness: domain.SourceChoiceStickiness.Retain} + }} + sur = ScriptUpdateRecord "test.NS", data, config, domain.ScriptType.Module, false, {warn: (=>)} + success, channel = sur\setChannel! + ut\assertTrue success + ut\assertEquals channel, "alpha" + ut\assertEquals sur.version, "3.0.0" + + -- the package source's channel wins over the pre-0.7 lastChannel, which froze at whatever the first + -- feed evaluation wrote and so can disagree with what resolutions have settled on since + setChannel_packageSourceChannelWinsOverLegacyKey: (ut) -> + data = {name: "TestScript", channels: { + stable: {default: true, version: "2.0.0", files: {}} + main: {version: "1.0.0", files: {}} + }} + config = {c: {lastChannel: "main", currentSource: {channel: "stable", stickiness: domain.SourceChoiceStickiness.Retain}}} + sur = ScriptUpdateRecord "test.NS", data, config, domain.ScriptType.Module, false, {warn: (=>)} + success, channel = sur\setChannel! + ut\assertTrue success + ut\assertEquals channel, "stable" + ut\assertEquals sur.version, "2.0.0" + + -- a pinned package source keeps the recorded channel and fails, so the pin isn't quietly broken + setChannel_recordedChannelGonePinnedFails: (ut) -> + data = {name: "TestScript", channels: {release: {default: true, version: "1.0.0", files: {}}}} + config = {c: {lastChannel: "main", currentSource: {stickiness: domain.SourceChoiceStickiness.Pinned}}} + sur = ScriptUpdateRecord "test.NS", data, config, domain.ScriptType.Module, false, {warn: (=>)} + success, channel = sur\setChannel! + ut\assertFalse success + ut\assertEquals channel, "main" + ut\assertEquals config.c.lastChannel, "main" -- untouched + checkPlatform_noConstraint: (ut) -> data = {channels: {release: {default: true, version: "1.0.0", files: {}}}, name: "T"} sur = ScriptUpdateRecord "test.NS", data, {c:{}}, domain.ScriptType.Module @@ -117,6 +213,10 @@ _order: { "getChannels_basic", "getChannels_noDefault", "getChannels_noChannels", "setChannel_valid", "setChannel_invalid", + "getDefaultChannel_picksSortedAndReportsConflict", "getChannels_ambiguousDefaultIsDeterministic", + "setChannel_recordedChannelGoneFallsBack", "setChannel_recordedChannelGonePinnedFails", + "setChannel_configuredChannelWinsOverInstalled", "setChannel_packageSourceChannelWinsOverLegacyKey", + "setChannel_explicitRequestOverridesRecorded", "checkPlatform_noConstraint", "checkPlatform_currentPlatform", "checkPlatform_notMatching", "getChangelog_noTable", "getChangelog_inRange", "getChangelog_allOutOfRange", "getChangelog_skipsMalformedKey", "getChangelog_groupsMarkedEntries" diff --git a/modules/l0/DependencyControl/test/SemanticVersion.moon b/modules/l0/DependencyControl/test/SemanticVersion.moon index fce2ff7..c7ae5d8 100644 --- a/modules/l0/DependencyControl/test/SemanticVersion.moon +++ b/modules/l0/DependencyControl/test/SemanticVersion.moon @@ -154,6 +154,16 @@ ut\assertTrue SemanticVersion\satisfiesRange("2.3.9", "1.2.3 - 2.3") -- partial upper => <2.4.0 ut\assertFalse SemanticVersion\satisfiesRange("2.4.0", "1.2.3 - 2.3") + -- npm allows an operator to stand apart from its version, and a range written that way must not + -- quietly reduce to one nothing satisfies + satisfiesRange_spacedOperator: (ut) -> + ut\assertTrue SemanticVersion\satisfiesRange("0.6.4", "< 0.7.0") + ut\assertFalse SemanticVersion\satisfiesRange("0.7.0", "< 0.7.0") + ut\assertTrue SemanticVersion\satisfiesRange("0.8.2", ">= 0.7.0 < 0.9.0") + ut\assertFalse SemanticVersion\satisfiesRange("0.9.0", ">= 0.7.0 < 0.9.0") + ut\assertTrue SemanticVersion\satisfiesRange("1.2.9", "~ 1.2.3") + ut\assertTrue SemanticVersion\satisfiesRange("1.2.3", "1.2.3 - 2.3.4") -- a hyphen range still parses + satisfiesRange_errors: (ut) -> r1, e1 = SemanticVersion\satisfiesRange "1.2.3", "garbage" ut\assertNil r1 @@ -302,7 +312,7 @@ "check_rangeMode", "check_rangeBadRange", "satisfiesRange_tilde", "satisfiesRange_caret", "satisfiesRange_xRangeAndAny", "satisfiesRange_exact", "satisfiesRange_comparators", "satisfiesRange_orUnion", - "satisfiesRange_hyphen", "satisfiesRange_errors", + "satisfiesRange_hyphen", "satisfiesRange_spacedOperator", "satisfiesRange_errors", "parseRange_intervals", "parseRange_unsatisfiableIsEmpty", "parseRange_badType", "rangesIntersect_overlap", "rangesIntersect_disjoint", "rangesIntersect_unionGroups", "rangesIntersect_emptyAndExactBounds", "rangesIntersect_error", diff --git a/modules/l0/DependencyControl/test/UpdateFeed.moon b/modules/l0/DependencyControl/test/UpdateFeed.moon index 1c13f79..55ccf64 100644 --- a/modules/l0/DependencyControl/test/UpdateFeed.moon +++ b/modules/l0/DependencyControl/test/UpdateFeed.moon @@ -197,11 +197,28 @@ }, macros: {}}, logger: DepCtrl.logger, __class: UpdateFeed } - providers = UpdateFeed.getProviders feed, "json" + providers = UpdateFeed.getProviders feed, "json", {} ut\assertEquals #providers, 1 ut\assertEquals providers[1].namespace, "l0.dkjson" ut\assertEquals providers[1].version, "2.10.0" + -- given DependencyControl's modules config, a provider's candidacy is judged on the channel recorded + -- for it, so the provides list and version come from the channel an install would actually use + getProviders_honorsRecordedChannel: (ut) -> + feed = { + data: {modules: { + "l0.dkjson": {name: "dkjson", channels: { + release: {default: true, version: "2.10.0", files: {}} + legacy: {version: "2.5.0", files: {}, provides: {"json"}} + }} + }, macros: {}}, + logger: DepCtrl.logger, __class: UpdateFeed + } + ut\assertEquals #(UpdateFeed.getProviders feed, "json", {}), 0 -- the default channel doesn't provide it + providers = UpdateFeed.getProviders feed, "json", {"l0.dkjson": {lastChannel: "legacy"}} + ut\assertEquals #providers, 1 + ut\assertEquals providers[1].version, "2.5.0" + getProviders_objectAliasEntries: (ut) -> feed = { data: {modules: { @@ -209,7 +226,7 @@ }, macros: {}}, logger: DepCtrl.logger, __class: UpdateFeed } - providers = UpdateFeed.getProviders feed, "yaml" + providers = UpdateFeed.getProviders feed, "yaml", {} ut\assertEquals #providers, 1 ut\assertEquals providers[1].namespace, "l0.prov" @@ -220,7 +237,7 @@ }, macros: {}}, logger: DepCtrl.logger, __class: UpdateFeed } - ut\assertEquals #UpdateFeed.getProviders(feed, "xml"), 0 + ut\assertEquals #UpdateFeed.getProviders(feed, "xml", {}), 0 getProviders_ignoresModulesWithoutProvides: (ut) -> feed = { @@ -229,11 +246,11 @@ }, macros: {}}, logger: DepCtrl.logger, __class: UpdateFeed } - ut\assertEquals #UpdateFeed.getProviders(feed, "json"), 0 + ut\assertEquals #UpdateFeed.getProviders(feed, "json", {}), 0 getProviders_noModulesSection: (ut) -> feed = {data: {macros: {}}, logger: DepCtrl.logger, __class: UpdateFeed} - ut\assertEquals #UpdateFeed.getProviders(feed, "json"), 0 + ut\assertEquals #UpdateFeed.getProviders(feed, "json", {}), 0 -- __normalizeModuleAliases: expands bare strings to ModuleAlias tables and preserves table fields @@ -546,6 +563,36 @@ ut\assertEquals feed.rawFeedData.modules["l0.Fresh"].channels.release.released, "2099-12-31" -- stamped ut\assertEquals feed.rawFeedData.modules["l0.Old"].channels.release.released, "2020-01-01" -- kept + -- updateFeed markReleased: a package whose default channel can't be resolved carries the reason in + -- its result and gets no release stamp on any channel, rather than being passed over in silence + updateFeed_markReleasedReportsAmbiguousDefault: (ut) -> + root = fileOps.joinPath basePath, "markrelAmbiguous" + fileOps.mkdir fileOps.joinPath(root, "modules", "l0"), false, true + fileOps.writeFile fileOps.joinPath(root, "modules", "l0", "Pkg.moon"), "-- pkg", true + feedPath = fileOps.joinPath root, "feed.json" + fileOps.writeFile feedPath, [[{ + "dependencyControlFeedFormatVersion": "0.4.0", + "name": "T", + "fileBaseUrl": "https://x.test/", + "fileBaseUrls": {"script": "@{fileBaseUrl}@{namespacePath}@{fileName}"}, + "localFileBasePaths": {"script": "@{localFileBasePath}modules/@{namespacePath}@{fileName}"}, + "modules": { + "l0.Pkg": {"name": "Pkg", "author": "a", "channels": { + "main": {"version": "1.0.0", "default": true, "released": null, + "files": [{"name": ".moon", "url": "@{fileBaseUrl}", "sha1": "0000000000000000000000000000000000000000"}]}, + "stable": {"version": "1.0.0", "default": true, "released": null, + "files": [{"name": ".moon", "url": "@{fileBaseUrl}", "sha1": "0000000000000000000000000000000000000000"}]}}} + } + }]], true + feed = UpdateFeed nil, false, feedPath + (ut\stub feed, "__refreshVersionRecord")\returns false + (ut\stub feed, "__refreshFiles")\returns false, {} + stats = feed\updateFeed {markReleased: "2099-12-31", outPath: false} + ut\assertEquals stats.errored, 1 + ut\assertContains stats.packages[1].errors[1], "several channels" + ut\assertTrue feed.rawFeedData.modules["l0.Pkg"].channels.main.released != "2099-12-31" -- no stamp on either channel + ut\assertTrue feed.rawFeedData.modules["l0.Pkg"].channels.stable.released != "2099-12-31" + -- mergeChannels copies the source channel into the destination channel(s), preserves channels not -- named, stamps the release date, sets the default flag, tracks top-level metadata, and adds packages mergeChannels_copiesPreservingOthers: (ut) -> @@ -586,6 +633,102 @@ ut\assertNotNil newCh.release -- new package added, carrying only the to-channel ut\assertNil newCh.alpha + -- Publishing leaves exactly one channel flagged as the default: the flag is cleared from every + -- channel but the one named, a copy of the source channel left behind by an earlier publish + -- included. Two channels claiming it leaves a client to pick between them by table order. + mergeChannels_leavesExactlyOneDefault: (ut) -> + root = fileOps.joinPath basePath, "merge2" + fileOps.mkdir root, false, true + srcPath = fileOps.joinPath root, "src.json" + dstPath = fileOps.joinPath root, "dst.json" + fileOps.writeFile srcPath, [[{ + "dependencyControlFeedFormatVersion": "0.4.0", "name": "N", "baseUrl": "b", + "modules": { + "l0.A": {"name": "A", "author": "x", "channels": {"main": {"version": "0.8.0", "released": null, "default": true, "files": [{"name": ".moon", "url": "u", "sha1": "AAA"}]}}} + } + }]], true + fileOps.writeFile dstPath, [[{ + "dependencyControlFeedFormatVersion": "0.4.0", "name": "N", "baseUrl": "b", + "modules": { + "l0.A": {"name": "A", "author": "x", "channels": { + "main": {"version": "0.7.0", "released": "2024-01-01", "default": true, "files": [{"name": ".moon", "url": "u", "sha1": "OLD"}]}, + "release": {"version": "0.6.0", "released": "2024-01-01", "default": false, "files": [{"name": ".moon", "url": "u", "sha1": "OLD"}]}, + "alpha": {"version": "0.6.0", "released": "2024-01-01", "default": true, "files": [{"name": ".moon", "url": "u", "sha1": "OLD"}]}}} + } + }]], true + source = UpdateFeed nil, false, srcPath + source\loadFile srcPath, UpdateFeed.ExpansionMode.Local + dest = UpdateFeed nil, false, dstPath + dest\loadFile dstPath, UpdateFeed.ExpansionMode.Local + merged, err = dest\mergeChannels source, {from: "main", to: {"release"}, defaultChannel: "release", outPath: false} + ut\assertNil err + ut\assertEquals #merged, 1 + ch = dest.rawFeedData.modules["l0.A"].channels + ut\assertTrue ch.release.default + ut\assertFalse ch.alpha.default -- a channel this run didn't write stops claiming the default + ut\assertEquals ch.alpha.version, "0.6.0" -- but keeps what it was publishing + ut\assertFalse ch.main.default -- as does the copy of the source channel left by an earlier publish + ut\assertEquals ch.main.version, "0.7.0" + + -- A section's own template keys travel with the merge: macros are stored flat while modules nest, so + -- a published feed that lost the macros section's `fileBaseUrls` resolves every macro to a module path. + mergeChannels_copiesSectionTemplates: (ut) -> + root = fileOps.joinPath basePath, "merge4" + fileOps.mkdir root, false, true + srcPath = fileOps.joinPath root, "src.json" + dstPath = fileOps.joinPath root, "dst.json" + fileOps.writeFile srcPath, [[{ + "dependencyControlFeedFormatVersion": "0.4.0", "name": "N", "baseUrl": "b", + "macros": { + "fileBaseUrls": {"script": "@{fileBaseUrl}@{namespace}@{fileName}"}, + "l0.Pkg": {"name": "P", "author": "x", "channels": {"main": {"version": "0.8.0", "released": null, "default": true, "files": [{"name": ".moon", "url": "u", "sha1": "AAA"}]}}} + } + }]], true + fileOps.writeFile dstPath, [[{"dependencyControlFeedFormatVersion": "0.4.0", "macros": {}, "modules": {}}]], true + source = UpdateFeed nil, false, srcPath + source\loadFile srcPath, UpdateFeed.ExpansionMode.Local + dest = UpdateFeed nil, false, dstPath + dest\loadFile dstPath, UpdateFeed.ExpansionMode.Local + merged, err = dest\mergeChannels source, {from: "main", to: {"stable"}, defaultChannel: "stable", outPath: false} + ut\assertNil err + ut\assertEquals #merged, 1 + macros = dest.rawFeedData.macros + ut\assertNotNil macros["l0.Pkg"].channels.stable -- the package published as usual + ut\assertEquals macros.fileBaseUrls.script, "@{fileBaseUrl}@{namespace}@{fileName}" + + -- Publishing to a channel that isn't the default leaves the default channel alone, flag and all, + -- so releasing to alpha only doesn't strip the feed of the default the caller still names. + mergeChannels_keepsDefaultOnUnwrittenChannel: (ut) -> + root = fileOps.joinPath basePath, "merge3" + fileOps.mkdir root, false, true + srcPath = fileOps.joinPath root, "src.json" + dstPath = fileOps.joinPath root, "dst.json" + fileOps.writeFile srcPath, [[{ + "dependencyControlFeedFormatVersion": "0.4.0", "name": "N", "baseUrl": "b", + "modules": { + "l0.A": {"name": "A", "author": "x", "channels": {"main": {"version": "0.8.0", "released": null, "default": true, "files": [{"name": ".moon", "url": "u", "sha1": "AAA"}]}}} + } + }]], true + fileOps.writeFile dstPath, [[{ + "dependencyControlFeedFormatVersion": "0.4.0", "name": "N", "baseUrl": "b", + "modules": { + "l0.A": {"name": "A", "author": "x", "channels": { + "release": {"version": "0.6.0", "released": "2024-01-01", "default": true, "files": [{"name": ".moon", "url": "u", "sha1": "OLD"}]}, + "alpha": {"version": "0.5.0", "released": "2023-01-01", "default": false, "files": [{"name": ".moon", "url": "u", "sha1": "OLD"}]}}} + } + }]], true + source = UpdateFeed nil, false, srcPath + source\loadFile srcPath, UpdateFeed.ExpansionMode.Local + dest = UpdateFeed nil, false, dstPath + dest\loadFile dstPath, UpdateFeed.ExpansionMode.Local + merged, err = dest\mergeChannels source, {from: "main", to: {"alpha"}, defaultChannel: "release", outPath: false} + ut\assertNil err + ch = dest.rawFeedData.modules["l0.A"].channels + ut\assertTrue ch.release.default -- the named default keeps its flag though this run didn't write it + ut\assertEquals ch.release.version, "0.6.0" + ut\assertEquals ch.alpha.version, "0.8.0" -- alpha published from the source's main channel + ut\assertFalse ch.alpha.default + -- bumpVersions off a released version starts a new cycle: it rewrites the marked source literal, -- bumps the channel version, clears the release date, and refreshes the file hash bumpVersions_startsCycleFromReleased: (ut) -> @@ -613,6 +756,26 @@ ut\assertEquals main.released, dkjson.null -- release date cleared (new build pending) ut\assertNotNil (fileOps.readFile srcFile)\match '"0%.8%.0"' -- marked source literal rewritten + -- bumpVersions: a feed flagging several defaults is refused before anything is rewritten — resolved + -- by table order, the bump would land on a channel nobody chose + bumpVersions_refusesAmbiguousDefault: (ut) -> + root = fileOps.joinPath basePath, "bumpAmbiguous" + fileOps.mkdir root, false, true + feedPath = fileOps.joinPath root, "feed.json" + fileOps.writeFile feedPath, [[{ + "dependencyControlFeedFormatVersion": "0.4.0", "name": "F", "fileBaseUrl": "u/", + "fileBaseUrls": {"script": "@{fileBaseUrl}@{fileName}"}, + "localFileBasePaths": {"script": "@{localFileBasePath}modules/@{namespacePath}@{fileName}"}, + "modules": {"l0.Pkg": {"name": "Pkg", "author": "x", "channels": { + "main": {"version": "0.7.0", "released": "2024-01-01", "default": true, "files": []}, + "stable": {"version": "0.7.0", "released": "2024-01-01", "default": true, "files": []}}}} + }]], true + feed = UpdateFeed nil, false, feedPath + feed\loadFile feedPath, UpdateFeed.ExpansionMode.Local + stats, err = feed\bumpVersions {level: "patch", namespaces: {"l0.Pkg"}, outPath: false} + ut\assertNil stats + ut\assertContains err, "several channels" + -- walkFiles walkFiles_yieldsProxies: (ut) -> @@ -983,7 +1146,8 @@ "getScript_invalidType", "getScript_missing", "getScript_found", "getMacro_usesAutomationType", "getModule_usesModuleType", "getModuleVersion_defaultChannel", "getModuleVersion_fallback", "getModuleVersion_missing", - "getProviders_findsByBareAlias", "getProviders_objectAliasEntries", "getProviders_noMatchReturnsEmpty", + "getProviders_findsByBareAlias", "getProviders_honorsRecordedChannel", "getProviders_objectAliasEntries", + "getProviders_noMatchReturnsEmpty", "getProviders_ignoresModulesWithoutProvides", "getProviders_noModulesSection", "normalizeModuleAliases_bareStringsToTables", "normalizeModuleAliases_preservesFields", "normalizeModuleAliases_dropsNonSchemaFields", "normalizeModuleAliases_nilAndEmpty", @@ -995,7 +1159,10 @@ "expand_legacyScalarBases", "findUnlistedFiles_discoversUnlistedFiles", "findUnlistedFiles_skipsUninvertibleTemplates", "updateFeed_addFilesAppendsEntries", "updateFeed_markReleasedStampsUnreleased", - "mergeChannels_copiesPreservingOthers", "bumpVersions_startsCycleFromReleased", + "updateFeed_markReleasedReportsAmbiguousDefault", + "mergeChannels_copiesPreservingOthers", "mergeChannels_leavesExactlyOneDefault", + "mergeChannels_keepsDefaultOnUnwrittenChannel", "mergeChannels_copiesSectionTemplates" + "bumpVersions_startsCycleFromReleased", "bumpVersions_refusesAmbiguousDefault", "walkFiles_yieldsProxies", "walkFiles_passesThroughLocalFilePath", "deployFiles_copiesToDist", "deployFiles_skipExistingNoClobber", "deployFiles_countsMissingSource", "deployFiles_removesDeleted", "deployFiles_deleteMissingIsNoOp", diff --git a/modules/l0/DependencyControl/test/UpdateTask.moon b/modules/l0/DependencyControl/test/UpdateTask.moon index 064091b..5cec7b1 100644 --- a/modules/l0/DependencyControl/test/UpdateTask.moon +++ b/modules/l0/DependencyControl/test/UpdateTask.moon @@ -10,6 +10,7 @@ UpdateFeed = require "l0.DependencyControl.UpdateFeed" Downloader = require "l0.DependencyControl.Downloader" ModuleLoader = require "l0.DependencyControl.ModuleLoader" + ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" FeedTrust = require "l0.DependencyControl.FeedTrust" {:stubSelf, :makeNullLogger, :makeSeededFeedTrust} = stubHelpers @@ -104,7 +105,7 @@ -- both prompts), selectReturn {pick, stickiness} (nil pick = abort), trustReturn (a FeedTrustDecision). makeResolveTask = (opts = {}) -> cfg = opts.config or {} - calls = {select: 0, trust: 0} + calls = {select: 0, trust: 0, loaded: {}} updaterConfig = { c: { feeds: {extraFeeds: cfg.extraFeeds, trustedFeeds: cfg.trustedFeeds, blockedFeeds: cfg.blockedFeeds} @@ -130,7 +131,6 @@ reason: opts.reason channel: opts.channel addFeeds: opts.addFeeds or {} - triedFeeds: {} _feeds: opts.feeds or {} record: { feed: opts.declaredFeed @@ -144,6 +144,7 @@ updater: {renewLock: ->, :feedTrust, config: updaterConfig} logger: makeNullLogger! __loadFeed: (url) => + calls.loaded[url] = true return nil, "feed not found: #{url}" unless @_feeds[url] providers = @_feeds[url].providers or {} {__url: url, getProviders: (=> providers)} @@ -521,9 +522,9 @@ persistSource_writesDirect: (ut) -> saved = {} task = makeSourceTask feed: "feed://declared", onSave: -> saved[1] = true - selected = {isDirect: true, feedUrl: "feed://declared", updateRecord: {namespace: "l0.x", activeChannel: "main"}} + selected = {isDirect: true, feedUrl: "feed://declared", updateRecord: {namespace: "l0.x", activeChannel: "main", getChannels: => {"main"}}} UpdateTask.__persistSource task, selected, SourceChoiceStickiness.Retain - cs = task.record.config.c.currentSource + cs = task.record.config.c.configuredSource ut\assertNotNil cs ut\assertEquals cs.feedSource, SourceFeedKind.SelfDeclared ut\assertEquals cs.stickiness, SourceChoiceStickiness.Retain @@ -532,27 +533,89 @@ persistSource_recordsProvider: (ut) -> task = makeSourceTask feed: "feed://declared" - selected = {isDirect: false, feedUrl: "feed://prov", providesVersion: "~1.2", updateRecord: {namespace: "l0.prov", activeChannel: "main"}} + selected = {isDirect: false, feedUrl: "feed://prov", providesVersion: "~1.2", updateRecord: {namespace: "l0.prov", activeChannel: "main", getChannels: => {"main"}}} UpdateTask.__persistSource task, selected, SourceChoiceStickiness.Pinned - cs = task.record.config.c.currentSource + cs = task.record.config.c.configuredSource ut\assertEquals cs.feedSource, SourceFeedKind.Provider ut\assertEquals cs.provider.namespace, "l0.prov" ut\assertEquals cs.provider.version, "~1.2" persistSource_storesFeedUrlForOther: (ut) -> task = makeSourceTask feed: "feed://declared" - selected = {isDirect: true, feedUrl: "feed://third", updateRecord: {namespace: "l0.x", activeChannel: "main"}} + selected = {isDirect: true, feedUrl: "feed://third", updateRecord: {namespace: "l0.x", activeChannel: "main", getChannels: => {"main"}}} UpdateTask.__persistSource task, selected, SourceChoiceStickiness.Once - ut\assertEquals task.record.config.c.currentSource.feedUrl, "feed://third" + ut\assertEquals task.record.config.c.configuredSource.feedUrl, "feed://third" persistSource_skipsUnchanged: (ut) -> saves = {n: 0} existing = {feedSource: SourceFeedKind.SelfDeclared, channel: "main", stickiness: SourceChoiceStickiness.Retain} - task = makeSourceTask feed: "feed://declared", currentSource: existing, onSave: -> saves.n += 1 - selected = {isDirect: true, feedUrl: "feed://declared", updateRecord: {namespace: "l0.x", activeChannel: "main"}} + task = makeSourceTask feed: "feed://declared", onSave: -> saves.n += 1 + task.record.config.c.configuredSource, task.record.config.c.channels = existing, {"main"} + selected = {isDirect: true, feedUrl: "feed://declared", updateRecord: {namespace: "l0.x", activeChannel: "main", getChannels: => {"main"}}} UpdateTask.__persistSource task, selected, SourceChoiceStickiness.Retain ut\assertEquals saves.n, 0 + -- a source recorded by a pre-0.9.0 version is honored as the configured source, and the write moves + -- it under its new key without touching what `currentSource` now means + persistSource_readsLegacyIntentAndWritesNewKey: (ut) -> + saves = {n: 0} + legacy = {feedSource: SourceFeedKind.SelfDeclared, channel: "main", stickiness: SourceChoiceStickiness.Pinned} + task = makeSourceTask feed: "feed://declared", currentSource: legacy, onSave: -> saves.n += 1 + selected = {isDirect: true, feedUrl: "feed://declared", updateRecord: {namespace: "l0.x", activeChannel: "main", getChannels: => {"main"}}} + UpdateTask.__persistSource task, selected + ut\assertEquals task.record.config.c.configuredSource.stickiness, SourceChoiceStickiness.Pinned -- carried over + ut\assertEquals task.record.config.c.currentSource, legacy -- untouched by the intent write + ut\assertEquals saves.n, 1 + + -- the feed's channel lineup is refreshed at resolution (sorted, so the persisted value doesn't ride + -- on table order), and an unchanged repeat writes nothing + persistSource_refreshesChannelLineup: (ut) -> + saves = {n: 0} + task = makeSourceTask feed: "feed://declared", onSave: -> saves.n += 1 + task.record.config.c.channels = {"alpha", "main"} + selected = {isDirect: true, feedUrl: "feed://declared", updateRecord: {namespace: "l0.x", activeChannel: "stable", getChannels: => {"stable", "alpha"}}} + UpdateTask.__persistSource task, selected, SourceChoiceStickiness.Retain + ut\assertEquals table.concat(task.record.config.c.channels, ","), "alpha,stable" + ut\assertEquals saves.n, 1 + -- a second identical resolution finds everything already recorded and leaves the config alone + UpdateTask.__persistSource task, selected, SourceChoiceStickiness.Retain + ut\assertEquals saves.n, 1 + + -- __recordInstalledSource: once the files are in place, the configured source that drove the install + -- becomes the installed copy's provenance, with lastChannel kept in step; a later change to the + -- configured source must not reach through into the recorded provenance + recordInstalledSource_copiesConfiguredToProvenance: (ut) -> + saves = {n: 0} + task = makeSourceTask feed: "feed://declared", onSave: -> saves.n += 1 + task.record.config.c.configuredSource = {feedSource: SourceFeedKind.SelfDeclared, channel: "stable", stickiness: SourceChoiceStickiness.Retain} + UpdateTask.__recordInstalledSource task + cs = task.record.config.c.currentSource + ut\assertEquals cs.channel, "stable" + ut\assertEquals cs.stickiness, SourceChoiceStickiness.Retain -- the full record, for troubleshooting + ut\assertEquals cs.feedUrl, "feed://declared" -- stamped, though a self-declared choice stores none + ut\assertEquals task.record.config.c.lastChannel, "stable" + ut\assertEquals saves.n, 1 + task.record.config.c.configuredSource.channel = "alpha" + ut\assertEquals cs.channel, "stable" -- a copy, not a shared reference + + -- __recordInstalledSource: the stamped URL is what the record keeps reporting even after the field + -- it was derived from changes, which is the whole point of recording provenance separately + recordInstalledSource_stampedUrlSurvivesFeedChange: (ut) -> + task = makeSourceTask feed: "feed://declared", userFeed: "feed://override" + task.record.config.c.configuredSource = {feedSource: SourceFeedKind.UserFeed, channel: "stable", stickiness: SourceChoiceStickiness.Unset} + UpdateTask.__recordInstalledSource task + ut\assertEquals task.record.config.c.currentSource.feedUrl, "feed://override" + task.record.config.c.userFeed = "feed://somewhere-else" + installed = task.record.config.c.currentSource + ut\assertEquals UpdateTask.resolveSourceUrl(installed, task.record.feed, task.record.config.c.userFeed), "feed://override" + -- __recordInstalledSource: with no source recorded at all (nothing resolved yet), nothing is written + recordInstalledSource_noopWithoutSource: (ut) -> + saves = {n: 0} + task = makeSourceTask feed: "feed://declared", onSave: -> saves.n += 1 + UpdateTask.__recordInstalledSource task + ut\assertNil task.record.config.c.currentSource + ut\assertEquals saves.n, 0 + -- UpdateTask.__resolve: walks the lazy trust-ranked feed cascade and the currentSource stickiness tree, -- running prompts inline, and returns either a candidate to install or a terminal status code. It -- performs no installs, so it's tested directly with feed I/O and prompts stubbed (see makeResolveTask). @@ -568,7 +631,7 @@ d = UpdateTask.__resolve task ut\assertTrue d.installRequired ut\assertEquals d.selectedSource.feedUrl, "feed://decl" - ut\assertNil task.triedFeeds["feed://extra"] -- tier 2 was never reached + ut\assertNil task.calls.loaded["feed://extra"] -- tier 2 was never reached -- cascade: an empty declared feed falls through to a user extra feed (trusted discovery, tier 2). -- Guards that extraFeeds is read from the `feeds` config section, not `updates`. @@ -763,6 +826,35 @@ -- blocked feed: a block-listed feed is skipped entirely (not even fetched, and despite being -- trusted); with no other source the required install fails + -- checkFeed: a feed that has retired the channel a package is recorded on moves it to the default, + -- except where the user pinned their source choice — that one fails so the pin isn't quietly broken + checkFeed_pinnedKeepsChannelOthersFollowDefault: (ut) -> + data = {name: "Pkg", channels: {stable: {default: true, version: "2.0.0", files: {}}}} + check = (stickiness) -> + config = {c: {lastChannel: "main", currentSource: {:stickiness}}} + task = stubSelf UpdateTask, { + logger: makeNullLogger! + record: {namespace: "l0.Pkg", name: "Pkg", scriptType: domain.ScriptType.Module, :config} + } + feed = {getScript: ((ns, st, cfg) => ScriptUpdateRecord ns, data, cfg, st, false, makeNullLogger!)} + UpdateTask.checkFeed task, feed + record = check SourceChoiceStickiness.Retain + ut\assertNotNil record + ut\assertEquals record.activeChannel, "stable" -- followed the feed to the channel it still offers + ut\assertNil check SourceChoiceStickiness.Pinned -- no candidate, so the pin surfaces as unavailable + + -- A task is cached per package for the whole session, so the feeds one run consulted must not be + -- skipped by the next — the declared feed first among them, which would leave nothing to install from. + resolve_reconsultsFeedsAfterEarlierRun: (ut) -> + task = makeResolveTask { + declaredFeed: "feed://decl", officialTrusted: {"feed://decl": true} + feeds: {"feed://decl": {direct: directRec version: "2.0.0"}} + } + UpdateTask.__resolve task + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals d.selectedSource.feedUrl, "feed://decl" + resolve_blockedFeedSkipped: (ut) -> task = makeResolveTask { declaredFeed: "feed://blocked", officialTrusted: {"feed://blocked": true} @@ -772,7 +864,7 @@ d = UpdateTask.__resolve task ut\assertFalse d.installRequired ut\assertEquals d.statusCode, UpdateStatus.NoSuitablePackage - ut\assertNil task.triedFeeds["feed://blocked"] -- skipped before being fetched + ut\assertNil task.calls.loaded["feed://blocked"] -- skipped before being fetched -- userFeed: an exclusive override feed is consulted in place of the declared-feed cascade resolve_userFeedUsedExclusively: (ut) -> @@ -783,7 +875,7 @@ d = UpdateTask.__resolve task ut\assertTrue d.installRequired ut\assertEquals d.selectedSource.feedUrl, "feed://user" - ut\assertNil task.triedFeeds["feed://decl"] -- the declared feed is never consulted + ut\assertNil task.calls.loaded["feed://decl"] -- the declared feed is never consulted -- run(): a direct-install resolution is persisted, then dispatched to performUpdate run_dispatchesDirectInstall: (ut) -> @@ -800,6 +892,42 @@ ut\assertNotNil task.calls.performUpdate ut\assertNil task.calls.installProvider + -- run(): a fresh install records source and provenance again once the real record is adopted — + -- the resolution-time writes landed on the virtual record's fileless config view and were lost + run_freshInstallRecordsSourceOnAdoptedRecord: (ut) -> + task = makeRunTask { + resolution: { + installRequired: true, stickiness: SourceChoiceStickiness.Once, maxVersion: 0 + selectedSource: {isDirect: true, updateRecord: {version: "1.0.0"}} + } + } + persists, provenances = 0, 0 + task.__persistSource = (sel, st) => persists += 1 + task.__recordInstalledSource = => provenances += 1 + task.performUpdate = (update) => + @record.virtual = false -- the install adopts the real record + UpdateStatus.Installed + ut\assertEquals UpdateTask.run(task), UpdateStatus.Installed + ut\assertEquals persists, 2 -- at resolution, and again once a config file backs the record + ut\assertEquals provenances, 1 + + -- run(): an update of an installed package doesn't repeat the writes — they were durable the first time + run_installedUpdateSkipsRepersist: (ut) -> + task = makeRunTask { + virtual: false + resolution: { + installRequired: true, stickiness: SourceChoiceStickiness.Once, maxVersion: 0 + selectedSource: {isDirect: true, updateRecord: {version: "1.0.0"}} + } + } + persists, provenances = 0, 0 + task.__persistSource = (sel, st) => persists += 1 + task.__recordInstalledSource = => provenances += 1 + task.performUpdate = (update) => UpdateStatus.Installed + ut\assertEquals UpdateTask.run(task), UpdateStatus.Installed + ut\assertEquals persists, 1 + ut\assertEquals provenances, 0 -- performUpdate's own in-place write covers it + -- run(): a provider (indirect) resolution is dispatched to installProvider, not performUpdate run_dispatchesProviderInstall: (ut) -> providerRef = {ref: true} @@ -1113,7 +1241,10 @@ "matchRememberedCandidate_ineligibleVersion", "matchRememberedCandidate_noUrlMatch", "feedSourceOf_provider", "feedSourceOf_selfDeclared", "feedSourceOf_userFeed", "feedSourceOf_other", "persistSource_writesDirect", "persistSource_recordsProvider", "persistSource_storesFeedUrlForOther", - "persistSource_skipsUnchanged", + "persistSource_skipsUnchanged", "persistSource_readsLegacyIntentAndWritesNewKey", + "persistSource_refreshesChannelLineup", + "recordInstalledSource_copiesConfiguredToProvenance", "recordInstalledSource_stampedUrlSurvivesFeedChange", + "recordInstalledSource_noopWithoutSource", "resolve_cascadeShortCircuitsOnDeclaredDirect", "resolve_fallsThroughToExtraFeed", "resolve_fallsThroughToTrustedDirect", "resolve_untrustedRequiredFailsWithoutPrompt", "resolve_untrustedApprovedProceeds", @@ -1123,8 +1254,10 @@ "resolve_retainReuseProceeds", "resolve_retainMissingNonInteractiveDowngrades", "resolve_retainMissingInteractivePicks", "resolve_choiceAbortRequiredFails", "resolve_autoNeverPrompts", "resolve_offerAllSourcesPromptsOnMultiple", + "checkFeed_pinnedKeepsChannelOthersFollowDefault", "resolve_reconsultsFeedsAfterEarlierRun", "resolve_blockedFeedSkipped", "resolve_userFeedUsedExclusively", - "run_dispatchesDirectInstall", "run_dispatchesProviderInstall", "run_upToDateShortCircuits", + "run_dispatchesDirectInstall", "run_freshInstallRecordsSourceOnAdoptedRecord", + "run_installedUpdateSkipsRepersist", "run_dispatchesProviderInstall", "run_upToDateShortCircuits", "run_reentrantInFlightSatisfiesRequirement", "run_reentrantInFlightBelowRequirementFails", "run_reentrantWithoutClaimStillGuardsRunning", "run_terminalResolutionReturnsStatus", "run_noInternetGuard", diff --git a/modules/l0/DependencyControl/test/config-schema.moon b/modules/l0/DependencyControl/test/config-schema.moon index 77e8818..e1ab6c6 100644 --- a/modules/l0/DependencyControl/test/config-schema.moon +++ b/modules/l0/DependencyControl/test/config-schema.moon @@ -7,6 +7,8 @@ {:migrate, :keyMap, :droppedKeys} = schema.migration + CONFIG_SCHEMA_ID_V0_7_0 = "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/schemas/config/v0.7.0.json" + -- the config keys shipped in v0.6.3 (the last release), each of which the migration must handle v063Keys = { "updaterEnabled", "updateInterval", "traceLevel", "extraFeeds", "tryAllFeeds", "dumpFeeds", "configDir" @@ -113,7 +115,7 @@ migratesOwnedAlphaPin: (ut) -> c = { modules: { - ["l0.DependencyControl"]: {lastChannel: "alpha", activeChannel: "alpha"} + ["l0.DependencyControl"]: {lastChannel: "alpha"} ["l0.Functional"]: {lastChannel: "alpha"} -- third-party feed: not ours to move } macros: { @@ -122,10 +124,78 @@ } migrate c, nil, schema.CONFIG_SCHEMA_ID_CURRENT ut\assertEquals c.modules["l0.DependencyControl"].lastChannel, "stable" - ut\assertEquals c.modules["l0.DependencyControl"].activeChannel, "stable" ut\assertEquals c.modules["l0.Functional"].lastChannel, "alpha" -- third-party pin preserved ut\assertEquals c.macros["l0.DependencyControl.Toolbox"].lastChannel, "beta" -- non-alpha pin preserved + -- a v0.7.0 config seeds each record's configuredSource from its currentSource, which v0.9.0 splits + -- into intent and provenance; the copy is independent and currentSource is left in place + migratesV070SourceSplit: (ut) -> + source = {feedSource: "self-declared", channel: "stable", stickiness: "pinned"} + c = { + modules: { + ["l0.Dep"]: {currentSource: source} + ["l0.Bare"]: {} -- no source recorded: nothing to seed + } + } + migrated = migrate c, CONFIG_SCHEMA_ID_V0_7_0, schema.CONFIG_SCHEMA_ID_CURRENT + ut\assertTrue migrated + dep = c.modules["l0.Dep"] + ut\assertEquals dep.configuredSource.channel, "stable" + ut\assertEquals dep.configuredSource.stickiness, "pinned" + ut\assertEquals dep.currentSource, source -- left in place + dep.currentSource.channel = "main" + ut\assertEquals dep.configuredSource.channel, "stable" -- a copy, not a shared reference + ut\assertNil c.modules["l0.Bare"].configuredSource + + -- a v0.7.0 install the CI mishap left on the maintenance channel moves to stable, in the recorded + -- channel and in the configured source seeded from its provenance; an alpha pin was chosen and stays + migratesV070MainPin: (ut) -> + c = { + modules: { + ["l0.DependencyControl"]: {lastChannel: "main", currentSource: {channel: "main", feedSource: "self-declared"}} + ["l0.dkjson"]: {lastChannel: "alpha", currentSource: {channel: "alpha"}} + ["l0.Functional"]: {lastChannel: "main"} -- third-party feed: its own channel names are its business + } + macros: { + ["l0.DependencyControl.Toolbox"]: {lastChannel: "main"} -- pinned, with nothing installed to record a source + } + } + migrate c, CONFIG_SCHEMA_ID_V0_7_0, schema.CONFIG_SCHEMA_ID_CURRENT + dep = c.modules["l0.DependencyControl"] + ut\assertEquals dep.lastChannel, "stable" + ut\assertEquals dep.configuredSource.channel, "stable" + ut\assertEquals dep.currentSource.channel, "main" -- provenance still records where it came from + ut\assertEquals c.macros["l0.DependencyControl.Toolbox"].lastChannel, "stable" + ut\assertEquals c.modules["l0.dkjson"].lastChannel, "alpha" + ut\assertEquals c.modules["l0.dkjson"].configuredSource.channel, "alpha" + ut\assertEquals c.modules["l0.Functional"].lastChannel, "main" + + -- a config already on the current schema is left alone + skipsCurrentSchema: (ut) -> + c = {modules: {["l0.Dep"]: {currentSource: {channel: "stable"}}}} + ut\assertFalse migrate c, schema.CONFIG_SCHEMA_ID_CURRENT, schema.CONFIG_SCHEMA_ID_CURRENT + ut\assertNil c.modules["l0.Dep"].configuredSource + + -- a pin on the feed maintenance channel is rewritten the same way, across all of our own packages + migratesOwnedMainPin: (ut) -> + c = { + modules: { + ["l0.DependencyControl"]: {lastChannel: "main"} + ["l0.dkjson"]: {lastChannel: "main"} + ["l0.MoonCats"]: {lastChannel: "main"} + ["l0.Functional"]: {lastChannel: "main"} -- third-party feed: its own channel names are its business + } + macros: { + ["l0.DependencyControl.Toolbox"]: {lastChannel: "main"} + } + } + migrate c, nil, schema.CONFIG_SCHEMA_ID_CURRENT + ut\assertEquals c.modules["l0.DependencyControl"].lastChannel, "stable" + ut\assertEquals c.modules["l0.dkjson"].lastChannel, "stable" + ut\assertEquals c.modules["l0.MoonCats"].lastChannel, "stable" + ut\assertEquals c.macros["l0.DependencyControl.Toolbox"].lastChannel, "stable" + ut\assertEquals c.modules["l0.Functional"].lastChannel, "main" -- third-party pin preserved + -- the migration handles exactly the v0.6.3 keys: each is either lifted or dropped, and nothing else is handlesExactlyV063Keys: (ut) -> handled = {k, true for k in *droppedKeys} @@ -137,6 +207,8 @@ _order: { "hasAllSections", "hasPolicyLiterals" "migratesFlatKeys", "dropsObsoleteKeys", "dropsObsoleteFormatVersion", "skipsWhenSchemaPresent" - "migratesLegacyRecordFields", "migratesOwnedAlphaPin", "preservesUnknownKeys", "handlesExactlyV063Keys" + "migratesLegacyRecordFields", "migratesOwnedAlphaPin", "migratesOwnedMainPin" + "migratesV070SourceSplit", "migratesV070MainPin", "skipsCurrentSchema", + "preservesUnknownKeys", "handlesExactlyV063Keys" } } diff --git a/modules/l0/DependencyControl/utils.moon b/modules/l0/DependencyControl/utils.moon index 1250496..8129ee9 100644 --- a/modules/l0/DependencyControl/utils.moon +++ b/modules/l0/DependencyControl/utils.moon @@ -220,6 +220,11 @@ Utils = { ---@return table copy The deep-copied table. Keys are carried over as-is; only values are copied. deepCopy: deepCopy + ---Deep-copies a table, and returns any other value as it is, for copying a mix of tables and scalars. + ---@param value any The value to copy. + ---@return any copy A deep copy of a table, or the value itself. + copyValue: (value) -> type(value) == "table" and deepCopy(value) or value + ---Builds (or extends) a set from an array's values: each value becomes a key mapped to `value`. ---@param source any[] Array whose values become the set's keys. ---@param target? table Table to populate (default a new table). diff --git a/schemas/config/v0.9.0.json b/schemas/config/v0.9.0.json new file mode 100644 index 0000000..5220dcc --- /dev/null +++ b/schemas/config/v0.9.0.json @@ -0,0 +1,446 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/schemas/config/v0.9.0.json", + "title": "DependencyControl Config File v0.9.0", + "description": "DependencyControl's on-disk configuration file (by default `/config/l0.DependencyControl.json`).", + "type": "object", + "additionalProperties": false, + "required": ["$schema"], + "properties": { + "$schema": { + "description": "Identifies the config format: the URL of the DependencyControl config schema this file conforms to. DependencyControl stamps it when it migrates a pre-0.7.0 config, and editors use it to validate.", + "const": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/schemas/config/v0.9.0.json" + }, + "config": { "$ref": "#/$defs/GlobalConfig" }, + "macros": { + "description": "Installed automation scripts, keyed by their namespaced identifier.", + "type": "object", + "propertyNames": { "$ref": "#/$defs/NamespacedIdentifier" }, + "additionalProperties": { "$ref": "#/$defs/ScriptRecord" } + }, + "modules": { + "description": "Installed modules, keyed by their namespaced identifier.", + "type": "object", + "propertyNames": { "$ref": "#/$defs/NamespacedIdentifier" }, + "additionalProperties": { "$ref": "#/$defs/ScriptRecord" } + } + }, + "$defs": { + "GlobalConfig": { + "description": "Global DependencyControl settings, organized into topic sections.", + "type": "object", + "properties": { + "updates": { + "description": "Update-engine settings.", + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "$ref": "#/$defs/UpdateContextCeiling", + "description": "Which update contexts may install/update at all: 'auto-update' (default) enables everything, 'dependency-resolution' disables background checks while keeping manual actions and dependency installs, 'user-requested' allows only actions you start yourself, and 'off' disables installs and updates entirely.", + "default": "auto-update" + }, + "checkInterval": { + "description": "Minimum seconds between automatic update checks for a given script.", + "type": "integer", + "minimum": 0, + "default": 302400 + }, + "waitTimeout": { + "description": "Seconds to wait for another process's in-progress update to finish before giving up.", + "type": "integer", + "minimum": 0, + "default": 60 + }, + "orphanTimeout": { + "description": "Seconds after which a held updater lock whose owner has vanished (e.g. a crashed process) is treated as stale and may be taken over.", + "type": "integer", + "minimum": 0, + "default": 50 + }, + "feedTrustPromptThreshold": { + "$ref": "#/$defs/UpdateContextCeiling", + "description": "How freely DependencyControl may prompt to trust an untrusted feed before installing from it. Defaults to prompting in all contexts, since it gates whether an update can succeed; with 'off' (or in a context above the ceiling) the install fails or is skipped instead.", + "default": "auto-update" + }, + "packageChoicePromptThreshold": { + "$ref": "#/$defs/UpdateContextCeiling", + "description": "How freely DependencyControl may prompt to choose among several equally-ranked package sources. Defaults to only actions you start yourself; with 'off' (or in a context above the ceiling) a stable tie-breaker picks instead.", + "default": "user-requested" + }, + "offerAllSources": { + "description": "When more than one source qualifies, offer every eligible one (including lower-ranked and untrusted sources) so you can override the ranking, instead of only the top-ranked candidates.", + "type": "boolean", + "default": false + }, + "blockPrivateHosts": { + "description": "Refuse feed and package downloads from hosts that resolve to a private, loopback, or link-local address (an SSRF safeguard). Turn off to use feeds on your local network.", + "type": "boolean", + "default": true + } + } + }, + "feeds": { + "description": "Feed trust, discovery, and caching settings.", + "type": "object", + "additionalProperties": false, + "properties": { + "extraFeeds": { + "description": "Your own feeds, trusted and used as discovery roots: they are searched for updates and their advertised `knownFeeds` are crawled.", + "type": "array", + "items": { "type": "string", "format": "uri" }, + "default": [] + }, + "trustedFeeds": { + "description": "Feeds you trust but that are not discovery roots: they grant trust to feeds reached another way and are never themselves crawled.", + "type": "array", + "items": { "type": "string", "format": "uri" }, + "default": [] + }, + "blockedFeeds": { + "description": "Feeds that must never be used as a package source, overriding trust. Merged with the official block list in DependencyControl's own feed.", + "type": "array", + "items": { "$ref": "#/$defs/BlockedFeed" }, + "default": [] + }, + "fetchUntrustedFeeds": { + "$ref": "#/$defs/FetchUntrustedFeeds", + "description": "Policy for fetching untrusted feeds encountered during discovery.", + "default": "always" + }, + "crawlLimits": { + "$ref": "#/$defs/CrawlLimits", + "description": "Bounds on untrusted feed-graph expansion during discovery. Any unset budget falls back to its built-in default." + }, + "cacheMaxAge": { + "description": "Default lifetime, in seconds, of a cached feed snapshot before it is refetched.", + "type": "integer", + "minimum": 0, + "default": 3600 + }, + "maxFeedSize": { + "description": "Maximum size, in bytes, of a single feed response; a fetch that grows past it is aborted. Guards against a hostile or runaway feed. 0 removes the limit.", + "type": "integer", + "minimum": 0, + "default": 5000000 + }, + "feedFetchTimeout": { + "description": "Maximum time, in seconds, to spend fetching a single feed before the transfer is aborted. 0 removes the timeout.", + "type": "integer", + "minimum": 0, + "default": 15 + } + } + }, + "logging": { + "description": "Logging and log-retention settings.", + "type": "object", + "additionalProperties": false, + "properties": { + "defaultLevel": { + "description": "Default log verbosity; higher levels log more (mirrors the Logger's `defaultLevel`).", + "type": "integer", + "default": 3 + }, + "toFile": { + "description": "Whether to write logs to file (mirrors the Logger's `toFile`).", + "type": "boolean", + "default": true + }, + "maxFiles": { + "description": "Maximum number of log files to retain before the oldest are trimmed.", + "type": "integer", + "minimum": 0, + "default": 200 + }, + "maxAge": { + "description": "Maximum age, in seconds, of a log file before it is trimmed.", + "type": "integer", + "minimum": 0, + "default": 604800 + }, + "maxSize": { + "description": "Maximum total size, in bytes, of retained log files before trimming.", + "type": "integer", + "minimum": 0, + "default": 10000000 + } + } + }, + "paths": { + "description": "Base directories for DependencyControl's files. Each supports Aegisub path tokens such as `?user` and `?data`.", + "type": "object", + "additionalProperties": false, + "properties": { + "config": { + "description": "Directory holding DependencyControl's config files.", + "type": "string", + "default": "?user/config" + }, + "log": { + "description": "Directory holding DependencyControl's log files.", + "type": "string", + "default": "?user/log" + }, + "cache": { + "description": "Base directory for DependencyControl's on-disk caches. Each cache lives in a `/` subdirectory beneath it (e.g. the feed cache at `/l0.DependencyControl/feeds`).", + "type": "string", + "default": "?user/cache" + } + } + } + } + }, + + "UpdateContextCeiling": { + "description": "A ceiling on the ladder of update contexts, naming the most autonomous context for which the gated behavior still applies. Each value includes all less autonomous contexts: 'off' (none at all) < 'user-requested' (actions you start yourself, e.g. via the Toolbox) < 'dependency-resolution' (also installing/updating a module as a dependency) < 'auto-update' (also background scheduled update checks).", + "enum": ["off", "user-requested", "dependency-resolution", "auto-update"] + }, + + "FetchUntrustedFeeds": { + "description": "Policy for fetching an untrusted feed: 'always' fetches without asking; 'never' skips it; 'prompt' asks first, falling back to 'never' where no prompter is available (e.g. headless).", + "enum": ["always", "never", "prompt"] + }, + + "CrawlLimits": { + "description": "Per-budget caps on untrusted feed-graph expansion during discovery, keyed by budget name.", + "type": "object", + "additionalProperties": false, + "properties": { + "per-feed": { + "description": "Cap on how many untrusted feeds a single feed may contribute.", + "type": "integer", + "minimum": 0, + "default": 25 + }, + "per-root": { + "description": "Per-subtree budget for untrusted expansion out from one config-derived feed.", + "type": "integer", + "minimum": 0, + "default": 50 + }, + "depth": { + "description": "Crawl-depth limit; a feed reached at this depth is left unfetched.", + "type": "integer", + "minimum": 0, + "default": 7 + } + } + }, + + "BlockedFeed": { + "description": "A blocked feed entry: the URL/prefix to match, how to match it, and why.", + "type": "object", + "required": ["url"], + "additionalProperties": false, + "properties": { + "url": { + "description": "The feed URL or URL prefix to match (case-insensitive).", + "type": "string" + }, + "matchMode": { + "description": "How `url` is matched: 'prefix' (default) blocks every feed whose URL starts with `url`; 'exact' blocks only that exact URL.", + "enum": ["prefix", "exact"], + "default": "prefix" + }, + "reason": { + "description": "Human-readable explanation of why the feed is blocked.", + "type": "string" + } + } + }, + + "NamespacedIdentifier": { + "description": "A dot-separated namespaced identifier (e.g. 'l0.DependencyControl.Toolbox', 'a-mo.LineCollection').", + "type": "string", + "pattern": "^[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)+$", + "lpegPattern": "[A-Za-z0-9_-]+ ('.' [A-Za-z0-9_-]+)+ !." + }, + + "ScriptRecord": { + "description": "The persisted installation record for one managed or unmanaged script/module. Beyond the fields below, DependencyControl may store additional bookkeeping here, so unknown keys are tolerated.", + "type": "object", + "additionalProperties": true, + "properties": { + "namespace": { + "$ref": "#/$defs/NamespacedIdentifier", + "description": "The script's namespaced identifier (matches its key in `macros`/`modules`)." + }, + "name": { + "description": "Human-readable script name.", + "type": "string" + }, + "version": { + "description": "Installed version as a semantic version string (major.minor.patch).", + "type": "string" + }, + "author": { + "description": "Script author.", + "type": "string" + }, + "description": { + "description": "Short description of the script.", + "type": "string" + }, + "feed": { + "description": "The feed this script declares for its own updates.", + "type": "string", + "format": "uri" + }, + "userFeed": { + "description": "A per-script feed override you set, taking precedence over the declared `feed`.", + "type": "string", + "format": "uri" + }, + "url": { + "description": "The script's homepage or project URL.", + "type": "string", + "format": "uri" + }, + "configFile": { + "description": "Name of this script's own per-script config file.", + "type": "string" + }, + "moduleName": { + "description": "The Lua module name for a module; `false` for an automation script.", + "type": ["string", "boolean"] + }, + "channels": { + "description": "Update channels available for this script.", + "type": "array", + "items": { "type": "string" } + }, + "lastChannel": { + "description": "The update channel last installed from.", + "type": "string" + }, + "requiredModules": { + "description": "The script's declared module dependencies.", + "type": "array", + "items": { "$ref": "#/$defs/ModuleDependency" } + }, + "provides": { + "description": "Module aliases this module satisfies for `require` (a bare string is shorthand for `{ \"name\": }`).", + "type": "array", + "items": { "$ref": "#/$defs/ModuleAlias" } + }, + "lastUpdateCheck": { + "description": "Unix time of the last automatic update check for this script.", + "type": "integer" + }, + "configuredSource": { + "$ref": "#/$defs/SourceChoiceRecord", + "description": "The source the next update of this script should use, and how sticky that choice is. Written when a resolution settles on a source, or edited by the user." + }, + "currentSource": { + "$ref": "#/$defs/SourceChoiceRecord", + "description": "The source the currently installed copy of this script came from (the configured source as it stood when the last install completed). Diverges from `configuredSource` while a source change hasn't been applied by an update yet." + }, + "unmanaged": { + "description": "True for a script present but installed/managed outside DependencyControl (never auto-updated).", + "type": "boolean" + } + } + }, + + "ModuleDependency": { + "description": "A declared dependency on another module.", + "type": "object", + "required": ["moduleName"], + "additionalProperties": true, + "properties": { + "moduleName": { + "description": "Lua require-path of the dependency — a bare module name ('ffi', 'json', 'Yutils') or a dotted path ('aegisub.util', 'l0.Functional').", + "type": "string" + }, + "version": { + "description": "Minimum required version (a semantic version string, or an npm-style range).", + "type": "string" + }, + "url": { + "description": "The required module's homepage or project URL.", + "type": "string", + "format": "uri" + }, + "feed": { + "description": "A feed to install the required module from.", + "type": "string", + "format": "uri" + }, + "name": { + "description": "Human-readable name of the required module.", + "type": "string" + }, + "optional": { + "description": "When true, DependencyControl skips (rather than fails) if the dependency can't be satisfied.", + "type": "boolean", + "default": false + } + } + }, + + "ModuleAlias": { + "description": "An alias a module provides for `require`. Either a bare alias name, or an object naming the alias and (optionally) the version range it satisfies.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "description": "The provided alias name.", + "type": "string" + }, + "version": { + "description": "The version (or npm-style range) the alias satisfies. Omit for any version.", + "type": "string" + } + } + } + ] + }, + + "SourceChoiceRecord": { + "description": "A remembered package-source choice, persisted so DependencyControl keeps using and updating the same source.", + "type": "object", + "required": ["feedSource", "channel", "stickiness"], + "additionalProperties": false, + "properties": { + "feedSource": { + "description": "Where the source came from: 'self-declared' (the record's own feed), 'user-feed' (a per-script override), 'provider' (another module that provides the required one), or 'other' (a third-party trusted/extra feed, whose URL is stored in `feedUrl`).", + "enum": ["self-declared", "user-feed", "provider", "other"] + }, + "feedUrl": { + "description": "The literal feed URL. In `configuredSource` it is stored only when `feedSource` is 'other', the other kinds being derived from current state so the choice survives a feed-URL migration. In `currentSource` it is always stored, so the recorded origin stays true when those fields change.", + "type": "string", + "format": "uri" + }, + "channel": { + "description": "The update channel the source was resolved on.", + "type": "string" + }, + "provider": { + "description": "The provider that satisfied the requirement, when resolved indirectly through a `provides` alias.", + "type": "object", + "required": ["namespace"], + "additionalProperties": false, + "properties": { + "namespace": { + "$ref": "#/$defs/NamespacedIdentifier", + "description": "Namespace of the providing module." + }, + "version": { + "description": "The provided alias version range, if the provider declared one.", + "type": "string" + } + } + }, + "stickiness": { + "description": "How sticky the choice is on later resolutions: 'unset' (resolve normally, prompt only if interactive); 'once' (prompt again but preselect this pick); 'retain' (reuse without prompting while still eligible); 'pinned' (always reuse; fail a required dependency if it's gone, skip an optional one); 'auto' (never prompt, always re-resolve by ranking).", + "enum": ["unset", "once", "retain", "pinned", "auto"] + } + } + } + } +}