@inventarch/cli 0.0.0-stage → 1.0.0
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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +19 -2
- package/SPEC.md +30 -0
- package/assets/base/71f0665cb1ffc3f3964fc7710528451f635d664bdf4b8c95005ee97c21db5928.ia.tgz +0 -0
- package/assets/base.json +6 -0
- package/assets/host/43a1f8c471141b1f2f46cef4411a9a61c00da1c1aa19d277189ae2423282bfd6.tgz +0 -0
- package/assets/host.json +6 -0
- package/assets/vocabulary.json +3268 -0
- package/dist/args.d.ts +62 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +117 -0
- package/dist/args.js.map +1 -0
- package/dist/briefing.d.ts +76 -0
- package/dist/briefing.d.ts.map +1 -0
- package/dist/briefing.js +105 -0
- package/dist/briefing.js.map +1 -0
- package/dist/channel.d.ts +22 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +74 -0
- package/dist/channel.js.map +1 -0
- package/dist/claude-cli.d.ts +35 -0
- package/dist/claude-cli.d.ts.map +1 -0
- package/dist/claude-cli.js +101 -0
- package/dist/claude-cli.js.map +1 -0
- package/dist/commands.d.ts +39 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +436 -0
- package/dist/commands.js.map +1 -0
- package/dist/compile.d.ts +39 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +136 -0
- package/dist/compile.js.map +1 -0
- package/dist/consumer.d.ts +137 -0
- package/dist/consumer.d.ts.map +1 -0
- package/dist/consumer.js +366 -0
- package/dist/consumer.js.map +1 -0
- package/dist/decline.d.ts +7 -0
- package/dist/decline.d.ts.map +1 -0
- package/dist/decline.js +78 -0
- package/dist/decline.js.map +1 -0
- package/dist/distribute.d.ts +163 -0
- package/dist/distribute.d.ts.map +1 -0
- package/dist/distribute.js +664 -0
- package/dist/distribute.js.map +1 -0
- package/dist/doctor-user.d.ts +45 -0
- package/dist/doctor-user.d.ts.map +1 -0
- package/dist/doctor-user.js +193 -0
- package/dist/doctor-user.js.map +1 -0
- package/dist/doctor.d.ts +57 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +754 -0
- package/dist/doctor.js.map +1 -0
- package/dist/format.d.ts +25 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +168 -0
- package/dist/format.js.map +1 -0
- package/dist/home-remedy.d.ts +10 -0
- package/dist/home-remedy.d.ts.map +1 -0
- package/dist/home-remedy.js +26 -0
- package/dist/home-remedy.js.map +1 -0
- package/dist/host-projection.d.ts +11 -0
- package/dist/host-projection.d.ts.map +1 -0
- package/dist/host-projection.js +73 -0
- package/dist/host-projection.js.map +1 -0
- package/dist/host.d.ts +162 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +599 -0
- package/dist/host.js.map +1 -0
- package/dist/init.d.ts +217 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +840 -0
- package/dist/init.js.map +1 -0
- package/dist/inspect.d.ts +32 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +226 -0
- package/dist/inspect.js.map +1 -0
- package/dist/main.d.ts +24 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +215 -0
- package/dist/main.js.map +1 -0
- package/dist/operation-help.d.ts +14 -0
- package/dist/operation-help.d.ts.map +1 -0
- package/dist/operation-help.js +75 -0
- package/dist/operation-help.js.map +1 -0
- package/dist/pack.d.ts +17 -0
- package/dist/pack.d.ts.map +1 -0
- package/dist/pack.js +121 -0
- package/dist/pack.js.map +1 -0
- package/dist/render.d.ts +165 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +277 -0
- package/dist/render.js.map +1 -0
- package/dist/session.d.ts +13 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +25 -0
- package/dist/session.js.map +1 -0
- package/dist/user-host.d.ts +59 -0
- package/dist/user-host.d.ts.map +1 -0
- package/dist/user-host.js +228 -0
- package/dist/user-host.js.map +1 -0
- package/dist/validate.d.ts +53 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +220 -0
- package/dist/validate.js.map +1 -0
- package/dist/vocabulary.d.ts +55 -0
- package/dist/vocabulary.d.ts.map +1 -0
- package/dist/vocabulary.js +179 -0
- package/dist/vocabulary.js.map +1 -0
- package/package.json +153 -3
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ia install`, `ia update`, `ia remove`, `ia restore`:
|
|
3
|
+
* docs/specs/consumer-cli-contract/README.md §§2.8–2.11.
|
|
4
|
+
*
|
|
5
|
+
* One shape, one set of flags, one plan/apply rule. Without `--apply` the command plans and renders; with
|
|
6
|
+
* `--apply` it plans and applies in one invocation and the installer re-checks every input between the two.
|
|
7
|
+
* Planning and applying are two functions here for the reason §2.8 rule 3 gives: on a terminal without `--yes`
|
|
8
|
+
* the change summary is shown and one question is asked between them, and a declined answer leaves the plan
|
|
9
|
+
* unapplied at exit 0. The other half of rule 3 — `--apply` with no terminal and no `--yes`, and `--apply --json`
|
|
10
|
+
* without `--yes`, are usage errors — is enforced at parse time in consumer.ts, which is what keeps §3's promise
|
|
11
|
+
* that an exit 2 read nothing and wrote nothing. By the time a handler runs, an unconfirmable apply is already
|
|
12
|
+
* gone, so reaching the question here means the question can be both asked and answered.
|
|
13
|
+
*
|
|
14
|
+
* The change table is a join the renderer performs: `changes` is four arrays of package ids and nothing else, so
|
|
15
|
+
* the version, digest and origin columns come from the plan's own lock, and an id in neither lock prints an em
|
|
16
|
+
* dash rather than a guess.
|
|
17
|
+
*
|
|
18
|
+
* Sources: docs/specs/registry/README.md §§4–6. Without `--catalog` or `--offline`, `install` and `update`
|
|
19
|
+
* resolve from registries. One chooser per command picks each id's registry by §4's precedence — `--registry`,
|
|
20
|
+
* `IA_REGISTRY`, `.ia/registries.json`, the user's `registries.json`, the built-in default — and reads that
|
|
21
|
+
* configuration once; its inputs are the host's environment and cwd, never the process's. `--offline` without
|
|
22
|
+
* `--catalog` resolves from the workspace's cached archives, reaching only the requested and locked ids and their
|
|
23
|
+
* dependencies (§5.4). `update` without `--to` keeps the request's range and drops only that id's pin from the
|
|
24
|
+
* preference, so the newest release in range wins (§6.2). `restore`, online and without `--catalog`, asks each locked
|
|
25
|
+
* package's registry whether its release was withdrawn before it re-acquires anything (§6.3). `remove` reads only
|
|
26
|
+
* the lock and never chooses a registry.
|
|
27
|
+
*
|
|
28
|
+
* Registered host projections (docs/specs/host-registration/README.md §4, "install, update, remove"):
|
|
29
|
+
* a workspace that owns a projection for a host — its `<host>-projection.json` state exists — lists installed
|
|
30
|
+
* distributions in it, so a change to the installed set changes the projection. `--apply` refuses before anything is
|
|
31
|
+
* acquired or written when an owned projection file was edited by hand, because the refresh would refuse it, and
|
|
32
|
+
* re-renders each registered projection after the install commits. MCP and hook entries do not depend on the
|
|
33
|
+
* installed set and are not touched. `restore` reinstalls the locked generation, whose admitted systems are
|
|
34
|
+
* unchanged, so it neither checks nor refreshes. A refresh that fails after the install committed cannot undo the
|
|
35
|
+
* install, and it is not hidden either: like `ia init --host` after initialization, the command refuses at class 3
|
|
36
|
+
* with the service's code, message and file, and its next action opens "The installation is applied." before the
|
|
37
|
+
* repair, so a script sees the failure and a reader sees what did happen. `ia doctor` reports the drift until then.
|
|
38
|
+
*/
|
|
39
|
+
import { existsSync } from 'node:fs';
|
|
40
|
+
import { resolve } from 'node:path';
|
|
41
|
+
import { WORKSPACE_PROJECTION_MARKER } from '@inventarch/compliance';
|
|
42
|
+
import { canonicalDistributionJson, decodeDistributionRequests, DISTRIBUTION_ENGINE_VERSION, INSTALL_PATHS, } from '@inventarch/db/distribution';
|
|
43
|
+
import { ARCHIVE_CACHE, applyInstallation, planInstallation } from '@inventarch/distribution/install';
|
|
44
|
+
import { applyProjection, observeProjection, planProjection } from '@inventarch/distribution/projection';
|
|
45
|
+
import { registryChooser, registryLocation, registryWithdrawals, resolveFromRegistries, unpublishedPins, } from '@inventarch/distribution/registry';
|
|
46
|
+
import { resolveReleases } from '@inventarch/distribution/resolve';
|
|
47
|
+
import { WORKSPACE_HOSTS } from '@inventarch/distribution/hosts';
|
|
48
|
+
import { acquireArtifact, cachedCandidates, planRestore, pruneLockRequest, readInstalledState, readWorkspaceJson, readWorkspaceLock, resolveCatalog, writeWorkOutput, } from '@inventarch/distribution/services';
|
|
49
|
+
import { UsageError } from './args.js';
|
|
50
|
+
import { confirm, Refusal, refusalOf, requireRoot } from './consumer.js';
|
|
51
|
+
import { renderProjectionFor } from './host-projection.js';
|
|
52
|
+
import { hostNext, lockRefusal, projectionRepair, refusedPath, STATE } from './host.js';
|
|
53
|
+
import { codeOf } from './session.js';
|
|
54
|
+
import { atom, commandFacts, document, entry, fieldRows, headerLine, quote, sectionLabel, truncateDigest, words, } from './render.js';
|
|
55
|
+
/** §2.8: the id grammar is `provider/name`, and a positional may pin it with `@<range>`. */
|
|
56
|
+
export const REQUEST = /^([a-z][a-z0-9.-]*\/[a-z][a-z0-9-]*)(?:@(.+))?$/;
|
|
57
|
+
/** A positional with no range asks for whatever the source offers; nothing in the tree supplies another default. */
|
|
58
|
+
export const ANY_VERSION = '*';
|
|
59
|
+
const SYMBOL = {
|
|
60
|
+
added: 'added',
|
|
61
|
+
removed: 'removed',
|
|
62
|
+
updated: 'updated',
|
|
63
|
+
shadowed: 'info',
|
|
64
|
+
};
|
|
65
|
+
/** §2.8's join. `changes` carries ids; every other column is looked up, and an absent value is never invented. */
|
|
66
|
+
export function changeRows(plan, previous) {
|
|
67
|
+
const rows = [];
|
|
68
|
+
const direct = new Set(plan.lock.requests.map((request) => request.id));
|
|
69
|
+
for (const kind of ['added', 'removed', 'updated', 'shadowed'])
|
|
70
|
+
for (const id of plan.changes[kind]) {
|
|
71
|
+
const locked = plan.lock.packages.find((pkg) => pkg.id === id) ?? previous?.packages.find((pkg) => pkg.id === id);
|
|
72
|
+
const required = plan.lock.packages
|
|
73
|
+
.filter((pkg) => pkg.dependencies.includes(id))
|
|
74
|
+
.map((pkg) => pkg.id)
|
|
75
|
+
.sort()[0];
|
|
76
|
+
rows.push({
|
|
77
|
+
kind,
|
|
78
|
+
id,
|
|
79
|
+
version: locked?.version ?? '—',
|
|
80
|
+
archive: locked?.archive ?? null,
|
|
81
|
+
origin: direct.has(id) ? 'direct' : required === undefined ? 'no longer required' : `required by ${required}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return rows;
|
|
85
|
+
}
|
|
86
|
+
const RETRY = 'Retry when the host is reachable, or add the archive to a local catalog entry {"path":"...","withdrawn":false} and re-run with --catalog <file> --offline.';
|
|
87
|
+
const REGISTRY_RETRY = 'Check network access to the registry named above, or pass --registry <url|dir>, map the provider in .ia/registries.json, or use --catalog <file>.';
|
|
88
|
+
const DEFAULT_RETRY = 'Pass --registry <url|dir>, map the provider to a registry in .ia/registries.json, or install from a local catalog with --catalog <file>.';
|
|
89
|
+
const OVERSIZE = 'The registry named above serves a document over the 4 MiB limit, and retrying will not change that. Choose another registry with --registry <url|dir> or .ia/registries.json, or use --catalog <file>.';
|
|
90
|
+
const INCOMPLETE = 'That registry directory is incomplete: it lists a release whose artifact file is missing. Re-add the release with "ia-distribution registry add --registry <dir> --archive <file>", or choose another registry with --registry <url|dir> or .ia/registries.json.';
|
|
91
|
+
/** Registry spec §4's remedy for an unmapped provider; `ia doctor` names the same one. */
|
|
92
|
+
export const UNMAPPED = "Every requested and locked package's provider needs a registry. Map the provider to an HTTPS URL or a workspace directory in .ia/registries.json, or pass --registry <url|dir>.";
|
|
93
|
+
const LICENSED = 'This CLI has no licensed acquisition path. Obtain the archive through its licensed channel, then install it from a local catalog with --catalog <file>.';
|
|
94
|
+
const REMOTE_LIMIT = /^(?:Remote archive|Registry document) /;
|
|
95
|
+
const SERVICE_CODE = /^IA-[A-Z]+-[A-Z-]+$/;
|
|
96
|
+
/** The repair for a cached archive that does not verify: the cache holds copies, and the consumer has no verb that cleans it. */
|
|
97
|
+
const deleteCached = (path) => `Delete ${path}, then re-run the command; a published archive is fetched again from its registry or catalog.`;
|
|
98
|
+
function unavailable(message, route) {
|
|
99
|
+
if (message.startsWith('The default registry '))
|
|
100
|
+
return DEFAULT_RETRY;
|
|
101
|
+
if (message.startsWith('Registry document '))
|
|
102
|
+
return OVERSIZE;
|
|
103
|
+
if (message.startsWith('Registry request '))
|
|
104
|
+
return REGISTRY_RETRY;
|
|
105
|
+
if (/^Registry .+ has no artifacts\//.test(message))
|
|
106
|
+
return INCOMPLETE;
|
|
107
|
+
return route === 'registry' ? REGISTRY_RETRY : RETRY;
|
|
108
|
+
}
|
|
109
|
+
async function acquiring(run, where, route) {
|
|
110
|
+
try {
|
|
111
|
+
return await run();
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
const code = codeOf(error, ''), message = error instanceof Error ? error.message : String(error);
|
|
115
|
+
// Only a service refusal (an IA code, §4.1) is located at the file it names. A Node error also carries `.path`, but
|
|
116
|
+
// its code is not one the envelope may carry, so it is left to `refusalOf`, which reports it as IA-CLI-FAILED.
|
|
117
|
+
const own = SERVICE_CODE.test(code) ? refusedPath(error) : null;
|
|
118
|
+
const located = own ?? where;
|
|
119
|
+
const at = located === null ? null : { path: located };
|
|
120
|
+
// A refusal located at a cached archive (a corrupt cached lock pin, registry spec §5.1) is repaired by deleting it.
|
|
121
|
+
const cachedFile = own !== null && own.startsWith(`${ARCHIVE_CACHE}/`) ? own : null;
|
|
122
|
+
if (code === 'IA-DIST-ARTIFACT-UNAVAILABLE' || (code === 'IA-DIST-LIMIT-EXCEEDED' && REMOTE_LIMIT.test(message)))
|
|
123
|
+
throw new Refusal(code, message, 4, at, cachedFile !== null ? deleteCached(cachedFile) : unavailable(message, route));
|
|
124
|
+
if (code === '')
|
|
125
|
+
throw new Refusal('IA-CLI-FAILED', `Artifact transport failed: ${message}`, 4, at, route === 'registry' ? REGISTRY_RETRY : RETRY);
|
|
126
|
+
if (code === 'IA-DIST-REGISTRY-UNMAPPED')
|
|
127
|
+
throw new Refusal(code, message, 3, at, UNMAPPED);
|
|
128
|
+
if (code === 'IA-DIST-LICENSE-REQUIRED')
|
|
129
|
+
throw new Refusal(code, message, 3, at, LICENSED);
|
|
130
|
+
// A service refusal the service located keeps its code and class 3, and gains its location (§4.3).
|
|
131
|
+
if (!(error instanceof Refusal) && own !== null)
|
|
132
|
+
throw new Refusal(code, message, 3, at, cachedFile !== null ? deleteCached(cachedFile) : null);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Registry spec §5.4: `--offline` without `--catalog` resolves from the workspace cache, reaching only the ids this
|
|
138
|
+
* command can need — its requests, the lock's packages and their dependencies — so an unrelated stale duplicate in
|
|
139
|
+
* the cache never reaches resolution. A cached file that cannot be read is located, and its repair is to delete it:
|
|
140
|
+
* the cache holds copies, and the consumer has no verb that cleans it. A cache over its bound names the directory
|
|
141
|
+
* and the two online sources.
|
|
142
|
+
*/
|
|
143
|
+
function cached(root, reach) {
|
|
144
|
+
try {
|
|
145
|
+
return cachedCandidates({ root, reach });
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
const refusal = refusalOf(error), path = refusedPath(error);
|
|
149
|
+
if (path !== null && path.startsWith(`${ARCHIVE_CACHE}/`))
|
|
150
|
+
throw new Refusal(refusal.code, refusal.message, 3, { path }, deleteCached(path));
|
|
151
|
+
if (refusal.code === 'IA-DIST-LIMIT-EXCEEDED')
|
|
152
|
+
throw new Refusal(refusal.code, refusal.message, 3, null, `Remove archives this workspace no longer needs from ${ARCHIVE_CACHE}/, or resolve online with --registry <url|dir> or --catalog <file> instead of --offline.`);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** §6.1's registry rows: one per provider and base, in provider order, so the plan reads the same on every run. */
|
|
157
|
+
function registrySources(choices) {
|
|
158
|
+
const unique = new Map();
|
|
159
|
+
for (const choice of choices) {
|
|
160
|
+
const base = registryLocation(choice.base);
|
|
161
|
+
unique.set(`${choice.provider}\n${base}\n${choice.level}`, {
|
|
162
|
+
provider: choice.provider,
|
|
163
|
+
base,
|
|
164
|
+
level: choice.level,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
168
|
+
return [...unique.values()].sort((a, b) => order(a.provider, b.provider) || order(a.base, b.base) || order(a.level, b.level));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Remote entries are acquired one at a time before the catalog is resolved, so a failure names the artifact it was
|
|
172
|
+
* reaching for rather than the file that listed it. The acquisition is the installer's own and the resolution that
|
|
173
|
+
* follows reads the same bytes back from the cache it filled; nothing here decides what a catalog entry means.
|
|
174
|
+
*/
|
|
175
|
+
async function warm(root, entries, offline, signal) {
|
|
176
|
+
signal?.throwIfAborted();
|
|
177
|
+
if (offline || !Array.isArray(entries))
|
|
178
|
+
return;
|
|
179
|
+
for (const row of entries) {
|
|
180
|
+
if (row === null || typeof row !== 'object' || typeof row.url !== 'string' || typeof row.digest !== 'string')
|
|
181
|
+
continue;
|
|
182
|
+
const url = row.url, digest = row.digest;
|
|
183
|
+
await acquiring(() => acquireArtifact({ root, url, digest, signal }), url, 'catalog');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** A plan that resolves from registries without a chooser is a caller defect, not a user's refusal. */
|
|
187
|
+
function chooserOf(request) {
|
|
188
|
+
if (request.choose === undefined)
|
|
189
|
+
throw new Refusal('IA-CLI-FAILED', `ia ${request.operation} resolves from registries but was given no registry chooser`, 3);
|
|
190
|
+
return request.choose;
|
|
191
|
+
}
|
|
192
|
+
/** §2.8: a positional adds or repins one direct request; `--requests` supplies the whole set the native path reads. */
|
|
193
|
+
export function mergeRequests(previous, ids) {
|
|
194
|
+
const requests = new Map((previous?.requests ?? []).map((request) => [request.id, request.range]));
|
|
195
|
+
for (const supplied of ids) {
|
|
196
|
+
const match = REQUEST.exec(supplied);
|
|
197
|
+
if (match === null)
|
|
198
|
+
throw new UsageError(`Expected <provider/name>[@<range>]; got ${supplied}`);
|
|
199
|
+
requests.set(match[1], match[2] ?? ANY_VERSION);
|
|
200
|
+
}
|
|
201
|
+
return [...requests].map(([id, range]) => ({ id, range })).sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* `update <id>` names one direct request. `--to` repins its range; without `--to` the request keeps its range and
|
|
205
|
+
* only the preference changes (registry spec §6.2). Either way an id that is not a direct request is refused.
|
|
206
|
+
*/
|
|
207
|
+
function repin(previous, id, to) {
|
|
208
|
+
if (previous === undefined)
|
|
209
|
+
throw new Refusal('IA-DIST-INPUT-INVALID', 'Update requires an existing installation', 3, null, 'Run "ia install <id>[@<range>]" first, or "ia doctor" for the installed state.');
|
|
210
|
+
if (!previous.requests.some((request) => request.id === id))
|
|
211
|
+
throw new Refusal('IA-DIST-INPUT-INVALID', `${id} is not a direct request of this installation`, 3, null, 'Run "ia inspect" for the installed generation and name one of its direct requests.');
|
|
212
|
+
return to === undefined
|
|
213
|
+
? previous.requests
|
|
214
|
+
: previous.requests.map((request) => (request.id === id ? { id, range: to } : request));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Host registration spec §4: the hosts whose projection this workspace owns. Ownership is the state file's presence,
|
|
218
|
+
* as `ia host` and `ia doctor` decide it. `restore` changes no admitted system, so it has none to refresh.
|
|
219
|
+
*/
|
|
220
|
+
export const registeredProjections = (root, operation) => operation === 'restore' ? [] : WORKSPACE_HOSTS.filter((host) => existsSync(resolve(root, STATE.projection(host))));
|
|
221
|
+
/**
|
|
222
|
+
* Host registration spec §4: `--apply` refuses before any install write when a registered projection would refuse.
|
|
223
|
+
* A hand edit to an owned file is the refusal the refresh would raise (§6.3), so it is found here, before
|
|
224
|
+
* acquisition, the saved plan or the lock is touched; the remedy is the one `ia host` and `ia doctor` name for it.
|
|
225
|
+
* Missing, outdated and unowned files do not block: the refresh rewrites the first two and never touches the third.
|
|
226
|
+
* A file the mechanism cannot read — an unreadable ownership state, an aliased or oversized managed file — would
|
|
227
|
+
* refuse the refresh too, so it is refused here, located at that file, with the repair `ia host` names for it.
|
|
228
|
+
*/
|
|
229
|
+
export function requireProjectionsClean(root, hosts, rooted) {
|
|
230
|
+
for (const host of hosts) {
|
|
231
|
+
const rerun = `ia host ${host} --apply`;
|
|
232
|
+
let drifts;
|
|
233
|
+
try {
|
|
234
|
+
drifts = observeProjection({ root, host, artifacts: null, marker: WORKSPACE_PROJECTION_MARKER });
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
const refusal = refusalOf(error), path = refusedPath(error) ?? STATE.projection(host);
|
|
238
|
+
throw new Refusal(refusal.code, refusal.message, 3, { path }, hostNext(projectionRepair(host, path, rerun), host, root, rooted));
|
|
239
|
+
}
|
|
240
|
+
const changed = drifts.find((drift) => drift.drift === 'changed');
|
|
241
|
+
if (changed !== undefined)
|
|
242
|
+
throw new Refusal('IA-DIST-LOCAL-MODIFICATION', `Managed file was edited by hand: ${changed.path}`, 3, { path: changed.path }, hostNext(projectionRepair(host, changed.path, rerun), host, root, rooted));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Host registration spec §4: after the install committed, a registered projection is re-rendered from the records and
|
|
247
|
+
* the new lock and applied through the projection mechanism, which re-checks every file itself. A refusal comes back
|
|
248
|
+
* rather than being thrown, so every registered host is attempted before the command refuses. A file the mechanism
|
|
249
|
+
* located gets `ia host`'s repair for it; a held host lock names the recovery that clears a dead holder's; a CLI
|
|
250
|
+
* refusal (the workspace no longer admits, the renderer refused) keeps its own next action; anything else names the
|
|
251
|
+
* rerun.
|
|
252
|
+
*/
|
|
253
|
+
function refreshed(view, host) {
|
|
254
|
+
try {
|
|
255
|
+
const artifacts = renderProjectionFor(view.root, host);
|
|
256
|
+
applyProjection(planProjection({ root: view.root, host, artifacts, marker: WORKSPACE_PROJECTION_MARKER }));
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
const busy = lockRefusal(error, view.root);
|
|
261
|
+
if (busy !== null)
|
|
262
|
+
return busy;
|
|
263
|
+
const refusal = refusalOf(error), path = refusedPath(error), rerun = `ia host ${host} --apply`;
|
|
264
|
+
const next = path !== null ? projectionRepair(host, path, rerun) : (refusal.next ?? `Run "${rerun}" to finish.`);
|
|
265
|
+
return new Refusal(refusal.code, refusal.message, 3, path === null ? refusal.where : { path }, hostNext(next, host, view.root, view.rooted));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The installed lock is the previous state for every operation; a fresh workspace simply has none. A committed lock
|
|
270
|
+
* with no active installation (a fresh clone) or one that drifted from it is what `restore` exists to repair, so for
|
|
271
|
+
* `restore` those two states are no previous state at all, as the installer's own restore planning reads them
|
|
272
|
+
* (apps/distribution/src/install.ts `planInstallation`). Every other operation, and every other state, still refuses.
|
|
273
|
+
*/
|
|
274
|
+
const RESTORABLE = new Set(['restore-required', 'lock-drift']);
|
|
275
|
+
function installedLock(root, operation) {
|
|
276
|
+
try {
|
|
277
|
+
return readInstalledState({ root }).lock;
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
const reason = error !== null && typeof error === 'object' && 'reason' in error ? error.reason : undefined;
|
|
281
|
+
if (operation === 'restore' && typeof reason === 'string' && RESTORABLE.has(reason))
|
|
282
|
+
return undefined;
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export async function collectPlan(request) {
|
|
287
|
+
request.signal?.throwIfAborted();
|
|
288
|
+
const { root, operation } = request;
|
|
289
|
+
const previous = installedLock(root, operation);
|
|
290
|
+
let plan;
|
|
291
|
+
let withdrawn = [];
|
|
292
|
+
let registries = [];
|
|
293
|
+
const withdrawnRefusalPrefix = 'Explicit --allow-withdrawn is required for';
|
|
294
|
+
if (operation === 'restore') {
|
|
295
|
+
// One lock is read, and the withdrawal check and the plan both use that same object (registry spec §6.3).
|
|
296
|
+
const lock = readWorkspaceLock({ root });
|
|
297
|
+
if (request.catalog === undefined && !request.offline) {
|
|
298
|
+
const choose = chooserOf(request);
|
|
299
|
+
const restored = await acquiring(async () => {
|
|
300
|
+
const pins = await registryWithdrawals(root, lock, choose, request.signal);
|
|
301
|
+
return planRestore({
|
|
302
|
+
root,
|
|
303
|
+
signal: request.signal,
|
|
304
|
+
lock,
|
|
305
|
+
offline: false,
|
|
306
|
+
allowWithdrawn: request.allowWithdrawn,
|
|
307
|
+
withdrawnRefusalPrefix,
|
|
308
|
+
withdrawn: pins,
|
|
309
|
+
});
|
|
310
|
+
}, null, 'registry');
|
|
311
|
+
plan = restored.plan;
|
|
312
|
+
withdrawn = restored.withdrawn;
|
|
313
|
+
// The registries asked: every locked package's except an unpublished cached pin, which no registry lists (§6.3).
|
|
314
|
+
const unpublished = unpublishedPins(root, lock);
|
|
315
|
+
registries = registrySources(lock.packages.filter((pkg) => !unpublished.has(pkg.id)).map((pkg) => choose(pkg.id)));
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
const entries = request.offline ? undefined : readWorkspaceJson({ root, path: request.catalog });
|
|
319
|
+
await warm(root, entries, request.offline, request.signal);
|
|
320
|
+
const restored = await acquiring(() => planRestore({
|
|
321
|
+
root,
|
|
322
|
+
signal: request.signal,
|
|
323
|
+
lock,
|
|
324
|
+
offline: request.offline,
|
|
325
|
+
allowWithdrawn: request.allowWithdrawn,
|
|
326
|
+
withdrawnRefusalPrefix,
|
|
327
|
+
...(request.offline ? {} : { catalog: entries }),
|
|
328
|
+
}), request.catalog ?? null, 'catalog');
|
|
329
|
+
plan = restored.plan;
|
|
330
|
+
withdrawn = restored.withdrawn;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
else if (operation === 'remove') {
|
|
334
|
+
plan = planInstallation(root, pruneLockRequest({ lock: readWorkspaceLock({ root }), id: request.ids[0] }), 'remove');
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
const updated = operation === 'update' ? request.ids[0] : undefined;
|
|
338
|
+
const requests = updated !== undefined
|
|
339
|
+
? repin(previous, updated, request.to)
|
|
340
|
+
: request.requestsFile === undefined
|
|
341
|
+
? mergeRequests(previous, request.ids)
|
|
342
|
+
: decodeDistributionRequests(readWorkspaceJson({ root, path: request.requestsFile }));
|
|
343
|
+
let choices;
|
|
344
|
+
if (request.catalog !== undefined) {
|
|
345
|
+
const entries = readWorkspaceJson({ root, path: request.catalog });
|
|
346
|
+
await warm(root, entries, request.offline, request.signal);
|
|
347
|
+
choices = await acquiring(() => resolveCatalog({ root, entries, offline: request.offline, signal: request.signal }), request.catalog, 'catalog');
|
|
348
|
+
}
|
|
349
|
+
else if (request.offline) {
|
|
350
|
+
choices = cached(root, [
|
|
351
|
+
...requests.map((dependency) => dependency.id),
|
|
352
|
+
...(previous?.packages ?? []).map((pkg) => pkg.id),
|
|
353
|
+
]);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
// Registry spec §5: metadata selection, then only the selected archives are fetched and verified. Only the ids the
|
|
357
|
+
// command names are looked up (§5.1): update's id; install's positionals; for a --requests file, the ids whose
|
|
358
|
+
// request is new or whose range changed, since an unchanged request changes nothing. Any other locked package whose
|
|
359
|
+
// exact archive is cached answers for itself, so no registry is asked about it.
|
|
360
|
+
const named = updated !== undefined
|
|
361
|
+
? [updated]
|
|
362
|
+
: request.requestsFile === undefined
|
|
363
|
+
? request.ids.map((supplied) => REQUEST.exec(supplied)[1])
|
|
364
|
+
: requests
|
|
365
|
+
.filter((dependency) => !previous?.requests.some((prior) => prior.id === dependency.id && prior.range === dependency.range))
|
|
366
|
+
.map((dependency) => dependency.id);
|
|
367
|
+
const choose = chooserOf(request);
|
|
368
|
+
const resolution = await acquiring(() => resolveFromRegistries({
|
|
369
|
+
root,
|
|
370
|
+
requests,
|
|
371
|
+
engine: DISTRIBUTION_ENGINE_VERSION,
|
|
372
|
+
previous,
|
|
373
|
+
preferredExcept: updated,
|
|
374
|
+
named,
|
|
375
|
+
choose,
|
|
376
|
+
signal: request.signal,
|
|
377
|
+
}), null, 'registry');
|
|
378
|
+
choices = resolution.candidates;
|
|
379
|
+
registries = registrySources(resolution.sources.values());
|
|
380
|
+
}
|
|
381
|
+
// An update drops its own package from the preference, so a repinned range is not held to the old choice and an
|
|
382
|
+
// unchanged range takes the newest release in it (registry spec §6.2).
|
|
383
|
+
const preference = updated !== undefined && previous !== undefined
|
|
384
|
+
? { ...previous, packages: previous.packages.filter((pkg) => pkg.id !== updated) }
|
|
385
|
+
: previous;
|
|
386
|
+
request.signal?.throwIfAborted();
|
|
387
|
+
plan = planInstallation(root, resolveReleases(requests, choices, DISTRIBUTION_ENGINE_VERSION, preference).lock, operation);
|
|
388
|
+
}
|
|
389
|
+
request.signal?.throwIfAborted();
|
|
390
|
+
const planOut = request.planOut === undefined
|
|
391
|
+
? null
|
|
392
|
+
: writeWorkOutput({
|
|
393
|
+
root,
|
|
394
|
+
path: request.planOut,
|
|
395
|
+
content: () => Buffer.from(canonicalDistributionJson(plan)),
|
|
396
|
+
refusal: 'A saved plan is written to a new file under .ia/work/',
|
|
397
|
+
});
|
|
398
|
+
return {
|
|
399
|
+
operation,
|
|
400
|
+
root,
|
|
401
|
+
plan,
|
|
402
|
+
rows: changeRows(plan, previous),
|
|
403
|
+
applied: null,
|
|
404
|
+
withdrawn,
|
|
405
|
+
registries,
|
|
406
|
+
invocation: request.invocation,
|
|
407
|
+
planOut,
|
|
408
|
+
refresh: request.refresh ?? registeredProjections(root, operation),
|
|
409
|
+
rooted: request.rooted ?? false,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* §2.8 rule 2's second half, separated from the first so rule 3's question can sit between them. `collectPlan`
|
|
414
|
+
* never writes installation state; this is the only call in the consumer that does, and `applyInstallation`
|
|
415
|
+
* re-runs the installer's own staleness check before it writes anything. Host registration spec §4 then refreshes
|
|
416
|
+
* each registered projection; that writes only the projection's own files and state, never installation state. A
|
|
417
|
+
* refused refresh is class 3 naming the first refusal, and the count when more than one host refused (file comment).
|
|
418
|
+
*/
|
|
419
|
+
export function applyPlan(view) {
|
|
420
|
+
const applied = { ...view, applied: applyInstallation(view.plan) };
|
|
421
|
+
const refused = view.refresh
|
|
422
|
+
.map((host) => refreshed(applied, host))
|
|
423
|
+
.filter((refusal) => refusal !== null);
|
|
424
|
+
const [first] = refused;
|
|
425
|
+
if (first !== undefined) {
|
|
426
|
+
const count = refused.length > 1 ? ` ${refused.length} registered host projections were not refreshed; this is the first.` : '';
|
|
427
|
+
throw new Refusal(first.code, first.message, 3, first.where, `The installation is applied.${count} ${first.next}`);
|
|
428
|
+
}
|
|
429
|
+
return applied;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* The `--json` envelope (§2.8): `{version: 1, command, plan, applied?, refresh?, registries?, withdrawn?, planOut?}`.
|
|
433
|
+
*
|
|
434
|
+
* - `plan` is the planner's own value, unchanged.
|
|
435
|
+
* - `registries` (registry spec §6.1) is present only when registries were the source: one `{provider, base, level}`
|
|
436
|
+
* per provider, sorted by provider, then base. `base` is the HTTPS URL or the absolute directory; `level` is the §4
|
|
437
|
+
* level that chose it, `flag`, `env`, `workspace`, `user` or `default`. For `restore` it names the registries asked
|
|
438
|
+
* whether a locked release was withdrawn. A package's registry is the entry for its provider.
|
|
439
|
+
* - `withdrawn` lists the withdrawn releases `--allow-withdrawn` accepted, and its entries differ by source: a catalog
|
|
440
|
+
* restore names bare package ids, as it always has; a registry restore names `id@version` pins (§6.3).
|
|
441
|
+
*/
|
|
442
|
+
export function planEnvelope(view) {
|
|
443
|
+
return {
|
|
444
|
+
version: 1,
|
|
445
|
+
command: view.operation,
|
|
446
|
+
plan: view.plan,
|
|
447
|
+
// `applied` is applyInstallation's return, its `host: 'pending'` literal included (§2.8).
|
|
448
|
+
...(view.applied === null ? {} : { applied: view.applied }),
|
|
449
|
+
// Host registration spec §4: the registered projections an apply refreshes.
|
|
450
|
+
...(view.refresh.length === 0 ? {} : { refresh: view.refresh }),
|
|
451
|
+
...(view.registries.length === 0 ? {} : { registries: view.registries }),
|
|
452
|
+
...(view.withdrawn.length === 0 ? {} : { withdrawn: view.withdrawn }),
|
|
453
|
+
...(view.planOut === null ? {} : { planOut: view.planOut }),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const headerBlock = (view, caps) => [
|
|
457
|
+
...headerLine('Plan', view.operation, [
|
|
458
|
+
{ text: `format ${view.plan.formatVersion}`, column: 23 },
|
|
459
|
+
{ text: `engine ${view.plan.engine}`, column: 35 },
|
|
460
|
+
{ text: `digest ${truncateDigest(view.plan.digest, caps.ascii)}`, column: 51 },
|
|
461
|
+
], caps),
|
|
462
|
+
...headerLine('Root', view.root, [], caps),
|
|
463
|
+
...registryLines(view, caps),
|
|
464
|
+
];
|
|
465
|
+
/**
|
|
466
|
+
* Registry spec §6.1: one "Registry" row per base, naming the level that chose it and the providers it answered for,
|
|
467
|
+
* so every package in the change table can be traced to the registry it came from.
|
|
468
|
+
*/
|
|
469
|
+
function registryLines(view, caps) {
|
|
470
|
+
const rows = new Map();
|
|
471
|
+
for (const source of view.registries) {
|
|
472
|
+
const key = `${source.base}\n${source.level}`;
|
|
473
|
+
const row = rows.get(key) ?? { base: source.base, level: source.level, providers: [] };
|
|
474
|
+
row.providers.push(source.provider);
|
|
475
|
+
rows.set(key, row);
|
|
476
|
+
}
|
|
477
|
+
return [...rows.values()].flatMap((row) => headerLine('Registry', row.base, [{ text: `for ${row.providers.join(', ')} (${row.level})` }], caps));
|
|
478
|
+
}
|
|
479
|
+
const changesBlock = (view, caps) => {
|
|
480
|
+
const { changes } = view.plan;
|
|
481
|
+
const counts = `${changes.added.length} added, ${changes.removed.length} removed, ${changes.updated.length} updated, ${changes.shadowed.length} shadowed.`;
|
|
482
|
+
return [
|
|
483
|
+
sectionLabel('Changes', caps),
|
|
484
|
+
...(view.rows.length === 0
|
|
485
|
+
? entry([words('No package changes; the resolved set already matches the lock.')], { depth: 1, symbol: 'info' }, caps)
|
|
486
|
+
: fieldRows(view.rows.map((row) => ({
|
|
487
|
+
symbol: SYMBOL[row.kind],
|
|
488
|
+
label: row.id,
|
|
489
|
+
value: [
|
|
490
|
+
atom(row.version),
|
|
491
|
+
atom(row.archive === null ? 'sha256 —' : `sha256 ${truncateDigest(row.archive, caps.ascii)}`, null, 2),
|
|
492
|
+
atom(row.origin, null, 2),
|
|
493
|
+
],
|
|
494
|
+
})), { depth: 1 }, caps)),
|
|
495
|
+
...entry([words(counts, 'dim')], { depth: 1 }, caps),
|
|
496
|
+
];
|
|
497
|
+
};
|
|
498
|
+
/** The warning prints the accepted releases as the source named them: bare ids from a catalog, `id@version` from a registry. */
|
|
499
|
+
const withdrawnBlocks = (view, caps) => view.withdrawn.length === 0
|
|
500
|
+
? []
|
|
501
|
+
: [
|
|
502
|
+
entry([words(`Withdrawn releases accepted with --allow-withdrawn: ${view.withdrawn.join(', ')}.`)], { depth: 1, symbol: 'warning' }, caps),
|
|
503
|
+
];
|
|
504
|
+
/** §2.8 rule 4's closed path set, which is what makes "only install state" a checkable claim rather than a promise. */
|
|
505
|
+
const wouldWriteBlocks = (view, caps) => {
|
|
506
|
+
const { plan } = view;
|
|
507
|
+
const paths = [
|
|
508
|
+
...plan.lock.packages.map((pkg) => `${INSTALL_PATHS.store}/${pkg.archive}/`),
|
|
509
|
+
`${INSTALL_PATHS.generations}/${plan.pointer.generation}/`,
|
|
510
|
+
INSTALL_PATHS.lock,
|
|
511
|
+
INSTALL_PATHS.active,
|
|
512
|
+
];
|
|
513
|
+
return [
|
|
514
|
+
[
|
|
515
|
+
sectionLabel('Would write', caps),
|
|
516
|
+
...paths.flatMap((path) => entry([[atom(path, 'cyan', 0)]], { depth: 1, symbol: 'info' }, caps)),
|
|
517
|
+
],
|
|
518
|
+
entry([words('Authored sources under .ia/src are not touched.')], { depth: 1 }, caps),
|
|
519
|
+
...projectionsBlocks(view, caps),
|
|
520
|
+
];
|
|
521
|
+
};
|
|
522
|
+
/** Host registration spec §4: the plan says which registered projections the apply re-renders. */
|
|
523
|
+
const projectionsBlocks = (view, caps) => view.refresh.length === 0
|
|
524
|
+
? []
|
|
525
|
+
: [
|
|
526
|
+
entry([words(`Registered host projections (${view.refresh.join(', ')}) are refreshed after apply.`)], { depth: 1, symbol: 'info' }, caps),
|
|
527
|
+
];
|
|
528
|
+
/**
|
|
529
|
+
* What an applied plan says about hosts. `applied.host` is applyInstallation's literal and is never presented as
|
|
530
|
+
* configured (§2.8): with no registered projection the installer's host state is "not reported"; with one, the line
|
|
531
|
+
* names what this command refreshed — a refused refresh never reaches here — and points at doctor for the rest.
|
|
532
|
+
*/
|
|
533
|
+
const hostLine = (view) => view.refresh.length === 0
|
|
534
|
+
? 'Host state is not reported by the installer; run "ia doctor" for it.'
|
|
535
|
+
: `Registered host projections (${view.refresh.join(', ')}) were refreshed; run "ia doctor" for host state.`;
|
|
536
|
+
export function renderPlan(view, caps) {
|
|
537
|
+
const blocks = [
|
|
538
|
+
headerBlock(view, caps),
|
|
539
|
+
view.applied === null
|
|
540
|
+
? entry([words('This is a preview. Nothing has been written.')], { depth: 1 }, caps)
|
|
541
|
+
: entry([
|
|
542
|
+
words(`Installed generation ${truncateDigest(view.applied.generation, caps.ascii)}, counter ${view.applied.counter}.`),
|
|
543
|
+
words(hostLine(view)),
|
|
544
|
+
], { depth: 1, symbol: 'success' }, caps),
|
|
545
|
+
changesBlock(view, caps),
|
|
546
|
+
...withdrawnBlocks(view, caps),
|
|
547
|
+
...(view.applied === null ? wouldWriteBlocks(view, caps) : []),
|
|
548
|
+
...(view.planOut === null
|
|
549
|
+
? []
|
|
550
|
+
: [
|
|
551
|
+
entry([[...words('Plan saved to', null, 0), atom(view.planOut, 'cyan', 1)]], { depth: 1, symbol: 'info' }, caps),
|
|
552
|
+
]),
|
|
553
|
+
entry(view.applied === null
|
|
554
|
+
? [
|
|
555
|
+
...commandFacts('Apply with "', `${view.invocation} --apply --yes`, '".', 3, caps),
|
|
556
|
+
words(`Save the plan for review with --plan-out .ia/work/${view.operation}-plan.json.`),
|
|
557
|
+
]
|
|
558
|
+
: [words('Run "ia validate" to check the installed workspace, or "ia doctor" for the installed state.')], { depth: 0, symbol: 'step' }, caps),
|
|
559
|
+
];
|
|
560
|
+
return document(blocks, { leadingBlank: true });
|
|
561
|
+
}
|
|
562
|
+
/** §2.8 rule 3's one question, asked once and only where an answer can arrive. */
|
|
563
|
+
export const CONFIRMATION = 'Apply these changes? [y/N] ';
|
|
564
|
+
/**
|
|
565
|
+
* The change summary rule 3 requires the question to show: what the preview shows about what would change, without
|
|
566
|
+
* the preview's "nothing has been written" note or its next-action footer, because the question *is* the action.
|
|
567
|
+
* It is the same blocks the preview builds, so a column the preview prints and this one does not cannot exist.
|
|
568
|
+
*/
|
|
569
|
+
export const renderSummary = (view, caps) => document([
|
|
570
|
+
headerBlock(view, caps),
|
|
571
|
+
changesBlock(view, caps),
|
|
572
|
+
...withdrawnBlocks(view, caps),
|
|
573
|
+
...wouldWriteBlocks(view, caps),
|
|
574
|
+
], {
|
|
575
|
+
leadingBlank: true,
|
|
576
|
+
});
|
|
577
|
+
/**
|
|
578
|
+
* A declined question is an answer, not a failure: §2.8 rule 3 asked, the user said no, and the command reports
|
|
579
|
+
* that and exits 0. The summary was on stderr, so what stdout carries is the outcome and the way to get the other
|
|
580
|
+
* one. `--plan-out` was already written, because a declined apply is exactly the plan-only run rule 1 describes.
|
|
581
|
+
*/
|
|
582
|
+
export const renderDeclined = (view, caps) => document([
|
|
583
|
+
entry([words('Nothing was applied.')], { depth: 0, symbol: 'info' }, caps),
|
|
584
|
+
entry(commandFacts('Apply with "', `${view.invocation} --apply --yes`, '".', 3, caps), { depth: 0, symbol: 'step' }, caps),
|
|
585
|
+
], { leadingBlank: true });
|
|
586
|
+
/** The invocation is rebuilt from the parsed arguments, so the printed command is the one that was understood. */
|
|
587
|
+
function invocationOf(context, operation) {
|
|
588
|
+
const { args } = context;
|
|
589
|
+
const parts = [`ia ${operation}`, ...args.positionals.map(quote)];
|
|
590
|
+
for (const name of ['to', 'requests', 'registry', 'catalog', 'plan-out']) {
|
|
591
|
+
const value = args.value(name);
|
|
592
|
+
if (value !== undefined)
|
|
593
|
+
parts.push(`--${name}`, quote(value));
|
|
594
|
+
}
|
|
595
|
+
for (const name of ['offline', 'allow-withdrawn'])
|
|
596
|
+
if (args.flag(name))
|
|
597
|
+
parts.push(`--${name}`);
|
|
598
|
+
const root = args.value('root');
|
|
599
|
+
if (root !== undefined)
|
|
600
|
+
parts.push('--root', quote(root));
|
|
601
|
+
return parts.join(' ');
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Registry spec §4: the one chooser a command builds, from the host rather than the process, so every verb that
|
|
605
|
+
* reports or uses registry routing (`install`, `update`, `restore`, `doctor`) picks the same base for an id. A
|
|
606
|
+
* relative `--registry` or `IA_REGISTRY` directory is the caller's (`host.cwd`), and the user file is found from the
|
|
607
|
+
* host's environment: `IA_CONFIG_HOME`, else the per-OS directory under the home `os.homedir()` would use —
|
|
608
|
+
* `USERPROFILE` on Windows, `HOME` elsewhere — and, when that variable is unset, `os.homedir()` itself. Construction
|
|
609
|
+
* reads nothing: the configuration is read on first use, once per command.
|
|
610
|
+
*/
|
|
611
|
+
export function hostRegistryChooser(host, root, flag) {
|
|
612
|
+
const home = (process.platform === 'win32' ? host.env['USERPROFILE'] : host.env['HOME']) || undefined;
|
|
613
|
+
return registryChooser({
|
|
614
|
+
root,
|
|
615
|
+
env: host.env,
|
|
616
|
+
cwd: host.cwd,
|
|
617
|
+
platform: process.platform,
|
|
618
|
+
...(home === undefined ? {} : { home }),
|
|
619
|
+
...(flag === undefined ? {} : { flag }),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
export function runDistribute(operation) {
|
|
623
|
+
return async (context) => {
|
|
624
|
+
const { args, caps, host, json } = context;
|
|
625
|
+
const catalog = args.value('catalog'), offline = args.flag('offline'), registry = args.value('registry');
|
|
626
|
+
const root = requireRoot(context), rooted = args.value('root') !== undefined;
|
|
627
|
+
const choose = operation === 'remove' ? undefined : hostRegistryChooser(host, root, registry);
|
|
628
|
+
// Host registration spec §4: a registered projection that would refuse is refused before any install write —
|
|
629
|
+
// before acquisition, the saved plan and the question too, so nothing is fetched for an apply that cannot finish.
|
|
630
|
+
const refresh = registeredProjections(root, operation);
|
|
631
|
+
if (args.flag('apply'))
|
|
632
|
+
requireProjectionsClean(root, refresh, rooted);
|
|
633
|
+
const planned = await collectPlan({
|
|
634
|
+
signal: host.signal,
|
|
635
|
+
root,
|
|
636
|
+
operation,
|
|
637
|
+
ids: args.positionals,
|
|
638
|
+
requestsFile: args.value('requests'),
|
|
639
|
+
catalog,
|
|
640
|
+
offline,
|
|
641
|
+
to: args.value('to'),
|
|
642
|
+
allowWithdrawn: args.flag('allow-withdrawn'),
|
|
643
|
+
planOut: args.value('plan-out'),
|
|
644
|
+
invocation: invocationOf(context, operation),
|
|
645
|
+
rooted,
|
|
646
|
+
refresh,
|
|
647
|
+
choose,
|
|
648
|
+
});
|
|
649
|
+
const rendered = (view) => json
|
|
650
|
+
? { exitCode: 0, stdout: JSON.stringify(planEnvelope(view)) + '\n', stderr: '' }
|
|
651
|
+
: { exitCode: 0, stdout: renderPlan(view, caps), stderr: '' };
|
|
652
|
+
host.signal?.throwIfAborted();
|
|
653
|
+
if (!args.flag('apply'))
|
|
654
|
+
return rendered(planned);
|
|
655
|
+
// §2.8 rule 3. Parsing has already refused every `--apply` whose question could not be asked or answered —
|
|
656
|
+
// no terminal, or `--json` — so the only two states left here are "answer it" and "`--yes` says skip it".
|
|
657
|
+
// A declined answer therefore cannot occur under `--json`, which is why there is no JSON shape for one.
|
|
658
|
+
if (!args.flag('yes') && !(await confirm(host.interaction, renderSummary(planned, caps), CONFIRMATION)))
|
|
659
|
+
return { exitCode: 0, stdout: renderDeclined(planned, caps), stderr: '' };
|
|
660
|
+
host.signal?.throwIfAborted();
|
|
661
|
+
return rendered(applyPlan(planned));
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
//# sourceMappingURL=distribute.js.map
|