@sous-io/sous 0.2.17 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/markdown/commands.md +36 -6
- package/docs/markdown/repositories-authoring.md +23 -2
- package/docs/markdown/repositories-consuming.md +44 -2
- package/docs/markdown/repositories-file-formats.md +2 -1
- package/package.json +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/repo/unlink.ts +333 -20
- package/src/commands/subscription/update.ts +215 -0
- package/src/lib/repos/formats/links-map.ts +5 -3
- package/src/lib/repos/git-clone.ts +71 -0
- package/src/lib/repos/links.ts +2 -1
- package/src/lib/repos/locked-recipes.ts +22 -0
- package/src/lib/repos/resolver.ts +25 -2
- package/src/lib/repos/seed.ts +64 -5
- package/src/lib/repos/store/hash.ts +68 -8
- package/src/lib/repos/subscription-service.ts +744 -20
- package/src/lib/repos/update-plan.ts +234 -0
|
@@ -71,6 +71,7 @@ import { describeIndexSearch } from "./ref-search.js";
|
|
|
71
71
|
import {
|
|
72
72
|
PROJECT_REQUESTER,
|
|
73
73
|
resolveRefs,
|
|
74
|
+
type MissingRepo,
|
|
74
75
|
type RefRequest,
|
|
75
76
|
type ResolvedRecipe,
|
|
76
77
|
type ResolverRepo,
|
|
@@ -112,8 +113,16 @@ import {
|
|
|
112
113
|
findNewerInRange,
|
|
113
114
|
recordUpstreamCheck,
|
|
114
115
|
shouldCheckUpstream,
|
|
116
|
+
type NewerVersion,
|
|
115
117
|
withDeadline,
|
|
116
118
|
} from "./freshness.js";
|
|
119
|
+
import {
|
|
120
|
+
describeUpdateScope,
|
|
121
|
+
formatUpdatePlan,
|
|
122
|
+
isEmptyUpdate,
|
|
123
|
+
recipeInScope,
|
|
124
|
+
type UpdateScope,
|
|
125
|
+
} from "./update-plan.js";
|
|
117
126
|
import { REPO_NAME_PATTERN } from "./formats/patterns.js";
|
|
118
127
|
import {
|
|
119
128
|
linkedPathFor,
|
|
@@ -123,14 +132,20 @@ import {
|
|
|
123
132
|
} from "./links.js";
|
|
124
133
|
import {
|
|
125
134
|
keysHeldBySubscription,
|
|
135
|
+
lockedClosure,
|
|
126
136
|
listLockedRecipes,
|
|
127
137
|
mapLinkedRecipes,
|
|
128
138
|
readRecipeManifestIn,
|
|
129
139
|
} from "./locked-recipes.js";
|
|
130
140
|
import { resolveStoreRoot } from "../sous-home.js";
|
|
131
|
-
import {
|
|
141
|
+
import {
|
|
142
|
+
packagedCoreIndexOverlay,
|
|
143
|
+
seedCoreRecipe,
|
|
144
|
+
type SeedCoreRecipeReport,
|
|
145
|
+
} from "./seed.js";
|
|
132
146
|
import { enabledRepos, enabledSubscriptions, isBuiltInEntry } from "./defaults.js";
|
|
133
147
|
import {
|
|
148
|
+
CORE_NAMESPACE,
|
|
134
149
|
CORE_RECIPE_KEY,
|
|
135
150
|
OFFICIAL_REPO_IDENTITY,
|
|
136
151
|
packagedCoreRecipeDir,
|
|
@@ -403,6 +418,69 @@ export type UpstreamCheckReport = {
|
|
|
403
418
|
unchecked: Array<{ repo: string; reason: string }>;
|
|
404
419
|
};
|
|
405
420
|
|
|
421
|
+
/** What `update` is asked to do. */
|
|
422
|
+
export type UpdateOptions = {
|
|
423
|
+
/**
|
|
424
|
+
* What to update: a repository, a namespace or a recipe, written at any level
|
|
425
|
+
* of qualification and resolved through `src/lib/refs/`. Left out, every
|
|
426
|
+
* subscription is updated.
|
|
427
|
+
*/
|
|
428
|
+
ref?: string;
|
|
429
|
+
/**
|
|
430
|
+
* A repository short name the caller has already settled, which narrows the
|
|
431
|
+
* update exactly as a reference naming that repository would. `sous repo
|
|
432
|
+
* unlink --update` passes this; it is never combined with `ref`.
|
|
433
|
+
*/
|
|
434
|
+
repo?: string;
|
|
435
|
+
/** Accept the plan, and trust any repository a newer version needs, without being asked. */
|
|
436
|
+
yes?: boolean;
|
|
437
|
+
/** Take the first candidate when the reference matched several things. */
|
|
438
|
+
acceptFirst?: boolean;
|
|
439
|
+
/** Answers supplied ahead of the questions the new versions ask. */
|
|
440
|
+
answers?: ProvidedAnswer[];
|
|
441
|
+
/** Work out and print the plan, writing nothing and downloading no recipe. */
|
|
442
|
+
dryRun?: boolean;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
/** What `update` did, or would do on a dry run. */
|
|
446
|
+
export type UpdateOutcome = {
|
|
447
|
+
/** What the update covered. */
|
|
448
|
+
scope: UpdateScope;
|
|
449
|
+
/** What changed in the lockfile. */
|
|
450
|
+
diff: LockDiff;
|
|
451
|
+
/** True when nothing needed moving, so nothing was asked and nothing written. */
|
|
452
|
+
nothingToUpdate: boolean;
|
|
453
|
+
/** Repositories whose index could not be fetched; their pins stayed. */
|
|
454
|
+
unreachable: Array<{ repo: string; reason: string }>;
|
|
455
|
+
/** Subscriptions that could not be resolved; their pins stayed. */
|
|
456
|
+
failed: Array<{ key: string; reason: string }>;
|
|
457
|
+
/** Linked repositories whose pins moved. */
|
|
458
|
+
linked: string[];
|
|
459
|
+
/** Repositories trusted along the way. */
|
|
460
|
+
trusted: string[];
|
|
461
|
+
/** What the variable questions produced, when any were asked. */
|
|
462
|
+
answers?: AskReport;
|
|
463
|
+
/** Dependency cycles the resolver noticed. */
|
|
464
|
+
cycles: string[][];
|
|
465
|
+
/** True when nothing was written, because this was a dry run. */
|
|
466
|
+
dryRun: boolean;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/** What looking for newer published versions of one repository's pins found. */
|
|
470
|
+
export type NewerVersionsReport =
|
|
471
|
+
| {
|
|
472
|
+
/** The index was fetched and compared. */
|
|
473
|
+
checked: true;
|
|
474
|
+
/** Every pin with a newer published version its range allows, by key. */
|
|
475
|
+
newer: NewerVersion[];
|
|
476
|
+
}
|
|
477
|
+
| {
|
|
478
|
+
/** The index could not be fetched in time, so nothing is known. */
|
|
479
|
+
checked: false;
|
|
480
|
+
/** Why not, as a sentence. */
|
|
481
|
+
reason: string;
|
|
482
|
+
};
|
|
483
|
+
|
|
406
484
|
// --- The service --------------------------------------------------------------------------------
|
|
407
485
|
|
|
408
486
|
/** Adds repositories, subscribes to recipes, and keeps the store and lockfile honest. */
|
|
@@ -511,6 +589,11 @@ export class SubscriptionService {
|
|
|
511
589
|
providerOptions: this.providerOptions,
|
|
512
590
|
warn: this.warn,
|
|
513
591
|
now: this.now,
|
|
592
|
+
// Every cache a command builds knows the packaged core version from the
|
|
593
|
+
// start, so a command that never seeds (a lockfile rebuild, a browsing
|
|
594
|
+
// command) resolves and lists exactly what a build does. Seeding
|
|
595
|
+
// replaces this with an overlay carrying the seeded entry's own hash.
|
|
596
|
+
overlay: packagedCoreIndexOverlay({ version: SOUS_VERSION, warn: this.warn }),
|
|
514
597
|
});
|
|
515
598
|
this.trust =
|
|
516
599
|
options.trust ??
|
|
@@ -1586,6 +1669,664 @@ export class SubscriptionService {
|
|
|
1586
1669
|
return lines;
|
|
1587
1670
|
}
|
|
1588
1671
|
|
|
1672
|
+
// --- Moving pins within their ranges -----------------------------------------------------------
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Moves the lockfile's pins to the newest published versions their ranges
|
|
1676
|
+
* allow, and nothing else: the subscriptions themselves are never edited, and
|
|
1677
|
+
* no range is ever widened.
|
|
1678
|
+
*
|
|
1679
|
+
* The order is the design, as it is for subscribing:
|
|
1680
|
+
*
|
|
1681
|
+
* - Every trusted repository's index is fetched fresh first. One that
|
|
1682
|
+
* cannot be reached is reported, and its pins stay exactly where they are.
|
|
1683
|
+
* - The reference, when there is one, is resolved through `src/lib/refs/`
|
|
1684
|
+
* against those indexes, and it narrows which pins may move. Everything
|
|
1685
|
+
* outside it is held at its locked version for as long as its ranges
|
|
1686
|
+
* allow, so dependencies move with the closure and nothing else does.
|
|
1687
|
+
* - The whole change is printed as a plan and asked about once. With
|
|
1688
|
+
* nothing to change, nothing is asked. `--yes` accepts it; a run with no
|
|
1689
|
+
* terminal and no `--yes` fails naming the flag.
|
|
1690
|
+
* - Any repository a newer version needs that the project does not trust
|
|
1691
|
+
* goes through the usual trust question, and the plan is worked out again
|
|
1692
|
+
* once it is trusted.
|
|
1693
|
+
* - The lockfile is written, and then the questions the new versions ask
|
|
1694
|
+
* are asked, exactly as `subscribe` asks them.
|
|
1695
|
+
*
|
|
1696
|
+
* A subscription sous provides itself (the `core` namespace) never moves: its
|
|
1697
|
+
* range is exactly the running sous version. Switched-off subscriptions take
|
|
1698
|
+
* no part. A linked repository's pins move like any other, and builds keep
|
|
1699
|
+
* reading its checkout until it is unlinked.
|
|
1700
|
+
*
|
|
1701
|
+
* @param options - What to update, and the confirmation, answer and dry-run flags.
|
|
1702
|
+
*/
|
|
1703
|
+
async update(options: UpdateOptions = {}): Promise<UpdateOutcome> {
|
|
1704
|
+
const dryRun = options.dryRun === true;
|
|
1705
|
+
|
|
1706
|
+
const repoNames = Object.keys(this.currentRepos()).sort();
|
|
1707
|
+
const { indexes, unreachable } = await this.fetchFreshIndexes(repoNames);
|
|
1708
|
+
const scope = await this.resolveUpdateScope(options, indexes);
|
|
1709
|
+
const unreachableNames = new Set(unreachable.map((entry) => entry.repo));
|
|
1710
|
+
|
|
1711
|
+
let plan = await this.planUpdate(scope, indexes, unreachableNames, dryRun);
|
|
1712
|
+
const facts = {
|
|
1713
|
+
scope,
|
|
1714
|
+
unreachable,
|
|
1715
|
+
builtIn: this.builtInSubscriptionsIn(scope),
|
|
1716
|
+
switchedOff: this.switchedOffSubscriptionsIn(scope),
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
const questions = planQuestions(this.definedVariables(plan.changed), this.ladderContext(), {
|
|
1720
|
+
sousDir: this.sousDir,
|
|
1721
|
+
}).filter((entry) => !entry.answered);
|
|
1722
|
+
|
|
1723
|
+
for (const line of formatUpdatePlan({
|
|
1724
|
+
...facts,
|
|
1725
|
+
diff: plan.diff,
|
|
1726
|
+
missingRepos: plan.missingRepos,
|
|
1727
|
+
questions,
|
|
1728
|
+
unreadable: dryRun ? this.unreadableRecipes(plan.changed) : [],
|
|
1729
|
+
linked: plan.linked,
|
|
1730
|
+
failed: plan.failed,
|
|
1731
|
+
})) {
|
|
1732
|
+
this.write(line === "" ? "" : indent(line));
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
const outcome = (trusted: string[], answers?: AskReport): UpdateOutcome => ({
|
|
1736
|
+
scope,
|
|
1737
|
+
diff: plan.diff,
|
|
1738
|
+
nothingToUpdate: isEmptyUpdate(plan),
|
|
1739
|
+
unreachable,
|
|
1740
|
+
failed: plan.failed,
|
|
1741
|
+
linked: plan.linked,
|
|
1742
|
+
trusted,
|
|
1743
|
+
...(answers === undefined ? {} : { answers }),
|
|
1744
|
+
cycles: plan.cycles,
|
|
1745
|
+
dryRun,
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
if (isEmptyUpdate(plan) || dryRun) return outcome([]);
|
|
1749
|
+
|
|
1750
|
+
if (options.yes !== true) {
|
|
1751
|
+
if (!this.interactive) {
|
|
1752
|
+
throw nonInteractiveError({
|
|
1753
|
+
prompt: `whether to update ${describeUpdateScope(scope)}`,
|
|
1754
|
+
remedy:
|
|
1755
|
+
"pass '--yes' (spelled '-y', '--force' or '--trust' if you prefer) to accept " +
|
|
1756
|
+
"the plan above without being asked.",
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
const proceed = await this.ask("Update the lockfile?");
|
|
1761
|
+
if (!proceed) {
|
|
1762
|
+
throw new ConfigError(
|
|
1763
|
+
`Nothing was written: the update was declined.\n` +
|
|
1764
|
+
` The lockfile and this project's config are exactly as they were.`
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// A newer version that needs a repository this project does not trust is a
|
|
1770
|
+
// trust decision, asked by name exactly as subscribing asks it. Once it is
|
|
1771
|
+
// trusted, its index is read and the plan is worked out again, since the
|
|
1772
|
+
// closure could not be walked past it before.
|
|
1773
|
+
const trusted: string[] = [];
|
|
1774
|
+
while (plan.missingRepos.length > 0) {
|
|
1775
|
+
const decided = await this.trust.confirmTrust(plan.missingRepos, {
|
|
1776
|
+
interactive: this.interactive,
|
|
1777
|
+
...(options.yes === undefined ? {} : { trustFlag: options.yes }),
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
if (decided.needUrl.length > 0) {
|
|
1781
|
+
throw new ConfigError(
|
|
1782
|
+
[
|
|
1783
|
+
`Sous does not know where ${
|
|
1784
|
+
decided.needUrl.length === 1
|
|
1785
|
+
? `the repository '${decided.needUrl[0]}' lives`
|
|
1786
|
+
: `these repositories live: ${decided.needUrl.map((n) => `'${n}'`).join(", ")}`
|
|
1787
|
+
}, so it cannot add ${decided.needUrl.length === 1 ? "it" : "them"} for you.`,
|
|
1788
|
+
" Nothing was written. Add each one with its URL, then run this command again:",
|
|
1789
|
+
"",
|
|
1790
|
+
...decided.needUrl.map((name) => ` sous repo add <url> --name ${name}`),
|
|
1791
|
+
].join("\n")
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
trusted.push(...decided.added);
|
|
1796
|
+
const added = await this.fetchFreshIndexes(decided.added);
|
|
1797
|
+
for (const [name, index] of added.indexes) indexes.set(name, index);
|
|
1798
|
+
plan = await this.planUpdate(scope, indexes, unreachableNames, false);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
// Supplied answers are checked before anything is written, so one that does
|
|
1802
|
+
// not fit fails the run rather than leaving the lockfile moved with its
|
|
1803
|
+
// questions unanswered.
|
|
1804
|
+
validateProvidedAnswers(this.definedVariables(plan.changed), options.answers ?? []);
|
|
1805
|
+
|
|
1806
|
+
for (const recipe of plan.changed) await this.ensureStored(recipe);
|
|
1807
|
+
this.lock.write(plan.after);
|
|
1808
|
+
|
|
1809
|
+
const answers = await this.askVariables(plan.changed, options.answers ?? []);
|
|
1810
|
+
return outcome(trusted, answers);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Fetches the index of each named repository straight from upstream,
|
|
1815
|
+
* ignoring the freshness window. A repository that cannot be reached is
|
|
1816
|
+
* reported rather than raised, and the copy already cached (when there is
|
|
1817
|
+
* one) stands in for it so its pins can still be held where they are.
|
|
1818
|
+
*
|
|
1819
|
+
* @param names - The repositories' short names.
|
|
1820
|
+
*/
|
|
1821
|
+
private async fetchFreshIndexes(names: string[]): Promise<{
|
|
1822
|
+
indexes: Map<string, IndexFile>;
|
|
1823
|
+
unreachable: Array<{ repo: string; reason: string }>;
|
|
1824
|
+
}> {
|
|
1825
|
+
const repos = this.currentRepos();
|
|
1826
|
+
const indexes = new Map<string, IndexFile>();
|
|
1827
|
+
const unreachable: Array<{ repo: string; reason: string }> = [];
|
|
1828
|
+
|
|
1829
|
+
for (const name of names) {
|
|
1830
|
+
const entry = repos[name];
|
|
1831
|
+
if (entry === undefined) continue;
|
|
1832
|
+
|
|
1833
|
+
let identity: string;
|
|
1834
|
+
try {
|
|
1835
|
+
identity = this.identityOf(entry.url, entry.provider);
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
unreachable.push({ repo: name, reason: describeError(error) });
|
|
1838
|
+
continue;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
try {
|
|
1842
|
+
const lookup = await this.indexCache.refresh(identity, {
|
|
1843
|
+
url: entry.url,
|
|
1844
|
+
label: name,
|
|
1845
|
+
...(entry.provider === undefined ? {} : { provider: entry.provider }),
|
|
1846
|
+
});
|
|
1847
|
+
indexes.set(name, lookup.index);
|
|
1848
|
+
} catch (error) {
|
|
1849
|
+
unreachable.push({ repo: name, reason: describeError(error) });
|
|
1850
|
+
const cached = this.indexCache.readCached(identity);
|
|
1851
|
+
if (cached !== undefined) indexes.set(name, cached);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
return { indexes, unreachable };
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* Settles what an update covers: a repository the caller named outright, the
|
|
1860
|
+
* repository, namespace or recipe a reference names, or everything.
|
|
1861
|
+
*
|
|
1862
|
+
* @param options - The reference or repository, and the accept-first flag.
|
|
1863
|
+
* @param indexes - The freshly fetched indexes, which the reference is resolved against.
|
|
1864
|
+
*/
|
|
1865
|
+
private async resolveUpdateScope(
|
|
1866
|
+
options: UpdateOptions,
|
|
1867
|
+
indexes: Map<string, IndexFile>
|
|
1868
|
+
): Promise<UpdateScope> {
|
|
1869
|
+
if (options.repo !== undefined) {
|
|
1870
|
+
this.repoSearchOrder(options.repo);
|
|
1871
|
+
return { kind: "repository", repo: options.repo };
|
|
1872
|
+
}
|
|
1873
|
+
if (options.ref === undefined) return { kind: "all" };
|
|
1874
|
+
|
|
1875
|
+
const repoOrder = this.repoSearchOrder();
|
|
1876
|
+
const repos = this.currentRepos();
|
|
1877
|
+
const urls: Record<string, string | undefined> = {};
|
|
1878
|
+
for (const name of repoOrder) urls[name] = repos[name]?.url;
|
|
1879
|
+
|
|
1880
|
+
const matches = findReference(
|
|
1881
|
+
options.ref,
|
|
1882
|
+
[SousScope.Repository, SousScope.Namespace, SousScope.Recipe],
|
|
1883
|
+
{ repos: referenceReposFromIndexes(repoOrder, indexes, urls) }
|
|
1884
|
+
);
|
|
1885
|
+
|
|
1886
|
+
const chosen = await pickReference(matches, {
|
|
1887
|
+
search: options.ref,
|
|
1888
|
+
interactive: this.interactive,
|
|
1889
|
+
...(options.acceptFirst === undefined ? {} : { acceptFirst: options.acceptFirst }),
|
|
1890
|
+
details: [
|
|
1891
|
+
` No trusted repository, namespace or recipe has that name.`,
|
|
1892
|
+
...describeIndexSearch({ name: options.ref, repoOrder, indexes }),
|
|
1893
|
+
],
|
|
1894
|
+
write: (message: string) => this.write(message),
|
|
1895
|
+
choose: (message, offered) => this.choose(message, offered),
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
switch (chosen.scope) {
|
|
1899
|
+
case SousScope.Repository:
|
|
1900
|
+
return { kind: "repository", repo: chosen.repo! };
|
|
1901
|
+
case SousScope.Namespace:
|
|
1902
|
+
return { kind: "namespace", repo: chosen.repo!, namespace: chosen.namespace! };
|
|
1903
|
+
default:
|
|
1904
|
+
return {
|
|
1905
|
+
kind: "recipe",
|
|
1906
|
+
repo: chosen.repo!,
|
|
1907
|
+
key: `${chosen.namespace!}/${chosen.recipe!}`,
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/**
|
|
1913
|
+
* Works out what an update would do, writing nothing.
|
|
1914
|
+
*
|
|
1915
|
+
* Every subscription the scope touches is resolved afresh against the indexes
|
|
1916
|
+
* given, one at a time so one that cannot be resolved does not stop the
|
|
1917
|
+
* others, with every pin outside the scope held where the lockfile has it.
|
|
1918
|
+
* What the resolutions settle on is laid over the lockfile, and every hold the
|
|
1919
|
+
* new closure no longer declares is released, refcounted, so a dependency a
|
|
1920
|
+
* newer version dropped leaves the lockfile unless something else still holds
|
|
1921
|
+
* it.
|
|
1922
|
+
*
|
|
1923
|
+
* @param scope - What may move.
|
|
1924
|
+
* @param indexes - The indexes to resolve against.
|
|
1925
|
+
* @param unreachable - Repositories whose pins must not move, because their
|
|
1926
|
+
* index could not be fetched.
|
|
1927
|
+
* @param dryRun - When true, read manifests only from disk and download nothing.
|
|
1928
|
+
*/
|
|
1929
|
+
private async planUpdate(
|
|
1930
|
+
scope: UpdateScope,
|
|
1931
|
+
indexes: Map<string, IndexFile>,
|
|
1932
|
+
unreachable: Set<string>,
|
|
1933
|
+
dryRun: boolean
|
|
1934
|
+
): Promise<{
|
|
1935
|
+
after: Lockfile;
|
|
1936
|
+
diff: LockDiff;
|
|
1937
|
+
changed: ResolvedRecipe[];
|
|
1938
|
+
missingRepos: MissingRepo[];
|
|
1939
|
+
failed: Array<{ key: string; reason: string }>;
|
|
1940
|
+
linked: string[];
|
|
1941
|
+
cycles: string[][];
|
|
1942
|
+
}> {
|
|
1943
|
+
const before = this.lock.read();
|
|
1944
|
+
const subscriptions = this.allSubscriptions();
|
|
1945
|
+
const repos = this.resolverRepos();
|
|
1946
|
+
|
|
1947
|
+
/** True when the update may move this recipe. */
|
|
1948
|
+
const movable = (key: string, repo: string): boolean => {
|
|
1949
|
+
if (unreachable.has(repo)) return false;
|
|
1950
|
+
if (key === CORE_RECIPE_KEY && this.isBuiltInSubscription(CORE_NAMESPACE)) {
|
|
1951
|
+
return false;
|
|
1952
|
+
}
|
|
1953
|
+
return recipeInScope(scope, key, repo);
|
|
1954
|
+
};
|
|
1955
|
+
|
|
1956
|
+
// Everything the update may not move is held where the lockfile has it.
|
|
1957
|
+
const keep: Record<string, string> = {};
|
|
1958
|
+
for (const [key, entry] of Object.entries(before.recipes)) {
|
|
1959
|
+
if (!movable(key, entry.repo)) keep[key] = entry.version;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
const targets = Object.keys(subscriptions)
|
|
1963
|
+
.sort()
|
|
1964
|
+
.filter((key) => !this.isBuiltInSubscription(key))
|
|
1965
|
+
.filter((key) => this.subscriptionTouches(key, scope, before, movable));
|
|
1966
|
+
|
|
1967
|
+
const merged = new Map<string, ResolvedRecipe>();
|
|
1968
|
+
const resolvedSubscriptions: string[] = [];
|
|
1969
|
+
const missing = new Map<string, MissingRepo>();
|
|
1970
|
+
const failed: Array<{ key: string; reason: string }> = [];
|
|
1971
|
+
const cycles: string[][] = [];
|
|
1972
|
+
|
|
1973
|
+
for (const key of targets) {
|
|
1974
|
+
const entry = subscriptions[key]!;
|
|
1975
|
+
let request: RefRequest;
|
|
1976
|
+
try {
|
|
1977
|
+
const parsed = parseRef(key);
|
|
1978
|
+
request = {
|
|
1979
|
+
ref: { ...parsed, ...(entry.range === undefined ? {} : { range: entry.range }) },
|
|
1980
|
+
requestedBy: PROJECT_REQUESTER,
|
|
1981
|
+
kind: "subscribes",
|
|
1982
|
+
...(entry.prerelease === true ? { prerelease: true } : {}),
|
|
1983
|
+
};
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
failed.push({ key, reason: describeError(error) });
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
let result;
|
|
1990
|
+
try {
|
|
1991
|
+
result = await resolveRefs([request], {
|
|
1992
|
+
indexes,
|
|
1993
|
+
repos,
|
|
1994
|
+
keep,
|
|
1995
|
+
loadManifest: (recipe) => this.loadRecipeManifest(recipe, dryRun),
|
|
1996
|
+
...(entry.prerelease === true ? { prerelease: true } : {}),
|
|
1997
|
+
});
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
failed.push({ key, reason: describeError(error) });
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
cycles.push(...result.cycles);
|
|
2004
|
+
for (const repo of result.missingRepos) {
|
|
2005
|
+
const known = missing.get(repo.name);
|
|
2006
|
+
if (known === undefined) missing.set(repo.name, { ...repo });
|
|
2007
|
+
else known.requiredBy.push(...repo.requiredBy);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
let conflict = false;
|
|
2011
|
+
for (const recipe of result.resolved) {
|
|
2012
|
+
const already = merged.get(recipe.key);
|
|
2013
|
+
if (already !== undefined && already.version !== recipe.version) {
|
|
2014
|
+
failed.push({
|
|
2015
|
+
key,
|
|
2016
|
+
reason:
|
|
2017
|
+
`It wants version ${recipe.version} of '${recipe.key}', and another ` +
|
|
2018
|
+
`subscription wants ${already.version}.`,
|
|
2019
|
+
});
|
|
2020
|
+
conflict = true;
|
|
2021
|
+
break;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
if (conflict) continue;
|
|
2025
|
+
|
|
2026
|
+
for (const recipe of result.resolved) {
|
|
2027
|
+
const already = merged.get(recipe.key);
|
|
2028
|
+
merged.set(recipe.key, already === undefined ? recipe : mergeHolders(already, recipe));
|
|
2029
|
+
}
|
|
2030
|
+
resolvedSubscriptions.push(key);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
const settled = [...merged.values()];
|
|
2034
|
+
const applied = this.lock.applyResolution(before, settled, this.lockRepoInputs());
|
|
2035
|
+
const others = Object.keys(subscriptions).filter(
|
|
2036
|
+
(key) => !resolvedSubscriptions.includes(key)
|
|
2037
|
+
);
|
|
2038
|
+
const after = this.releaseDroppedHolds(applied, merged, resolvedSubscriptions, others);
|
|
2039
|
+
const diff = this.lock.diff(before, after);
|
|
2040
|
+
|
|
2041
|
+
const changed = settled.filter(
|
|
2042
|
+
(recipe) => before.recipes[recipe.key]?.version !== recipe.version
|
|
2043
|
+
);
|
|
2044
|
+
const linked = [
|
|
2045
|
+
...new Set(
|
|
2046
|
+
changed
|
|
2047
|
+
.map((recipe) => recipe.repo)
|
|
2048
|
+
.filter((repo) => linkedPathFor(repo, this.sousDir, this.env) !== undefined)
|
|
2049
|
+
),
|
|
2050
|
+
].sort();
|
|
2051
|
+
|
|
2052
|
+
return {
|
|
2053
|
+
after,
|
|
2054
|
+
diff,
|
|
2055
|
+
changed,
|
|
2056
|
+
missingRepos: [...missing.values()].sort((left, right) =>
|
|
2057
|
+
left.name < right.name ? -1 : 1
|
|
2058
|
+
),
|
|
2059
|
+
failed,
|
|
2060
|
+
linked,
|
|
2061
|
+
cycles,
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
/**
|
|
2066
|
+
* True when a subscription reaches anything the update may move, which is
|
|
2067
|
+
* what decides whether it is resolved again. A subscription with nothing
|
|
2068
|
+
* locked yet counts when its own ref falls inside the scope.
|
|
2069
|
+
*
|
|
2070
|
+
* @param key - The subscription's ref key.
|
|
2071
|
+
* @param scope - What the update covers.
|
|
2072
|
+
* @param lock - The lockfile as it stands.
|
|
2073
|
+
* @param movable - Whether one locked recipe may move.
|
|
2074
|
+
*/
|
|
2075
|
+
private subscriptionTouches(
|
|
2076
|
+
key: string,
|
|
2077
|
+
scope: UpdateScope,
|
|
2078
|
+
lock: Lockfile,
|
|
2079
|
+
movable: (key: string, repo: string) => boolean
|
|
2080
|
+
): boolean {
|
|
2081
|
+
if (scope.kind === "all") return true;
|
|
2082
|
+
|
|
2083
|
+
let parsed: ParsedRef;
|
|
2084
|
+
try {
|
|
2085
|
+
parsed = parseRef(key);
|
|
2086
|
+
} catch {
|
|
2087
|
+
return false;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
const closure = lockedClosure(lock, keysHeldBySubscription(lock, refKey(parsed)));
|
|
2091
|
+
if (closure.some((held) => movable(held, lock.recipes[held]!.repo))) return true;
|
|
2092
|
+
|
|
2093
|
+
// Nothing locked reaches the scope, but the subscription may still name it:
|
|
2094
|
+
// a namespace subscription gains the recipes a namespace publishes later.
|
|
2095
|
+
if (parsed.repo !== undefined && parsed.repo !== scope.repo) return false;
|
|
2096
|
+
const covered = refKey(parsed);
|
|
2097
|
+
switch (scope.kind) {
|
|
2098
|
+
case "repository":
|
|
2099
|
+
return parsed.repo === scope.repo;
|
|
2100
|
+
case "namespace":
|
|
2101
|
+
return covered === scope.namespace || covered.startsWith(`${scope.namespace}/`);
|
|
2102
|
+
case "recipe":
|
|
2103
|
+
return covered === scope.key || scope.key.startsWith(`${covered}/`);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
/**
|
|
2108
|
+
* Releases every hold the new resolution no longer declares.
|
|
2109
|
+
*
|
|
2110
|
+
* Laying a resolution over the lockfile only ever adds holders, which is right
|
|
2111
|
+
* for a subscription and wrong for an update: a newer version that no longer
|
|
2112
|
+
* depends on something must stop holding it, and a namespace that stopped
|
|
2113
|
+
* publishing a recipe must stop holding that one. A hold is released when the
|
|
2114
|
+
* holder was resolved again and did not declare the recipe this time; an entry
|
|
2115
|
+
* left with no holders goes, and so does whatever only it was holding.
|
|
2116
|
+
*
|
|
2117
|
+
* @param lock - The lockfile with the resolution laid over it.
|
|
2118
|
+
* @param merged - What the resolutions settled on, by key.
|
|
2119
|
+
* @param resolvedSubscriptions - The subscriptions that were resolved again.
|
|
2120
|
+
* @param otherSubscriptions - Subscriptions that were not, whose holds stand.
|
|
2121
|
+
*/
|
|
2122
|
+
private releaseDroppedHolds(
|
|
2123
|
+
lock: Lockfile,
|
|
2124
|
+
merged: Map<string, ResolvedRecipe>,
|
|
2125
|
+
resolvedSubscriptions: string[],
|
|
2126
|
+
otherSubscriptions: string[]
|
|
2127
|
+
): Lockfile {
|
|
2128
|
+
const coverOf = (key: string): string | undefined => {
|
|
2129
|
+
try {
|
|
2130
|
+
return refKey(parseRef(key));
|
|
2131
|
+
} catch {
|
|
2132
|
+
return undefined;
|
|
2133
|
+
}
|
|
2134
|
+
};
|
|
2135
|
+
const covers = (cover: string | undefined, key: string): boolean =>
|
|
2136
|
+
cover !== undefined && (key === cover || key.startsWith(`${cover}/`));
|
|
2137
|
+
const resolvedCovers = resolvedSubscriptions.map(coverOf);
|
|
2138
|
+
const otherCovers = otherSubscriptions.map(coverOf);
|
|
2139
|
+
|
|
2140
|
+
const recipes: Record<string, LockedRecipe> = { ...lock.recipes };
|
|
2141
|
+
const orphaned: string[] = [];
|
|
2142
|
+
|
|
2143
|
+
for (const [key, entry] of Object.entries(lock.recipes)) {
|
|
2144
|
+
const settled = merged.get(key);
|
|
2145
|
+
const kept = entry.requestedBy.filter((holder) => {
|
|
2146
|
+
if (holder === PROJECT_HOLDER) {
|
|
2147
|
+
if (!resolvedCovers.some((cover) => covers(cover, key))) return true;
|
|
2148
|
+
if (otherCovers.some((cover) => covers(cover, key))) return true;
|
|
2149
|
+
return settled?.requestedBy.includes(PROJECT_HOLDER) === true;
|
|
2150
|
+
}
|
|
2151
|
+
if (!merged.has(holder)) return true;
|
|
2152
|
+
return settled?.requestedBy.includes(holder) === true;
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
if (kept.length === entry.requestedBy.length) continue;
|
|
2156
|
+
if (kept.length === 0) {
|
|
2157
|
+
delete recipes[key];
|
|
2158
|
+
orphaned.push(key);
|
|
2159
|
+
} else {
|
|
2160
|
+
recipes[key] = { ...entry, requestedBy: kept };
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
let result: Lockfile = { ...lock, recipes };
|
|
2165
|
+
for (const key of orphaned) result = this.lock.removeHolder(result, key);
|
|
2166
|
+
return result;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* True when a subscription is one sous provides itself, which an update never
|
|
2171
|
+
* moves: its range is exactly the running sous version.
|
|
2172
|
+
*
|
|
2173
|
+
* @param key - The subscription's ref key.
|
|
2174
|
+
*/
|
|
2175
|
+
private isBuiltInSubscription(key: string): boolean {
|
|
2176
|
+
const configured = enabledSubscriptions(this.settings) as Record<string, SubscriptionEntry>;
|
|
2177
|
+
return isBuiltInEntry(configured[key]);
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/**
|
|
2181
|
+
* The subscriptions sous provides itself that an update of this scope would
|
|
2182
|
+
* otherwise have covered, with the version each stays at, for the plan.
|
|
2183
|
+
*
|
|
2184
|
+
* @param scope - What the update covers.
|
|
2185
|
+
*/
|
|
2186
|
+
private builtInSubscriptionsIn(
|
|
2187
|
+
scope: UpdateScope
|
|
2188
|
+
): Array<{ key: string; version?: string }> {
|
|
2189
|
+
const lock = this.lock.read();
|
|
2190
|
+
return Object.keys(this.allSubscriptions())
|
|
2191
|
+
.filter((key) => this.isBuiltInSubscription(key))
|
|
2192
|
+
.filter((key) =>
|
|
2193
|
+
keysHeldBySubscription(lock, key).some((held) =>
|
|
2194
|
+
recipeInScope(scope, held, lock.recipes[held]!.repo)
|
|
2195
|
+
)
|
|
2196
|
+
)
|
|
2197
|
+
.map((key) => {
|
|
2198
|
+
const version = this.allSubscriptions()[key]?.range;
|
|
2199
|
+
return { key, ...(version === undefined ? {} : { version }) };
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* The switched-off subscriptions an update of this scope leaves alone.
|
|
2205
|
+
*
|
|
2206
|
+
* @param scope - What the update covers.
|
|
2207
|
+
*/
|
|
2208
|
+
private switchedOffSubscriptionsIn(scope: UpdateScope): string[] {
|
|
2209
|
+
const declared: Record<string, SubscriptionEntry> = {
|
|
2210
|
+
...((this.settings.subscriptions ?? {}) as Record<string, SubscriptionEntry>),
|
|
2211
|
+
...this.readSubscriptionEntries(),
|
|
2212
|
+
};
|
|
2213
|
+
|
|
2214
|
+
return Object.keys(declared)
|
|
2215
|
+
.filter((key) => declared[key]?.enabled === false)
|
|
2216
|
+
.filter((key) => {
|
|
2217
|
+
if (scope.kind === "all") return true;
|
|
2218
|
+
let parsed: ParsedRef;
|
|
2219
|
+
try {
|
|
2220
|
+
parsed = parseRef(key);
|
|
2221
|
+
} catch {
|
|
2222
|
+
return false;
|
|
2223
|
+
}
|
|
2224
|
+
const covered = refKey(parsed);
|
|
2225
|
+
switch (scope.kind) {
|
|
2226
|
+
case "repository":
|
|
2227
|
+
return parsed.repo === scope.repo;
|
|
2228
|
+
case "namespace":
|
|
2229
|
+
return covered === scope.namespace || covered.startsWith(`${scope.namespace}/`);
|
|
2230
|
+
case "recipe":
|
|
2231
|
+
return covered === scope.key || scope.key.startsWith(`${covered}/`);
|
|
2232
|
+
}
|
|
2233
|
+
})
|
|
2234
|
+
.sort();
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/**
|
|
2238
|
+
* Fetches one repository's index with a short timeout and says which of its
|
|
2239
|
+
* pins have a newer published version their ranges allow. Nothing moves; this
|
|
2240
|
+
* is the fact `sous repo unlink` reports. A fetch that fails or takes too long
|
|
2241
|
+
* is an answer too: that sous could not check.
|
|
2242
|
+
*
|
|
2243
|
+
* @param name - The repository's short name.
|
|
2244
|
+
* @param options - How long to wait for the index, in milliseconds.
|
|
2245
|
+
*/
|
|
2246
|
+
async newerPublishedVersions(
|
|
2247
|
+
name: string,
|
|
2248
|
+
options: { timeoutMs?: number } = {}
|
|
2249
|
+
): Promise<NewerVersionsReport> {
|
|
2250
|
+
const timeoutMs = options.timeoutMs ?? NEWER_VERSION_CHECK_TIMEOUT_MS;
|
|
2251
|
+
const lock = this.lock.read();
|
|
2252
|
+
const entry = this.currentRepos()[name];
|
|
2253
|
+
const url = entry?.url ?? lock.repos[name]?.url;
|
|
2254
|
+
const identity = this.identityForRepo(name, lock);
|
|
2255
|
+
if (url === undefined || identity === undefined) {
|
|
2256
|
+
return {
|
|
2257
|
+
checked: false,
|
|
2258
|
+
reason: `This project does not trust a repository called '${name}'.`,
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// The same deadline and the same comparison a build's newer-version report
|
|
2263
|
+
// uses, so the two can never disagree about what counts as newer.
|
|
2264
|
+
let index: IndexFile;
|
|
2265
|
+
try {
|
|
2266
|
+
index = (
|
|
2267
|
+
await withDeadline(
|
|
2268
|
+
(signal) =>
|
|
2269
|
+
this.indexCache.refresh(identity, {
|
|
2270
|
+
url,
|
|
2271
|
+
label: name,
|
|
2272
|
+
...(entry?.provider === undefined ? {} : { provider: entry.provider }),
|
|
2273
|
+
signal,
|
|
2274
|
+
}),
|
|
2275
|
+
timeoutMs
|
|
2276
|
+
)
|
|
2277
|
+
).index;
|
|
2278
|
+
} catch (error) {
|
|
2279
|
+
return { checked: false, reason: describeError(error) };
|
|
2280
|
+
} finally {
|
|
2281
|
+
recordUpstreamCheck(this.indexCache, identity, this.now());
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
return {
|
|
2285
|
+
checked: true,
|
|
2286
|
+
newer: this.newerInRange(index, name, lock, this.allSubscriptions()),
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* Every recipe the lockfile pins from one repository that has a newer
|
|
2292
|
+
* published version inside the range its holders declared, sorted by key.
|
|
2293
|
+
* Ranges come from `effectiveRangeForHolders`, so nothing is ever reported
|
|
2294
|
+
* past a constraint a dependency declared. A build's newer-version report and
|
|
2295
|
+
* `sous repo unlink` both read it.
|
|
2296
|
+
*
|
|
2297
|
+
* @param index - The repository's index.
|
|
2298
|
+
* @param repoName - The repository's short name.
|
|
2299
|
+
* @param lock - The lockfile as it stands.
|
|
2300
|
+
* @param subscriptions - Every subscription, for ranges and prerelease opt-ins.
|
|
2301
|
+
*/
|
|
2302
|
+
private newerInRange(
|
|
2303
|
+
index: IndexFile,
|
|
2304
|
+
repoName: string,
|
|
2305
|
+
lock: Lockfile,
|
|
2306
|
+
subscriptions: Record<string, SubscriptionEntry>
|
|
2307
|
+
): NewerVersion[] {
|
|
2308
|
+
const newer: NewerVersion[] = [];
|
|
2309
|
+
for (const key of Object.keys(lock.recipes).sort()) {
|
|
2310
|
+
const entry = lock.recipes[key]!;
|
|
2311
|
+
if (entry.repo !== repoName) continue;
|
|
2312
|
+
const range = this.effectiveRangeFor(key, entry, subscriptions);
|
|
2313
|
+
if (range === undefined) continue;
|
|
2314
|
+
const subscription = subscriptions[key] ?? subscriptions[key.split("/")[0]!];
|
|
2315
|
+
|
|
2316
|
+
const found = findNewerInRange({
|
|
2317
|
+
index,
|
|
2318
|
+
key,
|
|
2319
|
+
lockedVersion: entry.version,
|
|
2320
|
+
...(range === "*" ? {} : { range }),
|
|
2321
|
+
...(subscription?.prerelease === undefined
|
|
2322
|
+
? {}
|
|
2323
|
+
: { prerelease: subscription.prerelease }),
|
|
2324
|
+
});
|
|
2325
|
+
if (found !== undefined) newer.push(found);
|
|
2326
|
+
}
|
|
2327
|
+
return newer;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
1589
2330
|
// --- Restoring and upstream checks -------------------------------------------------------------
|
|
1590
2331
|
|
|
1591
2332
|
/**
|
|
@@ -2026,25 +2767,8 @@ export class SubscriptionService {
|
|
|
2026
2767
|
index ??= this.indexCache.readCached(identity);
|
|
2027
2768
|
if (index === undefined) return;
|
|
2028
2769
|
|
|
2029
|
-
for (const
|
|
2030
|
-
|
|
2031
|
-
)) {
|
|
2032
|
-
if (entry.repo !== repoName) continue;
|
|
2033
|
-
const range = this.effectiveRangeFor(key, entry, input.subscriptions);
|
|
2034
|
-
if (range === undefined) continue;
|
|
2035
|
-
const subscription =
|
|
2036
|
-
input.subscriptions[key] ?? input.subscriptions[key.split("/")[0]!];
|
|
2037
|
-
|
|
2038
|
-
const newer = findNewerInRange({
|
|
2039
|
-
index,
|
|
2040
|
-
key,
|
|
2041
|
-
lockedVersion: entry.version,
|
|
2042
|
-
...(range === "*" ? {} : { range }),
|
|
2043
|
-
...(subscription?.prerelease === undefined
|
|
2044
|
-
? {}
|
|
2045
|
-
: { prerelease: subscription.prerelease }),
|
|
2046
|
-
});
|
|
2047
|
-
if (newer !== undefined) report.newer.push({ ...newer, repo: repoName });
|
|
2770
|
+
for (const newer of this.newerInRange(index, repoName, lock, input.subscriptions)) {
|
|
2771
|
+
report.newer.push({ ...newer, repo: repoName });
|
|
2048
2772
|
}
|
|
2049
2773
|
}
|
|
2050
2774
|
|