@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.
Files changed (110) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +19 -2
  4. package/SPEC.md +30 -0
  5. package/assets/base/71f0665cb1ffc3f3964fc7710528451f635d664bdf4b8c95005ee97c21db5928.ia.tgz +0 -0
  6. package/assets/base.json +6 -0
  7. package/assets/host/43a1f8c471141b1f2f46cef4411a9a61c00da1c1aa19d277189ae2423282bfd6.tgz +0 -0
  8. package/assets/host.json +6 -0
  9. package/assets/vocabulary.json +3268 -0
  10. package/dist/args.d.ts +62 -0
  11. package/dist/args.d.ts.map +1 -0
  12. package/dist/args.js +117 -0
  13. package/dist/args.js.map +1 -0
  14. package/dist/briefing.d.ts +76 -0
  15. package/dist/briefing.d.ts.map +1 -0
  16. package/dist/briefing.js +105 -0
  17. package/dist/briefing.js.map +1 -0
  18. package/dist/channel.d.ts +22 -0
  19. package/dist/channel.d.ts.map +1 -0
  20. package/dist/channel.js +74 -0
  21. package/dist/channel.js.map +1 -0
  22. package/dist/claude-cli.d.ts +35 -0
  23. package/dist/claude-cli.d.ts.map +1 -0
  24. package/dist/claude-cli.js +101 -0
  25. package/dist/claude-cli.js.map +1 -0
  26. package/dist/commands.d.ts +39 -0
  27. package/dist/commands.d.ts.map +1 -0
  28. package/dist/commands.js +436 -0
  29. package/dist/commands.js.map +1 -0
  30. package/dist/compile.d.ts +39 -0
  31. package/dist/compile.d.ts.map +1 -0
  32. package/dist/compile.js +136 -0
  33. package/dist/compile.js.map +1 -0
  34. package/dist/consumer.d.ts +137 -0
  35. package/dist/consumer.d.ts.map +1 -0
  36. package/dist/consumer.js +366 -0
  37. package/dist/consumer.js.map +1 -0
  38. package/dist/decline.d.ts +7 -0
  39. package/dist/decline.d.ts.map +1 -0
  40. package/dist/decline.js +78 -0
  41. package/dist/decline.js.map +1 -0
  42. package/dist/distribute.d.ts +163 -0
  43. package/dist/distribute.d.ts.map +1 -0
  44. package/dist/distribute.js +664 -0
  45. package/dist/distribute.js.map +1 -0
  46. package/dist/doctor-user.d.ts +45 -0
  47. package/dist/doctor-user.d.ts.map +1 -0
  48. package/dist/doctor-user.js +193 -0
  49. package/dist/doctor-user.js.map +1 -0
  50. package/dist/doctor.d.ts +57 -0
  51. package/dist/doctor.d.ts.map +1 -0
  52. package/dist/doctor.js +754 -0
  53. package/dist/doctor.js.map +1 -0
  54. package/dist/format.d.ts +25 -0
  55. package/dist/format.d.ts.map +1 -0
  56. package/dist/format.js +168 -0
  57. package/dist/format.js.map +1 -0
  58. package/dist/home-remedy.d.ts +10 -0
  59. package/dist/home-remedy.d.ts.map +1 -0
  60. package/dist/home-remedy.js +26 -0
  61. package/dist/home-remedy.js.map +1 -0
  62. package/dist/host-projection.d.ts +11 -0
  63. package/dist/host-projection.d.ts.map +1 -0
  64. package/dist/host-projection.js +73 -0
  65. package/dist/host-projection.js.map +1 -0
  66. package/dist/host.d.ts +162 -0
  67. package/dist/host.d.ts.map +1 -0
  68. package/dist/host.js +599 -0
  69. package/dist/host.js.map +1 -0
  70. package/dist/init.d.ts +217 -0
  71. package/dist/init.d.ts.map +1 -0
  72. package/dist/init.js +840 -0
  73. package/dist/init.js.map +1 -0
  74. package/dist/inspect.d.ts +32 -0
  75. package/dist/inspect.d.ts.map +1 -0
  76. package/dist/inspect.js +226 -0
  77. package/dist/inspect.js.map +1 -0
  78. package/dist/main.d.ts +24 -0
  79. package/dist/main.d.ts.map +1 -0
  80. package/dist/main.js +215 -0
  81. package/dist/main.js.map +1 -0
  82. package/dist/operation-help.d.ts +14 -0
  83. package/dist/operation-help.d.ts.map +1 -0
  84. package/dist/operation-help.js +75 -0
  85. package/dist/operation-help.js.map +1 -0
  86. package/dist/pack.d.ts +17 -0
  87. package/dist/pack.d.ts.map +1 -0
  88. package/dist/pack.js +121 -0
  89. package/dist/pack.js.map +1 -0
  90. package/dist/render.d.ts +165 -0
  91. package/dist/render.d.ts.map +1 -0
  92. package/dist/render.js +277 -0
  93. package/dist/render.js.map +1 -0
  94. package/dist/session.d.ts +13 -0
  95. package/dist/session.d.ts.map +1 -0
  96. package/dist/session.js +25 -0
  97. package/dist/session.js.map +1 -0
  98. package/dist/user-host.d.ts +59 -0
  99. package/dist/user-host.d.ts.map +1 -0
  100. package/dist/user-host.js +228 -0
  101. package/dist/user-host.js.map +1 -0
  102. package/dist/validate.d.ts +53 -0
  103. package/dist/validate.d.ts.map +1 -0
  104. package/dist/validate.js +220 -0
  105. package/dist/validate.js.map +1 -0
  106. package/dist/vocabulary.d.ts +55 -0
  107. package/dist/vocabulary.d.ts.map +1 -0
  108. package/dist/vocabulary.js +179 -0
  109. package/dist/vocabulary.js.map +1 -0
  110. 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